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/pull_request_template.md b/.github/pull_request_template.md index 7f603cb10c..fa82ae26a1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -73,4 +73,4 @@ ### Contributor License Agreement -By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. +By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7d5e30e23e..019fbb6bae 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -36,7 +36,7 @@ jobs: echo "::set-output name=content::$CHANGELOG_ESCAPED" - name: Create GitHub release - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -61,7 +61,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Trigger Docker build workflow - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | github.rest.actions.createWorkflowDispatch({ diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml index 56074a84f4..562e6aa1c1 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml @@ -33,7 +33,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '${{ matrix.python-version }}' diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index df961ca3f5..eaa1072fbc 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index c4ae97422d..9995ccedae 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -21,10 +21,10 @@ jobs: fetch-depth: 0 - name: Install Git run: sudo apt-get update && sudo apt-get install -y git - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: 3.11 - name: Build diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5663bb58..7e04470d0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,147 @@ 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.30] - 2025-09-17 + +### Added + +- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows. + +### Fixed + +- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access. + +## [0.6.29] - 2025-09-17 + +### Added + +- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1) +- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145) +- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054) +- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418) +- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748) +- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2) +- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402) +- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690) +- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308) +- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697) +- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477) +- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479) +- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498) +- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499) +- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502) +- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503) +- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572) +- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50) +- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e) +- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275) +- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62) +- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5) +- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad) +- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c) +- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685) +- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533) +- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356) +- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd) +- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f) +- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd) +- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded. + +### Fixed + +- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12) +- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f) +- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0) +- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66) +- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396) +- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c) +- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4) +- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8) +- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab) +- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9) +- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa) +- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423) +- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3) + +### Changed + +- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696) +- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5) +- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956) +- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161) + +## [0.6.28] - 2025-09-10 + +### Added + +- 🔍 The "@" command for model selection now supports real-time search and filtering, improving usability and aligning its behavior with other input commands. [#17307](https://github.com/open-webui/open-webui/issues/17307), [Commit](https://github.com/open-webui/open-webui/commit/f2a09c71499489ee71599af4a179e7518aaf658b) +- 🛠️ External tool server data handling is now more robust, automatically attempting to parse specifications as JSON before falling back to YAML, regardless of the URL extension. [Commit](https://github.com/open-webui/open-webui/commit/774c0056bde88ed4831422efa81506488e3d6641) +- 🎯 The "Title" field is now automatically focused when creating a new chat folder, streamlining the folder creation process. [#17315](https://github.com/open-webui/open-webui/issues/17315), [Commit](https://github.com/open-webui/open-webui/commit/c51a651a2d5e2a27546416666812e9b92205562d) +- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌐 Brazilian Portuguese and Simplified Chinese translations were expanded and refined. + +### Fixed + +- 🔊 A regression affecting Text-to-Speech for local providers using the OpenAI engine was fixed by reverting a URL joining change. [#17316](https://github.com/open-webui/open-webui/issues/17316), [Commit](https://github.com/open-webui/open-webui/commit/8339f59cdfc63f2d58c8e26933d1bf1438479d75) +- 🪧 A regression was fixed where the input modal for prompts with placeholders would not open, causing the raw prompt text to be pasted into the chat input field instead. [#17325](https://github.com/open-webui/open-webui/issues/17325), [Commit](https://github.com/open-webui/open-webui/commit/d5cb65527eaa4831459a4c7dbf187daa9c0525ae) +- 🔑 An issue was resolved where modified connection keys in the OpenAIConnection component did not take effect. [#17324](https://github.com/open-webui/open-webui/pull/17324) + +## [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 diff --git a/LICENSE_NOTICE b/LICENSE_NOTICE new file mode 100644 index 0000000000..4e00d46d9a --- /dev/null +++ b/LICENSE_NOTICE @@ -0,0 +1,11 @@ +# Open WebUI Multi-License Notice + +This repository contains code governed by multiple licenses based on the date and origin of contribution: + +1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY). + +2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY). + +3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE). + +For details on which commits are covered by which license, refer to LICENSE_HISTORY. diff --git a/README.md b/README.md index 9b01496d9f..49c0a8d9d3 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http ## License 📜 -This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄 +This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files. ## Support 💬 diff --git a/backend/dev.sh b/backend/dev.sh index 504b8f7554..042fbd9efa 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -1,3 +1,3 @@ -export CORS_ALLOW_ORIGIN="http://localhost:5173" +export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080" PORT="${PORT:-8080}" uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 069faab439..2f5f34019d 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]): class AppConfig: - _state: dict[str, PersistentConfig] _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None _redis_key_prefix: str + _state: dict[str, PersistentConfig] + def __init__( self, redis_url: Optional[str] = None, @@ -233,9 +234,8 @@ class AppConfig: redis_cluster: Optional[bool] = False, redis_key_prefix: str = "open-webui", ): - super().__setattr__("_state", {}) - super().__setattr__("_redis_key_prefix", redis_key_prefix) if redis_url: + super().__setattr__("_redis_key_prefix", redis_key_prefix) super().__setattr__( "_redis", get_redis_connection( @@ -246,6 +246,8 @@ class AppConfig: ), ) + super().__setattr__("_state", {}) + def __setattr__(self, key, value): if isinstance(value, PersistentConfig): self._state[key] = value @@ -513,6 +515,30 @@ OAUTH_GROUPS_CLAIM = PersistentConfig( os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")), ) +FEISHU_CLIENT_ID = PersistentConfig( + "FEISHU_CLIENT_ID", + "oauth.feishu.client_id", + os.environ.get("FEISHU_CLIENT_ID", ""), +) + +FEISHU_CLIENT_SECRET = PersistentConfig( + "FEISHU_CLIENT_SECRET", + "oauth.feishu.client_secret", + os.environ.get("FEISHU_CLIENT_SECRET", ""), +) + +FEISHU_OAUTH_SCOPE = PersistentConfig( + "FEISHU_OAUTH_SCOPE", + "oauth.feishu.scope", + os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"), +) + +FEISHU_REDIRECT_URI = PersistentConfig( + "FEISHU_REDIRECT_URI", + "oauth.feishu.redirect_uri", + os.environ.get("FEISHU_REDIRECT_URI", ""), +) + ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig( "ENABLE_OAUTH_ROLE_MANAGEMENT", "oauth.enable_role_mapping", @@ -705,6 +731,33 @@ def load_oauth_providers(): "register": oidc_oauth_register, } + if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value: + + def feishu_oauth_register(client: OAuth): + client.register( + name="feishu", + client_id=FEISHU_CLIENT_ID.value, + client_secret=FEISHU_CLIENT_SECRET.value, + access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token", + authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize", + api_base_url="https://open.feishu.cn/open-apis", + userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info", + client_kwargs={ + "scope": FEISHU_OAUTH_SCOPE.value, + **( + {"timeout": int(OAUTH_TIMEOUT.value)} + if OAUTH_TIMEOUT.value + else {} + ), + }, + redirect_uri=FEISHU_REDIRECT_URI.value, + ) + + OAUTH_PROVIDERS["feishu"] = { + "register": feishu_oauth_register, + "sub_claim": "user_id", + } + configured_providers = [] if GOOGLE_CLIENT_ID.value: configured_providers.append("Google") @@ -712,6 +765,8 @@ def load_oauth_providers(): configured_providers.append("Microsoft") if GITHUB_CLIENT_ID.value: configured_providers.append("GitHub") + if FEISHU_CLIENT_ID.value: + configured_providers.append("Feishu") if configured_providers and not OPENID_PROVIDER_URL.value: provider_list = ", ".join(configured_providers) @@ -2116,10 +2171,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", ) -ONEDRIVE_CLIENT_ID = PersistentConfig( - "ONEDRIVE_CLIENT_ID", - "onedrive.client_id", - os.environ.get("ONEDRIVE_CLIENT_ID", ""), + +ENABLE_ONEDRIVE_PERSONAL = ( + os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true" +) +ENABLE_ONEDRIVE_BUSINESS = ( + os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true" +) + +ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "") +ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get( + "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID +) +ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get( + "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID ) ONEDRIVE_SHAREPOINT_URL = PersistentConfig( @@ -2232,6 +2297,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", @@ -2244,6 +2321,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", diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index f0b26ae25c..243b8212a8 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 @@ -534,16 +547,16 @@ else: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get( - "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10" + "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30" ) if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "": - CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 + CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 else: try: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES) except Exception: - CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 + CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 #################################### diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index db367ccbd0..d102263cb3 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -19,6 +19,7 @@ from fastapi import ( from starlette.responses import Response, StreamingResponse +from open_webui.constants import ERROR_MESSAGES from open_webui.socket.main import ( get_event_call, get_event_emitter, @@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str): function_module, _, _ = get_function_module_from_cache(request, pipe_id) if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): + Valves = function_module.Valves valves = Functions.get_function_valves_by_id(pipe_id) - function_module.valves = function_module.Valves(**(valves if valves else {})) + + if valves: + try: + function_module.valves = Valves( + **{k: v for k, v in valves.items() if v is not None} + ) + except Exception as e: + log.exception(f"Error loading valves for function {pipe_id}: {e}") + raise e + else: + function_module.valves = Valves() + return function_module @@ -70,65 +83,69 @@ async def get_function_models(request): pipe_models = [] for pipe in pipes: - function_module = get_function_module_by_id(request, pipe.id) + try: + function_module = get_function_module_by_id(request, pipe.id) - # Check if function is a manifold - if hasattr(function_module, "pipes"): - sub_pipes = [] - - # Handle pipes being a list, sync function, or async function - try: - if callable(function_module.pipes): - if asyncio.iscoroutinefunction(function_module.pipes): - sub_pipes = await function_module.pipes() - else: - sub_pipes = function_module.pipes() - else: - sub_pipes = function_module.pipes - except Exception as e: - log.exception(e) + # Check if function is a manifold + if hasattr(function_module, "pipes"): sub_pipes = [] - log.debug( - f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}" - ) + # Handle pipes being a list, sync function, or async function + try: + if callable(function_module.pipes): + if asyncio.iscoroutinefunction(function_module.pipes): + sub_pipes = await function_module.pipes() + else: + sub_pipes = function_module.pipes() + else: + sub_pipes = function_module.pipes + except Exception as e: + log.exception(e) + sub_pipes = [] - for p in sub_pipes: - sub_pipe_id = f'{pipe.id}.{p["id"]}' - sub_pipe_name = p["name"] + log.debug( + f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}" + ) - if hasattr(function_module, "name"): - sub_pipe_name = f"{function_module.name}{sub_pipe_name}" + for p in sub_pipes: + sub_pipe_id = f'{pipe.id}.{p["id"]}' + sub_pipe_name = p["name"] - pipe_flag = {"type": pipe.type} + if hasattr(function_module, "name"): + sub_pipe_name = f"{function_module.name}{sub_pipe_name}" + + pipe_flag = {"type": pipe.type} + + pipe_models.append( + { + "id": sub_pipe_id, + "name": sub_pipe_name, + "object": "model", + "created": pipe.created_at, + "owned_by": "openai", + "pipe": pipe_flag, + } + ) + else: + pipe_flag = {"type": "pipe"} + + log.debug( + f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}" + ) pipe_models.append( { - "id": sub_pipe_id, - "name": sub_pipe_name, + "id": pipe.id, + "name": pipe.name, "object": "model", "created": pipe.created_at, "owned_by": "openai", "pipe": pipe_flag, } ) - else: - pipe_flag = {"type": "pipe"} - - log.debug( - f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}" - ) - - pipe_models.append( - { - "id": pipe.id, - "name": pipe.name, - "object": "model", - "created": pipe.created_at, - "owned_by": "openai", - "pipe": pipe_flag, - } - ) + except Exception as e: + log.exception(e) + continue return pipe_models @@ -219,6 +236,16 @@ async def generate_function_chat_completion( __task__ = metadata.get("task", None) __task_body__ = metadata.get("task_body", None) + oauth_token = None + try: + if request.cookies.get("oauth_session_id", None): + oauth_token = await 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 +257,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 7decfcd83b..7ebb76cb58 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -110,9 +110,6 @@ from open_webui.config import ( OLLAMA_API_CONFIGS, # OpenAI ENABLE_OPENAI_API, - ONEDRIVE_CLIENT_ID, - ONEDRIVE_SHAREPOINT_URL, - ONEDRIVE_SHAREPOINT_TENANT_ID, OPENAI_API_BASE_URLS, OPENAI_API_KEYS, OPENAI_API_CONFIGS, @@ -244,8 +241,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, @@ -298,14 +300,17 @@ from open_webui.config import ( GOOGLE_PSE_ENGINE_ID, GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_API_KEY, - ONEDRIVE_CLIENT_ID, + ENABLE_ONEDRIVE_INTEGRATION, + ONEDRIVE_CLIENT_ID_PERSONAL, + ONEDRIVE_CLIENT_ID_BUSINESS, ONEDRIVE_SHAREPOINT_URL, ONEDRIVE_SHAREPOINT_TENANT_ID, + ENABLE_ONEDRIVE_PERSONAL, + ENABLE_ONEDRIVE_BUSINESS, ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_WEB_LOADER_SSL_VERIFICATION, ENABLE_GOOGLE_DRIVE_INTEGRATION, - ENABLE_ONEDRIVE_INTEGRATION, UPLOAD_DIR, EXTERNAL_WEB_SEARCH_URL, EXTERNAL_WEB_SEARCH_API_KEY, @@ -443,6 +448,7 @@ from open_webui.utils.models import ( get_all_models, get_all_base_models, check_model_access, + get_filtered_models, ) from open_webui.utils.chat import ( generate_chat_completion as chat_completion_handler, @@ -592,6 +598,7 @@ app = FastAPI( ) oauth_manager = OAuthManager(app) +app.state.oauth_manager = oauth_manager app.state.instance_id = None app.state.config = AppConfig( @@ -811,8 +818,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 @@ -1280,33 +1292,6 @@ if audit_level != AuditLevel.NONE: async def get_models( request: Request, refresh: bool = False, user=Depends(get_verified_user) ): - def get_filtered_models(models, user): - filtered_models = [] - for model in models: - if model.get("arena"): - if has_access( - user.id, - type="read", - access_control=model.get("info", {}) - .get("meta", {}) - .get("access_control", {}), - ): - filtered_models.append(model) - continue - - model_info = Models.get_model_by_id(model["id"]) - if model_info: - if ( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == model_info.user_id - or has_access( - user.id, type="read", access_control=model_info.access_control - ) - ): - filtered_models.append(model) - - return filtered_models - all_models = await get_all_models(request, refresh=refresh, user=user) models = [] @@ -1342,12 +1327,7 @@ async def get_models( ) ) - # Filter out models that the user does not have access to - if ( - user.role == "user" - or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL) - ) and not BYPASS_MODEL_ACCESS_CONTROL: - models = get_filtered_models(models, user) + models = get_filtered_models(models, user) log.debug( f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}" @@ -1551,6 +1531,14 @@ async def chat_completion( except: pass + finally: + try: + if mcp_clients := metadata.get("mcp_clients"): + for client in mcp_clients: + await client.disconnect() + except Exception as e: + log.debug(f"Error cleaning up: {e}") + pass if ( metadata.get("session_id") @@ -1719,6 +1707,14 @@ async def get_app_config(request: Request): "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + **( + { + "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL, + "enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS, + } + if app.state.config.ENABLE_ONEDRIVE_INTEGRATION + else {} + ), } if user is not None else {} @@ -1756,7 +1752,8 @@ async def get_app_config(request: Request): "api_key": GOOGLE_DRIVE_API_KEY.value, }, "onedrive": { - "client_id": ONEDRIVE_CLIENT_ID.value, + "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL, + "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS, "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, }, 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/chats.py b/backend/open_webui/models/chats.py index 56f992806a..cadb5a3a79 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -236,7 +236,7 @@ class ChatTable: return chat.chat.get("title", "New Chat") - def get_messages_by_chat_id(self, id: str) -> Optional[dict]: + def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: chat = self.get_chat_by_id(id) if chat is None: return None 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/functions.py b/backend/open_webui/models/functions.py index 7530573e79..e8ce3aa811 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -37,6 +37,7 @@ class Function(Base): class FunctionMeta(BaseModel): description: Optional[str] = None manifest: Optional[dict] = {} + model_config = ConfigDict(extra="allow") class FunctionModel(BaseModel): @@ -54,6 +55,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 +128,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 +183,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( @@ -237,6 +261,29 @@ class FunctionsTable: except Exception: return None + def update_function_metadata_by_id( + self, id: str, metadata: dict + ) -> Optional[FunctionModel]: + with get_db() as db: + try: + function = db.get(Function, id) + + if function: + if function.meta: + function.meta = {**function.meta, **metadata} + else: + function.meta = metadata + + function.updated_at = int(time.time()) + db.commit() + db.refresh(function) + return self.get_function_by_id(id) + else: + return None + except Exception as e: + log.exception(f"Error updating function metadata by id {id}: {e}") + return None + def get_user_valves_by_id_and_user_id( self, id: str, user_id: str ) -> Optional[dict]: diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 75f83d12cd..cfef77e237 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -129,7 +129,9 @@ class KnowledgeTable: def get_knowledge_bases(self) -> list[KnowledgeUserModel]: with get_db() as db: - all_knowledge = db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() + all_knowledge = ( + db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() + ) user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) @@ -149,6 +151,15 @@ 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]: @@ -158,7 +169,9 @@ class KnowledgeTable: 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, user_group_ids) + 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/messages.py b/backend/open_webui/models/messages.py index a27ae52519..ff4553ee9d 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -201,8 +201,14 @@ class MessageTable: with get_db() as db: message = db.get(Message, id) message.content = form_data.content - message.data = form_data.data - message.meta = form_data.meta + message.data = { + **(message.data if message.data else {}), + **(form_data.data if form_data.data else {}), + } + message.meta = { + **(message.meta if message.meta else {}), + **(form_data.meta if form_data.meta else {}), + } message.updated_at = int(time.time_ns()) db.commit() db.refresh(message) diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index c720ff80a4..b61e820eae 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -97,15 +97,26 @@ class NoteTable: db.commit() return note - def get_notes(self) -> list[NoteModel]: + def get_notes( + self, skip: Optional[int] = None, limit: Optional[int] = None + ) -> list[NoteModel]: with get_db() as db: - notes = db.query(Note).order_by(Note.updated_at.desc()).all() + query = db.query(Note).order_by(Note.updated_at.desc()) + if skip is not None: + query = query.offset(skip) + if limit is not None: + query = query.limit(limit) + notes = query.all() return [NoteModel.model_validate(note) for note in notes] def get_notes_by_user_id( - self, user_id: str, permission: str = "write" + self, + user_id: str, + permission: str = "write", + skip: Optional[int] = None, + limit: Optional[int] = None, ) -> list[NoteModel]: - notes = self.get_notes() + notes = self.get_notes(skip=skip, limit=limit) user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} return [ note 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/tools.py b/backend/open_webui/models/tools.py index b4ce4cd336..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 diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 620a746eed..05000744dd 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel): role: str +class UserIdNameResponse(BaseModel): + id: str + name: str + + class UserInfoListResponse(BaseModel): users: list[UserInfoResponse] total: int +class UserIdNameListResponse(BaseModel): + users: list[UserIdNameResponse] + total: int + + class UserResponse(BaseModel): id: str name: str @@ -210,7 +220,7 @@ class UsersTable: filter: Optional[dict] = None, skip: Optional[int] = None, limit: Optional[int] = None, - ) -> UserListResponse: + ) -> dict: with get_db() as db: query = db.query(User) 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 dead8458cb..65da1592e1 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.models.users import UserModel from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges + +from open_webui.models.chats import Chats from open_webui.models.notes import Notes from open_webui.retrieval.vector.main import GetResult from open_webui.utils.access_control import has_access +from open_webui.utils.misc import get_message_list from open_webui.env import ( @@ -124,7 +127,13 @@ def query_doc_with_hybrid_search( hybrid_bm25_weight: float, ) -> dict: try: - if not collection_result.documents[0]: + if ( + not collection_result + or not hasattr(collection_result, "documents") + or not collection_result.documents + or len(collection_result.documents) == 0 + or not collection_result.documents[0] + ): log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") return {"documents": [], "metadatas": [], "distances": []} @@ -432,13 +441,14 @@ def get_embedding_function( if isinstance(query, list): embeddings = [] for i in range(0, len(query), embedding_batch_size): - embeddings.extend( - func( - query[i : i + embedding_batch_size], - prefix=prefix, - user=user, - ) + batch_embeddings = func( + query[i : i + embedding_batch_size], + prefix=prefix, + user=user, ) + + if isinstance(batch_embeddings, list): + embeddings.extend(batch_embeddings) return embeddings else: return func(query, prefix, user) @@ -490,25 +500,37 @@ def get_sources_from_items( # Raw Text # Used during temporary chat file uploads or web page & youtube attachements - 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("meta", {})]], - } - else: - # Fallback to item content - query_result = { - "documents": [[item.get("content")]], - "metadatas": [ - [{"file_id": item.get("id"), "name": item.get("name")}] - ], - } + if item.get("context") == "full": + if 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("meta", {})]], + } + + if query_result is None: + # Fallback + 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("meta", {})]], + } + else: + # Fallback to item content + query_result = { + "documents": [[item.get("content")]], + "metadatas": [ + [{"file_id": item.get("id"), "name": item.get("name")}] + ], + } elif item.get("type") == "note": # Note Attached @@ -525,6 +547,30 @@ def get_sources_from_items( "metadatas": [[{"file_id": note.id, "name": note.title}]], } + elif item.get("type") == "chat": + # Chat Attached + chat = Chats.get_chat_by_id(item.get("id")) + + if chat and (user.role == "admin" or chat.user_id == user.id): + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + + if messages_map and message_id: + # Reconstruct the message list in order + message_list = get_message_list(messages_map, message_id) + message_history = "\n".join( + [ + f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n" + for m in message_list + ] + ) + + # User has access to the chat + query_result = { + "documents": [[message_history]], + "metadatas": [[{"file_id": chat.id, "name": chat.title}]], + } + elif item.get("type") == "file": if ( item.get("context") == "full" @@ -581,6 +627,7 @@ def get_sources_from_items( if knowledge_base and ( user.role == "admin" + or knowledge_base.user_id == user.id or has_access(user.id, "read", knowledge_base.access_control) ): 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 f71be198af..100610a83a 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -337,7 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: r = await session.post( - url=urljoin(request.app.state.config.TTS_OPENAI_API_BASE_URL, "/audio/speech"), + url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", json=payload, headers={ "Content-Type": "application/json", @@ -465,7 +465,8 @@ async def speech(request: Request, user=Depends(get_verified_user)): timeout=timeout, trust_env=True ) as session: async with session.post( - urljoin(base_url or f"https://{region}.tts.speech.microsoft.com", "/cognitiveservices/v1"), + (base_url or f"https://{region}.tts.speech.microsoft.com") + + "/cognitiveservices/v1", headers={ "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, "Content-Type": "application/ssml+xml", @@ -549,7 +550,7 @@ def transcription_handler(request, file_path, metadata): metadata = metadata or {} languages = [ - metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE, + metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE, None, # Always fallback to None in case transcription fails ] 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/channels.py b/backend/open_webui/routers/channels.py index cf3603c6ff..da52be6e79 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -24,9 +24,17 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS +from open_webui.utils.models import ( + get_all_models, + get_filtered_models, +) +from open_webui.utils.chat import generate_chat_completion + + from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, get_users_with_access from open_webui.utils.webhook import post_webhook +from open_webui.utils.channels import extract_mentions, replace_mentions log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -200,14 +208,11 @@ async def send_notification(name, webui_url, channel, message, active_user_ids): users = get_users_with_access("read", channel.access_control) for user in users: - if user.id in active_user_ids: - continue - else: + if user.id not in active_user_ids: if user.settings: webhook_url = user.settings.ui.get("notifications", {}).get( "webhook_url", None ) - if webhook_url: await post_webhook( name, @@ -221,14 +226,134 @@ async def send_notification(name, webui_url, channel, message, active_user_ids): }, ) + return True -@router.post("/{id}/messages/post", response_model=Optional[MessageModel]) -async def post_new_message( - request: Request, - id: str, - form_data: MessageForm, - background_tasks: BackgroundTasks, - user=Depends(get_verified_user), + +async def model_response_handler(request, channel, message, user): + MODELS = { + model["id"]: model + for model in get_filtered_models(await get_all_models(request, user=user), user) + } + + mentions = extract_mentions(message.content) + message_content = replace_mentions(message.content) + + # check if any of the mentions are models + model_mentions = [mention for mention in mentions if mention["id_type"] == "M"] + if not model_mentions: + return False + + for mention in model_mentions: + model_id = mention["id"] + model = MODELS.get(model_id, None) + + if model: + try: + # reverse to get in chronological order + thread_messages = Messages.get_messages_by_parent_id( + channel.id, + message.parent_id if message.parent_id else message.id, + )[::-1] + + response_message, channel = await new_message_handler( + request, + channel.id, + MessageForm( + **{ + "parent_id": ( + message.parent_id if message.parent_id else message.id + ), + "content": f"", + "data": {}, + "meta": { + "model_id": model_id, + "model_name": model.get("name", model_id), + }, + } + ), + user, + ) + + thread_history = [] + message_users = {} + + for thread_message in thread_messages: + message_user = None + if thread_message.user_id not in message_users: + message_user = Users.get_user_by_id(thread_message.user_id) + message_users[thread_message.user_id] = message_user + else: + message_user = message_users[thread_message.user_id] + + if thread_message.meta and thread_message.meta.get( + "model_id", None + ): + # If the message was sent by a model, use the model name + message_model_id = thread_message.meta.get("model_id", None) + message_model = MODELS.get(message_model_id, None) + username = ( + message_model.get("name", message_model_id) + if message_model + else message_model_id + ) + else: + username = message_user.name if message_user else "Unknown" + + thread_history.append( + f"{username}: {replace_mentions(thread_message.content)}" + ) + + system_message = { + "role": "system", + "content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational." + + ( + f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context." + if thread_history + else "" + ), + } + + form_data = { + "model": model_id, + "messages": [ + system_message, + { + "role": "user", + "content": f"{user.name if user else 'User'}: {message_content}", + }, + ], + "stream": False, + } + + res = await generate_chat_completion( + request, + form_data=form_data, + user=user, + ) + + if res: + await update_message_by_id( + channel.id, + response_message.id, + MessageForm( + **{ + "content": res["choices"][0]["message"]["content"], + "meta": { + "done": True, + }, + } + ), + user, + ) + except Exception as e: + log.info(e) + pass + + return True + + +async def new_message_handler( + request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user) ): channel = Channels.get_channel_by_id(id) if not channel: @@ -302,11 +427,30 @@ async def post_new_message( }, to=f"channel:{channel.id}", ) + return MessageModel(**message.model_dump()), channel + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) - active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") - background_tasks.add_task( - send_notification, +@router.post("/{id}/messages/post", response_model=Optional[MessageModel]) +async def post_new_message( + request: Request, + id: str, + form_data: MessageForm, + background_tasks: BackgroundTasks, + user=Depends(get_verified_user), +): + + try: + message, channel = await new_message_handler(request, id, form_data, user) + active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") + + async def background_handler(): + await model_response_handler(request, channel, message, user) + await send_notification( request.app.state.WEBUI_NAME, request.app.state.config.WEBUI_URL, channel, @@ -314,7 +458,12 @@ async def post_new_message( active_user_ids, ) - return MessageModel(**message.model_dump()) + background_tasks.add_task(background_handler) + + return message + + except HTTPException as e: + raise e except Exception as e: log.exception(e) raise HTTPException( diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 6f853ab266..847368412e 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -166,7 +166,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user) @router.get("/search", response_model=list[ChatTitleIdResponse]) -async def search_user_chats( +def search_user_chats( text: str, page: Optional[int] = None, user=Depends(get_verified_user) ): if page is None: diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 8ce4e0d247..31d7bce404 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,3 +1,4 @@ +import logging from fastapi import APIRouter, Depends, Request, HTTPException from pydantic import BaseModel, ConfigDict @@ -12,10 +13,16 @@ from open_webui.utils.tools import ( get_tool_server_url, set_tool_servers, ) +from open_webui.utils.mcp.client import MCPClient + +from open_webui.env import SRC_LOG_LEVELS router = APIRouter() +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + ############################ # ImportConfig @@ -87,6 +94,7 @@ async def set_connections_config( class ToolServerConnection(BaseModel): url: str path: str + type: Optional[str] = "openapi" # openapi, mcp auth_type: Optional[str] key: Optional[str] config: Optional[dict] @@ -129,19 +137,72 @@ async def verify_tool_servers_config( Verify the connection to the tool server. """ try: + if form_data.type == "mcp": + try: + client = MCPClient() + auth = None + headers = None - token = None - if form_data.auth_type == "bearer": - token = form_data.key - elif form_data.auth_type == "session": - token = request.state.token.credentials + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + elif form_data.auth_type == "system_oauth": + try: + if request.cookies.get("oauth_session_id", None): + token = ( + await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + ) + except Exception as e: + pass - url = get_tool_server_url(form_data.url, form_data.path) - return await get_tool_server_data(token, url) + if token: + headers = {"Authorization": f"Bearer {token}"} + + await client.connect(form_data.url, auth=auth, headers=headers) + specs = await client.list_tool_specs() + return { + "status": True, + "specs": specs, + } + except Exception as e: + log.debug(f"Failed to create MCP client: {e}") + raise HTTPException( + status_code=400, + detail=f"Failed to create MCP client", + ) + finally: + if client: + await client.disconnect() + else: # openapi + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + elif form_data.auth_type == "system_oauth": + try: + if request.cookies.get("oauth_session_id", None): + token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get("oauth_session_id", None), + ) + except Exception as e: + pass + + url = get_tool_server_url(form_data.url, form_data.path) + return await get_tool_server_data(token, url) + except HTTPException as e: + raise e except Exception as e: + log.debug(f"Failed to connect to the tool server: {e}") raise HTTPException( status_code=400, - detail=f"Failed to connect to the tool server: {str(e)}", + detail=f"Failed to connect to the tool server", ) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index d08c5396ce..84d8f841cf 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -120,11 +120,6 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us f"File type {file.content_type} is not provided, but trying to process anyway" ) process_file(request, ProcessFileForm(file_id=file_item.id), user=user) - - Files.update_file_data_by_id( - file_item.id, - {"status": "completed"}, - ) except Exception as e: log.error(f"Error processing file: {file_item.id}") Files.update_file_data_by_id( @@ -411,25 +406,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 cd333d9071..36dbfee5c5 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -15,6 +15,9 @@ from open_webui.models.folders import ( 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 @@ -45,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(), diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index b5beb96cf0..202aa74ca4 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) ): @@ -147,6 +148,18 @@ async def sync_functions( content=function.content, ) + if hasattr(function_module, "Valves") and function.valves: + Valves = function_module.Valves + try: + Valves( + **{k: v for k, v in function.valves.items() if v is not None} + ) + except Exception as e: + log.exception( + f"Error validating valves for function {function.id}: {e}" + ) + raise e + return Functions.sync_functions(user.id, form_data.functions) except Exception as e: log.exception(f"Failed to load a function: {e}") @@ -191,6 +204,9 @@ async def create_new_function( function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) + if function_type == "filter" and getattr(function_module, "toggle", None): + Functions.update_function_metadata_by_id(id, {"toggle": True}) + if function: return function else: @@ -307,6 +323,9 @@ async def update_function_by_id( function = Functions.update_function_by_id(id, updated) + if function_type == "filter" and getattr(function_module, "toggle", None): + Functions.update_function_metadata_by_id(id, {"toggle": True}) + if function: return function else: diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 10af496579..71722d706e 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -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(), diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a4d4e3668e..05d7c68006 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -1,4 +1,6 @@ from typing import Optional +import io +import base64 from open_webui.models.models import ( ModelForm, @@ -10,12 +12,13 @@ from open_webui.models.models import ( from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status, Response +from fastapi.responses import FileResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission -from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR router = APIRouter() @@ -129,6 +132,39 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)): ) +########################### +# GetModelById +########################### + + +@router.get("/model/profile/image") +async def get_model_profile_image(id: str, user=Depends(get_verified_user)): + model = Models.get_model_by_id(id) + if model: + if model.meta.profile_image_url: + if model.meta.profile_image_url.startswith("http"): + return Response( + status_code=status.HTTP_302_FOUND, + headers={"Location": model.meta.profile_image_url}, + ) + elif model.meta.profile_image_url.startswith("data:image"): + try: + header, base64_data = model.meta.profile_image_url.split(",", 1) + image_data = base64.b64decode(base64_data) + image_buffer = io.BytesIO(image_data) + + return StreamingResponse( + image_buffer, + media_type="image/png", + headers={"Content-Disposition": "inline; filename=image.png"}, + ) + except Exception as e: + pass + return FileResponse(f"{STATIC_DIR}/favicon.png") + else: + return FileResponse(f"{STATIC_DIR}/favicon.png") + + ############################ # ToggleModelById ############################ diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 375f59ff6c..dff7bc2e7f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel): @router.get("/list", response_model=list[NoteTitleIdResponse]) -async def get_note_list(request: Request, user=Depends(get_verified_user)): - +async def get_note_list( + request: Request, page: Optional[int] = None, user=Depends(get_verified_user) +): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS ): @@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.UNAUTHORIZED, ) + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + notes = [ NoteTitleIdResponse(**note.model_dump()) - for note in Notes.get_notes_by_user_id(user.id, "write") + for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit) ] return notes diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 4c5cdce8ca..bf11ffa0dd 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, key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models") +@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: @@ -1691,25 +1694,27 @@ async def download_file_stream( yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n' if done: - file.seek(0) - chunk_size = 1024 * 1024 * 2 - hashed = calculate_sha256(file, chunk_size) - file.seek(0) + file.close() - url = f"{ollama_url}/api/blobs/sha256:{hashed}" - response = requests.post(url, data=file) + with open(file_path, "rb") as file: + chunk_size = 1024 * 1024 * 2 + hashed = calculate_sha256(file, chunk_size) - if response.ok: - res = { - "done": done, - "blob": f"sha256:{hashed}", - "name": file_name, - } - os.remove(file_path) + url = f"{ollama_url}/api/blobs/sha256:{hashed}" + with requests.Session() as session: + response = session.post(url, data=file, timeout=30) - yield f"data: {json.dumps(res)}\n\n" - else: - raise "Ollama: Could not create blob, Please try again." + if response.ok: + res = { + "done": done, + "blob": f"sha256:{hashed}", + "name": file_name, + } + os.remove(file_path) + + yield f"data: {json.dumps(res)}\n\n" + else: + raise "Ollama: Could not create blob, Please try again." # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf" diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index a94791bdf5..e8865b90a0 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -9,6 +9,8 @@ from aiocache import cached import requests from urllib.parse import quote +from azure.identity import DefaultAzureCredential, get_bearer_token_provider + from fastapi import Depends, HTTPException, Request, APIRouter from fastapi.responses import ( FileResponse, @@ -119,6 +121,93 @@ def openai_reasoning_model_handler(payload): return payload +async 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: + if request.cookies.get("oauth_session_id", None): + oauth_token = await 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', '')}" + + elif auth_type in ("azure_ad", "microsoft_entra_id"): + token = get_microsoft_entra_id_access_token() + + if token: + headers["Authorization"] = f"Bearer {token}" + + return headers, cookies + + +def get_microsoft_entra_id_access_token(): + """ + Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI. + Returns the token string or None if authentication fails. + """ + try: + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" + ) + return token_provider() + except Exception as e: + log.error(f"Error getting Microsoft Entra ID access token: {e}") + return None + + ########################################## # # API routes @@ -210,34 +299,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 = await 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 +479,10 @@ async def get_filtered_models(models, user): return filtered_models -@cached(ttl=MODELS_CACHE_TTL, key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models") +@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 +570,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 = await get_headers_and_cookies( + request, url, key, api_config, user=user + ) if api_config.get("azure", False): models = { @@ -509,11 +580,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 +642,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,27 +656,21 @@ 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 = await get_headers_and_cookies( + request, url, key, api_config, user=user + ) if api_config.get("azure", False): - headers["api-key"] = key - api_version = api_config.get("api_version", "") or "2023-03-15-preview" + # Only set api-key header if not using Azure Entra ID authentication + auth_type = api_config.get("auth_type", "bearer") + if auth_type not in ("azure_ad", "microsoft_entra_id"): + headers["api-key"] = key + api_version = api_config.get("api_version", "") or "2023-03-15-preview" 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 +690,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,42 +901,23 @@ 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 = await 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") request_url, payload = convert_to_azure_payload(url, payload, api_version) - headers["api-key"] = key + + # Only set api-key header if not using Azure Entra ID authentication + auth_type = api_config.get("auth_type", "bearer") + if auth_type not in ("azure_ad", "microsoft_entra_id"): + headers["api-key"] = key + headers["api-version"] = api_version 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 +936,7 @@ async def generate_chat_completion( url=request_url, data=payload, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) @@ -951,31 +998,29 @@ 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 = await 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,23 +1082,18 @@ 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 = await 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") - headers["api-key"] = key + + # Only set api-key header if not using Azure Entra ID authentication + auth_type = api_config.get("auth_type", "bearer") + if auth_type not in ("azure_ad", "microsoft_entra_id"): + headers["api-key"] = key + headers["api-version"] = api_version payload = json.loads(body) @@ -1062,7 +1102,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 +1110,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..0ddf824efa 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, @@ -1295,7 +1334,7 @@ def save_docs_to_vector_db( ) return True - log.info(f"adding to collection {collection_name}") + log.info(f"generating embeddings for {collection_name}") embedding_function = get_embedding_function( request.app.state.config.RAG_EMBEDDING_ENGINE, request.app.state.config.RAG_EMBEDDING_MODEL, @@ -1331,6 +1370,7 @@ def save_docs_to_vector_db( prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user, ) + log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items") items = [ { @@ -1342,11 +1382,13 @@ def save_docs_to_vector_db( for idx, text in enumerate(texts) ] + log.info(f"adding to collection {collection_name}") VECTOR_DB_CLIENT.insert( collection_name=collection_name, items=items, ) + log.info(f"added {len(items)} items to collection {collection_name}") return True except Exception as e: log.exception(e) @@ -1453,8 +1495,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, @@ -1500,13 +1547,20 @@ def process_file( log.debug(f"text_content: {text_content}") Files.update_file_data_by_id( file.id, - {"status": "completed", "content": text_content}, + {"content": text_content}, ) - hash = calculate_sha256_string(text_content) Files.update_file_hash_by_id(file.id, hash) - if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + Files.update_file_data_by_id(file.id, {"status": "completed"}) + return { + "status": True, + "collection_name": None, + "filename": file.filename, + "content": text_content, + } + else: try: result = save_docs_to_vector_db( request, @@ -1520,6 +1574,7 @@ def process_file( add=(True if form_data.collection_name else False), user=user, ) + log.info(f"added {len(docs)} items to collection {collection_name}") if result: Files.update_file_metadata_by_id( @@ -1529,21 +1584,21 @@ def process_file( }, ) + Files.update_file_data_by_id( + file.id, + {"status": "completed"}, + ) + return { "status": True, "collection_name": collection_name, "filename": file.filename, "content": text_content, } + else: + raise Exception("Error saving document to vector database") except Exception as e: raise e - else: - return { - "status": True, - "collection_name": None, - "filename": file.filename, - "content": text_content, - } except Exception as e: log.exception(e) @@ -1945,6 +2000,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 +2023,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 +2068,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 +2110,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 5f82e7f1bd..71c7069fd3 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -43,6 +43,7 @@ router = APIRouter() async def get_tools(request: Request, user=Depends(get_verified_user)): tools = Tools.get_tools() + # OpenAPI Tool Servers for server in await get_tool_servers(request): tools.append( ToolUserResponse( @@ -68,6 +69,29 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): ) ) + # MCP Tool Servers + for server in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if server.get("type", "openapi") == "mcp": + tools.append( + ToolUserResponse( + **{ + "id": f"server:mcp:{server.get('info', {}).get('id')}", + "user_id": f"server:mcp:{server.get('info', {}).get('id')}", + "name": server.get("info", {}).get("name", "MCP Tool Server"), + "meta": { + "description": server.get("info", {}).get( + "description", "" + ), + }, + "access_control": server.get("config", {}).get( + "access_control", None + ), + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + ) + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: # Admin can see all tools return tools diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 4d2539a18e..9a0f8c6aaf 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -10,12 +10,15 @@ 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 ( UserModel, UserListResponse, UserInfoListResponse, + UserIdNameListResponse, UserRoleUpdateForm, Users, UserSettings, @@ -98,6 +101,23 @@ async def get_all_users( return Users.get_users() +@router.get("/search", response_model=UserIdNameListResponse) +async def search_users( + query: Optional[str] = None, + user=Depends(get_verified_user), +): + limit = PAGE_ITEM_COUNT + + page = 1 # Always return the first page for search + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + + return Users.get_users(filter=filter, skip=skip, limit=limit) + + ############################ # User Groups ############################ @@ -340,6 +360,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/utils/access_control.py b/backend/open_webui/utils/access_control.py index 1529773c44..6215a6ac22 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control.py @@ -130,9 +130,10 @@ def has_access( # Get all users with access to a resource def get_users_with_access( type: str = "write", access_control: Optional[dict] = None -) -> List[UserModel]: +) -> list[UserModel]: if access_control is None: - return Users.get_users() + result = Users.get_users() + return result.get("users", []) permission_access = access_control.get(type, {}) permitted_group_ids = permission_access.get("group_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/channels.py b/backend/open_webui/utils/channels.py new file mode 100644 index 0000000000..312b5ea24c --- /dev/null +++ b/backend/open_webui/utils/channels.py @@ -0,0 +1,31 @@ +import re + + +def extract_mentions(message: str, triggerChar: str = "@"): + # Escape triggerChar in case it's a regex special character + triggerChar = re.escape(triggerChar) + pattern = rf"<{triggerChar}([A-Z]):([^|>]+)" + + matches = re.findall(pattern, message) + return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches] + + +def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True): + """ + Replace mentions in the message with either their label (after the pipe `|`) + or their id if no label exists. + + Example: + "<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True) + "<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False) + """ + # Escape triggerChar + triggerChar = re.escape(triggerChar) + + def replacer(match): + id_type, id_value, label = match.groups() + return label if use_label and label else id_value + + # Regex captures: idType, id, optional label + pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>" + return re.sub(pattern, replacer, message) diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py new file mode 100644 index 0000000000..b410cbab50 --- /dev/null +++ b/backend/open_webui/utils/files.py @@ -0,0 +1,97 @@ +from open_webui.routers.images import ( + load_b64_image_data, + upload_image, +) + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Request, + UploadFile, +) + +from open_webui.routers.files import upload_file_handler + +import mimetypes +import base64 +import io + + +def get_image_url_from_base64(request, base64_image_string, metadata, user): + if "data:image/png;base64" in base64_image_string: + image_url = "" + # Extract base64 image data from the line + image_data, content_type = load_b64_image_data(base64_image_string) + if image_data is not None: + image_url = upload_image( + request, + image_data, + content_type, + metadata, + user, + ) + return image_url + return None + + +def load_b64_audio_data(b64_str): + try: + if "," in b64_str: + header, b64_data = b64_str.split(",", 1) + else: + b64_data = b64_str + header = "data:audio/wav;base64" + audio_data = base64.b64decode(b64_data) + content_type = ( + header.split(";")[0].split(":")[1] if ";" in header else "audio/wav" + ) + return audio_data, content_type + except Exception as e: + print(f"Error decoding base64 audio data: {e}") + return None, None + + +def upload_audio(request, audio_data, content_type, metadata, user): + audio_format = mimetypes.guess_extension(content_type) + file = UploadFile( + file=io.BytesIO(audio_data), + filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file + headers={ + "content-type": content_type, + }, + ) + file_item = upload_file_handler( + request, + file=file, + metadata=metadata, + process=False, + user=user, + ) + url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) + return url + + +def get_audio_url_from_base64(request, base64_audio_string, metadata, user): + if "data:audio/wav;base64" in base64_audio_string: + audio_url = "" + # Extract base64 audio data from the line + audio_data, content_type = load_b64_audio_data(base64_audio_string) + if audio_data is not None: + audio_url = upload_audio( + request, + audio_data, + content_type, + metadata, + user, + ) + return audio_url + return None + + +def get_file_url_from_base64(request, base64_file_string, metadata, user): + if "data:image/png;base64" in base64_file_string: + return get_image_url_from_base64(request, base64_file_string, metadata, user) + elif "data:audio/wav;base64" in base64_file_string: + return get_audio_url_from_base64(request, base64_file_string, metadata, user) + return None diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 1986e55b64..663b4e3fb7 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -127,8 +127,10 @@ async def process_filter_functions( raise e # Handle file cleanup for inlet - if skip_files and "files" in form_data.get("metadata", {}): - del form_data["files"] - del form_data["metadata"]["files"] + if skip_files: + if "files" in form_data.get("metadata", {}): + del form_data["metadata"]["files"] + if "files" in form_data: + del form_data["files"] return form_data, {} diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py new file mode 100644 index 0000000000..2d352ead24 --- /dev/null +++ b/backend/open_webui/utils/mcp/client.py @@ -0,0 +1,114 @@ +import asyncio +from typing import Optional +from contextlib import AsyncExitStack + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamablehttp_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class MCPClient: + def __init__(self): + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + + async def connect( + self, url: str, headers: Optional[dict] = None, auth: Optional[any] = None + ): + try: + self._streams_context = streamablehttp_client( + url, headers=headers, auth=auth + ) + + transport = await self.exit_stack.enter_async_context(self._streams_context) + read_stream, write_stream, _ = transport + + self._session_context = ClientSession( + read_stream, write_stream + ) # pylint: disable=W0201 + + self.session = await self.exit_stack.enter_async_context( + self._session_context + ) + await self.session.initialize() + except Exception as e: + await self.disconnect() + raise e + + async def list_tool_specs(self) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.list_tools() + tools = result.tools + + tool_specs = [] + for tool in tools: + name = tool.name + description = tool.description + + inputSchema = tool.inputSchema + + # TODO: handle outputSchema if needed + outputSchema = getattr(tool, "outputSchema", None) + + tool_specs.append( + {"name": name, "description": description, "parameters": inputSchema} + ) + + return tool_specs + + async def call_tool( + self, function_name: str, function_args: dict + ) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.call_tool(function_name, function_args) + if not result: + raise Exception("No result returned from MCP tool call.") + + result_dict = result.model_dump(mode="json") + result_content = result_dict.get("content", {}) + + if result.isError: + raise Exception(result_content) + else: + return result_content + + async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.list_resources(cursor=cursor) + if not result: + raise Exception("No result returned from MCP list_resources call.") + + result_dict = result.model_dump() + resources = result_dict.get("resources", []) + + return resources + + async def read_resource(self, uri: str) -> Optional[dict]: + if not self.session: + raise RuntimeError("MCP client is not connected.") + + result = await self.session.read_resource(uri) + if not result: + raise Exception("No result returned from MCP read_resource call.") + result_dict = result.model_dump() + + return result_dict + + async def disconnect(self): + # Clean up and close the session + await self.exit_stack.aclose() + + async def __aenter__(self): + await self.exit_stack.__aenter__() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.exit_stack.__aexit__(exc_type, exc_value, traceback) + await self.disconnect() diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 1de1592da1..52c78f199c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -20,6 +20,7 @@ from concurrent.futures import ThreadPoolExecutor from fastapi import Request, HTTPException +from fastapi.responses import HTMLResponse from starlette.responses import Response, StreamingResponse, JSONResponse @@ -52,6 +53,11 @@ from open_webui.routers.pipelines import ( from open_webui.routers.memories import query_memory, QueryMemoryForm from open_webui.utils.webhook import post_webhook +from open_webui.utils.files import ( + get_audio_url_from_base64, + get_file_url_from_base64, + get_image_url_from_base64, +) from open_webui.models.users import UserModel @@ -86,6 +92,7 @@ from open_webui.utils.filter import ( ) from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.payload import apply_system_prompt_to_body +from open_webui.utils.mcp.client import MCPClient from open_webui.config import ( @@ -144,12 +151,14 @@ async def chat_completion_tools_handler( def get_tools_function_calling_payload(messages, task_model_id, content): user_message = get_last_user_message(messages) - history = "\n".join( + + recent_messages = messages[-4:] if len(messages) > 4 else messages + chat_history = "\n".join( f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\"" - for message in messages[::-1][:4] + for message in recent_messages ) - prompt = f"History:\n{history}\nQuery: {user_message}" + prompt = f"History:\n{chat_history}\nQuery: {user_message}" return { "model": task_model_id, @@ -369,7 +378,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 +444,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 +496,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 +539,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 +591,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 +634,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 +672,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 +719,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 +824,16 @@ 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: + if request.cookies.get("oauth_session_id", None): + oauth_token = await 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 +841,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 @@ -931,14 +996,91 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Server side tools tool_ids = metadata.get("tool_ids", None) # Client side tools - tool_servers = metadata.get("tool_servers", None) + direct_tool_servers = metadata.get("tool_servers", None) log.debug(f"{tool_ids=}") - log.debug(f"{tool_servers=}") + log.debug(f"{direct_tool_servers=}") tools_dict = {} + mcp_clients = [] + mcp_tools_dict = {} + if tool_ids: + for tool_id in tool_ids: + if tool_id.startswith("server:mcp:"): + try: + server_id = tool_id[len("server:mcp:") :] + + mcp_server_connection = None + for ( + server_connection + ) in request.app.state.config.TOOL_SERVER_CONNECTIONS: + if ( + server_connection.get("type", "") == "mcp" + and server_connection.get("info", {}).get("id") == server_id + ): + mcp_server_connection = server_connection + break + + if not mcp_server_connection: + log.error(f"MCP server with id {server_id} not found") + continue + + auth_type = mcp_server_connection.get("auth_type", "") + + headers = {} + if auth_type == "bearer": + headers["Authorization"] = ( + f"Bearer {mcp_server_connection.get('key', '')}" + ) + elif auth_type == "none": + # No authentication + pass + elif auth_type == "session": + headers["Authorization"] = ( + f"Bearer {request.state.token.credentials}" + ) + elif auth_type == "system_oauth": + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = ( + f"Bearer {oauth_token.get('access_token', '')}" + ) + + mcp_client = MCPClient() + await mcp_client.connect( + url=mcp_server_connection.get("url", ""), + headers=headers if headers else None, + ) + + tool_specs = await mcp_client.list_tool_specs() + for tool_spec in tool_specs: + + def make_tool_function(function_name): + async def tool_function(**kwargs): + return await mcp_client.call_tool( + function_name, + function_args=kwargs, + ) + + return tool_function + + tool_function = make_tool_function(tool_spec["name"]) + + mcp_tools_dict[tool_spec["name"]] = { + "spec": tool_spec, + "callable": tool_function, + "type": "mcp", + "client": mcp_client, + "direct": False, + } + + mcp_clients.append(mcp_client) + except Exception as e: + log.debug(e) + continue + tools_dict = await get_tools( request, tool_ids, @@ -950,9 +1092,11 @@ async def process_chat_payload(request, form_data, user, metadata, model): "__files__": metadata.get("files", []), }, ) + if mcp_tools_dict: + tools_dict = {**tools_dict, **mcp_tools_dict} - if tool_servers: - for tool_server in tool_servers: + if direct_tool_servers: + for tool_server in direct_tool_servers: tool_specs = tool_server.pop("specs", []) for tool in tool_specs: @@ -962,6 +1106,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): "server": tool_server, } + if mcp_clients: + metadata["mcp_clients"] = mcp_clients + if tools_dict: if metadata.get("params", {}).get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler @@ -970,6 +1117,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): {"type": "function", "function": tool.get("spec", {})} for tool in tools_dict.values() ] + else: # If the function calling is not native, then call the tools function calling handler try: @@ -981,7 +1129,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) @@ -1073,11 +1223,11 @@ async def process_chat_response( request, response, form_data, user, metadata, model, events, tasks ): async def background_tasks_handler(): - message_map = Chats.get_messages_by_chat_id(metadata["chat_id"]) - message = message_map.get(metadata["message_id"]) if message_map else None + messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"]) + message = messages_map.get(metadata["message_id"]) if messages_map else None if message: - message_list = get_message_list(message_map, metadata["message_id"]) + message_list = get_message_list(messages_map, metadata["message_id"]) # Remove details tags and files from the messages. # as get_message_list creates a new list, it does not affect @@ -1437,11 +1587,22 @@ async def process_chat_response( ): return response + oauth_token = None + try: + if request.cookies.get("oauth_session_id", None): + oauth_token = await 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, } @@ -1512,7 +1673,8 @@ async def process_chat_response( break if tool_result is not None: - tool_calls_display_content = f'{tool_calls_display_content}
\nTool Executed\n
\n' + tool_result_embeds = result.get("embeds", "") + 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' @@ -1962,6 +2124,20 @@ async def process_chat_response( ) else: choices = data.get("choices", []) + + # 17421 + usage = data.get("usage", {}) or {} + usage.update(data.get("timings", {})) # llama.cpp + if usage: + await event_emitter( + { + "type": "chat:completion", + "data": { + "usage": usage, + }, + } + ) + if not choices: error = data.get("error", {}) if error: @@ -1973,20 +2149,6 @@ async def process_chat_response( }, } ) - usage = data.get("usage", {}) - usage.update( - data.get("timing", {}) - ) # llama.cpp - - if usage: - await event_emitter( - { - "type": "chat:completion", - "data": { - "usage": usage, - }, - } - ) continue delta = choices[0].get("delta", {}) @@ -2259,6 +2421,8 @@ async def process_chat_response( results = [] for tool_call in response_tool_calls: + + print("tool_call", tool_call) tool_call_id = tool_call.get("id", "") tool_name = tool_call.get("function", {}).get("name", "") tool_args = tool_call.get("function", {}).get("arguments", "{}") @@ -2333,14 +2497,133 @@ async def process_chat_response( except Exception as e: tool_result = str(e) + tool_result_embeds = [] + if isinstance(tool_result, HTMLResponse): + content_disposition = tool_result.headers.get( + "Content-Disposition", "" + ) + if "inline" in content_disposition: + content = tool_result.body.decode("utf-8") + tool_result_embeds.append(content) + + if 200 <= tool_result.status_code < 300: + tool_result = { + "status": "success", + "code": "ui_component", + "message": "Embedded UI result is active and visible to the user.", + } + elif 400 <= tool_result.status_code < 500: + tool_result = { + "status": "error", + "code": "ui_component", + "message": f"Client error {tool_result.status_code} from embedded UI result.", + } + elif 500 <= tool_result.status_code < 600: + tool_result = { + "status": "error", + "code": "ui_component", + "message": f"Server error {tool_result.status_code} from embedded UI result.", + } + else: + tool_result = { + "status": "error", + "code": "ui_component", + "message": f"Unexpected status code {tool_result.status_code} from embedded UI result.", + } + else: + tool_result = tool_result.body.decode("utf-8") + + elif tool.get("type") == "external" and isinstance( + tool_result, tuple + ): + tool_result, tool_response_headers = tool_result + + if tool_response_headers: + content_disposition = tool_response_headers.get( + "Content-Disposition", "" + ) + + if "inline" in content_disposition: + content_type = tool_response_headers.get( + "Content-Type", "" + ) + location = tool_response_headers.get("Location", "") + + if "text/html" in content_type: + # Display as iframe embed + tool_result_embeds.append(tool_result) + tool_result = { + "status": "success", + "code": "ui_component", + "message": "Embedded UI result is active and visible to the user.", + } + elif location: + tool_result_embeds.append(location) + tool_result = { + "status": "success", + "code": "ui_component", + "message": "Embedded UI result is active and visible to the user.", + } + tool_result_files = [] if isinstance(tool_result, list): for item in tool_result: # check if string if isinstance(item, str) and item.startswith("data:"): - tool_result_files.append(item) + tool_result_files.append( + { + "type": "data", + "content": item, + } + ) tool_result.remove(item) + if tool.get("type") == "mcp": + if isinstance(item, dict): + if ( + item.get("type") == "image" + or item.get("type") == "audio" + ): + file_url = get_file_url_from_base64( + request, + f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}", + { + "chat_id": metadata.get( + "chat_id", None + ), + "message_id": metadata.get( + "message_id", None + ), + "session_id": metadata.get( + "session_id", None + ), + "result": item, + }, + user, + ) + + tool_result_files.append( + { + "type": item.get("type", "data"), + "url": file_url, + } + ) + tool_result.remove(item) + + if tool_result_files: + if not isinstance(tool_result, list): + tool_result = [ + tool_result, + ] + + for file in tool_result_files: + tool_result.append( + { + "type": file.get("type", "data"), + "content": "Result is being displayed as a file.", + } + ) + if isinstance(tool_result, dict) or isinstance( tool_result, list ): @@ -2357,6 +2640,11 @@ async def process_chat_response( if tool_result_files else {} ), + **( + {"embeds": tool_result_embeds} + if tool_result_embeds + else {} + ), } ) @@ -2502,23 +2790,18 @@ async def process_chat_response( if isinstance(stdout, str): stdoutLines = stdout.split("\n") for idx, line in enumerate(stdoutLines): + if "data:image/png;base64" in line: - image_url = "" - # Extract base64 image data from the line - image_data, content_type = ( - load_b64_image_data(line) + image_url = get_image_url_from_base64( + request, + line, + metadata, + user, ) - if image_data is not None: - image_url = upload_image( - request, - image_data, - content_type, - metadata, - user, + if image_url: + stdoutLines[idx] = ( + f"![Output Image]({image_url})" ) - stdoutLines[idx] = ( - f"![Output Image]({image_url})" - ) output["stdout"] = "\n".join(stdoutLines) @@ -2528,19 +2811,12 @@ async def process_chat_response( resultLines = result.split("\n") for idx, line in enumerate(resultLines): if "data:image/png;base64" in line: - image_url = "" - # Extract base64 image data from the line - image_data, content_type = ( - load_b64_image_data(line) + image_url = get_image_url_from_base64( + request, + line, + metadata, + user, ) - if image_data is not None: - image_url = upload_image( - request, - image_data, - content_type, - metadata, - user, - ) resultLines[idx] = ( f"![Output Image]({image_url})" ) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 82729c34e0..370cf26c48 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -26,7 +26,7 @@ def deep_update(d, u): return d -def get_message_list(messages, message_id): +def get_message_list(messages_map, message_id): """ Reconstructs a list of messages in order up to the specified message_id. @@ -36,11 +36,11 @@ def get_message_list(messages, message_id): """ # Handle case where messages is None - if not messages: + if not messages_map: return [] # Return empty list instead of None to prevent iteration errors # Find the message by its id - current_message = messages.get(message_id) + current_message = messages_map.get(message_id) if not current_message: return [] # Return empty list instead of None to prevent iteration errors @@ -53,7 +53,7 @@ def get_message_list(messages, message_id): 0, current_message ) # Insert the message at the beginning of the list parent_id = current_message.get("parentId") # Use .get() for safety - current_message = messages.get(parent_id) if parent_id else None + current_message = messages_map.get(parent_id) if parent_id else None return message_list diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index b713b84307..7e69661f56 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -22,10 +22,11 @@ from open_webui.utils.access_control import has_access from open_webui.config import ( + BYPASS_ADMIN_ACCESS_CONTROL, DEFAULT_ARENA_MODEL, ) -from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL +from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.models.users import UserModel @@ -332,3 +333,40 @@ def check_model_access(user, model): ) ): raise Exception("Model not found") + + +def get_filtered_models(models, user): + # Filter out models that the user does not have access to + if ( + user.role == "user" + or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL) + ) and not BYPASS_MODEL_ACCESS_CONTROL: + filtered_models = [] + for model in models: + if model.get("arena"): + if has_access( + user.id, + type="read", + access_control=model.get("info", {}) + .get("meta", {}) + .get("access_control", {}), + ): + filtered_models.append(model) + continue + + model_info = Models.get_model_by_id(model["id"]) + if model_info: + if ( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == model_info.user_id + or has_access( + user.id, + type="read", + access_control=model_info.access_control, + ) + ): + filtered_models.append(model) + + return filtered_models + else: + return models diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index d38771d4c1..ee3ba79990 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -4,9 +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 @@ -17,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, @@ -49,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 @@ -130,11 +137,187 @@ 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 + + async 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 = await 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() @@ -401,185 +584,211 @@ 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 ( + provider == "feishu" + and isinstance(user_data, dict) + and "data" in user_data + ): + user_data = user_data["data"] + 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) @@ -587,6 +796,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 @@ -599,22 +812,48 @@ class OAuthManager: secure=WEBUI_AUTH_COOKIE_SECURE, ) - if ENABLE_OAUTH_SIGNUP.value: - oauth_access_token = token.get("access_token") + # Legacy cookies for compatibility with older frontend versions + if ENABLE_OAUTH_ID_TOKEN_COOKIE: response.set_cookie( - key="oauth_access_token", - value=oauth_access_token, + key="oauth_id_token", + value=token.get("id_token"), httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, ) - oauth_id_token = token.get("id_token") + 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_id_token", - value=oauth_id_token, + 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/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 75c13ccc0a..c7b47c0231 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -163,20 +163,27 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: @app.middleware("http") async def _metrics_middleware(request: Request, call_next): start_time = time.perf_counter() - response = await call_next(request) - elapsed_ms = (time.perf_counter() - start_time) * 1000.0 - # Route template e.g. "/items/{item_id}" instead of real path. - route = request.scope.get("route") - route_path = getattr(route, "path", request.url.path) + status_code = None + try: + response = await call_next(request) + status_code = getattr(response, "status_code", 500) + return response + except Exception: + status_code = 500 + raise + finally: + elapsed_ms = (time.perf_counter() - start_time) * 1000.0 - attrs: Dict[str, str | int] = { - "http.method": request.method, - "http.route": route_path, - "http.status_code": response.status_code, - } + # Route template e.g. "/items/{item_id}" instead of real path. + route = request.scope.get("route") + route_path = getattr(route, "path", request.url.path) - request_counter.add(1, attrs) - duration_histogram.record(elapsed_ms, attrs) + attrs: Dict[str, str | int] = { + "http.method": request.method, + "http.route": route_path, + "http.status_code": status_code, + } - return response + request_counter.add(1, attrs) + duration_histogram.record(elapsed_ms, attrs) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index d3ea432019..cb3626146a 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -96,80 +96,118 @@ async def get_tools( for tool_id in tool_ids: tool = Tools.get_tool_by_id(tool_id) if tool is None: + if tool_id.startswith("server:"): - server_id = tool_id.split(":")[1] + splits = tool_id.split(":") - tool_server_data = None - for server in await get_tool_servers(request): - if server["id"] == server_id: - tool_server_data = server - break + if len(splits) == 2: + type = "openapi" + server_id = splits[1] + elif len(splits) == 3: + type = splits[1] + server_id = splits[2] - if tool_server_data is None: - log.warning(f"Tool server data not found for {server_id}") + server_id_splits = server_id.split("|") + if len(server_id_splits) == 2: + server_id = server_id_splits[0] + function_names = server_id_splits[1].split(",") + + if type == "openapi": + + tool_server_data = None + for server in await get_tool_servers(request): + if server["id"] == server_id: + tool_server_data = server + break + + if tool_server_data is None: + log.warning(f"Tool server data not found for {server_id}") + continue + + tool_server_idx = tool_server_data.get("idx", 0) + tool_server_connection = ( + request.app.state.config.TOOL_SERVER_CONNECTIONS[ + tool_server_idx + ] + ) + + specs = tool_server_data.get("specs", []) + for spec in specs: + function_name = spec["name"] + + auth_type = tool_server_connection.get("auth_type", "bearer") + + cookies = {} + headers = {} + + if auth_type == "bearer": + headers["Authorization"] = ( + f"Bearer {tool_server_connection.get('key', '')}" + ) + elif auth_type == "none": + # No authentication + pass + elif auth_type == "session": + 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', '')}" + ) + + 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( + url=tool_server_data["url"], + headers=headers, + cookies=cookies, + name=function_name, + params=kwargs, + server_data=tool_server_data, + ) + + return tool_function + + tool_function = make_tool_function( + function_name, tool_server_data, headers + ) + + callable = get_async_tool_function_and_apply_extra_params( + tool_function, + {}, + ) + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + # Misc info + "type": "external", + } + + # Handle function name collisions + while function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + # Prepend server ID to function name + function_name = f"{server_id}_{function_name}" + + tools_dict[function_name] = tool_dict + + else: + log.warning(f"Unsupported tool server type: {type}") continue - tool_server_idx = tool_server_data.get("idx", 0) - tool_server_connection = ( - request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx] - ) - - specs = tool_server_data.get("specs", []) - for spec in specs: - function_name = spec["name"] - - auth_type = tool_server_connection.get("auth_type", "bearer") - headers = {} - - if auth_type == "bearer": - headers["Authorization"] = ( - f"Bearer {tool_server_connection.get('key', '')}" - ) - elif auth_type == "session": - headers["Authorization"] = ( - f"Bearer {request.state.token.credentials}" - ) - elif auth_type == "request_headers": - headers.update(dict(request.headers)) - - 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( - url=tool_server_data["url"], - headers=headers, - name=function_name, - params=kwargs, - server_data=tool_server_data, - ) - - return tool_function - - tool_function = make_tool_function( - function_name, tool_server_data, headers - ) - - callable = get_async_tool_function_and_apply_extra_params( - tool_function, - {}, - ) - - tool_dict = { - "tool_id": tool_id, - "callable": callable, - "spec": spec, - } - - # Handle function name collisions - while function_name in tools_dict: - log.warning( - f"Tool {function_name} already exists in another tools!" - ) - # Prepend server ID to function name - function_name = f"{server_id}_{function_name}" - - tools_dict[function_name] = tool_dict else: continue else: @@ -526,12 +564,23 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]: error_body = await response.json() raise Exception(error_body) + text_content = None + # Check if URL ends with .yaml or .yml to determine format if url.lower().endswith((".yaml", ".yml")): text_content = await response.text() res = yaml.safe_load(text_content) else: - res = await response.json() + text_content = await response.text() + + try: + res = json.loads(text_content) + except json.JSONDecodeError: + try: + res = yaml.safe_load(text_content) + except Exception as e: + raise e + except Exception as err: log.exception(f"Could not fetch tool server spec from {url}") if isinstance(err, dict) and "detail" in err: @@ -550,13 +599,14 @@ 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): - if server.get("config", {}).get("enable"): + if ( + server.get("config", {}).get("enable") + and server.get("type", "openapi") == "openapi" + ): # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL openapi_path = server.get("path", "openapi.json") full_url = get_tool_server_url(server.get("url"), openapi_path) @@ -568,8 +618,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: @@ -620,10 +671,11 @@ async def get_tool_servers_data( async def execute_tool_server( url: str, headers: Dict[str, str], + cookies: Dict[str, str], name: str, params: Dict[str, Any], server_data: Dict[str, Any], -) -> Any: +) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: error = None try: openapi = server_data.get("openapi", {}) @@ -693,7 +745,9 @@ async def execute_tool_server( final_url, json=body_params, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + allow_redirects=False, ) as response: if response.status >= 400: text = await response.text() @@ -704,12 +758,15 @@ async def execute_tool_server( except Exception: response_data = await response.text() - return response_data + response_headers = response.headers + return (response_data, response_headers) else: async with request_method( final_url, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + allow_redirects=False, ) as response: if response.status >= 400: text = await response.text() @@ -720,12 +777,13 @@ async def execute_tool_server( except Exception: response_data = await response.text() - return response_data + response_headers = response.headers + return (response_data, response_headers) except Exception as err: error = str(err) log.exception(f"API Request Error: {error}") - return {"error": error} + return ({"error": error}, None) def get_tool_server_url(url: Optional[str], path: str) -> str: diff --git a/backend/requirements.txt b/backend/requirements.txt index 4410b25537..1b14ac1429 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.115.7 uvicorn[standard]==0.35.0 pydantic==2.11.7 python-multipart==0.0.20 +itsdangerous==2.2.0 python-socketio==5.13.0 python-jose==3.4.0 @@ -20,8 +21,8 @@ sqlalchemy==2.0.38 alembic==1.14.0 peewee==3.18.1 peewee-migrate==1.12.2 -psycopg2-binary==2.9.9 -pgvector==0.4.0 +psycopg2-binary==2.9.10 +pgvector==0.4.1 PyMySQL==1.1.1 bcrypt==4.3.0 @@ -45,18 +46,18 @@ anthropic google-genai==1.32.0 google-generativeai==0.8.5 tiktoken +mcp==1.14.1 langchain==0.3.26 -langchain-community==0.3.26 +langchain-community==0.3.27 fake-useragent==2.2.0 -chromadb==0.6.3 -posthog==5.4.0 +chromadb==1.0.20 pymilvus==2.5.0 qdrant-client==1.14.3 opensearch-py==2.8.0 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml -elasticsearch==9.0.1 +elasticsearch==9.1.0 pinecone==6.0.2 oracledb==3.2.0 @@ -70,7 +71,7 @@ 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,7 +100,7 @@ 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.2.2 @@ -118,10 +119,10 @@ docker~=7.1.0 pytest~=8.4.1 pytest-docker~=3.1.1 -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.70.0 google-cloud-storage==2.19.0 -azure-identity==1.23.0 +azure-identity==1.25.0 azure-storage-blob==12.24.1 diff --git a/package-lock.json b/package-lock.json index 2d78cb673e..75c7d6e63f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.30", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -23,7 +23,7 @@ "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^3.0.7", - "@tiptap/extension-drag-handle": "^3.0.7", + "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-highlight": "^3.3.0", @@ -37,7 +37,9 @@ "@tiptap/extensions": "^3.0.7", "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", + "@tiptap/suggestion": "^3.4.2", "@xyflow/svelte": "^0.1.19", + "alpinejs": "^3.15.0", "async": "^3.2.5", "bits-ui": "^0.21.15", "chart.js": "^4.5.0", @@ -3382,9 +3384,9 @@ } }, "node_modules/@tiptap/extension-collaboration": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz", - "integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz", + "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==", "license": "MIT", "peer": true, "funding": { @@ -3392,8 +3394,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7", + "@tiptap/core": "^3.4.5", + "@tiptap/pm": "^3.4.5", "@tiptap/y-tiptap": "^3.0.0-beta.3", "yjs": "^13" } @@ -3412,9 +3414,9 @@ } }, "node_modules/@tiptap/extension-drag-handle": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz", - "integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz", + "integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13" @@ -3424,10 +3426,10 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/extension-collaboration": "^3.0.7", - "@tiptap/extension-node-range": "^3.0.7", - "@tiptap/pm": "^3.0.7", + "@tiptap/core": "^3.4.5", + "@tiptap/extension-collaboration": "^3.4.5", + "@tiptap/extension-node-range": "^3.4.5", + "@tiptap/pm": "^3.4.5", "@tiptap/y-tiptap": "^3.0.0-beta.3" } }, @@ -3641,9 +3643,9 @@ } }, "node_modules/@tiptap/extension-node-range": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz", - "integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz", + "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==", "license": "MIT", "peer": true, "funding": { @@ -3651,8 +3653,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.4.5", + "@tiptap/pm": "^3.4.5" } }, "node_modules/@tiptap/extension-ordered-list": { @@ -3855,18 +3857,17 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz", - "integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz", + "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.9", - "@tiptap/pm": "^3.0.9" + "@tiptap/core": "^3.4.2", + "@tiptap/pm": "^3.4.2" } }, "node_modules/@tiptap/y-tiptap": { @@ -4569,6 +4570,21 @@ "@types/estree": "^1.0.0" } }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, "node_modules/@webreflection/fetch": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", @@ -4672,6 +4688,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/alpinejs": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz", + "integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, "node_modules/amator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz", diff --git a/package.json b/package.json index d8bff46ba8..14ba04fa89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.26", + "version": "0.6.30", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -67,7 +67,7 @@ "@tiptap/core": "^3.0.7", "@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-code-block-lowlight": "^3.0.7", - "@tiptap/extension-drag-handle": "^3.0.7", + "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-highlight": "^3.3.0", @@ -81,7 +81,9 @@ "@tiptap/extensions": "^3.0.7", "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", + "@tiptap/suggestion": "^3.4.2", "@xyflow/svelte": "^0.1.19", + "alpinejs": "^3.15.0", "async": "^3.2.5", "bits-ui": "^0.21.15", "chart.js": "^4.5.0", diff --git a/pyproject.toml b/pyproject.toml index 73409618fd..09fcce07fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "uvicorn[standard]==0.35.0", "pydantic==2.11.7", "python-multipart==0.0.20", + "itsdangerous==2.2.0", "python-socketio==5.13.0", "python-jose==3.4.0", @@ -18,7 +19,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", @@ -46,33 +47,28 @@ dependencies = [ "asgiref==3.8.1", "tiktoken", + "mcp==1.14.1", + "openai", "anthropic", "google-genai==1.32.0", "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", - "pymilvus==2.5.0", - "qdrant-client==1.14.3", + "chromadb==1.0.20", "opensearch-py==2.8.0", - "playwright==1.49.1", - "elasticsearch==9.0.1", - "pinecone==6.0.2", - "oracledb==3.2.0", - + "transformers", "sentence-transformers==4.1.0", "accelerate", - "colbert-ai==0.2.21", "pyarrow==20.0.0", "einops==0.8.1", "ftfy==6.2.3", - "pypdf==4.3.1", + "pypdf==6.0.0", "fpdf2==2.8.2", "pymdown-extensions==10.14.2", "docx2txt==0.8", @@ -112,10 +108,10 @@ dependencies = [ - "googleapis-common-protos==1.63.2", + "googleapis-common-protos==1.70.0", "google-cloud-storage==2.19.0", - "azure-identity==1.20.0", + "azure-identity==1.25.0", "azure-storage-blob==12.24.1", "ldap3==2.9.1", @@ -124,7 +120,6 @@ dependencies = [ "tencentcloud-sdk-python==3.0.1336", "oracledb>=3.2.0", - "posthog==5.4.0", ] readme = "README.md" @@ -142,8 +137,8 @@ classifiers = [ [project.optional-dependencies] postgres = [ - "psycopg2-binary==2.9.9", - "pgvector==0.4.0", + "psycopg2-binary==2.9.10", + "pgvector==0.4.1", ] all = [ @@ -155,6 +150,15 @@ all = [ "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.1.1", + "playwright==1.49.1", + "elasticsearch==9.1.0", + + "qdrant-client==1.14.3", + "pymilvus==2.5.0", + "pinecone==6.0.2", + "oracledb==3.2.0", + + "colbert-ai==0.2.21", ] [project.scripts] diff --git a/src/app.css b/src/app.css index c48914febf..e8f4ee137b 100644 --- a/src/app.css +++ b/src/app.css @@ -70,23 +70,23 @@ textarea::placeholder { } .input-prose { - @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; + @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; } .input-prose-sm { - @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; + @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; } .markdown-prose { - @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown-prose-sm { - @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown-prose-xs { - @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; + @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; } .markdown a { @@ -116,7 +116,7 @@ li p { ::-webkit-scrollbar-thumb { --tw-border-opacity: 1; - background-color: rgba(215, 215, 215, 0.8); + background-color: rgba(215, 215, 215, 0.6); border-color: rgba(255, 255, 255, var(--tw-border-opacity)); border-radius: 9999px; border-width: 1px; @@ -124,12 +124,12 @@ li p { /* Dark theme scrollbar styles */ .dark ::-webkit-scrollbar-thumb { - background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */ + background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */ border-color: rgba(0, 0, 0, var(--tw-border-opacity)); } ::-webkit-scrollbar { - height: 0.6rem; + height: 0.4rem; width: 0.4rem; } @@ -409,17 +409,33 @@ input[type='number'] { } } -.tiptap .mention { +.mention { border-radius: 0.4rem; box-decoration-break: clone; padding: 0.1rem 0.3rem; - @apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20; + @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15; } -.tiptap .mention::after { +.mention::after { content: '\200B'; } +.tiptap .suggestion { + border-radius: 0.4rem; + box-decoration-break: clone; + padding: 0.1rem 0.3rem; + @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15; +} + +.tiptap .suggestion::after { + content: '\200B'; +} + +.tiptap .suggestion.is-empty::after { + content: '\00A0'; + border-bottom: 1px dotted rgba(31, 41, 55, 0.12); +} + .input-prose .tiptap ul[data-type='taskList'] { list-style: none; margin-left: 0; @@ -645,3 +661,112 @@ body { background: #171717; color: #eee; } + +/* Position the handle relative to each LI */ +.pm-li--with-handle { + position: relative; + margin-left: 12px; /* make space for the handle */ +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + margin-left: 0px; +} + +/* The drag handle itself */ +.pm-list-drag-handle { + position: absolute; + left: -36px; /* pull into the left gutter */ + top: 1px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1; + border-radius: 4px; + cursor: grab; + user-select: none; + opacity: 0.35; + transition: + opacity 120ms ease, + background 120ms ease; +} + +.tiptap ul[data-type='taskList'] .pm-list-drag-handle { + left: -16px; /* pull into the left gutter more to avoid the checkbox */ +} + +.pm-list-drag-handle:active { + cursor: grabbing; +} +.pm-li--with-handle:hover > .pm-list-drag-handle { + opacity: 1; +} +.pm-list-drag-handle:hover { + background: rgba(0, 0, 0, 0.06); +} + +:root { + --pm-accent: color-mix(in oklab, Highlight 70%, transparent); + --pm-fill-target: color-mix(in oklab, Highlight 26%, transparent); + --pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent); +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + position: relative; +} + +/* BEFORE/AFTER lines */ +.pm-li-drop-before::before, +.pm-li-drop-after::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 3px; + background: var(--pm-accent); + pointer-events: none; +} +.pm-li-drop-before::before { + top: -2px; +} +.pm-li-drop-after::after { + bottom: -2px; +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + background: var(--pm-fill-target); + border-radius: 6px; +} + +.pm-li-drop-outdent::before { + content: ''; + position: absolute; + inset-block: 0; + inset-inline-start: 0; + width: 3px; + background: color-mix(in oklab, Highlight 35%, transparent); +} + +.pm-li--with-handle:has(.pm-li-drop-before), +.pm-li--with-handle:has(.pm-li-drop-after), +.pm-li--with-handle:has(.pm-li-drop-into), +.pm-li--with-handle:has(.pm-li-drop-outdent) { + background: var(--pm-fill-ancestor); + border-radius: 6px; +} + +.pm-li-drop-before, +.pm-li-drop-after, +.pm-li-drop-into, +.pm-li-drop-outdent { + position: relative; + z-index: 0; +} diff --git a/src/app.html b/src/app.html index be2cc0f4ad..6c1c362005 100644 --- a/src/app.html +++ b/src/app.html @@ -2,29 +2,42 @@ - - - - - - - - + + + + + + - - - - + + +
-
📄
-
+
{#if title} {title} {:else} @@ -17,7 +16,7 @@
+ >
{#if content} {content} {:else} diff --git a/src/lib/components/AddServerModal.svelte b/src/lib/components/AddToolServerModal.svelte similarity index 76% rename from src/lib/components/AddServerModal.svelte rename to src/lib/components/AddToolServerModal.svelte index 6fad62bc15..01c87010ef 100644 --- a/src/lib/components/AddServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -30,6 +30,8 @@ let url = ''; let path = 'openapi.json'; + let type = 'openapi'; // 'openapi', 'mcp' + let auth_type = 'bearer'; let key = ''; @@ -70,6 +72,7 @@ const res = await verifyToolServerConnection(localStorage.token, { url, path, + type, auth_type, key, config: { @@ -97,10 +100,16 @@ // remove trailing slash from url url = url.replace(/\/$/, ''); + if (id.includes(':') || id.includes('|')) { + toast.error($i18n.t('ID cannot contain ":" or "|" characters')); + loading = false; + return; + } const connection = { url, path, + type, auth_type, key, config: { @@ -119,8 +128,11 @@ loading = false; show = false; + // reset form url = ''; path = 'openapi.json'; + type = 'openapi'; + key = ''; auth_type = 'bearer'; @@ -137,6 +149,7 @@ url = connection.url; path = connection?.path ?? 'openapi.json'; + type = connection?.type ?? 'openapi'; auth_type = connection?.auth_type ?? 'bearer'; key = connection?.key ?? ''; @@ -189,6 +202,50 @@ }} >
+ {#if !direct} +
+
+
{$i18n.t('Type')}
+ +
+ +
+
+
+ {/if} + + {#if type === 'mcp'} +
+ + {$i18n.t('Warning')}: + + {$i18n.t( + 'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.' + )} + + {$i18n.t('Read more →')} +
+ {/if} +
@@ -243,30 +300,36 @@
-
- - -
+ {#if ['', 'openapi'].includes(type)} +
+ + +
+ {/if}
-
- {$i18n.t(`WebUI will make requests to "{{url}}"`, { - url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}` - })} -
+ {#if ['', 'openapi'].includes(type)} +
+ {$i18n.t(`WebUI will make requests to "{{url}}"`, { + url: path.includes('://') + ? path + : `${url}${path.startsWith('/') ? '' : '/'}${path}` + })} +
+ {/if}
@@ -283,11 +346,13 @@ class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`} bind:value={auth_type} > + + {#if !direct} - + {/if}
@@ -299,17 +364,23 @@ placeholder={$i18n.t('API Key')} required={false} /> + {:else if auth_type === 'none'} +
+ {$i18n.t('No authentication')} +
{:else if auth_type === 'session'}
{$i18n.t('Forwards system user session credentials to authenticate')}
- {:else if auth_type === 'request_headers'} + {:else if auth_type === 'system_oauth'}
- {$i18n.t('Forwards system user headers to authenticate')} + {$i18n.t('Forwards system user OAuth access token to authenticate')}
{/if}
@@ -326,9 +397,12 @@ for="enter-id" class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} >{$i18n.t('ID')} - {$i18n.t('Optional')} + + {#if type !== 'mcp'} + {$i18n.t('Optional')} + {/if}
@@ -339,6 +413,7 @@ bind:value={id} placeholder={$i18n.t('Enter ID')} autocomplete="off" + required={type === 'mcp'} />
@@ -388,7 +463,7 @@
-
+
diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index ea65760628..d0d1c638a7 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -1,4 +1,6 @@ - -
+ +
-
+
{$i18n.t("What's New in")} {$WEBUI_NAME} @@ -46,7 +51,7 @@
{$i18n.t('Release Notes')}
-
+
v{WEBUI_VERSION}
@@ -54,7 +59,7 @@
-
+
{#if changelog} {#each Object.keys(changelog) as version} @@ -63,31 +68,28 @@ v{version} - {changelog[version].date}
-
+
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section} -
+
{section}
-
- {#each Object.keys(changelog[version][section]) as item} -
-
- {changelog[version][section][item].title} -
-
{changelog[version][section][item].content}
+
+ {#each changelog[version][section] as entry} +
+ {@html DOMPurify.sanitize(entry?.raw)}
{/each}
diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte index bfb1ff691f..1b8d9fae8b 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -12,6 +12,43 @@ export let title: string = 'HI'; export let content: string; + let startX = 0, + startY = 0; + let moved = false; + const DRAG_THRESHOLD_PX = 6; + + const clickHandler = () => { + onClick(); + dispatch('closeToast'); + }; + + function onPointerDown(e: PointerEvent) { + startX = e.clientX; + startY = e.clientY; + moved = false; + // Ensure we continue to get events even if the toast moves under the pointer. + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + } + + function onPointerMove(e: PointerEvent) { + if (moved) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + if (dx * dx + dy * dy > DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) { + moved = true; + } + } + + function onPointerUp(e: PointerEvent) { + // Release capture if taken + (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); + + // Only treat as a click if there wasn't a drag + if (!moved) { + clickHandler(); + } + } + onMount(() => { if (!navigator.userActivation.hasBeenActive) { return; @@ -31,24 +68,33 @@ }); - +
diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte index d223db57ce..d29dee746c 100644 --- a/src/lib/components/admin/Evaluations.svelte +++ b/src/lib/components/admin/Evaluations.svelte @@ -56,7 +56,7 @@
-
+
{#if selectedTab === 'leaderboard'} {:else if selectedTab === 'feedbacks'} diff --git a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte index fa24467a47..515408e463 100644 --- a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte @@ -13,7 +13,7 @@ import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; import Pencil from '$lib/components/icons/Pencil.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import Download from '$lib/components/icons/ArrowDownTray.svelte'; + import Download from '$lib/components/icons/Download.svelte'; let show = false; @@ -25,7 +25,7 @@
-
+
{$i18n.t('Feedback History')} @@ -187,31 +187,25 @@ exportHandler(); }} > - +
{/if}
-
+
{#if (feedbacks ?? []).length === 0}
{$i18n.t('No feedbacks found')}
{:else} - - - +
+ + - + diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index ce6c526638..db46729b36 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -1,9 +1,4 @@ - +
-
+
{#if selectedTab === 'overview'} {:else if selectedTab === 'groups'} diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index 9a6412a485..cc57536f83 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -216,7 +216,7 @@
{:else}
-
+
{$i18n.t('Group')}
{$i18n.t('Users')}
diff --git a/src/lib/components/admin/Users/Groups/GroupItem.svelte b/src/lib/components/admin/Users/Groups/GroupItem.svelte index f7a065eda6..7a208f84a6 100644 --- a/src/lib/components/admin/Users/Groups/GroupItem.svelte +++ b/src/lib/components/admin/Users/Groups/GroupItem.svelte @@ -1,6 +1,7 @@ - +{#if loaded} + -{#if acceptFiles} - { - if (inputFiles && inputFiles.length > 0) { - inputFilesHandler(Array.from(inputFiles)); - } else { - toast.error($i18n.t(`File not found.`)); - } + {#if acceptFiles} + { + if (inputFiles && inputFiles.length > 0) { + inputFilesHandler(Array.from(inputFiles)); + } else { + toast.error($i18n.t(`File not found.`)); + } - filesInputElement.value = ''; - }} + filesInputElement.value = ''; + }} + /> + {/if} + + -{/if} - { - inputVariableValues = { ...inputVariableValues, ...variableValues }; - replaceVariables(inputVariableValues); - }} -/> - -
-
-
-
-
- {#if scrollEnd === false} -
- -
- {/if} -
- -
-
- {#if typingUsers.length > 0} -
- - {typingUsers.map((user) => user.name).join(', ')} - - {$i18n.t('is typing...')} + + + +
{/if}
- + {#if typingUsers.length > 0} +
+
+ + +
+ + {typingUsers.map((user) => user.name).join(', ')} + + {$i18n.t('is typing...')} +
+
+
+ {/if}
-
-
- {#if recording} - { - recording = false; +
+ {#if recording} + { + recording = false; - await tick(); + await tick(); - if (chatInputElement) { - chatInputElement.focus(); - } - }} - onConfirm={async (data) => { - const { text, filename } = data; - recording = false; + if (chatInputElement) { + chatInputElement.focus(); + } + }} + onConfirm={async (data) => { + const { text, filename } = data; + recording = false; - await tick(); - insertTextAtCursor(text); + await tick(); + insertTextAtCursor(text); - await tick(); + await tick(); - if (chatInputElement) { - chatInputElement.focus(); - } - }} - /> - {:else} - { - submitHandler(); - }} - > -
+ {:else} + { + submitHandler(); + }} > - {#if files.length > 0} -
- {#each files as file, fileIdx} - {#if file.type === 'image'} -
-
- +
+ {#if files.length > 0} +
+ {#each files as file, fileIdx} + {#if file.type === 'image'} +
+
+ +
+
+ +
-
+ {:else} + { + files.splice(fileIdx, 1); + files = files; + }} + on:click={() => { + console.log(file); + }} + /> + {/if} + {/each} +
+ {/if} + +
+
+ {#key $settings?.richTextInput} + 0 || + navigator.msMaxTouchPoints > 0 + ))} + largeTextAsFile={$settings?.largeTextAsFile ?? false} + floatingMenuPlacement={'top-start'} + {suggestions} + onChange={(e) => { + const { md } = e; + content = md; + command = getCommand(); + }} + on:keydown={async (e) => { + e = e.detail.event; + const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac + + const suggestionsContainerElement = + document.getElementById('suggestions-container'); + + if (!suggestionsContainerElement) { + if ( + !$mobile || + !( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0 + ) + ) { + // Prevent Enter key from creating a new line + // Uses keyCode '13' for Enter key for chinese/japanese keyboards + if (e.keyCode === 13 && !e.shiftKey) { + e.preventDefault(); + } + + // Submit the content when Enter key is pressed + if (content !== '' && e.keyCode === 13 && !e.shiftKey) { + submitHandler(); + } + } + } + + if (e.key === 'Escape') { + console.info('Escape'); + } + }} + on:paste={async (e) => { + e = e.detail.event; + console.log(e); + + const clipboardData = e.clipboardData || window.clipboardData; + + if (clipboardData && clipboardData.items) { + for (const item of clipboardData.items) { + if (item.type.indexOf('image') !== -1) { + const blob = item.getAsFile(); + const reader = new FileReader(); + + reader.onload = function (e) { + files = [ + ...files, + { + type: 'image', + url: `${e.target.result}` + } + ]; + }; + + reader.readAsDataURL(blob); + } else if (item?.kind === 'file') { + const file = item.getAsFile(); + if (file) { + const _files = [file]; + await inputFilesHandler(_files); + e.preventDefault(); + } + } + } + } + }} + /> + {/key} +
+
+ +
+
+ + {#if acceptFiles} + { + filesInputElement.click(); + }} + > -
-
- {:else} - { - files.splice(fileIdx, 1); - files = files; - }} - on:click={() => { - console.log(file); - }} - /> - {/if} - {/each} -
- {/if} + + {/if} + +
-
-
- 0 || - navigator.msMaxTouchPoints > 0 - ))} - largeTextAsFile={$settings?.largeTextAsFile ?? false} - floatingMenuPlacement={'top-start'} - onChange={(e) => { - const { md } = e; - content = md; - command = getCommand(); - }} - on:keydown={async (e) => { - e = e.detail.event; - const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac - - const commandsContainerElement = document.getElementById('commands-container'); - - if (commandsContainerElement) { - if (commandsContainerElement && e.key === 'ArrowUp') { - e.preventDefault(); - commandsElement.selectUp(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'ArrowDown') { - e.preventDefault(); - commandsElement.selectDown(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - commandOptionButton.scrollIntoView({ block: 'center' }); - } - - if (commandsContainerElement && e.key === 'Tab') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - commandOptionButton?.click(); - } - - if (commandsContainerElement && e.key === 'Enter') { - e.preventDefault(); - - const commandOptionButton = [ - ...document.getElementsByClassName('selected-command-option-button') - ]?.at(-1); - - if (commandOptionButton) { - commandOptionButton?.click(); - } else { - document.getElementById('send-message-button')?.click(); - } - } - } else { - if ( - !$mobile || - !( - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0 - ) - ) { - // Prevent Enter key from creating a new line - // Uses keyCode '13' for Enter key for chinese/japanese keyboards - if (e.keyCode === 13 && !e.shiftKey) { - e.preventDefault(); - } - - // Submit the content when Enter key is pressed - if (content !== '' && e.keyCode === 13 && !e.shiftKey) { - submitHandler(); - } - } - } - - if (e.key === 'Escape') { - console.info('Escape'); - } - }} - on:paste={async (e) => { - e = e.detail.event; - console.info(e); - }} - /> -
-
- -
-
- - {#if acceptFiles} - { - filesInputElement.click(); - }} - > +
+ {#if content === ''} + - + {/if} - -
-
- {#if content === ''} - - - - {/if} - -
- {#if inputLoading && onStop} -
- - - -
- {:else} -
- - + +
+ {:else} +
+ + - -
- {/if} + + + + + +
+ {/if} +
-
- - {/if} + + {/if} +
-
+{/if} diff --git a/src/lib/components/channel/MessageInput/InputMenu.svelte b/src/lib/components/channel/MessageInput/InputMenu.svelte index 7226c34cb9..c94b8f9a23 100644 --- a/src/lib/components/channel/MessageInput/InputMenu.svelte +++ b/src/lib/components/channel/MessageInput/InputMenu.svelte @@ -13,6 +13,8 @@ import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import CameraSolid from '$lib/components/icons/CameraSolid.svelte'; + import Camera from '$lib/components/icons/Camera.svelte'; + import Clip from '$lib/components/icons/Clip.svelte'; const i18n = getContext('i18n'); @@ -44,34 +46,32 @@
- {#if !$mobile} - { - screenCaptureHandler(); - }} - > - -
{$i18n.t('Capture')}
-
- {/if} - { uploadFilesHandler(); }} > - +
{$i18n.t('Upload Files')}
+ + { + screenCaptureHandler(); + }} + > + +
{$i18n.t('Capture')}
+
diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte new file mode 100644 index 0000000000..30ba8f7513 --- /dev/null +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -0,0 +1,205 @@ + + +{#if filteredItems.length} +
+
+ {#each filteredItems as item, i} + {#if i === 0 || item?.type !== filteredItems[i - 1]?.type} +
+ {#if item?.type === 'user'} + {$i18n.t('Users')} + {:else if item?.type === 'model'} + {$i18n.t('Models')} + {:else if item?.type === 'channel'} + {$i18n.t('Channels')} + {/if} +
+ {/if} + + + + + {/each} +
+
+{/if} diff --git a/src/lib/components/channel/Messages.svelte b/src/lib/components/channel/Messages.svelte index e95a6e100d..540891b500 100644 --- a/src/lib/components/channel/Messages.svelte +++ b/src/lib/components/channel/Messages.svelte @@ -63,11 +63,7 @@
{:else if !thread} -
+
{#if channel}
{channel.name}
@@ -99,7 +95,8 @@ {message} {thread} showUserProfile={messageIdx === 0 || - messageList.at(messageIdx - 1)?.user_id !== message.user_id} + messageList.at(messageIdx - 1)?.user_id !== message.user_id || + messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id} onDelete={() => { messages = messages.filter((m) => m.id !== message.id); diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 649529a6f9..4ea6a67aea 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -15,7 +15,7 @@ import { settings, user, shortCodesToEmojis } from '$lib/stores'; - import { WEBUI_BASE_URL } from '$lib/constants'; + import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte'; @@ -34,6 +34,8 @@ import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import { formatDate } from '$lib/utils'; import Emoji from '$lib/components/common/Emoji.svelte'; + import { t } from 'i18next'; + import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; export let message; export let showUserProfile = true; @@ -64,9 +66,7 @@
{#if !edit}
-
+
{#if showUserProfile} - - - + {:else} + + + + {/if} {:else} @@ -170,7 +173,11 @@ {#if showUserProfile}
- {message?.user?.name} + {#if message?.meta?.model_id} + {message?.meta?.model_name ?? message?.meta?.model_id} + {:else} + {message?.user?.name} + {/if}
{#if message.created_at} @@ -178,7 +185,12 @@ class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]" > - {formatDate(message.created_at / 1000000)} + + {$i18n.t(formatDate(message.created_at / 1000000), { + LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'), + LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L') + })} +
{/if} @@ -198,7 +210,7 @@ name={file.name} type={file.type} size={file?.size} - colorClassName="bg-white dark:bg-gray-850 " + small={true} /> {/if}
@@ -228,7 +240,7 @@
{:else}
- {#if message.created_at !== message.updated_at}(edited){/if} + {#if (message?.content ?? '').trim() === '' && message?.meta?.model_id} + + {:else} + {#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}({$i18n.t('edited')}){/if} + {/if}
{#if (message?.reactions ?? []).length > 0} diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index c4286db9a4..620905e5ff 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -1,101 +1,18 @@ - {}} - typeahead={false} -> - + + - + - - - {#if user} -
-
- profile -
- -
-
- {user.name} -
- -
- {#if active} -
- - - - -
- -
- {$i18n.t('Active')} -
- {:else} -
- - - -
- -
- {$i18n.t('Away')} -
- {/if} -
-
-
- {/if} -
-
-
+ + diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte new file mode 100644 index 0000000000..689a4d5f54 --- /dev/null +++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte @@ -0,0 +1,50 @@ + + +{#if user} +
+
+ profile +
+ +
+
+ {user.name} +
+ +
+ {#if user?.active} +
+ + + + +
+ + {$i18n.t('Active')} + {:else} +
+ + + +
+ + {$i18n.t('Away')} + {/if} +
+
+
+{/if} diff --git a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte new file mode 100644 index 0000000000..0660548891 --- /dev/null +++ b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte @@ -0,0 +1,37 @@ + + +{#if user} + + + +{/if} diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte index b6ff4f42a7..dd20097fae 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/src/lib/components/channel/Thread.svelte @@ -159,7 +159,7 @@ {#if channel}
-
+
{$i18n.t('Thread')}
@@ -174,7 +174,7 @@
-
+
-
- +
+
diff --git a/src/lib/components/chat/Artifacts.svelte b/src/lib/components/chat/Artifacts.svelte index bbe2132b90..848d81f635 100644 --- a/src/lib/components/chat/Artifacts.svelte +++ b/src/lib/components/chat/Artifacts.svelte @@ -12,7 +12,7 @@ import Tooltip from '../common/Tooltip.svelte'; import SvgPanZoom from '../common/SVGPanZoom.svelte'; import ArrowLeft from '../icons/ArrowLeft.svelte'; - import ArrowDownTray from '../icons/ArrowDownTray.svelte'; + import Download from '../icons/Download.svelte'; export let overlay = false; export let history; @@ -205,7 +205,7 @@
@@ -213,15 +213,6 @@
- -
@@ -294,7 +285,7 @@ class=" bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md p-0.5" on:click={downloadArtifact} > - + diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index f258207593..b2dc86dfe0 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1,7 +1,6 @@ + +
+
+ {#if !loading} + {#if char === '/'} + { + const { type, data } = e; + + if (type === 'prompt') { + insertTextHandler(data.content); + } + }} + /> + {:else if char === '#'} + { + const { type, data } = e; + + if (type === 'knowledge') { + insertTextHandler(''); + + onUpload({ + type: 'file', + data: data + }); + } else if (type === 'youtube') { + insertTextHandler(''); + + onUpload({ + type: 'youtube', + data: data + }); + } else if (type === 'web') { + insertTextHandler(''); + + onUpload({ + type: 'web', + data: data + }); + } + }} + /> + {:else if char === '@'} + { + const { type, data } = e; + + if (type === 'model') { + insertTextHandler(''); + + onSelect({ + type: 'model', + data: data + }); + } + }} + /> + {/if} + {:else} +
+ +
+ {/if} +
+
diff --git a/src/lib/components/chat/MessageInput/Commands.svelte b/src/lib/components/chat/MessageInput/Commands.svelte deleted file mode 100644 index af71458522..0000000000 --- a/src/lib/components/chat/MessageInput/Commands.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - -{#if show} - {#if !loading} - {#if command?.charAt(0) === '/'} - { - const { type, data } = e; - - if (type === 'prompt') { - insertTextHandler(data.content); - } - }} - /> - {:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))} - { - const { type, data } = e; - - if (type === 'knowledge') { - insertTextHandler(''); - - onUpload({ - type: 'file', - data: data - }); - } else if (type === 'youtube') { - insertTextHandler(''); - - onUpload({ - type: 'youtube', - data: data - }); - } else if (type === 'web') { - insertTextHandler(''); - - onUpload({ - type: 'web', - data: data - }); - } - }} - /> - {:else if command?.charAt(0) === '@'} - { - const { type, data } = e; - - if (type === 'model') { - insertTextHandler(''); - - onSelect({ - type: 'model', - data: data - }); - } - }} - /> - {/if} - {:else} -
-
-
- -
-
-
- {/if} -{/if} diff --git a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte index 781437e86e..5a6ce96cc4 100644 --- a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte @@ -8,29 +8,48 @@ import { tick, getContext, onMount, onDestroy } from 'svelte'; import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; - import { knowledge } from '$lib/stores'; - import { getNoteList, getNotes } from '$lib/apis/notes'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; + import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; + import Database from '$lib/components/icons/Database.svelte'; + import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; + import Youtube from '$lib/components/icons/Youtube.svelte'; const i18n = getContext('i18n'); - export let command = ''; + export let query = ''; export let onSelect = (e) => {}; + export let knowledge = []; + let selectedIdx = 0; let items = []; let fuse = null; - let filteredItems = []; + export let filteredItems = []; $: if (fuse) { - filteredItems = command.slice(1) - ? fuse.search(command).map((e) => { - return e.item; - }) - : items; + filteredItems = [ + ...(query + ? fuse.search(query).map((e) => { + return e.item; + }) + : items), + + ...(query.startsWith('http') + ? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be') + ? [{ type: 'youtube', name: query, description: query }] + : [ + { + type: 'web', + name: query, + description: query + } + ] + : []) + ]; } - $: if (command) { + $: if (query) { selectedIdx = 0; } @@ -42,32 +61,14 @@ selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); }; - let container; - let adjustHeightDebounce; - - const adjustHeight = () => { - if (container) { - if (adjustHeightDebounce) { - clearTimeout(adjustHeightDebounce); - } - - adjustHeightDebounce = setTimeout(() => { - if (!container) return; - - // Ensure the container is visible before adjusting height - const rect = container.getBoundingClientRect(); - container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px'; - }, 100); + export const select = async () => { + // find item with data-selected=true + const item = document.querySelector(`[data-selected="true"]`); + if (item) { + // click the item + item.click(); } }; - - const confirmSelect = async (type, data) => { - onSelect({ - type: type, - data: data - }); - }; - const decodeString = (str: string) => { try { return decodeURIComponent(str); @@ -77,22 +78,7 @@ }; onMount(async () => { - window.addEventListener('resize', adjustHeight); - - let notes = await getNoteList(localStorage.token).catch(() => { - return []; - }); - - notes = notes.map((note) => { - return { - ...note, - type: 'note', - name: note.title, - description: dayjs(note.updated_at / 1000000).fromNow() - }; - }); - - let legacy_documents = $knowledge + let legacy_documents = knowledge .filter((item) => item?.meta?.document) .map((item) => ({ ...item, @@ -127,16 +113,16 @@ ] : []; - let collections = $knowledge + let collections = knowledge .filter((item) => !item?.meta?.document) .map((item) => ({ ...item, type: 'collection' })); let collection_files = - $knowledge.length > 0 + knowledge.length > 0 ? [ - ...$knowledge + ...knowledge .reduce((a, item) => { return [ ...new Set([ @@ -158,196 +144,145 @@ ] : []; - items = [ - ...notes, - ...collections, - ...collection_files, - ...legacy_collections, - ...legacy_documents - ].map((item) => { - return { - ...item, - ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {}) - }; - }); + items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map( + (item) => { + return { + ...item, + ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {}) + }; + } + ); fuse = new Fuse(items, { keys: ['name', 'description'] }); await tick(); - adjustHeight(); + }); + + const onKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + select(); + } + }; + onMount(() => { + window.addEventListener('keydown', onKeyDown); }); onDestroy(() => { - window.removeEventListener('resize', adjustHeight); + window.removeEventListener('keydown', onKeyDown); }); -{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')} -
-
-
-
- {#each filteredItems as item, idx} - + +
+ {decodeString(item?.name)} +
+
+
+ + {/if} + {/each} - - {/each} - - {#if command.substring(1).startsWith('https://www.youtube.com') || command - .substring(1) - .startsWith('https://youtu.be')} - - {:else if command.substring(1).startsWith('http')} - - {/if} +
+ {query}
-
-
+ + {:else if query.startsWith('http')} + + {/if} {/if} diff --git a/src/lib/components/chat/MessageInput/Commands/Models.svelte b/src/lib/components/chat/MessageInput/Commands/Models.svelte index 7f87164111..0177e6fdf3 100644 --- a/src/lib/components/chat/MessageInput/Commands/Models.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Models.svelte @@ -6,14 +6,15 @@ import { models } from '$lib/stores'; import { WEBUI_BASE_URL } from '$lib/constants'; + import Tooltip from '$lib/components/common/Tooltip.svelte'; const i18n = getContext('i18n'); - export let command = ''; + export let query = ''; export let onSelect = (e) => {}; let selectedIdx = 0; - let filteredItems = []; + export let filteredItems = []; let fuse = new Fuse( $models @@ -29,17 +30,17 @@ }), { keys: ['value', 'tags', 'modelName'], - threshold: 0.3 + threshold: 0.5 } ); - $: filteredItems = command.slice(1) - ? fuse.search(command).map((e) => { + $: filteredItems = query + ? fuse.search(query).map((e) => { return e.item; }) : $models.filter((model) => !model?.info?.meta?.hidden); - $: if (command) { + $: if (query) { selectedIdx = 0; } @@ -51,85 +52,46 @@ selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1); }; - let container; - let adjustHeightDebounce; - - const adjustHeight = () => { - if (container) { - if (adjustHeightDebounce) { - clearTimeout(adjustHeightDebounce); - } - - adjustHeightDebounce = setTimeout(() => { - if (!container) return; - - // Ensure the container is visible before adjusting height - const rect = container.getBoundingClientRect(); - container.style.maxHeight = Math.max(Math.min(240, rect.bottom - 100), 100) + 'px'; - }, 100); + export const select = async () => { + const model = filteredItems[selectedIdx]; + if (model) { + onSelect({ type: 'model', data: model }); } }; - - const confirmSelect = async (model) => { - onSelect({ type: 'model', data: model }); - }; - - onMount(async () => { - window.addEventListener('resize', adjustHeight); - - await tick(); - const chatInputElement = document.getElementById('chat-input'); - await tick(); - chatInputElement?.focus(); - await tick(); - - adjustHeight(); - }); - - onDestroy(() => { - window.removeEventListener('resize', adjustHeight); - }); +
+ {$i18n.t('Models')} +
+ {#if filteredItems.length > 0} -
-
-
-
- {#each filteredItems as model, modelIdx} - - {/each} + {#each filteredItems as model, modelIdx} + +
-
-
+ + + {/each} {/if} diff --git a/src/lib/components/chat/MessageInput/Commands/Prompts.svelte b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte index ffd02fbc41..5df3c4691b 100644 --- a/src/lib/components/chat/MessageInput/Commands/Prompts.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Prompts.svelte @@ -1,140 +1,71 @@ -{#if filteredPrompts.length > 0} -
-
-
-
+ {$i18n.t('Prompts')} +
+ +{#if filteredItems.length > 0} +
+ {#each filteredItems as promptItem, promptIdx} + + - {/each} -
- -
-
- - - -
- -
- {$i18n.t( - 'Tip: Update multiple variable slots consecutively by pressing the tab key in the chat input after each replacement.' - )} -
-
-
-
+ + {promptItem.title} + + + + {/each}
{/if} diff --git a/src/lib/components/chat/MessageInput/FilesOverlay.svelte b/src/lib/components/chat/MessageInput/FilesOverlay.svelte index 7c35ceb673..d8a09b0b1e 100644 --- a/src/lib/components/chat/MessageInput/FilesOverlay.svelte +++ b/src/lib/components/chat/MessageInput/FilesOverlay.svelte @@ -24,8 +24,10 @@ role="region" aria-label="Drag and Drop Container" > -
-
+
+
diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 351c882388..a8a44944c3 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -1,27 +1,33 @@ @@ -101,299 +102,390 @@
- {#if tools} - {#if Object.keys(tools).length > 0} -
- {#each Object.keys(tools) as toolId} + {#if tab === ''} +
+ + { + if (fileUploadEnabled) { + uploadFilesHandler(); + } + }} + > + + +
{$i18n.t('Upload Files')}
+
+
+ + + { + if (fileUploadEnabled) { + if (!detectMobile()) { + screenCaptureHandler(); + } else { + const cameraInputElement = document.getElementById('camera-input'); + + if (cameraInputElement) { + cameraInputElement.click(); + } + } + } + }} + > + +
{$i18n.t('Capture')}
+
+
+ + {#if $config?.features?.enable_notes ?? false} + - {/each} -
- {#if Object.keys(tools).length > 3} - + + {/if} + + + + + + {#if ($chats ?? []).length > 0} + + + + {/if} + + {#if fileUploadEnabled} + {#if $config?.features?.enable_google_drive_integration} + { + uploadGoogleDriveHandler(); + }} + > + + + + + + + + +
{$i18n.t('Google Drive')}
+
+ {/if} + + {#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{$i18n.t('Microsoft OneDrive')}
+
+ + {#if $config?.features?.enable_onedrive_personal} + { + uploadOneDriveHandler('personal'); + }} + > +
+
{$i18n.t('Microsoft OneDrive (personal)')}
+
+
+ {/if} + + {#if $config?.features?.enable_onedrive_business} + { + uploadOneDriveHandler('organizations'); + }} + > +
+
+ {$i18n.t('Microsoft OneDrive (work/school)')} +
+
{$i18n.t('Includes SharePoint')}
+
+
+ {/if} +
+
+ {/if} {/if} -
- {/if} - {:else} -
-
- -
- {/if} - - - { - if (fileUploadEnabled) { - if (!detectMobile()) { - screenCaptureHandler(); - } else { - const cameraInputElement = document.getElementById('camera-input'); - - if (cameraInputElement) { - cameraInputElement.click(); - } - } - } - }} - > - -
{$i18n.t('Capture')}
-
-
- - - { - if (fileUploadEnabled) { - uploadFilesHandler(); - } - }} - > - -
{$i18n.t('Upload Files')}
-
-
- - {#if fileUploadEnabled} - {#if $config?.features?.enable_google_drive_integration} - + + + +
+ {:else if tab === 'notes'} +
+ + + +
+ {:else if tab === 'chats'} +
+ + + +
{/if}
diff --git a/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte b/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte new file mode 100644 index 0000000000..b4ef49dce5 --- /dev/null +++ b/src/lib/components/chat/MessageInput/InputMenu/Chats.svelte @@ -0,0 +1,125 @@ + + +{#if loaded} + {#if items.length === 0} +
{$i18n.t('No chats found')}
+ {:else} +
+ {#each items as item, idx} + + {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
+ {/if} +{:else} +
+ +
+{/if} diff --git a/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte new file mode 100644 index 0000000000..df8d8aabab --- /dev/null +++ b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte @@ -0,0 +1,163 @@ + + +{#if loaded} +
+ {#each items as item, idx} + + {/each} +
+{:else} +
+ +
+{/if} diff --git a/src/lib/components/chat/MessageInput/InputMenu/Notes.svelte b/src/lib/components/chat/MessageInput/InputMenu/Notes.svelte new file mode 100644 index 0000000000..93c5bba180 --- /dev/null +++ b/src/lib/components/chat/MessageInput/InputMenu/Notes.svelte @@ -0,0 +1,128 @@ + + +{#if loaded} + {#if items.length === 0} +
{$i18n.t('No notes found')}
+ {:else} +
+ {#each items as item, idx} + + {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
+ {/if} +{:else} +
+ +
+{/if} diff --git a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte index b507c3ff2d..4554ea72d3 100644 --- a/src/lib/components/chat/MessageInput/InputVariablesModal.svelte +++ b/src/lib/components/chat/MessageInput/InputVariablesModal.svelte @@ -84,8 +84,8 @@
{variable} - {#if variables[variable]?.required ?? true} - *required + {#if variables[variable]?.required ?? false} + *{$i18n.t('required')} {/if}
@@ -134,7 +134,7 @@ placeholder={$i18n.t('Enter value (true/false)')} bind:value={variableValues[variable]} autocomplete="off" - required + required={variables[variable]?.required ?? false} />
{:else if variables[variable]?.type === 'color'} @@ -159,7 +159,7 @@ placeholder={$i18n.t('Enter hex color (e.g. #FF0000)')} bind:value={variableValues[variable]} autocomplete="off" - required + required={variables[variable]?.required ?? false} />
{:else if variables[variable]?.type === 'date'} @@ -170,7 +170,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'datetime-local'} @@ -181,7 +181,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'email'} @@ -192,7 +192,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'month'} @@ -203,7 +203,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'number'} @@ -214,7 +214,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'range'} @@ -235,7 +235,7 @@ placeholder={$i18n.t('Enter value')} bind:value={variableValues[variable]} autocomplete="off" - required + required={variables[variable]?.required ?? false} />
@@ -256,7 +256,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'text'} @@ -267,7 +267,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'time'} @@ -278,7 +278,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'url'} @@ -289,7 +289,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} {...variableAttributes} /> {:else if variables[variable]?.type === 'map'} @@ -311,7 +311,7 @@ placeholder={$i18n.t('Enter coordinates (e.g. 51.505, -0.09)')} bind:value={variableValues[variable]} autocomplete="off" - required + required={variables[variable]?.required ?? false} />
{:else} @@ -321,7 +321,7 @@ bind:value={variableValues[variable]} autocomplete="off" id="input-variable-{idx}" - required + required={variables[variable]?.required ?? false} /> {/if}
diff --git a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte new file mode 100644 index 0000000000..1abefc8aad --- /dev/null +++ b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte @@ -0,0 +1,345 @@ + + + { + if (e.detail === false) { + onClose(); + } + }} +> + + + +
+ + {#if tab === ''} +
+ {#if tools} + {#if Object.keys(tools).length > 0} + + {/if} + {:else} +
+ +
+ {/if} + + {#if toggleFilters && toggleFilters.length > 0} + {#each toggleFilters.sort( (a, b) => a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } ) ) as filter, filterIdx (filter.id)} + + + + {/each} + {/if} + + {#if showWebSearchButton} + + + + {/if} + + {#if showImageGenerationButton} + + + + {/if} + + {#if showCodeInterpreterButton} + + + + {/if} +
+ {:else if tab === 'tools' && tools} +
+ + + {#each Object.keys(tools) as toolId} + + {/each} +
+ {/if} +
+
+
diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index f7e7a8345d..784514679c 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -454,7 +454,7 @@ {/each} -
+
{#if bottomPadding}
{/if} diff --git a/src/lib/components/chat/Messages/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte index fa75589abf..6ffdf4362d 100644 --- a/src/lib/components/chat/Messages/Citations.svelte +++ b/src/lib/components/chat/Messages/Citations.svelte @@ -1,10 +1,6 @@ - {#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} + +{#if showCitations} +
+
+ {#each citations as citation, idx} + + {/each} +
{/if} diff --git a/src/lib/components/chat/Messages/CitationsModal.svelte b/src/lib/components/chat/Messages/Citations/CitationModal.svelte similarity index 53% rename from src/lib/components/chat/Messages/CitationsModal.svelte rename to src/lib/components/chat/Messages/Citations/CitationModal.svelte index 566f0c6e06..114d4f48d2 100644 --- a/src/lib/components/chat/Messages/CitationsModal.svelte +++ b/src/lib/components/chat/Messages/Citations/CitationModal.svelte @@ -60,9 +60,37 @@
-
-
- {$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 mergedDocuments as document, documentIdx} -
-
- {$i18n.t('Source')} -
- - {#if document.source?.name} - -
- - {decodeString(document?.metadata?.name ?? document.source.name)} - - {#if Number.isInteger(document?.metadata?.page)} - - ({$i18n.t('page')} - {document.metadata.page + 1}) - - {/if} -
-
- {#if document.metadata?.parameters} -
+
+ {#if document.metadata?.parameters} +
+
{$i18n.t('Parameters')}
- {/if} - {#if showRelevance} -
- {$i18n.t('Relevance')} -
- {#if document.distance !== undefined} +
+ {/if} + +
+
+ {$i18n.t('Content')} + + {#if showRelevance && document.distance !== undefined} @@ -141,12 +143,6 @@ {percentage.toFixed(2)}% {/if} - - {#if typeof document?.distance === 'number'} - - ({(document?.distance ?? 0).toFixed(4)}) - - {/if} {:else if typeof document?.distance === 'number'} ({(document?.distance ?? 0).toFixed(4)}) @@ -154,39 +150,30 @@ {/if}
- {:else} -
- {$i18n.t('No distance available')} -
{/if} - {/if} - {:else} -
- {$i18n.t('No source available')} + + {#if Number.isInteger(document?.metadata?.page)} + + ({$i18n.t('page')} + {document.metadata.page + 1}) + + {/if}
- {/if} -
-
-
- {$i18n.t('Content')} -
- {#if document.metadata?.html} - - {:else} -
+
+							{#if document.metadata?.html}
+								
+							{:else}
+								
                 {document.document}
               
- {/if} + {/if} +
- - {#if documentIdx !== mergedDocuments.length - 1} -
- {/if} {/each}
diff --git a/src/lib/components/chat/Messages/Citations/CitationsModal.svelte b/src/lib/components/chat/Messages/Citations/CitationsModal.svelte new file mode 100644 index 0000000000..435e2735cd --- /dev/null +++ b/src/lib/components/chat/Messages/Citations/CitationsModal.svelte @@ -0,0 +1,82 @@ + + + + + +
+
+
+ {$i18n.t('Citations')} +
+ +
+ +
+
+ {#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 f3da6d8f75..01e512eff3 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -1,17 +1,12 @@
-
+
{#if lang === 'mermaid'} {#if mermaidHtml} @@ -428,16 +396,18 @@
{code}
{/if} {:else} -
+
{lang}
- {#if preview && ['html', 'svg'].includes(lang)} - - {/if} - {#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))} {#if executing} -
+
{$i18n.t('Running')}
{:else if run} + + {#if preview && ['html', 'svg'].includes(lang)} + + {/if}
-
+
{#if !collapsed} {#if edit} - { - saveCode(); - }} - onChange={(value) => { - _code = value; - }} - /> + {#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }} + { + saveCode(); + }} + onChange={(value) => { + _code = value; + }} + /> + {/await} {:else}
 						
 							{$i18n.t('{{COUNT}} hidden lines', {
@@ -561,7 +527,7 @@
 
 				{#if executing || stdout || stderr || result || files}
 					
{#if executing}
diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 736c93cb0d..c33e452a6c 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -5,6 +5,7 @@ import markedExtension from '$lib/utils/marked/extension'; import markedKatexExtension from '$lib/utils/marked/katex-extension'; + import { mentionExtension } from '$lib/utils/marked/mention-extension'; import MarkdownTokens from './Markdown/MarkdownTokens.svelte'; @@ -37,6 +38,9 @@ marked.use(markedKatexExtension(options)); marked.use(markedExtension(options)); + marked.use({ + extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })] + }); $: (async () => { if (content) { diff --git a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte index 4dfb9f2c5b..d28edb224f 100644 --- a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte +++ b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte @@ -1,10 +1,22 @@ -{@html katex.renderToString(content, { displayMode, throwOnError: false })} +{#if renderToString} + {@html renderToString(content, { displayMode, throwOnError: false })} +{/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index c49d60df69..8a0358a752 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -16,6 +16,7 @@ import HtmlToken from './HTMLToken.svelte'; import TextToken from './MarkdownInlineTokens/TextToken.svelte'; import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte'; + import MentionToken from './MarkdownInlineTokens/MentionToken.svelte'; export let id: string; export let done = true; @@ -60,6 +61,8 @@ frameborder="0" onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" > + {:else if token.type === 'mention'} + {:else if token.type === 'text'} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte new file mode 100644 index 0000000000..19f23b2aa0 --- /dev/null +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte @@ -0,0 +1,108 @@ + + + + + + + + { + if (triggerChar === '@') { + if (idType === 'U') { + // Open user profile + console.log('Clicked user mention', id); + } else if (idType === 'M') { + console.log('Clicked model mention', id); + await goto(`/?model=${id}`); + } + } else if (triggerChar === '#') { + if (idType === 'C') { + // Open channel + if ($channels.find((c) => c.id === id)) { + await goto(`/channels/${id}`); + } + } else if (idType === 'T') { + // Open thread + } + } else { + // Unknown trigger char, just log + console.log('Clicked mention', id); + } + }} + > + {triggerChar}{label} + + + + {#if triggerChar === '@' && idType === 'U'} + + {/if} + diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index c5c0b43e88..e568d92bac 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -17,7 +17,7 @@ import AlertRenderer, { alertComponent } from './AlertRenderer.svelte'; import Collapsible from '$lib/components/common/Collapsible.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; + import Download from '$lib/components/icons/Download.svelte'; import Source from './Source.svelte'; import { settings } from '$lib/stores'; @@ -109,7 +109,7 @@ {save} {preview} edit={editCodeBlock} - stickyButtonsClassName={topPadding ? 'top-8' : 'top-0'} + stickyButtonsClassName={topPadding ? 'top-7' : 'top-0'} onSave={(value) => { onSave({ raw: token.raw, @@ -124,19 +124,19 @@ {token.text} {/if} {:else if token.type === 'table'} -
-
+
+
setSortKey('user')} >
@@ -234,7 +228,7 @@
setSortKey('model_id')} >
@@ -257,7 +251,7 @@
setSortKey('rating')} >
@@ -280,7 +274,7 @@
setSortKey('updated_at')} >
@@ -301,7 +295,7 @@
{#each token.header as header, headerIdx} {#each token.rows as row, rowIdx} - + {#each row ?? [] as cell, cellIdx}
@@ -155,10 +155,14 @@
@@ -186,7 +190,7 @@ exportTableToCSVHandler(token, tokenIdx); }} > - +
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..bbda9ac277 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; @@ -633,7 +634,12 @@ : 'invisible group-hover:visible transition text-gray-400'}" > - {formatDate(message.timestamp * 1000)} + {$i18n.t(formatDate(message.timestamp * 1000), { + LOCALIZED_TIME: dayjs(message.timestamp * 1000).format('LT'), + LOCALIZED_DATE: dayjs(message.timestamp * 1000).format('L') + })} {/if} @@ -642,76 +648,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} @@ -727,7 +668,7 @@ name={file.name} type={file.type} size={file?.size} - colorClassName="bg-white dark:bg-gray-850 " + small={true} /> {/if}
@@ -764,7 +705,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 +1280,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..da234b64a2 --- /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/Skeleton.svelte b/src/lib/components/chat/Messages/Skeleton.svelte index 90d8a99131..b2631701af 100644 --- a/src/lib/components/chat/Messages/Skeleton.svelte +++ b/src/lib/components/chat/Messages/Skeleton.svelte @@ -2,14 +2,22 @@ export let size = 'md'; - + diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 07cc6467eb..0f328d7b0a 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -153,7 +153,16 @@ : 'invisible group-hover:visible transition'}" > - {formatDate(message.timestamp * 1000)} + + + + + {$i18n.t(formatDate(message.timestamp * 1000), { + LOCALIZED_TIME: dayjs(message.timestamp * 1000).format('LT'), + LOCALIZED_DATE: dayjs(message.timestamp * 1000).format('L') + })}
{/if} @@ -168,7 +177,12 @@ : 'invisible group-hover:visible transition text-gray-400'}" > - {formatDate(message.timestamp * 1000)} + {$i18n.t(formatDate(message.timestamp * 1000), { + LOCALIZED_TIME: dayjs(message.timestamp * 1000).format('LT'), + LOCALIZED_DATE: dayjs(message.timestamp * 1000).format('L') + })}
@@ -189,7 +203,7 @@ name={file.name} type={file.type} size={file?.size} - colorClassName="bg-white dark:bg-gray-850 " + small={true} /> {/if} @@ -290,7 +304,7 @@
{/if} {/if} + {#if $mobile && !$temporaryChatEnabled && chat && chat.id} + + + + {/if} + {#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)} {/if} - {#if $mobile} - - - - {/if} - {#if $user !== undefined && $user !== null} {#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))} -
+
{#if ($config?.license_metadata?.type ?? null) === 'trial'} {/if} - {#if showBanners} - {#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)} - { - const bannerId = e.detail; + {#each $banners.filter((b) => ![...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]'), ...closedBannerIds].includes(b.id)) as banner (banner.id)} + { + const bannerId = e.detail; - if (banner.dismissible) { - localStorage.setItem( - 'dismissedBannerIds', - JSON.stringify( - [ - bannerId, - ...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]') - ].filter((id) => $banners.find((b) => b.id === id)) - ) - ); - } else { - closedBannerIds = [...closedBannerIds, bannerId]; - } - }} - /> - {/each} - {/if} + if (banner.dismissible) { + localStorage.setItem( + 'dismissedBannerIds', + JSON.stringify( + [ + bannerId, + ...JSON.parse(localStorage.getItem('dismissedBannerIds') ?? '[]') + ].filter((id) => $banners.find((b) => b.id === id)) + ) + ); + } else { + closedBannerIds = [...closedBannerIds, bannerId]; + } + }} + /> + {/each}
{/if} diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 8d68cb0fae..bf4986c590 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -77,7 +77,7 @@ className="w-full flex justify-center mb-0.5" placement="top" > -
+
{$i18n.t('Temporary Chat')}
diff --git a/src/lib/components/chat/Placeholder/FolderTitle.svelte b/src/lib/components/chat/Placeholder/FolderTitle.svelte index a8e2004ef0..bfd72681d3 100644 --- a/src/lib/components/chat/Placeholder/FolderTitle.svelte +++ b/src/lib/components/chat/Placeholder/FolderTitle.svelte @@ -32,7 +32,7 @@ let showFolderModal = false; let showDeleteConfirm = false; - const updateHandler = async ({ name, data }) => { + const updateHandler = async ({ name, meta, data }) => { if (name === '') { toast.error($i18n.t('Folder name cannot be empty.')); return; @@ -45,6 +45,7 @@ const res = await updateFolderById(localStorage.token, folder.id, { name, + ...(meta ? { meta } : {}), ...(data ? { data } : {}) }).catch((error) => { toast.error(`${error}`); diff --git a/src/lib/components/chat/Settings/About.svelte b/src/lib/components/chat/Settings/About.svelte index dd33eef39f..215b53b511 100644 --- a/src/lib/components/chat/Settings/About.svelte +++ b/src/lib/components/chat/Settings/About.svelte @@ -45,7 +45,7 @@
-
+
diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index 04ea315d24..3abdbdfc98 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/src/lib/components/chat/Settings/Account.svelte @@ -117,7 +117,7 @@
-
+
-
+
{$i18n.t('STT Settings')}
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/Chats.svelte b/src/lib/components/chat/Settings/DataControls.svelte similarity index 99% rename from src/lib/components/chat/Settings/Chats.svelte rename to src/lib/components/chat/Settings/DataControls.svelte index 784a723815..1ca5dd6da5 100644 --- a/src/lib/components/chat/Settings/Chats.svelte +++ b/src/lib/components/chat/Settings/DataControls.svelte @@ -117,7 +117,7 @@
-
+
-
+
{$i18n.t('WebUI Settings')}
@@ -277,7 +277,7 @@
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)} -
+
{$i18n.t('System Prompt')}
@@ -285,8 +285,8 @@ bind:value={system} className={'w-full text-sm outline-hidden resize-vertical' + ($settings.highContrastMode - ? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden' - : ' bg-white dark:text-gray-300 dark:bg-gray-900')} + ? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden' + : ' dark:text-gray-300 ')} rows="4" placeholder={$i18n.t('Enter system prompt here')} /> diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 9c6526c1d2..185c4e147f 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -9,6 +9,7 @@ import Plus from '$lib/components/icons/Plus.svelte'; import Switch from '$lib/components/common/Switch.svelte'; import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte'; + import ManageImageCompressionModal from './Interface/ManageImageCompressionModal.svelte'; const dispatch = createEventDispatcher(); const i18n = getContext('i18n'); @@ -49,6 +50,7 @@ let largeTextAsFile = false; + let insertSuggestionPrompt = false; let keepFollowUpPrompts = false; let insertFollowUpPrompt = false; @@ -92,6 +94,7 @@ let iframeSandboxAllowForms = false; let showManageFloatingActionButtonsModal = false; + let showManageImageCompressionModal = false; const toggleLandingPageMode = async () => { landingPageMode = landingPageMode === '' ? 'chat' : ''; @@ -200,6 +203,7 @@ insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false; promptAutocomplete = $settings?.promptAutocomplete ?? false; + insertSuggestionPrompt = $settings?.insertSuggestionPrompt ?? false; keepFollowUpPrompts = $settings?.keepFollowUpPrompts ?? false; insertFollowUpPrompt = $settings?.insertFollowUpPrompt ?? false; @@ -258,6 +262,14 @@ }} /> + { + saveSettings({ imageCompressionSize: size }); + }} +/> +
-
+

{$i18n.t('UI')}

@@ -697,6 +709,25 @@
+
+
+
+ {$i18n.t('Insert Suggestion Prompt to Input')} +
+ +
+ { + saveSettings({ insertSuggestionPrompt }); + }} + /> +
+
+
+
@@ -1133,7 +1164,20 @@ {$i18n.t('Image Compression')}
-
+
+ {#if imageCompression} + + {/if} + {#if imageCompression} -
-
-
- {$i18n.t('Image Max Compression Size')} -
- -
- - x - - -
-
-
-
diff --git a/src/lib/components/chat/Settings/Interface/ManageImageCompressionModal.svelte b/src/lib/components/chat/Settings/Interface/ManageImageCompressionModal.svelte new file mode 100644 index 0000000000..652db8ab4e --- /dev/null +++ b/src/lib/components/chat/Settings/Interface/ManageImageCompressionModal.svelte @@ -0,0 +1,108 @@ + + + +
+
+

+ {$i18n.t('Manage')} +

+ +
+ +
+
+ { + e.preventDefault(); + submitHandler(); + }} + > +
+
+
+
+ {$i18n.t('Image Max Compression Size')} +
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+
+ +
+ +
+ +
+
+
+
diff --git a/src/lib/components/chat/Settings/Personalization.svelte b/src/lib/components/chat/Settings/Personalization.svelte index 855399f319..ebef87847f 100644 --- a/src/lib/components/chat/Settings/Personalization.svelte +++ b/src/lib/components/chat/Settings/Personalization.svelte @@ -30,7 +30,7 @@ dispatch('save'); }} > -
+
-
+
-