diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index a1ea5c8e20..1a1f0d1f4f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -11,7 +11,7 @@ body: ## Important Notes - - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. + - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.** - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. @@ -25,7 +25,9 @@ body: label: Check Existing Issues description: Confirm that you’ve checked for existing reports before submitting a new one. options: - - label: I have searched the existing issues and discussions. + - label: I have searched for any existing and/or related issues. + required: true + - label: I have searched for any existing and/or related discussions. required: true - label: I am using the latest version of Open WebUI. required: true @@ -47,7 +49,7 @@ body: id: open-webui-version attributes: label: Open WebUI Version - description: Specify the version (e.g., v0.3.11) + description: Specify the version (e.g., v0.6.26) validations: required: true @@ -63,7 +65,7 @@ body: id: operating-system attributes: label: Operating System - description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04) + description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12) validations: required: true @@ -126,6 +128,7 @@ body: description: | Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables. **If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.** + **If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.** placeholder: | Example (include every detail): @@ -163,5 +166,5 @@ body: attributes: value: | ## Note - If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue. + **If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue. Thank you for contributing to Open WebUI! diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ed93957ea4..1c83fd305b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,12 +12,6 @@ updates: interval: monthly target-branch: 'dev' - - package-ecosystem: npm - directory: '/' - schedule: - interval: monthly - target-branch: 'dev' - - package-ecosystem: 'github-actions' directory: '/' schedule: diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 443d904199..7d5e30e23e 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check for changes in package.json run: | diff --git a/.github/workflows/deploy-to-hf-spaces.yml b/.github/workflows/deploy-to-hf-spaces.yml index 7fc66acf5c..a30046af89 100644 --- a/.github/workflows/deploy-to-hf-spaces.yml +++ b/.github/workflows/deploy-to-hf-spaces.yml @@ -27,7 +27,7 @@ jobs: HF_TOKEN: ${{ secrets.HF_TOKEN }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: lfs: true diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index e597ff8055..a8f9266e9d 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -43,7 +43,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -142,7 +142,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -244,7 +244,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -347,7 +347,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -449,7 +449,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -535,7 +535,7 @@ jobs: IMAGE_NAME: '${{ github.repository }}' - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: digests-main-* path: /tmp/digests @@ -589,7 +589,7 @@ jobs: IMAGE_NAME: '${{ github.repository }}' - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: digests-cuda-* path: /tmp/digests @@ -645,7 +645,7 @@ jobs: IMAGE_NAME: '${{ github.repository }}' - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: digests-cuda126-* path: /tmp/digests @@ -701,7 +701,7 @@ jobs: IMAGE_NAME: '${{ github.repository }}' - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: digests-ollama-* path: /tmp/digests @@ -757,7 +757,7 @@ jobs: IMAGE_NAME: '${{ github.repository }}' - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: pattern: digests-slim-* path: /tmp/digests diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml index 1bcdd92c1d..56074a84f4 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml @@ -30,7 +30,7 @@ jobs: - 3.12.x steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 15dc53cc63..df961ca3f5 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index fd1adab3a9..c4ae97422d 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -16,7 +16,7 @@ jobs: id-token: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Git diff --git a/CHANGELOG.md b/CHANGELOG.md index 349b984e19..2af109cb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,68 @@ 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.27] - 2025-09-09 + +### Added + +- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255) +- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5) +- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a) +- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158) +- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679) +- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166) +- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222) +- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229) +- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7) +- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9) +- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672) +- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880) +- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5) +- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df) +- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae) +- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3) +- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded. + +### Fixed + +- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046) +- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195) +- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095) +- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b) +- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175) +- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9) +- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012) +- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061) +- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064) +- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000) +- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b) +- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085) +- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186) +- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191) +- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213) +- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225) +- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5) +- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb) +- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6) +- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de) +- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6) +- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4) +- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958) +- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e) + +### Changed + +- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f) +- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285) + ## [0.6.26] - 2025-08-28 ### Added - 🛂 **Granular Chat Interaction Permissions**: Added fine-grained permission controls for individual chat actions including "Continue Response", "Regenerate Response", "Rate Response", and "Delete Messages". Administrators can now configure these permissions per user group or set system defaults via environment variables, providing enhanced security and governance by preventing potential system prompt leakage through response continuation and enabling precise control over user interactions with AI responses. - 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility. -- 📱 **Pull-to-Refresh SupportA**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA. +- 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA. - 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows. - 🔐 **Azure Document Intelligence DefaultAzureCredential Support**: Added support for authenticating with Azure Document Intelligence using DefaultAzureCredential in addition to API key authentication, enabling seamless integration with Azure Entra ID and managed identity authentication for enterprise Azure environments. - 🔐 **Authentication Bootstrapping Enhancements**: Added "ENABLE_INITIAL_ADMIN_SIGNUP" environment variable and "?form=true" URL parameter to enable initial admin user creation and forced login form display in SSO-only deployments. This resolves bootstrap issues where administrators couldn't create the first user when login forms were disabled, allowing proper initialization of SSO-configured deployments without requiring temporary configuration changes. diff --git a/Dockerfile b/Dockerfile index 9c982e69e2..ad393338d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ARG USE_CUDA=false ARG USE_OLLAMA=false ARG USE_SLIM=false +ARG USE_PERMISSION_HARDENING=false # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) ARG USE_CUDA_VER=cu128 # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers @@ -25,6 +26,9 @@ ARG GID=0 FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build ARG BUILD_HASH +# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory) +# ENV NODE_OPTIONS="--max-old-space-size=4096" + WORKDIR /app # to store git revision in build @@ -45,6 +49,7 @@ ARG USE_CUDA ARG USE_OLLAMA ARG USE_CUDA_VER ARG USE_SLIM +ARG USE_PERMISSION_HARDENING ARG USE_EMBEDDING_MODEL ARG USE_RERANKING_MODEL ARG UID @@ -123,7 +128,6 @@ RUN apt-get update && \ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt RUN pip3 install --no-cache-dir uv && \ - if [ "$USE_SLIM" != "true" ]; then \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ @@ -134,17 +138,17 @@ RUN pip3 install --no-cache-dir uv && \ else \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ + if [ "$USE_SLIM" != "true" ]; then \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ fi; \ - else \ - uv pip install --system -r requirements.txt --no-cache-dir; \ fi; \ - mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ + mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \ + rm -rf /var/lib/apt/lists/*; # Install Ollama if requested -RUN if [ "$USE_OLLAMA" = "true" ] && [ "$USE_SLIM" != "true" ]; then \ +RUN if [ "$USE_OLLAMA" = "true" ]; then \ date +%s > /tmp/ollama_build_hash && \ echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \ curl -fsSL https://ollama.com/install.sh | sh && \ @@ -170,11 +174,13 @@ HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq # Minimal, atomic permission hardening for OpenShift (arbitrary UID): # - Group 0 owns /app and /root # - Directories are group-writable and have SGID so new files inherit GID 0 -RUN set -eux; \ +RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \ + set -eux; \ chgrp -R 0 /app /root || true; \ chmod -R g+rwX /app /root || true; \ find /app -type d -exec chmod g+s {} + || true; \ - find /root -type d -exec chmod g+s {} + || true + find /root -type d -exec chmod g+s {} + || true; \ + fi USER $UID:$GID diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 1fe031cdad..11698d87af 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -313,7 +313,7 @@ JWT_EXPIRES_IN = PersistentConfig( #################################### ENABLE_OAUTH_PERSISTENT_CONFIG = ( - os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true" + os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true" ) ENABLE_OAUTH_SIGNUP = PersistentConfig( @@ -660,7 +660,7 @@ def load_oauth_providers(): if ( OAUTH_CLIENT_ID.value - and OAUTH_CLIENT_SECRET.value + and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value) and OPENID_PROVIDER_URL.value ): @@ -1998,6 +1998,9 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536") ) +PGVECTOR_CREATE_EXTENSION = ( + os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true" +) PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true" PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None) if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY: @@ -2229,6 +2232,18 @@ DOCLING_SERVER_URL = PersistentConfig( os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), ) +DOCLING_DO_OCR = PersistentConfig( + "DOCLING_DO_OCR", + "rag.docling_do_ocr", + os.getenv("DOCLING_DO_OCR", "True").lower() == "true", +) + +DOCLING_FORCE_OCR = PersistentConfig( + "DOCLING_FORCE_OCR", + "rag.docling_force_ocr", + os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true", +) + DOCLING_OCR_ENGINE = PersistentConfig( "DOCLING_OCR_ENGINE", "rag.docling_ocr_engine", @@ -2241,6 +2256,24 @@ DOCLING_OCR_LANG = PersistentConfig( os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"), ) +DOCLING_PDF_BACKEND = PersistentConfig( + "DOCLING_PDF_BACKEND", + "rag.docling_pdf_backend", + os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"), +) + +DOCLING_TABLE_MODE = PersistentConfig( + "DOCLING_TABLE_MODE", + "rag.docling_table_mode", + os.getenv("DOCLING_TABLE_MODE", "accurate"), +) + +DOCLING_PIPELINE = PersistentConfig( + "DOCLING_PIPELINE", + "rag.docling_pipeline", + os.getenv("DOCLING_PIPELINE", "standard"), +) + DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig( "DOCLING_DO_PICTURE_DESCRIPTION", "rag.docling_do_picture_description", @@ -3097,6 +3130,12 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig( "image_generation.openai.api_base_url", os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), ) +IMAGES_OPENAI_API_VERSION = PersistentConfig( + "IMAGES_OPENAI_API_VERSION", + "image_generation.openai.api_version", + os.getenv("IMAGES_OPENAI_API_VERSION", ""), +) + IMAGES_OPENAI_API_KEY = PersistentConfig( "IMAGES_OPENAI_API_KEY", "image_generation.openai.api_key", diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index f0b26ae25c..b4fdc97d82 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -465,6 +465,19 @@ ENABLE_COMPRESSION_MIDDLEWARE = ( os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true" ) +#################################### +# OAUTH Configuration +#################################### + + +ENABLE_OAUTH_ID_TOKEN_COOKIE = ( + os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true" +) + +OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get( + "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY +) + #################################### # SCIM Configuration diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index db367ccbd0..4122cbbe0d 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -219,6 +219,15 @@ async def generate_function_chat_completion( __task__ = metadata.get("task", None) __task_body__ = metadata.get("task_body", None) + oauth_token = None + try: + oauth_token = request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + extra_params = { "__event_emitter__": __event_emitter__, "__event_call__": __event_call__, @@ -230,6 +239,7 @@ async def generate_function_chat_completion( "__files__": files, "__user__": user.model_dump() if isinstance(user, UserModel) else {}, "__metadata__": metadata, + "__oauth_token__": oauth_token, "__request__": request, } extra_params["__tools__"] = await get_tools( diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index d24bd5dcf1..a5d55f75ab 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -157,6 +157,7 @@ from open_webui.config import ( IMAGE_SIZE, IMAGE_STEPS, IMAGES_OPENAI_API_BASE_URL, + IMAGES_OPENAI_API_VERSION, IMAGES_OPENAI_API_KEY, IMAGES_GEMINI_API_BASE_URL, IMAGES_GEMINI_API_KEY, @@ -243,8 +244,13 @@ from open_webui.config import ( EXTERNAL_DOCUMENT_LOADER_API_KEY, TIKA_SERVER_URL, DOCLING_SERVER_URL, + DOCLING_DO_OCR, + DOCLING_FORCE_OCR, DOCLING_OCR_ENGINE, DOCLING_OCR_LANG, + DOCLING_PDF_BACKEND, + DOCLING_TABLE_MODE, + DOCLING_PIPELINE, DOCLING_DO_PICTURE_DESCRIPTION, DOCLING_PICTURE_DESCRIPTION_MODE, DOCLING_PICTURE_DESCRIPTION_LOCAL, @@ -591,6 +597,7 @@ app = FastAPI( ) oauth_manager = OAuthManager(app) +app.state.oauth_manager = oauth_manager app.state.instance_id = None app.state.config = AppConfig( @@ -810,8 +817,13 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL +app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR +app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG +app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND +app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE +app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL @@ -1019,6 +1031,7 @@ app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION 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 @@ -1405,6 +1418,14 @@ async def chat_completion( model_item = form_data.pop("model_item", {}) tasks = form_data.pop("background_tasks", None) + oauth_token = None + try: + oauth_token = request.app.state.oauth_manager.get_oauth_token( + user.id, request.cookies.get("oauth_session_id", None) + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + metadata = {} try: if not model_item.get("direct", False): @@ -1519,7 +1540,7 @@ async def chat_completion( try: event_emitter = get_event_emitter(metadata) await event_emitter( - {"type": "task-cancelled"}, + {"type": "chat:tasks:cancel"}, ) except Exception as e: pass @@ -1535,14 +1556,21 @@ async def chat_completion( "error": {"content": str(e)}, }, ) + + event_emitter = get_event_emitter(metadata) + await event_emitter( + { + "type": "chat:message:error", + "data": {"error": {"content": str(e)}}, + } + ) + await event_emitter( + {"type": "chat:tasks:cancel"}, + ) + except: pass - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) - if ( metadata.get("session_id") and metadata.get("chat_id") @@ -1642,8 +1670,18 @@ async def list_tasks_by_chat_id_endpoint( @app.get("/api/config") async def get_app_config(request: Request): user = None - if "token" in request.cookies: + token = None + + auth_header = request.headers.get("Authorization") + if auth_header: + cred = get_http_authorization_cred(auth_header) + if cred: + token = cred.credentials + + if not token and "token" in request.cookies: token = request.cookies.get("token") + + if token: try: data = decode_token(token) except Exception as e: diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py new file mode 100644 index 0000000000..8ead6db6d4 --- /dev/null +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -0,0 +1,52 @@ +"""Add oauth_session table + +Revision ID: 38d63c18f30f +Revises: 3af16a1c9fb6 +Create Date: 2025-09-08 14:19:59.583921 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "38d63c18f30f" +down_revision: Union[str, None] = "3af16a1c9fb6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create oauth_session table + op.create_table( + "oauth_session", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("provider", sa.Text(), nullable=False), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("expires_at", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + ) + + # Create indexes for better performance + op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"]) + op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"]) + op.create_index( + "idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"] + ) + + +def downgrade() -> None: + # Drop indexes first + op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session") + op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session") + op.drop_index("idx_oauth_session_user_id", table_name="oauth_session") + + # Drop the table + op.drop_table("oauth_session") diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 6f1511cd13..57978225d4 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -147,6 +147,15 @@ class FilesTable: with get_db() as db: return [FileModel.model_validate(file) for file in db.query(File).all()] + def check_access_by_user_id(self, id, user_id, permission="write") -> bool: + file = self.get_file_by_id(id) + if not file: + return False + if file.user_id == user_id: + return True + # Implement additional access control logic here as needed + return False + def get_files_by_ids(self, ids: list[str]) -> list[FileModel]: with get_db() as db: return [ diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 15deecbf42..c876645750 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -58,6 +58,14 @@ class FolderModel(BaseModel): class FolderForm(BaseModel): name: str data: Optional[dict] = None + meta: Optional[dict] = None + model_config = ConfigDict(extra="allow") + + +class FolderUpdateForm(BaseModel): + name: Optional[str] = None + data: Optional[dict] = None + meta: Optional[dict] = None model_config = ConfigDict(extra="allow") @@ -191,7 +199,7 @@ class FolderTable: return def update_folder_by_id_and_user_id( - self, id: str, user_id: str, form_data: FolderForm + self, id: str, user_id: str, form_data: FolderUpdateForm ) -> Optional[FolderModel]: try: with get_db() as db: @@ -222,8 +230,13 @@ class FolderTable: **form_data["data"], } - folder.updated_at = int(time.time()) + if "meta" in form_data: + folder.meta = { + **(folder.meta or {}), + **form_data["meta"], + } + folder.updated_at = int(time.time()) db.commit() return FolderModel.model_validate(folder) diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 7530573e79..2bb6d60889 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -54,6 +54,22 @@ class FunctionModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class FunctionWithValvesModel(BaseModel): + id: str + user_id: str + name: str + type: str + content: str + meta: FunctionMeta + valves: Optional[dict] = None + is_active: bool = False + is_global: bool = False + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + #################### # Forms #################### @@ -111,8 +127,8 @@ class FunctionsTable: return None def sync_functions( - self, user_id: str, functions: list[FunctionModel] - ) -> list[FunctionModel]: + self, user_id: str, functions: list[FunctionWithValvesModel] + ) -> list[FunctionWithValvesModel]: # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present. try: with get_db() as db: @@ -166,17 +182,24 @@ class FunctionsTable: except Exception: return None - def get_functions(self, active_only=False) -> list[FunctionModel]: + def get_functions( + self, active_only=False, include_valves=False + ) -> list[FunctionModel | FunctionWithValvesModel]: with get_db() as db: if active_only: + functions = db.query(Function).filter_by(is_active=True).all() + + else: + functions = db.query(Function).all() + + if include_valves: return [ - FunctionModel.model_validate(function) - for function in db.query(Function).filter_by(is_active=True).all() + FunctionWithValvesModel.model_validate(function) + for function in functions ] else: return [ - FunctionModel.model_validate(function) - for function in db.query(Function).all() + FunctionModel.model_validate(function) for function in functions ] def get_functions_by_type( diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index bed3d5542e..cfef77e237 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db from open_webui.env import SRC_LOG_LEVELS from open_webui.models.files import FileMetadataResponse +from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse @@ -128,11 +129,18 @@ class KnowledgeTable: def get_knowledge_bases(self) -> list[KnowledgeUserModel]: with get_db() as db: - knowledge_bases = [] - for knowledge in ( + all_knowledge = ( db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() - ): - user = Users.get_user_by_id(knowledge.user_id) + ) + + user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) + + users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users_dict = {user.id: user for user in users} + + knowledge_bases = [] + for knowledge in all_knowledge: + user = users_dict.get(knowledge.user_id) knowledge_bases.append( KnowledgeUserModel.model_validate( { @@ -143,15 +151,27 @@ class KnowledgeTable: ) return knowledge_bases + def check_access_by_user_id(self, id, user_id, permission="write") -> bool: + knowledge = self.get_knowledge_by_id(id) + if not knowledge: + return False + if knowledge.user_id == user_id: + return True + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + return has_access(user_id, permission, knowledge.access_control, user_group_ids) + def get_knowledge_bases_by_user_id( self, user_id: str, permission: str = "write" ) -> list[KnowledgeUserModel]: knowledge_bases = self.get_knowledge_bases() + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ knowledge_base for knowledge_base in knowledge_bases if knowledge_base.user_id == user_id - or has_access(user_id, permission, knowledge_base.access_control) + or has_access( + user_id, permission, knowledge_base.access_control, user_group_ids + ) ] def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 1a29b86eae..93dafe0f05 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -5,6 +5,7 @@ from typing import Optional from open_webui.internal.db import Base, JSONField, get_db from open_webui.env import SRC_LOG_LEVELS +from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse @@ -175,9 +176,16 @@ class ModelsTable: def get_models(self) -> list[ModelUserResponse]: with get_db() as db: + all_models = db.query(Model).filter(Model.base_model_id != None).all() + + user_ids = list(set(model.user_id for model in all_models)) + + users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users_dict = {user.id: user for user in users} + models = [] - for model in db.query(Model).filter(Model.base_model_id != None).all(): - user = Users.get_user_by_id(model.user_id) + for model in all_models: + user = users_dict.get(model.user_id) models.append( ModelUserResponse.model_validate( { @@ -199,11 +207,12 @@ class ModelsTable: self, user_id: str, permission: str = "write" ) -> list[ModelUserResponse]: models = self.get_models() + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ model for model in models if model.user_id == user_id - or has_access(user_id, permission, model.access_control) + or has_access(user_id, permission, model.access_control, user_group_ids) ] def get_model_by_id(self, id: str) -> Optional[ModelModel]: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index ce3b9f2e20..c720ff80a4 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -4,6 +4,7 @@ import uuid from typing import Optional from open_webui.internal.db import Base, get_db +from open_webui.models.groups import Groups from open_webui.utils.access_control import has_access from open_webui.models.users import Users, UserResponse @@ -105,11 +106,12 @@ class NoteTable: self, user_id: str, permission: str = "write" ) -> list[NoteModel]: notes = self.get_notes() + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ note for note in notes if note.user_id == user_id - or has_access(user_id, permission, note.access_control) + or has_access(user_id, permission, note.access_control, user_group_ids) ] def get_note_by_id(self, id: str) -> Optional[NoteModel]: diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py new file mode 100644 index 0000000000..9fd5335ce5 --- /dev/null +++ b/backend/open_webui/models/oauth_sessions.py @@ -0,0 +1,246 @@ +import time +import logging +import uuid +from typing import Optional, List +import base64 +import hashlib +import json + +from cryptography.fernet import Fernet + +from open_webui.internal.db import Base, get_db +from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, String, Text, Index + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +#################### +# DB MODEL +#################### + + +class OAuthSession(Base): + __tablename__ = "oauth_session" + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + provider = Column(Text, nullable=False) + token = Column( + Text, nullable=False + ) # JSON with access_token, id_token, refresh_token + expires_at = Column(BigInteger, nullable=False) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + # Add indexes for better performance + __table_args__ = ( + Index("idx_oauth_session_user_id", "user_id"), + Index("idx_oauth_session_expires_at", "expires_at"), + Index("idx_oauth_session_user_provider", "user_id", "provider"), + ) + + +class OAuthSessionModel(BaseModel): + id: str + user_id: str + provider: str + token: dict + expires_at: int # timestamp in epoch + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class OAuthSessionResponse(BaseModel): + id: str + user_id: str + provider: str + expires_at: int + + +class OAuthSessionTable: + def __init__(self): + self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY + if not self.encryption_key: + raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set") + + # check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes) + if len(self.encryption_key) != 44: + key_bytes = hashlib.sha256(self.encryption_key.encode()).digest() + self.encryption_key = base64.urlsafe_b64encode(key_bytes) + else: + self.encryption_key = self.encryption_key.encode() + + try: + self.fernet = Fernet(self.encryption_key) + except Exception as e: + log.error(f"Error initializing Fernet with provided key: {e}") + raise + + def _encrypt_token(self, token) -> str: + """Encrypt OAuth tokens for storage""" + try: + token_json = json.dumps(token) + encrypted = self.fernet.encrypt(token_json.encode()).decode() + return encrypted + except Exception as e: + log.error(f"Error encrypting tokens: {e}") + raise + + def _decrypt_token(self, token: str): + """Decrypt OAuth tokens from storage""" + try: + decrypted = self.fernet.decrypt(token.encode()).decode() + return json.loads(decrypted) + except Exception as e: + log.error(f"Error decrypting tokens: {e}") + raise + + def create_session( + self, + user_id: str, + provider: str, + token: dict, + ) -> Optional[OAuthSessionModel]: + """Create a new OAuth session""" + try: + with get_db() as db: + current_time = int(time.time()) + id = str(uuid.uuid4()) + + result = OAuthSession( + **{ + "id": id, + "user_id": user_id, + "provider": provider, + "token": self._encrypt_token(token), + "expires_at": token.get("expires_at"), + "created_at": current_time, + "updated_at": current_time, + } + ) + + db.add(result) + db.commit() + db.refresh(result) + + if result: + result.token = token # Return decrypted token + return OAuthSessionModel.model_validate(result) + else: + return None + except Exception as e: + log.error(f"Error creating OAuth session: {e}") + return None + + def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]: + """Get OAuth session by ID""" + try: + with get_db() as db: + session = db.query(OAuthSession).filter_by(id=session_id).first() + if session: + session.token = self._decrypt_token(session.token) + return OAuthSessionModel.model_validate(session) + + return None + except Exception as e: + log.error(f"Error getting OAuth session by ID: {e}") + return None + + def get_session_by_id_and_user_id( + self, session_id: str, user_id: str + ) -> Optional[OAuthSessionModel]: + """Get OAuth session by ID and user ID""" + try: + with get_db() as db: + session = ( + db.query(OAuthSession) + .filter_by(id=session_id, user_id=user_id) + .first() + ) + if session: + session.token = self._decrypt_token(session.token) + return OAuthSessionModel.model_validate(session) + + return None + except Exception as e: + log.error(f"Error getting OAuth session by ID: {e}") + return None + + def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]: + """Get all OAuth sessions for a user""" + try: + with get_db() as db: + sessions = db.query(OAuthSession).filter_by(user_id=user_id).all() + + results = [] + for session in sessions: + session.token = self._decrypt_token(session.token) + results.append(OAuthSessionModel.model_validate(session)) + + return results + + except Exception as e: + log.error(f"Error getting OAuth sessions by user ID: {e}") + return [] + + def update_session_by_id( + self, session_id: str, token: dict + ) -> Optional[OAuthSessionModel]: + """Update OAuth session tokens""" + try: + with get_db() as db: + current_time = int(time.time()) + + db.query(OAuthSession).filter_by(id=session_id).update( + { + "token": self._encrypt_token(token), + "expires_at": token.get("expires_at"), + "updated_at": current_time, + } + ) + db.commit() + session = db.query(OAuthSession).filter_by(id=session_id).first() + + if session: + session.token = self._decrypt_token(session.token) + return OAuthSessionModel.model_validate(session) + + return None + except Exception as e: + log.error(f"Error updating OAuth session tokens: {e}") + return None + + def delete_session_by_id(self, session_id: str) -> bool: + """Delete an OAuth session""" + try: + with get_db() as db: + result = db.query(OAuthSession).filter_by(id=session_id).delete() + db.commit() + return result > 0 + except Exception as e: + log.error(f"Error deleting OAuth session: {e}") + return False + + def delete_sessions_by_user_id(self, user_id: str) -> bool: + """Delete all OAuth sessions for a user""" + try: + with get_db() as db: + result = db.query(OAuthSession).filter_by(user_id=user_id).delete() + db.commit() + return True + except Exception as e: + log.error(f"Error deleting OAuth sessions by user ID: {e}") + return False + + +OAuthSessions = OAuthSessionTable() diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 8ef4cd2bec..7502f34ccd 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -2,6 +2,7 @@ import time from typing import Optional from open_webui.internal.db import Base, get_db +from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse from pydantic import BaseModel, ConfigDict @@ -103,10 +104,16 @@ class PromptsTable: def get_prompts(self) -> list[PromptUserResponse]: with get_db() as db: - prompts = [] + all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all() - for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all(): - user = Users.get_user_by_id(prompt.user_id) + user_ids = list(set(prompt.user_id for prompt in all_prompts)) + + users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users_dict = {user.id: user for user in users} + + prompts = [] + for prompt in all_prompts: + user = users_dict.get(prompt.user_id) prompts.append( PromptUserResponse.model_validate( { @@ -122,12 +129,13 @@ class PromptsTable: self, user_id: str, permission: str = "write" ) -> list[PromptUserResponse]: prompts = self.get_prompts() + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ prompt for prompt in prompts if prompt.user_id == user_id - or has_access(user_id, permission, prompt.access_control) + or has_access(user_id, permission, prompt.access_control, user_group_ids) ] def update_prompt_by_command( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 7f1409a900..3a47fa008d 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -4,6 +4,8 @@ from typing import Optional from open_webui.internal.db import Base, JSONField, get_db from open_webui.models.users import Users, UserResponse +from open_webui.models.groups import Groups + from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text, JSON @@ -144,9 +146,16 @@ class ToolsTable: def get_tools(self) -> list[ToolUserModel]: with get_db() as db: + all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all() + + user_ids = list(set(tool.user_id for tool in all_tools)) + + users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users_dict = {user.id: user for user in users} + tools = [] - for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all(): - user = Users.get_user_by_id(tool.user_id) + for tool in all_tools: + user = users_dict.get(tool.user_id) tools.append( ToolUserModel.model_validate( { @@ -161,12 +170,13 @@ class ToolsTable: self, user_id: str, permission: str = "write" ) -> list[ToolUserModel]: tools = self.get_tools() + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ tool for tool in tools if tool.user_id == user_id - or has_access(user_id, permission, tool.access_control) + or has_access(user_id, permission, tool.access_control, user_group_ids) ] def get_tool_valves_by_id(self, id: str) -> Optional[dict]: diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index c0ccd72432..1be2ca3f24 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -1,6 +1,7 @@ import requests import logging, os from typing import Iterator, List, Union +from urllib.parse import quote from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document @@ -37,7 +38,7 @@ class ExternalDocumentLoader(BaseLoader): headers["Authorization"] = f"Bearer {self.api_key}" try: - headers["X-Filename"] = os.path.basename(self.file_path) + headers["X-Filename"] = quote(os.path.basename(self.file_path)) except: pass diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 9b90dca041..45f3d8c941 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -148,7 +148,7 @@ class DoclingLoader: ) } - params = {"image_export_mode": "placeholder", "table_mode": "accurate"} + params = {"image_export_mode": "placeholder"} if self.params: if self.params.get("do_picture_description"): @@ -174,7 +174,15 @@ class DoclingLoader: self.params.get("picture_description_api", {}) ) - if self.params.get("ocr_engine") and self.params.get("ocr_lang"): + params["do_ocr"] = self.params.get("do_ocr") + + params["force_ocr"] = self.params.get("force_ocr") + + if ( + self.params.get("do_ocr") + and self.params.get("ocr_engine") + and self.params.get("ocr_lang") + ): params["ocr_engine"] = self.params.get("ocr_engine") params["ocr_lang"] = [ lang.strip() @@ -182,6 +190,15 @@ class DoclingLoader: if lang.strip() ] + if self.params.get("pdf_backend"): + params["pdf_backend"] = self.params.get("pdf_backend") + + if self.params.get("table_mode"): + params["table_mode"] = self.params.get("table_mode") + + if self.params.get("pipeline"): + params["pipeline"] = self.params.get("pipeline") + endpoint = f"{self.url}/v1/convert/file" r = requests.post(endpoint, files=files, data=params) diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index be5e533588..360ef0a6c7 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -98,10 +98,9 @@ class YoutubeLoader: else: youtube_proxies = None + transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies) try: - transcript_list = YouTubeTranscriptApi.list_transcripts( - self.video_id, proxies=youtube_proxies - ) + transcript_list = transcript_api.list(self.video_id) except Exception as e: log.exception("Loading YouTube transcript failed") return [] diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 100c92c6c0..dead8458cb 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -128,14 +128,13 @@ def query_doc_with_hybrid_search( log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") return {"documents": [], "metadatas": [], "distances": []} - # BM_25 required only if weight is greater than 0 - if hybrid_bm25_weight > 0: - log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") - bm25_retriever = BM25Retriever.from_texts( - texts=collection_result.documents[0], - metadatas=collection_result.metadatas[0], - ) - bm25_retriever.k = k + log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") + + bm25_retriever = BM25Retriever.from_texts( + texts=collection_result.documents[0], + metadatas=collection_result.metadatas[0], + ) + bm25_retriever.k = k vector_search_retriever = VectorSearchRetriever( collection_name=collection_name, @@ -343,22 +342,18 @@ def query_collection_with_hybrid_search( # Fetch collection data once per collection sequentially # Avoid fetching the same data multiple times later collection_results = {} - # Only retrieve entire collection if bm_25 calculation is required - if hybrid_bm25_weight > 0: - for collection_name in collection_names: - try: - log.debug( - f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}" - ) - collection_results[collection_name] = VECTOR_DB_CLIENT.get( - collection_name=collection_name - ) - except Exception as e: - log.exception(f"Failed to fetch collection {collection_name}: {e}") - collection_results[collection_name] = None - else: - for collection_name in collection_names: - collection_results[collection_name] = [] + for collection_name in collection_names: + try: + log.debug( + f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}" + ) + collection_results[collection_name] = VECTOR_DB_CLIENT.get( + collection_name=collection_name + ) + except Exception as e: + log.exception(f"Failed to fetch collection {collection_name}: {e}") + collection_results[collection_name] = None + log.info( f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." ) @@ -493,17 +488,18 @@ def get_sources_from_items( if item.get("type") == "text": # Raw Text - # Used during temporary chat file uploads + # Used during temporary chat file uploads or web page & youtube attachements - if item.get("file"): + if item.get("collection_name"): + # If item has a collection name, use it + collection_names.append(item.get("collection_name")) + elif item.get("file"): # if item has file data, use it query_result = { "documents": [ [item.get("file", {}).get("data", {}).get("content")] ], - "metadatas": [ - [item.get("file", {}).get("data", {}).get("meta", {})] - ], + "metadatas": [[item.get("file", {}).get("meta", {})]], } else: # Fallback to item content diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index d978f0c824..06c1698cdd 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -37,6 +37,7 @@ from open_webui.retrieval.vector.main import ( from open_webui.config import ( PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH, + PGVECTOR_CREATE_EXTENSION, PGVECTOR_PGCRYPTO, PGVECTOR_PGCRYPTO_KEY, PGVECTOR_POOL_SIZE, @@ -112,18 +113,19 @@ class PgvectorClient(VectorDBBase): try: # Ensure the pgvector extension is available # Use a conditional check to avoid permission issues on Azure PostgreSQL - self.session.execute( - text( - """ - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN - CREATE EXTENSION IF NOT EXISTS vector; - END IF; - END $$; - """ + if PGVECTOR_CREATE_EXTENSION: + self.session.execute( + text( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN + CREATE EXTENSION IF NOT EXISTS vector; + END IF; + END $$; + """ + ) ) - ) if PGVECTOR_PGCRYPTO: # Ensure the pgcrypto extension is available for encryption diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index bf9b01a39f..5ba27ee8f0 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader): async with session.get( url, **(self.requests_kwargs | kwargs), + allow_redirects=False, ) as response: if self.raise_for_status: response.raise_for_status() diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index cc5711569d..4d50ee9e7e 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -4,7 +4,6 @@ import logging import os import uuid from functools import lru_cache -from pathlib import Path from pydub import AudioSegment from pydub.silence import split_on_silence from concurrent.futures import ThreadPoolExecutor @@ -15,7 +14,7 @@ import aiohttp import aiofiles import requests import mimetypes -from urllib.parse import quote +from urllib.parse import urljoin, quote from fastapi import ( Depends, @@ -338,7 +337,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: r = await session.post( - url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", + url=urljoin( + request.app.state.config.TTS_OPENAI_API_BASE_URL, + "/audio/speech", + ), json=payload, headers={ "Content-Type": "application/json", @@ -466,8 +468,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: async with session.post( - (base_url or f"https://{region}.tts.speech.microsoft.com") - + "/cognitiveservices/v1", + urljoin( + base_url or f"https://{region}.tts.speech.microsoft.com", + "/cognitiveservices/v1", + ), headers={ "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, "Content-Type": "application/ssml+xml", diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index b8670edeaa..e3271250c1 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -19,6 +19,7 @@ from open_webui.models.auths import ( ) from open_webui.models.users import Users, UpdateProfileForm from open_webui.models.groups import Groups +from open_webui.models.oauth_sessions import OAuthSessions from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( @@ -676,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm): async def signout(request: Request, response: Response): response.delete_cookie("token") response.delete_cookie("oui-session") + response.delete_cookie("oauth_id_token") - if ENABLE_OAUTH_SIGNUP.value: - oauth_id_token = request.cookies.get("oauth_id_token") - if oauth_id_token and OPENID_PROVIDER_URL.value: + oauth_session_id = request.cookies.get("oauth_session_id") + if oauth_session_id: + response.delete_cookie("oauth_session_id") + + session = OAuthSessions.get_session_by_id(oauth_session_id) + oauth_server_metadata_url = ( + request.app.state.oauth_manager.get_server_metadata_url(session.provider) + if session + else None + ) or OPENID_PROVIDER_URL.value + + if session and oauth_server_metadata_url: + oauth_id_token = session.token.get("id_token") try: async with ClientSession(trust_env=True) as session: - async with session.get(OPENID_PROVIDER_URL.value) as resp: - if resp.status == 200: - openid_data = await resp.json() + async with session.get(oauth_server_metadata_url) as r: + if r.status == 200: + openid_data = await r.json() logout_url = openid_data.get("end_session_endpoint") - if logout_url: - response.delete_cookie("oauth_id_token") + if logout_url: return JSONResponse( status_code=200, content={ @@ -703,15 +714,14 @@ async def signout(request: Request, response: Response): headers=response.headers, ) else: - raise HTTPException( - status_code=resp.status, - detail="Failed to fetch OpenID configuration", - ) + raise Exception("Failed to fetch OpenID configuration") + except Exception as e: log.error(f"OpenID signout error: {str(e)}") raise HTTPException( status_code=500, detail="Failed to sign out from the OpenID provider.", + headers=response.headers, ) if WEBUI_AUTH_SIGNOUT_REDIRECT_URL: diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index d08c5396ce..778fbdec27 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -411,25 +411,28 @@ async def get_file_process_status( MAX_FILE_PROCESSING_DURATION = 3600 * 2 async def event_stream(file_item): - for _ in range(MAX_FILE_PROCESSING_DURATION): - file_item = Files.get_file_by_id(file_item.id) - if file_item: - data = file_item.model_dump().get("data", {}) - status = data.get("status") + if file_item: + for _ in range(MAX_FILE_PROCESSING_DURATION): + file_item = Files.get_file_by_id(file_item.id) + if file_item: + data = file_item.model_dump().get("data", {}) + status = data.get("status") - if status: - event = {"status": status} - if status == "failed": - event["error"] = data.get("error") + if status: + event = {"status": status} + if status == "failed": + event["error"] = data.get("error") - yield f"data: {json.dumps(event)}\n\n" - if status in ("completed", "failed"): + yield f"data: {json.dumps(event)}\n\n" + if status in ("completed", "failed"): + break + else: + # Legacy break - else: - # Legacy - break - await asyncio.sleep(0.5) + await asyncio.sleep(0.5) + else: + yield f"data: {json.dumps({'status': 'not_found'})}\n\n" return StreamingResponse( event_stream(file), diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index e419989e46..36dbfee5c5 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -10,10 +10,14 @@ import mimetypes from open_webui.models.folders import ( FolderForm, + FolderUpdateForm, FolderModel, Folders, ) from open_webui.models.chats import Chats +from open_webui.models.files import Files +from open_webui.models.knowledge import Knowledges + from open_webui.config import UPLOAD_DIR from open_webui.env import SRC_LOG_LEVELS @@ -44,6 +48,31 @@ router = APIRouter() async def get_folders(user=Depends(get_verified_user)): folders = Folders.get_folders_by_user_id(user.id) + # Verify folder data integrity + for folder in folders: + if folder.data: + if "files" in folder.data: + valid_files = [] + for file in folder.data["files"]: + + if file.get("type") == "file": + if Files.check_access_by_user_id( + file.get("id"), user.id, "read" + ): + valid_files.append(file) + elif file.get("type") == "collection": + if Knowledges.check_access_by_user_id( + file.get("id"), user.id, "read" + ): + valid_files.append(file) + else: + valid_files.append(file) + + folder.data["files"] = valid_files + Folders.update_folder_by_id_and_user_id( + folder.id, user.id, FolderUpdateForm(data=folder.data) + ) + return [ { **folder.model_dump(), @@ -113,22 +142,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/update") async def update_folder_name_by_id( - id: str, form_data: FolderForm, user=Depends(get_verified_user) + id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user) ): folder = Folders.get_folder_by_id_and_user_id(id, user.id) if folder: - existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - folder.parent_id, user.id, form_data.name - ) - if existing_folder and existing_folder.id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + + if form_data.name is not None: + # Check if folder with same name exists + existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( + folder.parent_id, user.id, form_data.name ) + if existing_folder and existing_folder.id != id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + ) try: folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data) - return folder except Exception as e: log.exception(e) diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index b5beb96cf0..9ef6915709 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -10,6 +10,7 @@ from open_webui.models.functions import ( FunctionForm, FunctionModel, FunctionResponse, + FunctionWithValvesModel, Functions, ) from open_webui.utils.plugin import ( @@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)): ############################ -@router.get("/export", response_model=list[FunctionModel]) -async def get_functions(user=Depends(get_admin_user)): - return Functions.get_functions() +@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel]) +async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)): + return Functions.get_functions(include_valves=include_valves) ############################ @@ -132,10 +133,10 @@ async def load_function_from_url( class SyncFunctionsForm(BaseModel): - functions: list[FunctionModel] = [] + functions: list[FunctionWithValvesModel] = [] -@router.post("/sync", response_model=list[FunctionModel]) +@router.post("/sync", response_model=list[FunctionWithValvesModel]) async def sync_functions( request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) ): diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 9311cb6e2c..802a3e9924 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -48,6 +48,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)): "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": { @@ -72,6 +73,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)): class OpenAIConfigForm(BaseModel): OPENAI_API_BASE_URL: str + OPENAI_API_VERSION: str OPENAI_API_KEY: str @@ -119,6 +121,9 @@ async def update_config( 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 = ( @@ -165,6 +170,7 @@ async def update_config( "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": { @@ -544,10 +550,16 @@ async def image_generations( ), } + api_version_query_param = "" + if request.app.state.config.IMAGES_OPENAI_API_VERSION: + api_version_query_param = ( + f"?api-version={request.app.state.config.IMAGES_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_OPENAI_API_BASE_URL}/images/generations", + url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations{api_version_query_param}", json=data, headers=headers, ) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index e9ba9c39ad..71722d706e 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel -from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi import APIRouter, Depends, HTTPException, status, Request, Query import logging from open_webui.models.knowledge import ( @@ -151,6 +151,18 @@ async def create_new_knowledge( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Check if user can share publicly + if ( + user.role != "admin" + and form_data.access_control == None + and not has_permission( + user.id, + "sharing.public_knowledge", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_control = {} + knowledge = Knowledges.insert_new_knowledge(user.id, form_data) if knowledge: @@ -285,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse]) async def update_knowledge_by_id( + request: Request, id: str, form_data: KnowledgeForm, user=Depends(get_verified_user), @@ -306,10 +319,22 @@ async def update_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Check if user can share publicly + if ( + user.role != "admin" + and form_data.access_control == None + and not has_permission( + user.id, + "sharing.public_knowledge", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_control = {} + knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -492,6 +517,7 @@ def update_file_from_knowledge_by_id( def remove_file_from_knowledge_by_id( id: str, form_data: KnowledgeFileIdForm, + delete_file: bool = Query(True), user=Depends(get_verified_user), ): knowledge = Knowledges.get_knowledge_by_id(id=id) @@ -528,18 +554,19 @@ def remove_file_from_knowledge_by_id( log.debug(e) pass - try: - # Remove the file's collection from vector database - file_collection = f"file-{form_data.file_id}" - if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) - except Exception as e: - log.debug("This was most likely caused by bypassing embedding processing") - log.debug(e) - pass + if delete_file: + try: + # Remove the file's collection from vector database + file_collection = f"file-{form_data.file_id}" + if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + except Exception as e: + log.debug("This was most likely caused by bypassing embedding processing") + log.debug(e) + pass - # Delete file from database - Files.delete_file_by_id(form_data.file_id) + # Delete file from database + Files.delete_file_by_id(form_data.file_id) if knowledge: data = knowledge.data or {} diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 1a6b75c555..8dadf3523a 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -340,7 +340,10 @@ def merge_ollama_models_lists(model_lists): return list(merged_models.values()) -@cached(ttl=MODELS_CACHE_TTL) +@cached( + ttl=MODELS_CACHE_TTL, + key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models", +) async def get_all_models(request: Request, user: UserModel = None): log.info("get_all_models()") if request.app.state.config.ENABLE_OLLAMA_API: diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 7ba0c5f68a..184f47038d 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -119,6 +119,74 @@ def openai_reasoning_model_handler(payload): return payload +def get_headers_and_cookies( + request: Request, + url, + key=None, + config=None, + metadata: Optional[dict] = None, + user: UserModel = None, +): + cookies = {} + headers = { + "Content-Type": "application/json", + **( + { + "HTTP-Referer": "https://openwebui.com/", + "X-Title": "Open WebUI", + } + if "openrouter.ai" in url + else {} + ), + **( + { + "X-OpenWebUI-User-Name": quote(user.name, safe=" "), + "X-OpenWebUI-User-Id": user.id, + "X-OpenWebUI-User-Email": user.email, + "X-OpenWebUI-User-Role": user.role, + **( + {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")} + if metadata and metadata.get("chat_id") + else {} + ), + } + if ENABLE_FORWARD_USER_INFO_HEADERS + else {} + ), + } + + token = None + auth_type = config.get("auth_type") + + if auth_type == "bearer" or auth_type is None: + # Default to bearer if not specified + token = f"{key}" + elif auth_type == "none": + token = None + elif auth_type == "session": + cookies = request.cookies + token = request.state.token.credentials + elif auth_type == "system_oauth": + cookies = request.cookies + + oauth_token = None + try: + oauth_token = request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + + if oauth_token: + token = f"{oauth_token.get('access_token', '')}" + + if token: + headers["Authorization"] = f"Bearer {token}" + + return headers, cookies + + ########################################## # # API routes @@ -210,34 +278,23 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) url = request.app.state.config.OPENAI_API_BASE_URLS[idx] + key = request.app.state.config.OPENAI_API_KEYS[idx] + api_config = request.app.state.config.OPENAI_API_CONFIGS.get( + str(idx), + request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support + ) + + headers, cookies = get_headers_and_cookies( + request, url, key, api_config, user=user + ) r = None try: r = requests.post( url=f"{url}/audio/speech", data=body, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}", - **( - { - "HTTP-Referer": "https://openwebui.com/", - "X-Title": "Open WebUI", - } - if "openrouter.ai" in url - else {} - ), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - }, + headers=headers, + cookies=cookies, stream=True, ) @@ -401,7 +458,10 @@ async def get_filtered_models(models, user): return filtered_models -@cached(ttl=MODELS_CACHE_TTL) +@cached( + ttl=MODELS_CACHE_TTL, + key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models", +) async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: log.info("get_all_models()") @@ -489,19 +549,9 @@ async def get_models( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - headers = { - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - } + headers, cookies = get_headers_and_cookies( + request, url, key, api_config, user=user + ) if api_config.get("azure", False): models = { @@ -509,11 +559,10 @@ async def get_models( "object": "list", } else: - headers["Authorization"] = f"Bearer {key}" - async with session.get( f"{url}/models", headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: @@ -572,7 +621,9 @@ class ConnectionVerificationForm(BaseModel): @router.post("/verify") async def verify_connection( - form_data: ConnectionVerificationForm, user=Depends(get_admin_user) + request: Request, + form_data: ConnectionVerificationForm, + user=Depends(get_admin_user), ): url = form_data.url key = form_data.key @@ -584,19 +635,9 @@ async def verify_connection( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - headers = { - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - } + headers, cookies = get_headers_and_cookies( + request, url, key, api_config, user=user + ) if api_config.get("azure", False): headers["api-key"] = key @@ -605,6 +646,7 @@ async def verify_connection( async with session.get( url=f"{url}/openai/models?api-version={api_version}", headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: try: @@ -624,11 +666,10 @@ async def verify_connection( return response_data else: - headers["Authorization"] = f"Bearer {key}" - async with session.get( f"{url}/models", headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: try: @@ -836,32 +877,9 @@ async def generate_chat_completion( convert_logit_bias_input_to_json(payload["logit_bias"]) ) - headers = { - "Content-Type": "application/json", - **( - { - "HTTP-Referer": "https://openwebui.com/", - "X-Title": "Open WebUI", - } - if "openrouter.ai" in url - else {} - ), - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - **( - {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")} - if metadata and metadata.get("chat_id") - else {} - ), - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - } + headers, cookies = get_headers_and_cookies( + request, url, key, api_config, metadata, user=user + ) if api_config.get("azure", False): api_version = api_config.get("api_version", "2023-03-15-preview") @@ -871,7 +889,6 @@ async def generate_chat_completion( request_url = f"{request_url}/chat/completions?api-version={api_version}" else: request_url = f"{url}/chat/completions" - headers["Authorization"] = f"Bearer {key}" payload = json.dumps(payload) @@ -890,6 +907,7 @@ async def generate_chat_completion( url=request_url, data=payload, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) @@ -951,31 +969,27 @@ async def embeddings(request: Request, form_data: dict, user): models = request.app.state.OPENAI_MODELS if model_id in models: idx = models[model_id]["urlIdx"] + url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] + api_config = request.app.state.config.OPENAI_API_CONFIGS.get( + str(idx), + request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support + ) + r = None session = None streaming = False + + headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user) try: session = aiohttp.ClientSession(trust_env=True) r = await session.request( method="POST", url=f"{url}/embeddings", data=body, - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS and user - else {} - ), - }, + headers=headers, + cookies=cookies, ) if "text/event-stream" in r.headers.get("Content-Type", ""): @@ -1037,19 +1051,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): streaming = False try: - headers = { - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": quote(user.name, safe=" "), - "X-OpenWebUI-User-Id": user.id, - "X-OpenWebUI-User-Email": user.email, - "X-OpenWebUI-User-Role": user.role, - } - if ENABLE_FORWARD_USER_INFO_HEADERS - else {} - ), - } + headers, cookies = get_headers_and_cookies( + request, url, key, api_config, user=user + ) if api_config.get("azure", False): api_version = api_config.get("api_version", "2023-03-15-preview") @@ -1062,7 +1066,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): request_url = f"{url}/{path}?api-version={api_version}" else: - headers["Authorization"] = f"Bearer {key}" request_url = f"{url}/{path}" session = aiohttp.ClientSession(trust_env=True) @@ -1071,6 +1074,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): url=request_url, data=body, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index fdb7786258..dd5e2d5bc4 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -426,8 +426,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, + "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, + "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND, + "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE, + "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE, "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, @@ -596,8 +601,13 @@ class ConfigForm(BaseModel): TIKA_SERVER_URL: Optional[str] = None DOCLING_SERVER_URL: Optional[str] = None + DOCLING_DO_OCR: Optional[bool] = None + DOCLING_FORCE_OCR: Optional[bool] = None DOCLING_OCR_ENGINE: Optional[str] = None DOCLING_OCR_LANG: Optional[str] = None + DOCLING_PDF_BACKEND: Optional[str] = None + DOCLING_TABLE_MODE: Optional[str] = None + DOCLING_PIPELINE: Optional[str] = None DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None @@ -767,6 +777,16 @@ async def update_rag_config( if form_data.DOCLING_SERVER_URL is not None else request.app.state.config.DOCLING_SERVER_URL ) + request.app.state.config.DOCLING_DO_OCR = ( + form_data.DOCLING_DO_OCR + if form_data.DOCLING_DO_OCR is not None + else request.app.state.config.DOCLING_DO_OCR + ) + request.app.state.config.DOCLING_FORCE_OCR = ( + form_data.DOCLING_FORCE_OCR + if form_data.DOCLING_FORCE_OCR is not None + else request.app.state.config.DOCLING_FORCE_OCR + ) request.app.state.config.DOCLING_OCR_ENGINE = ( form_data.DOCLING_OCR_ENGINE if form_data.DOCLING_OCR_ENGINE is not None @@ -777,7 +797,21 @@ async def update_rag_config( if form_data.DOCLING_OCR_LANG is not None else request.app.state.config.DOCLING_OCR_LANG ) - + request.app.state.config.DOCLING_PDF_BACKEND = ( + form_data.DOCLING_PDF_BACKEND + if form_data.DOCLING_PDF_BACKEND is not None + else request.app.state.config.DOCLING_PDF_BACKEND + ) + request.app.state.config.DOCLING_TABLE_MODE = ( + form_data.DOCLING_TABLE_MODE + if form_data.DOCLING_TABLE_MODE is not None + else request.app.state.config.DOCLING_TABLE_MODE + ) + request.app.state.config.DOCLING_PIPELINE = ( + form_data.DOCLING_PIPELINE + if form_data.DOCLING_PIPELINE is not None + else request.app.state.config.DOCLING_PIPELINE + ) request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = ( form_data.DOCLING_DO_PICTURE_DESCRIPTION if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None @@ -1062,8 +1096,13 @@ async def update_rag_config( "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, + "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, + "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, + "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND, + "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE, + "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE, "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, @@ -1453,8 +1492,13 @@ def process_file( TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, DOCLING_PARAMS={ + "do_ocr": request.app.state.config.DOCLING_DO_OCR, + "force_ocr": request.app.state.config.DOCLING_FORCE_OCR, "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE, "ocr_lang": request.app.state.config.DOCLING_OCR_LANG, + "pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND, + "table_mode": request.app.state.config.DOCLING_TABLE_MODE, + "pipeline": request.app.state.config.DOCLING_PIPELINE, "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, @@ -1945,6 +1989,8 @@ async def process_web_search( ): urls = [] + result_items = [] + try: logging.info( f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" @@ -1966,6 +2012,7 @@ async def process_web_search( if result: for item in result: if item and item.link: + result_items.append(item) urls.append(item.link) urls = list(dict.fromkeys(urls)) @@ -2010,12 +2057,16 @@ async def process_web_search( urls = [ doc.metadata.get("source") for doc in docs if doc.metadata.get("source") ] # only keep the urls returned by the loader + result_items = [ + dict(item) for item in result_items if item.link in urls + ] # only keep the search results that have been loaded if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: return { "status": True, "collection_name": None, "filenames": urls, + "items": result_items, "docs": [ { "content": doc.page_content, @@ -2048,6 +2099,7 @@ async def process_web_search( return { "status": True, "collection_names": [collection_name], + "items": result_items, "filenames": urls, "loaded_count": len(docs), } diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index c017233765..5f82e7f1bd 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -4,6 +4,7 @@ from typing import Optional import time import re import aiohttp +from open_webui.models.groups import Groups from pydantic import BaseModel, HttpUrl from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -71,11 +72,12 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): # Admin can see all tools return tools else: + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} tools = [ tool for tool in tools if tool.user_id == user.id - or has_access(user.id, "read", tool.access_control) + or has_access(user.id, "read", tool.access_control, user_group_ids) ] return tools diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 4d2539a18e..5b331dce73 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -10,6 +10,8 @@ from pydantic import BaseModel from open_webui.models.auths import Auths +from open_webui.models.oauth_sessions import OAuthSessions + from open_webui.models.groups import Groups from open_webui.models.chats import Chats from open_webui.models.users import ( @@ -340,6 +342,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): ) +@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict]) +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: + return sessions + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + ############################ # GetUserProfileImageById ############################ diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 714c532fca..a15e8ac146 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -153,9 +153,9 @@ async def stop_task(redis, task_id: str): # Optionally check if task_id still in Redis a few moments later for feedback? return {"status": True, "message": f"Stop signal sent for {task_id}"} - task = tasks.pop(task_id) + task = tasks.pop(task_id, None) if not task: - raise ValueError(f"Task with ID {task_id} not found.") + return {"status": False, "message": f"Task with ID {task_id} not found."} task.cancel() # Request task cancellation try: diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py index c36d861ad6..1529773c44 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, List, Dict, Any +from typing import Optional, Set, Union, List, Dict, Any from open_webui.models.users import Users, UserModel from open_webui.models.groups import Groups @@ -109,12 +109,15 @@ def has_access( user_id: str, type: str = "write", access_control: Optional[dict] = None, + user_group_ids: Optional[Set[str]] = None, ) -> bool: if access_control is None: return type == "read" - user_groups = Groups.get_groups_by_member_id(user_id) - user_group_ids = [group.id for group in user_groups] + if user_group_ids is None: + user_groups = Groups.get_groups_by_member_id(user_id) + user_group_ids = {group.id for group in user_groups} + permission_access = access_control.get(type, {}) permitted_group_ids = permission_access.get("group_ids", []) permitted_user_ids = permission_access.get("user_ids", []) diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 228dd3e30a..f941ef9263 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -261,55 +261,67 @@ def get_current_user( return user # auth by jwt token - try: - data = decode_token(token) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", - ) - if data is not None and "id" in data: - user = Users.get_user_by_id(data["id"]) - if user is None: + try: + try: + data = decode_token(token) + except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.INVALID_TOKEN, + detail="Invalid token", ) - else: - if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: - trusted_email = request.headers.get( - WEBUI_AUTH_TRUSTED_EMAIL_HEADER, "" - ).lower() - if trusted_email and user.email != trusted_email: - # Delete the token cookie - response.delete_cookie("token") - # Delete OAuth token if present - if request.cookies.get("oauth_id_token"): - response.delete_cookie("oauth_id_token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User mismatch. Please sign in again.", + + if data is not None and "id" in data: + user = Users.get_user_by_id(data["id"]) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.INVALID_TOKEN, + ) + else: + if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: + trusted_email = request.headers.get( + WEBUI_AUTH_TRUSTED_EMAIL_HEADER, "" + ).lower() + if trusted_email and user.email != trusted_email: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User mismatch. Please sign in again.", + ) + + # Add user info to current span + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute("client.user.id", user.id) + current_span.set_attribute("client.user.email", user.email) + current_span.set_attribute("client.user.role", user.role) + current_span.set_attribute("client.auth.type", "jwt") + + # Refresh the user's last active timestamp asynchronously + # to prevent blocking the request + if background_tasks: + background_tasks.add_task( + Users.update_user_last_active_by_id, user.id ) + return user + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + except Exception as e: + # Delete the token cookie + if request.cookies.get("token"): + response.delete_cookie("token") - # Add user info to current span - current_span = trace.get_current_span() - if current_span: - current_span.set_attribute("client.user.id", user.id) - current_span.set_attribute("client.user.email", user.email) - current_span.set_attribute("client.user.role", user.role) - current_span.set_attribute("client.auth.type", "jwt") + if request.cookies.get("oauth_id_token"): + response.delete_cookie("oauth_id_token") - # Refresh the user's last active timestamp asynchronously - # to prevent blocking the request - if background_tasks: - background_tasks.add_task(Users.update_user_last_active_by_id, user.id) - return user - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.UNAUTHORIZED, - ) + # Delete OAuth session if present + if request.cookies.get("oauth_session_id"): + response.delete_cookie("oauth_session_id") + + raise e def get_current_user_by_api_key(api_key: str): diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index a298ebeb31..ae2c96c6da 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -369,7 +369,7 @@ async def chat_web_search_handler( "type": "status", "data": { "action": "web_search", - "description": "Generating search query", + "description": "Searching the web", "done": False, }, } @@ -435,8 +435,8 @@ async def chat_web_search_handler( { "type": "status", "data": { - "action": "web_search", - "description": "Searching the web", + "action": "web_search_queries_generated", + "queries": queries, "done": False, }, } @@ -487,6 +487,7 @@ async def chat_web_search_handler( "action": "web_search", "description": "Searched {{count}} sites", "urls": results["filenames"], + "items": results.get("items", []), "done": True, }, } @@ -529,7 +530,7 @@ async def chat_image_generation_handler( await __event_emitter__( { "type": "status", - "data": {"description": "Generating an image", "done": False}, + "data": {"description": "Creating image", "done": False}, } ) @@ -581,7 +582,7 @@ async def chat_image_generation_handler( await __event_emitter__( { "type": "status", - "data": {"description": "Generated an image", "done": True}, + "data": {"description": "Image created", "done": True}, } ) @@ -624,8 +625,9 @@ async def chat_image_generation_handler( async def chat_completion_files_handler( - request: Request, body: dict, user: UserModel + request: Request, body: dict, extra_params: dict, user: UserModel ) -> tuple[dict, dict[str, list]]: + __event_emitter__ = extra_params["__event_emitter__"] sources = [] if files := body.get("metadata", {}).get("files", None): @@ -661,6 +663,17 @@ async def chat_completion_files_handler( if len(queries) == 0: queries = [get_last_user_message(body["messages"])] + await __event_emitter__( + { + "type": "status", + "data": { + "action": "queries_generated", + "queries": queries, + "done": False, + }, + } + ) + try: # Offload get_sources_from_items to a separate thread loop = asyncio.get_running_loop() @@ -697,6 +710,38 @@ async def chat_completion_files_handler( log.debug(f"rag_contexts:sources: {sources}") + unique_ids = set() + + for source in sources or []: + if not source or len(source.keys()) == 0: + continue + + documents = source.get("document") or [] + metadatas = source.get("metadata") or [] + src_info = source.get("source") or {} + + for index, _ in enumerate(documents): + metadata = metadatas[index] if index < len(metadatas) else None + _id = ( + (metadata or {}).get("source") + or (src_info or {}).get("id") + or "N/A" + ) + unique_ids.add(_id) + + sources_count = len(unique_ids) + + await __event_emitter__( + { + "type": "status", + "data": { + "action": "sources_retrieved", + "count": sources_count, + "done": True, + }, + } + ) + return body, {"sources": sources} @@ -770,6 +815,15 @@ async def process_chat_payload(request, form_data, user, metadata, model): event_emitter = get_event_emitter(metadata) event_call = get_event_call(metadata) + oauth_token = None + try: + oauth_token = request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + extra_params = { "__event_emitter__": event_emitter, "__event_call__": event_call, @@ -777,6 +831,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): "__metadata__": metadata, "__request__": request, "__model__": model, + "__oauth_token__": oauth_token, } # Initialize events to store additional event to be sent to the client @@ -885,7 +940,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): extra_params=extra_params, ) except Exception as e: - raise Exception(f"Error: {e}") + raise Exception(f"{e}") features = form_data.pop("features", None) if features: @@ -981,7 +1036,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): log.exception(e) try: - form_data, flags = await chat_completion_files_handler(request, form_data, user) + form_data, flags = await chat_completion_files_handler( + request, form_data, extra_params, user + ) sources.extend(flags.get("sources", [])) except Exception as e: log.exception(e) @@ -1284,118 +1341,134 @@ async def process_chat_response( # Non-streaming response if not isinstance(response, StreamingResponse): if event_emitter: - if isinstance(response, dict) or isinstance(response, JSONResponse): + try: + if isinstance(response, dict) or isinstance(response, JSONResponse): + if isinstance(response, list) and len(response) == 1: + # If the response is a single-item list, unwrap it #17213 + response = response[0] - if isinstance(response, JSONResponse) and isinstance( - response.body, bytes - ): - try: - response_data = json.loads(response.body.decode("utf-8")) - except json.JSONDecodeError: - response_data = {"error": {"detail": "Invalid JSON response"}} - else: - response_data = response - - if "error" in response_data: - error = response_data["error"].get("detail", response_data["error"]) - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "error": {"content": error}, - }, - ) - if isinstance(error, str) or isinstance(error, dict): - await event_emitter( - { - "type": "chat:message:error", - "data": {"error": {"content": error}}, - }, - ) - - if "selected_model_id" in response_data: - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "selectedModelId": response_data["selected_model_id"], - }, - ) - - choices = response_data.get("choices", []) - if choices and choices[0].get("message", {}).get("content"): - content = response_data["choices"][0]["message"]["content"] - - if content: - await event_emitter( - { - "type": "chat:completion", - "data": response_data, + if isinstance(response, JSONResponse) and isinstance( + response.body, bytes + ): + try: + response_data = json.loads(response.body.decode("utf-8")) + except json.JSONDecodeError: + response_data = { + "error": {"detail": "Invalid JSON response"} } - ) + else: + response_data = response - title = Chats.get_chat_title_by_id(metadata["chat_id"]) + if "error" in response_data: + error = response_data.get("error") - await event_emitter( - { - "type": "chat:completion", - "data": { - "done": True, - "content": content, - "title": title, - }, - } - ) + if isinstance(error, dict): + error = error.get("detail", error) + else: + error = str(error) - # Save message in the database Chats.upsert_message_to_chat_by_id_and_message_id( metadata["chat_id"], metadata["message_id"], { - "role": "assistant", - "content": content, + "error": {"content": error}, + }, + ) + if isinstance(error, str) or isinstance(error, dict): + await event_emitter( + { + "type": "chat:message:error", + "data": {"error": {"content": error}}, + } + ) + + if "selected_model_id" in response_data: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "selectedModelId": response_data["selected_model_id"], }, ) - # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): - webhook_url = Users.get_user_webhook_url_by_id(user.id) - if webhook_url: - await post_webhook( - request.app.state.WEBUI_NAME, - webhook_url, - f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", - { - "action": "chat", - "message": content, + choices = response_data.get("choices", []) + if choices and choices[0].get("message", {}).get("content"): + content = response_data["choices"][0]["message"]["content"] + + if content: + await event_emitter( + { + "type": "chat:completion", + "data": response_data, + } + ) + + title = Chats.get_chat_title_by_id(metadata["chat_id"]) + + await event_emitter( + { + "type": "chat:completion", + "data": { + "done": True, + "content": content, "title": title, - "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}", }, - ) + } + ) - await background_tasks_handler() + # Save message in the database + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "role": "assistant", + "content": content, + }, + ) - if events and isinstance(events, list): - extra_response = {} - for event in events: - if isinstance(event, dict): - extra_response.update(event) - else: - extra_response[event] = True + # Send a webhook notification if the user is not active + if not get_active_status_by_user_id(user.id): + webhook_url = Users.get_user_webhook_url_by_id(user.id) + if webhook_url: + await post_webhook( + request.app.state.WEBUI_NAME, + webhook_url, + f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", + { + "action": "chat", + "message": content, + "title": title, + "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}", + }, + ) - response_data = { - **extra_response, - **response_data, - } + await background_tasks_handler() - if isinstance(response, dict): - response = response_data - if isinstance(response, JSONResponse): - response = JSONResponse( - content=response_data, - headers=response.headers, - status_code=response.status_code, - ) + if events and isinstance(events, list): + extra_response = {} + for event in events: + if isinstance(event, dict): + extra_response.update(event) + else: + extra_response[event] = True + + response_data = { + **extra_response, + **response_data, + } + + if isinstance(response, dict): + response = response_data + if isinstance(response, JSONResponse): + response = JSONResponse( + content=response_data, + headers=response.headers, + status_code=response.status_code, + ) + + except Exception as e: + log.debug(f"Error occurred while processing request: {e}") + pass return response else: @@ -1421,11 +1494,21 @@ async def process_chat_response( ): return response + oauth_token = None + try: + oauth_token = request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + log.error(f"Error getting OAuth token: {e}") + extra_params = { "__event_emitter__": event_emitter, "__event_call__": event_caller, "__user__": user.model_dump() if isinstance(user, UserModel) else {}, "__metadata__": metadata, + "__oauth_token__": oauth_token, "__request__": request, "__model__": model, } @@ -1495,7 +1578,7 @@ async def process_chat_response( tool_result_files = result.get("files", None) break - if tool_result: + if tool_result is not None: tool_calls_display_content = f'{tool_calls_display_content}
\nTool Executed\n
\n' else: tool_calls_display_content = f'{tool_calls_display_content}
\nExecuting...\n
\n' @@ -1610,7 +1693,7 @@ async def process_chat_response( { "role": "tool", "tool_call_id": result["tool_call_id"], - "content": result["content"], + "content": result.get("content", "") or "", } ) temp_blocks = [] @@ -1958,6 +2041,10 @@ async def process_chat_response( } ) usage = data.get("usage", {}) + usage.update( + data.get("timing", {}) + ) # llama.cpp + if usage: await event_emitter( { @@ -2331,7 +2418,7 @@ async def process_chat_response( results.append( { "tool_call_id": tool_call_id, - "content": tool_result, + "content": tool_result or "", **( {"files": tool_result_files} if tool_result_files @@ -2618,7 +2705,7 @@ async def process_chat_response( await background_tasks_handler() except asyncio.CancelledError: log.warning("Task was cancelled!") - await event_emitter({"type": "task-cancelled"}) + await event_emitter({"type": "chat:tasks:cancel"}) if not ENABLE_REALTIME_CHAT_SAVE: # Save message in the database diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 5ac189d48d..7eedc30c31 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -4,6 +4,11 @@ import mimetypes import sys import uuid import json +from datetime import datetime, timedelta + +import re +import fnmatch +import time import aiohttp from authlib.integrations.starlette_client import OAuth @@ -14,8 +19,12 @@ from fastapi import ( ) from starlette.responses import RedirectResponse + from open_webui.models.auths import Auths +from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.users import Users + + from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm from open_webui.config import ( DEFAULT_USER_ROLE, @@ -46,6 +55,7 @@ from open_webui.env import ( WEBUI_NAME, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, + ENABLE_OAUTH_ID_TOKEN_COOKIE, ) from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token @@ -79,15 +89,235 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN +def is_in_blocked_groups(group_name: str, groups: list) -> bool: + """ + Check if a group name matches any blocked pattern. + Supports exact matches, shell-style wildcards (*, ?), and regex patterns. + + Args: + group_name: The group name to check + groups: List of patterns to match against + + Returns: + True if the group is blocked, False otherwise + """ + if not groups: + return False + + for group_pattern in groups: + if not group_pattern: # Skip empty patterns + continue + + # Exact match + if group_name == group_pattern: + return True + + # Try as regex pattern first if it contains regex-specific characters + if any( + char in group_pattern + for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"] + ): + try: + # Use the original pattern as-is for regex matching + if re.search(group_pattern, group_name): + return True + except re.error: + # If regex is invalid, fall through to wildcard check + pass + + # Shell-style wildcard match (supports * and ?) + if "*" in group_pattern or "?" in group_pattern: + if fnmatch.fnmatch(group_name, group_pattern): + return True + + return False + + class OAuthManager: def __init__(self, app): self.oauth = OAuth() self.app = app + + self._clients = {} for _, provider_config in OAUTH_PROVIDERS.items(): provider_config["register"](self.oauth) def get_client(self, provider_name): - return self.oauth.create_client(provider_name) + if provider_name not in self._clients: + self._clients[provider_name] = self.oauth.create_client(provider_name) + return self._clients[provider_name] + + def get_server_metadata_url(self, provider_name): + if provider_name in self._clients: + client = self._clients[provider_name] + return ( + client.server_metadata_url + if hasattr(client, "server_metadata_url") + else None + ) + return None + + def get_oauth_token( + self, user_id: str, session_id: str, force_refresh: bool = False + ): + """ + Get a valid OAuth token for the user, automatically refreshing if needed. + + Args: + user_id: The user ID + provider: Optional provider name. If None, gets the most recent session. + force_refresh: Force token refresh even if current token appears valid + + Returns: + dict: OAuth token data with access_token, or None if no valid token available + """ + try: + # Get the OAuth session + session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id) + if not session: + log.warning( + f"No OAuth session found for user {user_id}, session {session_id}" + ) + return None + + if force_refresh or datetime.now() + timedelta( + minutes=5 + ) >= datetime.fromtimestamp(session.expires_at): + log.debug( + f"Token refresh needed for user {user_id}, provider {session.provider}" + ) + refreshed_token = self._refresh_token(session) + if refreshed_token: + return refreshed_token + else: + log.warning( + f"Token refresh failed for user {user_id}, provider {session.provider}" + ) + return None + return session.token + + except Exception as e: + log.error(f"Error getting OAuth token for user {user_id}: {e}") + return None + + async def _refresh_token(self, session) -> dict: + """ + Refresh an OAuth token if needed, with concurrency protection. + + Args: + session: The OAuth session object + + Returns: + dict: Refreshed token data, or None if refresh failed + """ + try: + # Perform the actual refresh + refreshed_token = await self._perform_token_refresh(session) + + if refreshed_token: + # Update the session with new token data + session = OAuthSessions.update_session_by_id( + session.id, refreshed_token + ) + log.info(f"Successfully refreshed token for session {session.id}") + return session.token + else: + log.error(f"Failed to refresh token for session {session.id}") + return None + + except Exception as e: + log.error(f"Error refreshing token for session {session.id}: {e}") + return None + + async def _perform_token_refresh(self, session) -> dict: + """ + Perform the actual OAuth token refresh. + + Args: + session: The OAuth session object + + Returns: + dict: New token data, or None if refresh failed + """ + provider = session.provider + token_data = session.token + + if not token_data.get("refresh_token"): + log.warning(f"No refresh token available for session {session.id}") + return None + + try: + client = self.get_client(provider) + if not client: + log.error(f"No OAuth client found for provider {provider}") + return None + + token_endpoint = None + async with aiohttp.ClientSession(trust_env=True) as session_http: + async with session_http.get(client.gserver_metadata_url) as r: + if r.status == 200: + openid_data = await r.json() + token_endpoint = openid_data.get("token_endpoint") + else: + log.error( + f"Failed to fetch OpenID configuration for provider {provider}" + ) + if not token_endpoint: + log.error(f"No token endpoint found for provider {provider}") + return None + + # Prepare refresh request + refresh_data = { + "grant_type": "refresh_token", + "refresh_token": token_data["refresh_token"], + "client_id": client.client_id, + } + # Add client_secret if available (some providers require it) + if hasattr(client, "client_secret") and client.client_secret: + refresh_data["client_secret"] = client.client_secret + + # Make refresh request + async with aiohttp.ClientSession(trust_env=True) as session_http: + async with session_http.post( + token_endpoint, + data=refresh_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status == 200: + new_token_data = await r.json() + + # Merge with existing token data (preserve refresh_token if not provided) + if "refresh_token" not in new_token_data: + new_token_data["refresh_token"] = token_data[ + "refresh_token" + ] + + # Add timestamp for tracking + new_token_data["issued_at"] = datetime.now().timestamp() + + # Calculate expires_at if we have expires_in + if ( + "expires_in" in new_token_data + and "expires_at" not in new_token_data + ): + new_token_data["expires_at"] = ( + datetime.now().timestamp() + + new_token_data["expires_in"] + ) + + log.debug(f"Token refresh successful for provider {provider}") + return new_token_data + else: + error_text = await r.text() + log.error( + f"Token refresh failed for provider {provider}: {r.status} - {error_text}" + ) + return None + + except Exception as e: + log.error(f"Exception during token refresh for provider {provider}: {e}") + return None def get_user_role(self, user, user_data): user_count = Users.get_num_users() @@ -238,7 +468,7 @@ class OAuthManager: if ( user_oauth_groups and group_model.name not in user_oauth_groups - and group_model.name not in blocked_groups + and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Remove group from user log.debug( @@ -269,7 +499,7 @@ class OAuthManager: user_oauth_groups and group_model.name in user_oauth_groups and not any(gm.name == group_model.name for gm in user_current_groups) - and group_model.name not in blocked_groups + and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Add user to group log.debug( @@ -354,185 +584,205 @@ class OAuthManager: async def handle_callback(self, request, provider, response): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) - client = self.get_client(provider) + + error_message = None try: - token = await client.authorize_access_token(request) - except Exception as e: - log.warning(f"OAuth callback error: {e}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - user_data: UserInfo = token.get("userinfo") - if ( - (not user_data) - or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) - or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) - ): - user_data: UserInfo = await client.userinfo(token=token) - if not user_data: - log.warning(f"OAuth callback failed, user data is missing: {token}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + client = self.get_client(provider) + try: + token = await client.authorize_access_token(request) + except Exception as e: + log.warning(f"OAuth callback error: {e}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - if auth_manager_config.OAUTH_SUB_CLAIM: - sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) - else: - # Fallback to the default sub claim if not configured - sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) + # Try to get userinfo from the token first, some providers include it there + user_data: UserInfo = token.get("userinfo") + if ( + (not user_data) + or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) + or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) + ): + user_data: UserInfo = await client.userinfo(token=token) + if not user_data: + log.warning(f"OAuth callback failed, user data is missing: {token}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - if not sub: - log.warning(f"OAuth callback failed, sub is missing: {user_data}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + # Extract the "sub" claim, using custom claim if configured + if auth_manager_config.OAUTH_SUB_CLAIM: + sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) + else: + # Fallback to the default sub claim if not configured + sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) + if not sub: + log.warning(f"OAuth callback failed, sub is missing: {user_data}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - provider_sub = f"{provider}@{sub}" + provider_sub = f"{provider}@{sub}" - email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM - email = user_data.get(email_claim, "") - # We currently mandate that email addresses are provided - if not email: - # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email - if provider == "github": - try: - access_token = token.get("access_token") - headers = {"Authorization": f"Bearer {access_token}"} - async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get( - "https://api.github.com/user/emails", - headers=headers, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as resp: - if resp.ok: - emails = await resp.json() - # use the primary email as the user's email - primary_email = next( - (e["email"] for e in emails if e.get("primary")), - None, - ) - if primary_email: - email = primary_email - else: - log.warning( - "No primary email found in GitHub response" + # Email extraction + email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM + email = user_data.get(email_claim, "") + # We currently mandate that email addresses are provided + if not email: + # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email + if provider == "github": + try: + access_token = token.get("access_token") + headers = {"Authorization": f"Bearer {access_token}"} + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get( + "https://api.github.com/user/emails", + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as resp: + if resp.ok: + emails = await resp.json() + # use the primary email as the user's email + primary_email = next( + ( + e["email"] + for e in emails + if e.get("primary") + ), + None, ) + if primary_email: + email = primary_email + else: + log.warning( + "No primary email found in GitHub response" + ) + raise HTTPException( + 400, detail=ERROR_MESSAGES.INVALID_CRED + ) + else: + log.warning("Failed to fetch GitHub email") raise HTTPException( 400, detail=ERROR_MESSAGES.INVALID_CRED ) - else: - log.warning("Failed to fetch GitHub email") - raise HTTPException( - 400, detail=ERROR_MESSAGES.INVALID_CRED - ) - except Exception as e: - log.warning(f"Error fetching GitHub email: {e}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - else: - log.warning(f"OAuth callback failed, email is missing: {user_data}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - email = email.lower() - if ( - "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - ): - log.warning( - f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}" - ) - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - - # Check if the user exists - user = Users.get_user_by_oauth_sub(provider_sub) - - if not user: - # If the user does not exist, check if merging is enabled - if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: - # Check if the user exists by email - user = Users.get_user_by_email(email) - if user: - # Update the user with the new oauth sub - Users.update_user_oauth_sub_by_id(user.id, provider_sub) - - if user: - determined_role = self.get_user_role(user, user_data) - if user.role != determined_role: - Users.update_user_role_by_id(user.id, determined_role) - - # Update profile picture if enabled and different from current - if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: - picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM - if picture_claim: - new_picture_url = user_data.get( - picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") - ) - processed_picture_url = await self._process_picture_url( - new_picture_url, token.get("access_token") - ) - if processed_picture_url != user.profile_image_url: - Users.update_user_profile_image_url_by_id( - user.id, processed_picture_url - ) - log.debug(f"Updated profile picture for user {user.email}") - - if not user: - # If the user does not exist, check if signups are enabled - if auth_manager_config.ENABLE_OAUTH_SIGNUP: - # Check if an existing user with the same email already exists - existing_user = Users.get_user_by_email(email) - if existing_user: - raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) - - picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM - if picture_claim: - picture_url = user_data.get( - picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") - ) - picture_url = await self._process_picture_url( - picture_url, token.get("access_token") - ) + except Exception as e: + log.warning(f"Error fetching GitHub email: {e}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: - picture_url = "/user.png" + log.warning(f"OAuth callback failed, email is missing: {user_data}") + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + email = email.lower() - username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM - - name = user_data.get(username_claim) - if not name: - log.warning("Username claim is missing, using email as name") - name = email - - role = self.get_user_role(None, user_data) - - user = Auths.insert_new_auth( - email=email, - password=get_password_hash( - str(uuid.uuid4()) - ), # Random password, not used - name=name, - profile_image_url=picture_url, - role=role, - oauth_sub=provider_sub, + # If allowed domains are configured, check if the email domain is in the list + if ( + "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + and email.split("@")[-1] + not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + ): + log.warning( + f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}" ) + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - if auth_manager_config.WEBHOOK_URL: - await post_webhook( - WEBUI_NAME, - auth_manager_config.WEBHOOK_URL, - WEBHOOK_MESSAGES.USER_SIGNUP(user.name), - { - "action": "signup", - "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name), - "user": user.model_dump_json(exclude_none=True), - }, - ) + # Check if the user exists + user = Users.get_user_by_oauth_sub(provider_sub) + if not user: + # If the user does not exist, check if merging is enabled + if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: + # Check if the user exists by email + user = Users.get_user_by_email(email) + if user: + # Update the user with the new oauth sub + Users.update_user_oauth_sub_by_id(user.id, provider_sub) + + if user: + determined_role = self.get_user_role(user, user_data) + if user.role != determined_role: + Users.update_user_role_by_id(user.id, determined_role) + # Update profile picture if enabled and different from current + if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + if picture_claim: + new_picture_url = user_data.get( + picture_claim, + OAUTH_PROVIDERS[provider].get("picture_url", ""), + ) + processed_picture_url = await self._process_picture_url( + new_picture_url, token.get("access_token") + ) + if processed_picture_url != user.profile_image_url: + Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url + ) + log.debug(f"Updated profile picture for user {user.email}") else: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED + # If the user does not exist, check if signups are enabled + if auth_manager_config.ENABLE_OAUTH_SIGNUP: + # Check if an existing user with the same email already exists + existing_user = Users.get_user_by_email(email) + if existing_user: + raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) + + picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + if picture_claim: + picture_url = user_data.get( + picture_claim, + OAUTH_PROVIDERS[provider].get("picture_url", ""), + ) + picture_url = await self._process_picture_url( + picture_url, token.get("access_token") + ) + else: + picture_url = "/user.png" + username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM + + name = user_data.get(username_claim) + if not name: + log.warning("Username claim is missing, using email as name") + name = email + + user = Auths.insert_new_auth( + email=email, + password=get_password_hash( + str(uuid.uuid4()) + ), # Random password, not used + name=name, + profile_image_url=picture_url, + role=self.get_user_role(None, user_data), + oauth_sub=provider_sub, + ) + + if auth_manager_config.WEBHOOK_URL: + await post_webhook( + WEBUI_NAME, + auth_manager_config.WEBHOOK_URL, + WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + { + "action": "signup", + "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + "user": user.model_dump_json(exclude_none=True), + }, + ) + else: + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + jwt_token = create_token( + data={"id": user.id}, + expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), + ) + if ( + auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT + and user.role != "admin" + ): + self.update_user_groups( + user=user, + user_data=user_data, + default_permissions=request.app.state.config.USER_PERMISSIONS, ) - jwt_token = create_token( - data={"id": user.id}, - expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), - ) - - if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin": - self.update_user_groups( - user=user, - user_data=user_data, - default_permissions=request.app.state.config.USER_PERMISSIONS, + except Exception as e: + log.error(f"Error during OAuth process: {e}") + error_message = ( + e.detail + if isinstance(e, HTTPException) and e.detail + else ERROR_MESSAGES.DEFAULT("Error during OAuth process") ) redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url) @@ -540,6 +790,10 @@ class OAuthManager: redirect_base_url = redirect_base_url[:-1] redirect_url = f"{redirect_base_url}/auth" + if error_message: + redirect_url = f"{redirect_url}?error={error_message}" + return RedirectResponse(url=redirect_url, headers=response.headers) + response = RedirectResponse(url=redirect_url, headers=response.headers) # Set the cookie token @@ -552,13 +806,48 @@ class OAuthManager: secure=WEBUI_AUTH_COOKIE_SECURE, ) - if ENABLE_OAUTH_SIGNUP.value: - oauth_id_token = token.get("id_token") + # Legacy cookies for compatibility with older frontend versions + if ENABLE_OAUTH_ID_TOKEN_COOKIE: response.set_cookie( key="oauth_id_token", - value=oauth_id_token, + value=token.get("id_token"), httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, ) + + try: + # Add timestamp for tracking + token["issued_at"] = datetime.now().timestamp() + + # Calculate expires_at if we have expires_in + if "expires_in" in token and "expires_at" not in token: + token["expires_at"] = datetime.now().timestamp() + token["expires_in"] + + # Clean up any existing sessions for this user/provider first + sessions = OAuthSessions.get_sessions_by_user_id(user.id) + for session in sessions: + if session.provider == provider: + OAuthSessions.delete_session_by_id(session.id) + + session = OAuthSessions.create_session( + user_id=user.id, + provider=provider, + token=token, + ) + + response.set_cookie( + key="oauth_session_id", + value=session.id, + httponly=True, + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, + ) + + log.info( + f"Stored OAuth session server-side for user {user.id}, provider {provider}" + ) + except Exception as e: + log.error(f"Failed to store OAuth session server-side: {e}") + return response diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e68124bd5c..0ef0cf47fb 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -119,18 +119,38 @@ async def get_tools( function_name = spec["name"] auth_type = tool_server_connection.get("auth_type", "bearer") - token = None + + cookies = {} + headers = {} if auth_type == "bearer": - token = tool_server_connection.get("key", "") + headers["Authorization"] = ( + f"Bearer {tool_server_connection.get('key', '')}" + ) + elif auth_type == "none": + # No authentication + pass elif auth_type == "session": - token = request.state.token.credentials + cookies = request.cookies + headers["Authorization"] = ( + f"Bearer {request.state.token.credentials}" + ) + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = ( + f"Bearer {oauth_token.get('access_token', '')}" + ) - def make_tool_function(function_name, token, tool_server_data): + headers["Content-Type"] = "application/json" + + def make_tool_function(function_name, tool_server_data, headers): async def tool_function(**kwargs): return await execute_tool_server( - token=token, url=tool_server_data["url"], + headers=headers, + cookies=cookies, name=function_name, params=kwargs, server_data=tool_server_data, @@ -139,7 +159,7 @@ async def get_tools( return tool_function tool_function = make_tool_function( - function_name, token, tool_server_data + function_name, tool_server_data, headers ) callable = get_async_tool_function_and_apply_extra_params( @@ -542,9 +562,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]: return data -async def get_tool_servers_data( - servers: List[Dict[str, Any]], session_token: Optional[str] = None -) -> List[Dict[str, Any]]: +async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]: # Prepare list of enabled servers along with their original index server_entries = [] for idx, server in enumerate(servers): @@ -560,8 +578,9 @@ async def get_tool_servers_data( if auth_type == "bearer": token = server.get("key", "") - elif auth_type == "session": - token = session_token + elif auth_type == "none": + # No authentication + pass id = info.get("id") if not id: @@ -610,7 +629,12 @@ async def get_tool_servers_data( async def execute_tool_server( - token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any] + url: str, + headers: Dict[str, str], + cookies: Dict[str, str], + name: str, + params: Dict[str, Any], + server_data: Dict[str, Any], ) -> Any: error = None try: @@ -671,11 +695,6 @@ async def execute_tool_server( f"Request body expected for operation '{name}' but none found." ) - headers = {"Content-Type": "application/json"} - - if token: - headers["Authorization"] = f"Bearer {token}" - async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: @@ -686,6 +705,7 @@ async def execute_tool_server( final_url, json=body_params, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ) as response: if response.status >= 400: @@ -702,6 +722,7 @@ async def execute_tool_server( async with request_method( final_url, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ) as response: if response.status >= 400: diff --git a/backend/requirements.txt b/backend/requirements.txt index 03eeba2a1e..9df7fe0ce6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,7 +29,7 @@ pymongo redis boto3==1.40.5 -argon2-cffi==23.1.0 +argon2-cffi==25.1.0 APScheduler==3.10.4 pycrdt==0.12.25 @@ -47,7 +47,7 @@ google-generativeai==0.8.5 tiktoken langchain==0.3.26 -langchain-community==0.3.26 +langchain-community==0.3.27 fake-useragent==2.2.0 chromadb==0.6.3 @@ -65,12 +65,12 @@ transformers sentence-transformers==4.1.0 accelerate colbert-ai==0.2.21 -pyarrow==20.0.0 -einops==0.8.1 +pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 +einops==0.8.1 ftfy==6.2.3 -pypdf==4.3.1 +pypdf==6.0.0 fpdf2==2.8.2 pymdown-extensions==10.14.2 docx2txt==0.8 @@ -99,10 +99,10 @@ onnxruntime==1.20.1 faster-whisper==1.1.1 PyJWT[crypto]==2.10.1 -authlib==1.6.1 +authlib==1.6.3 black==25.1.0 -youtube-transcript-api==1.1.0 +youtube-transcript-api==1.2.2 pytube==15.0.0 pydub @@ -115,7 +115,7 @@ google-auth-oauthlib ## Tests docker~=7.1.0 -pytest~=8.3.5 +pytest~=8.4.1 pytest-docker~=3.1.1 googleapis-common-protos==1.63.2 diff --git a/backend/start.sh b/backend/start.sh index 9e106760c8..c32498aa45 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -53,12 +53,12 @@ if [ -n "$SPACE_ID" ]; then WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' & webui_pid=$! echo "Waiting for webui to start..." - while ! curl -s http://localhost:8080/health > /dev/null; do + while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do sleep 1 done echo "Creating admin user..." curl \ - -X POST "http://localhost:8080/api/v1/auths/signup" \ + -X POST "http://localhost:${PORT}/api/v1/auths/signup" \ -H "accept: application/json" \ -H "Content-Type: application/json" \ -d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }" diff --git a/package-lock.json b/package-lock.json index f5960587a1..91055a9400 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.27", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -26,7 +26,7 @@ "@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", - "@tiptap/extension-highlight": "^3.0.7", + "@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", "@tiptap/extension-list": "^3.0.7", @@ -46,7 +46,7 @@ "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", - "dompurify": "^3.2.5", + "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "focus-trap": "^7.6.4", @@ -66,10 +66,10 @@ "leaflet": "^1.9.4", "lowlight": "^3.3.0", "marked": "^9.1.0", - "mermaid": "^11.6.0", + "mermaid": "^11.10.1", "paneforge": "^0.0.6", "panzoom": "^9.4.3", - "pdfjs-dist": "^5.3.93", + "pdfjs-dist": "^5.4.149", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.0", "prosemirror-example-setup": "^1.2.3", @@ -82,10 +82,11 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.3", + "pyodide": "^0.28.2", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", + "svelte-tiptap": "^3.0.0", "tippy.js": "^6.3.7", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", @@ -2224,9 +2225,9 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz", - "integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", + "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", "license": "MIT", "dependencies": { "langium": "3.3.1" @@ -2238,9 +2239,9 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" }, "node_modules/@napi-rs/canvas": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz", - "integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz", + "integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==", "license": "MIT", "optional": true, "workspaces": [ @@ -2250,22 +2251,22 @@ "node": ">= 10" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.73", - "@napi-rs/canvas-darwin-arm64": "0.1.73", - "@napi-rs/canvas-darwin-x64": "0.1.73", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.73", - "@napi-rs/canvas-linux-arm64-musl": "0.1.73", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.73", - "@napi-rs/canvas-linux-x64-gnu": "0.1.73", - "@napi-rs/canvas-linux-x64-musl": "0.1.73", - "@napi-rs/canvas-win32-x64-msvc": "0.1.73" + "@napi-rs/canvas-android-arm64": "0.1.78", + "@napi-rs/canvas-darwin-arm64": "0.1.78", + "@napi-rs/canvas-darwin-x64": "0.1.78", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.78", + "@napi-rs/canvas-linux-arm64-musl": "0.1.78", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.78", + "@napi-rs/canvas-linux-x64-gnu": "0.1.78", + "@napi-rs/canvas-linux-x64-musl": "0.1.78", + "@napi-rs/canvas-win32-x64-msvc": "0.1.78" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz", - "integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz", + "integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==", "cpu": [ "arm64" ], @@ -2279,9 +2280,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz", - "integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz", + "integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==", "cpu": [ "arm64" ], @@ -2295,9 +2296,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz", - "integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz", + "integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==", "cpu": [ "x64" ], @@ -2311,9 +2312,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz", - "integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz", + "integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==", "cpu": [ "arm" ], @@ -2327,9 +2328,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz", - "integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz", + "integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==", "cpu": [ "arm64" ], @@ -2343,9 +2344,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz", - "integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz", + "integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==", "cpu": [ "arm64" ], @@ -2359,9 +2360,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz", - "integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz", + "integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==", "cpu": [ "riscv64" ], @@ -2375,9 +2376,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz", - "integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz", + "integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==", "cpu": [ "x64" ], @@ -2391,9 +2392,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz", - "integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz", + "integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==", "cpu": [ "x64" ], @@ -2407,9 +2408,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz", - "integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==", + "version": "0.1.78", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz", + "integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==", "cpu": [ "x64" ], @@ -3516,16 +3517,16 @@ } }, "node_modules/@tiptap/extension-highlight": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.0.7.tgz", - "integrity": "sha512-3oIRuXAg7l9+VPIMwHycXcqtZ7XJcC5vnLhPAQXIesYun6L9EoXmQox0225z8jpPG70N8zfl+YSd4qjsTMPaAg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz", + "integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.3.0" } }, "node_modules/@tiptap/extension-horizontal-rule": { @@ -6767,9 +6768,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", - "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -9577,14 +9578,14 @@ } }, "node_modules/mermaid": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz", - "integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==", + "version": "11.10.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz", + "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.4.0", + "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", @@ -9593,11 +9594,11 @@ "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", - "dompurify": "^3.2.4", - "katex": "^0.16.9", + "dompurify": "^3.2.5", + "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^15.0.7", + "marked": "^16.0.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -9605,15 +9606,15 @@ } }, "node_modules/mermaid/node_modules/marked": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", - "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz", + "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/mermaid/node_modules/uuid": { @@ -10252,15 +10253,15 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.3.93", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz", - "integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==", + "version": "5.4.149", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz", + "integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==", "license": "Apache-2.0", "engines": { "node": ">=20.16.0 || >=22.3.0" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.71" + "@napi-rs/canvas": "^0.1.77" } }, "node_modules/pend": { @@ -10905,9 +10906,9 @@ } }, "node_modules/pyodide": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz", - "integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz", + "integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==", "license": "MPL-2.0", "dependencies": { "ws": "^8.5.0" @@ -12502,6 +12503,26 @@ "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, + "node_modules/svelte-tiptap": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.0.tgz", + "integrity": "sha512-digFHOJe16RX0HIU+u8hOaCS9sIgktTpYHSF9yJ6dgxPv/JWJdYCdwoX65lcHitFhhCG7xnolJng6PJa9M9h3w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.0.0", + "@tiptap/extension-bubble-menu": "^3.0.0", + "@tiptap/extension-floating-menu": "^3.0.0", + "@tiptap/pm": "^3.0.0", + "svelte": "^5.0.0" + } + }, "node_modules/svelte/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", diff --git a/package.json b/package.json index d4f736d598..13c0d7578e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.27", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -70,7 +70,7 @@ "@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", - "@tiptap/extension-highlight": "^3.0.7", + "@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", "@tiptap/extension-list": "^3.0.7", @@ -90,7 +90,7 @@ "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", - "dompurify": "^3.2.5", + "dompurify": "^3.2.6", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", "focus-trap": "^7.6.4", @@ -110,10 +110,10 @@ "leaflet": "^1.9.4", "lowlight": "^3.3.0", "marked": "^9.1.0", - "mermaid": "^11.6.0", + "mermaid": "^11.10.1", "paneforge": "^0.0.6", "panzoom": "^9.4.3", - "pdfjs-dist": "^5.3.93", + "pdfjs-dist": "^5.4.149", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.0", "prosemirror-example-setup": "^1.2.3", @@ -126,10 +126,11 @@ "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.3", + "pyodide": "^0.28.2", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.6", "svelte-sonner": "^0.3.19", + "svelte-tiptap": "^3.0.0", "tippy.js": "^6.3.7", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", diff --git a/pyproject.toml b/pyproject.toml index 73409618fd..336ef15ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "bcrypt==4.3.0", "argon2-cffi==23.1.0", "PyJWT[crypto]==2.10.1", - "authlib==1.6.1", + "authlib==1.6.3", "requests==2.32.4", "aiohttp==3.12.15", @@ -52,7 +52,7 @@ dependencies = [ "google-generativeai==0.8.5", "langchain==0.3.26", - "langchain-community==0.3.26", + "langchain-community==0.3.27", "fake-useragent==2.2.0", "chromadb==0.6.3", @@ -72,7 +72,7 @@ dependencies = [ "einops==0.8.1", "ftfy==6.2.3", - "pypdf==4.3.1", + "pypdf==6.0.0", "fpdf2==2.8.2", "pymdown-extensions==10.14.2", "docx2txt==0.8", diff --git a/src/app.css b/src/app.css index 7d465210ba..c48914febf 100644 --- a/src/app.css +++ b/src/app.css @@ -282,6 +282,14 @@ input[type='number'] { outline: none; } +.cm-gutters { + @apply !bg-white dark:!bg-black !border-none; +} + +.cm-editor { + @apply bg-white dark:bg-black; +} + .tippy-box[data-theme~='dark'] { @apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl; } diff --git a/src/app.html b/src/app.html index be2cc0f4ad..f7167d42f2 100644 --- a/src/app.html +++ b/src/app.html @@ -2,14 +2,35 @@ - - - - - + + + + + - + - - + + {#if citations.length > 0} -
- {#if citations.length <= 3} -
- {#each citations as citation, idx} - - {/each} + {@const urlCitations = citations.filter((c) => c?.source?.name?.startsWith('http'))} +
+ - {/each} -
-
-
- - {citations.length - ($mobile ? 1 : 2)} - {$i18n.t('more')} -
-
-
- {#if isCollapsibleOpen} - - {:else} - - {/if} -
- -
-
- {#each citations.slice($mobile ? 1 : 2) as citation, idx} - - {/each} -
-
- - {/if} + {/if} diff --git a/src/lib/components/chat/Messages/CitationsModal.svelte b/src/lib/components/chat/Messages/Citations/CitationModal.svelte similarity index 55% rename from src/lib/components/chat/Messages/CitationsModal.svelte rename to src/lib/components/chat/Messages/Citations/CitationModal.svelte index 566f0c6e06..c6e460d964 100644 --- a/src/lib/components/chat/Messages/CitationsModal.svelte +++ b/src/lib/components/chat/Messages/Citations/CitationModal.svelte @@ -61,8 +61,34 @@
-
- {$i18n.t('Citation')} +
+ {#if citation?.source?.name} + {@const document = mergedDocuments?.[0]} + {#if document?.metadata?.file_id || document.source?.url?.includes('http')} + + + {decodeString(citation?.source?.name)} + + + {:else} + {decodeString(citation?.source?.name)} + {/if} + {:else} + {$i18n.t('Citation')} + {/if}
+
+ +
+
+ {#each citations as citation, idx} + + {/each} +
+
+
+ diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index e3109a25e7..f3da6d8f75 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -216,19 +216,19 @@ const executePythonAsWorker = async (code) => { let packages = [ - code.includes('requests') ? 'requests' : null, - code.includes('bs4') ? 'beautifulsoup4' : null, - code.includes('numpy') ? 'numpy' : null, - code.includes('pandas') ? 'pandas' : null, - code.includes('sklearn') ? 'scikit-learn' : null, - code.includes('scipy') ? 'scipy' : null, - code.includes('re') ? 'regex' : null, - code.includes('seaborn') ? 'seaborn' : null, - code.includes('sympy') ? 'sympy' : null, - code.includes('tiktoken') ? 'tiktoken' : null, - code.includes('matplotlib') ? 'matplotlib' : null, - code.includes('pytz') ? 'pytz' : null, - code.includes('openai') ? 'openai' : null + /\bimport\s+requests\b|\bfrom\s+requests\b/.test(code) ? 'requests' : null, + /\bimport\s+bs4\b|\bfrom\s+bs4\b/.test(code) ? 'beautifulsoup4' : null, + /\bimport\s+numpy\b|\bfrom\s+numpy\b/.test(code) ? 'numpy' : null, + /\bimport\s+pandas\b|\bfrom\s+pandas\b/.test(code) ? 'pandas' : null, + /\bimport\s+matplotlib\b|\bfrom\s+matplotlib\b/.test(code) ? 'matplotlib' : null, + /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null, + /\bimport\s+sklearn\b|\bfrom\s+sklearn\b/.test(code) ? 'scikit-learn' : null, + /\bimport\s+scipy\b|\bfrom\s+scipy\b/.test(code) ? 'scipy' : null, + /\bimport\s+re\b|\bfrom\s+re\b/.test(code) ? 'regex' : null, + /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null, + /\bimport\s+sympy\b|\bfrom\s+sympy\b/.test(code) ? 'sympy' : null, + /\bimport\s+tiktoken\b|\bfrom\s+tiktoken\b/.test(code) ? 'tiktoken' : null, + /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null ].filter(Boolean); console.log(packages); @@ -416,11 +416,11 @@
-
+
{#if lang === 'mermaid'} {#if mermaidHtml} @@ -428,16 +428,16 @@
{code}
{/if} {:else} -
+
{lang}
-
+
-
+
{#if !collapsed} {#if edit} @@ -542,7 +542,7 @@ {/if} {:else}
{$i18n.t('{{COUNT}} hidden lines', { @@ -556,12 +556,12 @@ {#if !collapsed}
{#if executing || stdout || stderr || result || files}
{#if executing}
diff --git a/src/lib/components/chat/Messages/Markdown/Source.svelte b/src/lib/components/chat/Messages/Markdown/Source.svelte index 7215d19134..b298337320 100644 --- a/src/lib/components/chat/Messages/Markdown/Source.svelte +++ b/src/lib/components/chat/Messages/Markdown/Source.svelte @@ -21,6 +21,10 @@ // Helper function to return only the domain from a URL function getDomain(url: string): string { const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0]; + + if (domain.startsWith('www.')) { + return domain.slice(4); + } return domain; } @@ -33,6 +37,14 @@ return title; } + const getDisplayTitle = (title: string) => { + if (!title) return 'N/A'; + if (title.length > 30) { + return title.slice(0, 15) + '...' + title.slice(-10); + } + return title; + }; + $: attributes = extractAttributes(token.text); @@ -44,7 +56,11 @@ }} > - {attributes.title ? formattedTitle(attributes.title) : ''} + {getDisplayTitle( + decodeURIComponent(attributes.title) + ? formattedTitle(decodeURIComponent(attributes.title)) + : '' + )} {/if} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index e67d85d760..fd4b2ebb45 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -52,6 +52,7 @@ import { fade } from 'svelte/transition'; import { flyAndScale } from '$lib/utils/transitions'; import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte'; + import StatusHistory from './ResponseMessage/StatusHistory.svelte'; interface MessageType { id: string; @@ -642,76 +643,11 @@
- {#if (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length > 0} - {@const status = ( - message?.statusHistory ?? [...(message?.status ? [message?.status] : [])] - ).at(-1)} - {#if !status?.hidden} -
- {#if status?.action === 'web_search' && status?.urls} - -
-
- - - - - {#if status?.description.includes('{{count}}')} - {$i18n.t(status?.description, { - count: status?.urls.length - })} - {:else if status?.description === 'No search query generated'} - {$i18n.t('No search query generated')} - {:else if status?.description === 'Generating search query'} - {$i18n.t('Generating search query')} - {:else} - {status?.description} - {/if} -
-
-
- {:else if status?.action === 'knowledge_search'} -
-
- {$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, { - searchQuery: status.query - })} -
-
- {:else} -
-
- - {#if status?.description.includes('{{searchQuery}}')} - {$i18n.t(status?.description, { - searchQuery: status?.query - })} - {:else if status?.description === 'No search query generated'} - {$i18n.t('No search query generated')} - {:else if status?.description === 'Generating search query'} - {$i18n.t('Generating search query')} - {:else if status?.description === 'Searching the web'} - {$i18n.t('Searching the web...')} - {:else} - {status?.description} - {/if} -
-
- {/if} -
- {/if} + {#if model?.info?.meta?.capabilities?.status_updates ?? true} + {/if} {#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0} @@ -798,7 +734,7 @@
{:else}
- {#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0} + {#if message.content === '' && !message.error && ((model?.info?.meta?.capabilities?.status_updates ?? true) ? (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0 || (message?.statusHistory?.at(-1)?.hidden ?? false) : true)} {:else if message.content && message.error !== true} @@ -1339,7 +1275,7 @@ {/if} - {#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? false)} + {#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)} {#if $settings?.regenerateMenu ?? true} +
+ {/if} +{/if} diff --git a/src/lib/components/chat/Messages/ResponseMessage/StatusHistory/StatusItem.svelte b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory/StatusItem.svelte new file mode 100644 index 0000000000..6b6422f32a --- /dev/null +++ b/src/lib/components/chat/Messages/ResponseMessage/StatusHistory/StatusItem.svelte @@ -0,0 +1,150 @@ + + +{#if !status?.hidden} +
+ {#if status?.action === 'web_search' && (status?.urls || status?.items)} + +
+
+ + + + {#if status?.description.includes('{{count}}')} + {$i18n.t(status?.description, { + count: (status?.urls || status?.items).length + })} + {:else if status?.description === 'No search query generated'} + {$i18n.t('No search query generated')} + {:else if status?.description === 'Generating search query'} + {$i18n.t('Generating search query')} + {:else} + {status?.description} + {/if} +
+
+
+ {:else if status?.action === 'knowledge_search'} +
+
+ {$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, { + searchQuery: status.query + })} +
+
+ {:else if status?.action === 'web_search_queries_generated' && status?.queries} +
+
+ {$i18n.t(`Searching`)} +
+ +
+ {#each status.queries as query, idx (query)} +
+
+ +
+ + + {query} + +
+ {/each} +
+
+ {:else if status?.action === 'queries_generated' && status?.queries} +
+
+ {$i18n.t(`Querying`)} +
+ +
+ {#each status.queries as query, idx (query)} +
+
+ +
+ + + {query} + +
+ {/each} +
+
+ {:else if status?.action === 'sources_retrieved' && status?.count !== undefined} +
+
+ {#if status.count === 0} + {$i18n.t('No sources found')} + {:else if status.count === 1} + {$i18n.t('Retrieved 1 source')} + {:else} + + + + + {$i18n.t('Retrieved {{count}} sources', { + count: status.count + })} + {/if} +
+
+ {:else} +
+
+ + {#if status?.description?.includes('{{searchQuery}}')} + {$i18n.t(status?.description, { + searchQuery: status?.query + })} + {:else if status?.description === 'No search query generated'} + {$i18n.t('No search query generated')} + {:else if status?.description === 'Generating search query'} + {$i18n.t('Generating search query')} + {:else if status?.description === 'Searching the web'} + {$i18n.t('Searching the web')} + {:else} + {status?.description} + {/if} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte b/src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte index cfb9d4d95e..bcd35f7586 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte @@ -8,27 +8,25 @@ let state = false; - -
+ +
- {#if state} - + {:else} - + {/if}
+
{#if status?.query} + + {/each} + {:else if status?.urls} + {#each status.urls as url, urlIdx} + +
+
+ favicon +
+ +
+ {url} +
+
+ +
+ + + + +
+
+ {/each} + {/if}
diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 6369ad8604..07cc6467eb 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -327,7 +327,7 @@
{ + const res = await updateFolderById(localStorage.token, folder.id, { + meta: { + icon: iconName + } + }).catch((error) => { + toast.error(`${error}`); + return null; + }); + + if (res) { + folder.meta = { ...folder.meta, icon: iconName }; + + toast.success($i18n.t('Folder updated successfully')); + selectedFolder.set(folder); + onUpdate(folder); + } + }; + const deleteHandler = async () => { const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => { toast.error(`${error}`); @@ -116,9 +137,23 @@
-
- -
+ {}} + onSubmit={(name) => { + console.log(name); + updateIconHandler(name); + }} + > + +
{folder.name} diff --git a/src/lib/components/chat/Settings/Connections/Connection.svelte b/src/lib/components/chat/Settings/Connections/Connection.svelte index ea9089f2f1..c8a5ec152a 100644 --- a/src/lib/components/chat/Settings/Connections/Connection.svelte +++ b/src/lib/components/chat/Settings/Connections/Connection.svelte @@ -72,12 +72,6 @@ autocomplete="off" />
- -
diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 9c6526c1d2..c383b831bb 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -49,6 +49,7 @@ let largeTextAsFile = false; + let insertSuggestionPrompt = false; let keepFollowUpPrompts = false; let insertFollowUpPrompt = false; @@ -200,6 +201,7 @@ insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false; promptAutocomplete = $settings?.promptAutocomplete ?? false; + insertSuggestionPrompt = $settings?.insertSuggestionPrompt ?? false; keepFollowUpPrompts = $settings?.keepFollowUpPrompts ?? false; insertFollowUpPrompt = $settings?.insertFollowUpPrompt ?? false; @@ -697,6 +699,25 @@
+
+
+
+ {$i18n.t('Insert Suggestion Prompt to Input')} +
+ +
+ { + saveSettings({ insertSuggestionPrompt }); + }} + /> +
+
+
+
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index 6e3e6fe5e7..d3da37631c 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -212,7 +212,7 @@ }, { id: 'tools', - title: 'Tools', + title: 'External Tools', keywords: [ 'addconnection', 'add connection', @@ -743,7 +743,7 @@ />
-
{$i18n.t('Tools')}
+
{$i18n.t('External Tools')}
{/if} {:else if tabId === 'personalization'} diff --git a/src/lib/components/common/Banner.svelte b/src/lib/components/common/Banner.svelte index a79b8b42c3..a64cc857ff 100644 --- a/src/lib/components/common/Banner.svelte +++ b/src/lib/components/common/Banner.svelte @@ -46,7 +46,7 @@ {#if !dismissed} {#if mounted}
diff --git a/src/lib/components/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index 56e214ae34..b092a49826 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -169,7 +169,10 @@
{ + on:click={(e) => { + e.stopPropagation(); + }} + on:pointerup={(e) => { if (!disabled) { open = !open; } diff --git a/src/lib/components/common/Emoji.svelte b/src/lib/components/common/Emoji.svelte new file mode 100644 index 0000000000..be1fe36475 --- /dev/null +++ b/src/lib/components/common/Emoji.svelte @@ -0,0 +1,20 @@ + + +{#if $shortCodesToEmojis[shortCode]} + {shortCode} +{:else} +
+ {shortCode} +
+{/if} diff --git a/src/lib/components/channel/Messages/Message/ReactionPicker.svelte b/src/lib/components/common/EmojiPicker.svelte similarity index 95% rename from src/lib/components/channel/Messages/Message/ReactionPicker.svelte rename to src/lib/components/common/EmojiPicker.svelte index 23f1a465bd..cc761039b8 100644 --- a/src/lib/components/channel/Messages/Message/ReactionPicker.svelte +++ b/src/lib/components/common/EmojiPicker.svelte @@ -30,16 +30,18 @@ $: { if (search) { emojis = Object.keys(emojiShortCodes).reduce((acc, key) => { - if (key.includes(search)) { + if (key.includes(search.toLowerCase())) { acc[key] = emojiShortCodes[key]; } else { if (Array.isArray(emojiShortCodes[key])) { - const filtered = emojiShortCodes[key].filter((emoji) => emoji.includes(search)); + const filtered = emojiShortCodes[key].filter((emoji) => + emoji.includes(search.toLowerCase()) + ); if (filtered.length) { acc[key] = filtered; } } else { - if (emojiShortCodes[key].includes(search)) { + if (emojiShortCodes[key].includes(search.toLowerCase())) { acc[key] = emojiShortCodes[key]; } } diff --git a/src/lib/components/common/Folder.svelte b/src/lib/components/common/Folder.svelte index 20edab332c..ba947901fc 100644 --- a/src/lib/components/common/Folder.svelte +++ b/src/lib/components/common/Folder.svelte @@ -16,6 +16,7 @@ export let name = ''; export let collapsible = true; + export let chevron = true; export let onAddLabel: string = ''; export let onAdd: null | Function = null; @@ -137,16 +138,18 @@ >