Merge branch 'open-webui:dev' into dev

This commit is contained in:
Aleix Dorca 2025-09-24 10:59:32 +02:00 committed by GitHub
commit cf49b823b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
333 changed files with 15898 additions and 7001 deletions

View file

@ -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 youve 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!

View file

@ -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.

View file

@ -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({

View file

@ -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 }}'

View file

@ -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'

View file

@ -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

View file

@ -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

11
LICENSE_NOTICE Normal file
View file

@ -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.

View file

@ -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 💬

View file

@ -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

View file

@ -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",

View file

@ -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
####################################

View file

@ -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(

View file

@ -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,
},

View file

@ -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")

View file

@ -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

View file

@ -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 [

View file

@ -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]:

View file

@ -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]:

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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 []

View file

@ -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)
):

View file

@ -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()

View file

@ -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
]

View file

@ -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:

View file

@ -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(

View file

@ -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:

View file

@ -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",
)

View file

@ -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),

View file

@ -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(),

View file

@ -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:

View file

@ -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(),

View file

@ -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
############################

View file

@ -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

View file

@ -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"

View file

@ -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,
)

View file

@ -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),
}

View file

@ -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

View file

@ -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
############################

View file

@ -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", [])

View file

@ -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):

View file

@ -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)

View file

@ -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

View file

@ -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, {}

View file

@ -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()

View file

@ -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}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
tool_result_embeds = result.get("embeds", "")
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\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})"
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

77
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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]

View file

@ -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;
}

View file

@ -2,29 +2,42 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/png" href="/static/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="shortcut icon" href="/static/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link rel="icon" type="image/png" href="/static/favicon.png" crossorigin="use-credentials" />
<link
rel="icon"
type="image/png"
href="/static/favicon-96x96.png"
sizes="96x96"
crossorigin="use-credentials"
/>
<link
rel="icon"
type="image/svg+xml"
href="/static/favicon.svg"
crossorigin="use-credentials"
/>
<link rel="shortcut icon" href="/static/favicon.ico" crossorigin="use-credentials" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/apple-touch-icon.png"
crossorigin="use-credentials"
/>
<link
rel="manifest"
href="/manifest.json"
crossorigin="use-credentials"
crossorigin="use-credentials"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" />
<meta name="description" content="Open WebUI" />
<link
rel="search"
type="application/opensearchdescription+xml"
title="Open WebUI"
href="/opensearch.xml"
/>
<script src="/static/loader.js" defer></script>
<link rel="stylesheet" href="/static/custom.css" />
<script src="/static/loader.js" defer crossorigin="use-credentials"></script>
<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
<script>
function resizeIframe(obj) {

View file

@ -354,8 +354,19 @@ export const getToolServersData = async (servers: object[]) => {
.filter((server) => server?.config?.enable)
.map(async (server) => {
let error = null;
let toolServerToken = null;
const auth_type = server?.auth_type ?? 'bearer';
if (auth_type === 'bearer') {
toolServerToken = server?.key;
} else if (auth_type === 'none') {
// No authentication
} else if (auth_type === 'session') {
toolServerToken = localStorage.token;
}
const data = await getToolServerData(
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
toolServerToken,
(server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`

View file

@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
return grouped;
};
export const getNoteList = async (token: string = '') => {
export const getNoteList = async (token: string = '', page: number | null = null) => {
let error = null;
const searchParams = new URLSearchParams();
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
return res;
};
export const searchUsers = async (token: string, query: string) => {
let error = null;
let res = null;
res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getUserSettings = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {

View file

@ -31,6 +31,7 @@
let url = '';
let key = '';
let auth_type = 'bearer';
let connectionType = 'external';
let azure = false;
@ -74,6 +75,7 @@
url,
key,
config: {
auth_type,
azure: azure,
api_version: apiVersion
}
@ -120,7 +122,7 @@
return;
}
if (!key) {
if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
loading = false;
toast.error($i18n.t('Key is required'));
@ -146,6 +148,7 @@
prefix_id: prefixId,
model_ids: modelIds,
connection_type: connectionType,
auth_type,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
}
};
@ -157,6 +160,7 @@
url = '';
key = '';
auth_type = 'bearer';
prefixId = '';
tags = [];
modelIds = [];
@ -167,6 +171,8 @@
url = connection.url;
key = connection.key;
auth_type = connection.config.auth_type ?? 'bearer';
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
@ -305,23 +311,72 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
>
{$i18n.t('Key')}
</div>
<div class="flex-1">
<SensitiveInput
inputClassName={`w-full text-sm bg-transparent ${($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={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
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}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !ollama}
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{#if azure}
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
{/if}
{/if}
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
@ -355,7 +410,7 @@
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider')}</label
>{$i18n.t('Provider Type')}</label
>
<div>
@ -397,37 +452,7 @@
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between">
<div
class={`mb-0.5 text-xs text-gray-500
@ -483,8 +508,6 @@
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
<div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input
@ -512,6 +535,34 @@
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button

View file

@ -7,8 +7,7 @@
</script>
<div class="px-3">
<div class="text-center text-6xl mb-3">📄</div>
<div class="text-center dark:text-white text-xl font-semibold z-50">
<div class="text-center dark:text-white text-2xl font-medium z-50">
{#if title}
{title}
{:else}
@ -17,7 +16,7 @@
</div>
<slot
><div class="px-2 mt-2 text-center text-sm dark:text-gray-200 w-full">
><div class="px-2 mt-2 text-center text-gray-700 dark:text-gray-200 w-full">
{#if content}
{content}
{:else}

View file

@ -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 @@
}}
>
<div class="px-1">
{#if !direct}
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
</div>
</div>
{/if}
{#if type === 'mcp'}
<div
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
>
<span class="font-medium">
{$i18n.t('Warning')}:
</span>
{$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.'
)}
<a
class="font-medium underline"
href="https://docs.openwebui.com/features/mcp"
target="_blank">{$i18n.t('Read more →')}</a
>
</div>
{/if}
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5">
@ -243,30 +300,36 @@
</Tooltip>
</div>
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{#if ['', 'openapi'].includes(type)}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{/if}
</div>
</div>
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{#if ['', 'openapi'].includes(type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
@ -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}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="request_headers">{$i18n.t('Request Headers')}</option>
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select>
</div>
@ -299,17 +364,23 @@
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'request_headers'}
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user headers to authenticate')}
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
@ -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')}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{#if type !== 'mcp'}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{/if}
</label>
<div class="flex-1">
@ -339,6 +413,7 @@
bind:value={id}
placeholder={$i18n.t('Enter ID')}
autocomplete="off"
required={type === 'mcp'}
/>
</div>
</div>
@ -388,7 +463,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>

View file

@ -1,4 +1,6 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { onMount, getContext } from 'svelte';
import { Confetti } from 'svelte-confetti';
@ -17,16 +19,19 @@
let changelog = null;
onMount(async () => {
const res = await getChangelog();
changelog = res;
});
const init = async () => {
changelog = await getChangelog();
};
$: if (show) {
init();
}
</script>
<Modal bind:show size="lg">
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
<Modal bind:show size="xl">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<div class="text-xl font-semibold">
<div class="text-xl font-medium">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
@ -46,7 +51,7 @@
</div>
<div class="flex items-center mt-1">
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
<div class="text-sm dark:text-gray-200">
v{WEBUI_VERSION}
</div>
@ -54,7 +59,7 @@
</div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-96 scrollbar-hidden">
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
<div class="mb-3">
{#if changelog}
{#each Object.keys(changelog) as version}
@ -63,31 +68,28 @@
v{version} - {changelog[version].date}
</div>
<hr class="border-gray-100 dark:border-gray-850 my-2" />
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="">
<div class="w-full">
<div
class="font-semibold uppercase text-xs {section === 'added'
? 'text-white bg-blue-600'
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
: section === 'fixed'
? 'text-white bg-green-600'
? 'bg-green-500/20 text-green-700 dark:text-green-200'
: section === 'changed'
? 'text-white bg-yellow-600'
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
: section === 'removed'
? 'text-white bg-red-600'
: ''} w-fit px-3 rounded-full my-2.5"
? 'bg-red-500/20 text-red-700 dark:text-red-200'
: ''} w-fit rounded-xl px-2 my-2.5"
>
{section}
</div>
<div class="my-2.5 px-1.5">
{#each Object.keys(changelog[version][section]) as item}
<div class="text-sm mb-2">
<div class="font-semibold uppercase">
{changelog[version][section][item].title}
</div>
<div class="mb-2 mt-1">{changelog[version][section][item].content}</div>
<div class="my-2.5 px-1.5 markdown-prose-sm !list-none !w-full !max-w-none">
{#each changelog[version][section] as entry}
<div class="my-2">
{@html DOMPurify.sanitize(entry?.raw)}
</div>
{/each}
</div>

View file

@ -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 @@
});
</script>
<button
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-850 rounded-xl px-3.5 py-3.5"
on:click={() => {
onClick();
dispatch('closeToast');
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
on:dragstart|preventDefault
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={() => (moved = true)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clickHandler();
}
}}
>
<div class="shrink-0 self-top -translate-y-0.5">
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
</div>
<div>
{#if title}
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{title}</div>
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
{/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
{@html DOMPurify.sanitize(marked(content))}
</div>
</div>
</button>
</div>

View file

@ -56,7 +56,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
id="leaderboard"
@ -113,7 +113,7 @@
</button>
</div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
{#if selectedTab === 'leaderboard'}
<Leaderboard {feedbacks} />
{:else if selectedTab === 'feedbacks'}

View file

@ -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;
</script>
@ -25,7 +25,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[150px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
class="w-full max-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"

View file

@ -13,7 +13,7 @@
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
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 Badge from '$lib/components/common/Badge.svelte';
import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
@ -169,7 +169,7 @@
<FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
<div class="mt-0.5 mb-2 gap-1 flex flex-row justify-between">
<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Feedback History')}
@ -187,31 +187,25 @@
exportHandler();
}}
>
<ArrowDownTray className="size-3" />
<Download className="size-3" />
</button>
</Tooltip>
</div>
{/if}
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none w-3"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -234,7 +228,7 @@
<th
scope="col"
class="px-3 pr-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
@ -257,7 +251,7 @@
<th
scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -280,7 +274,7 @@
<th
scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-0"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -301,7 +295,7 @@
</div>
</th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">

View file

@ -1,9 +1,4 @@
<script lang="ts">
import * as ort from 'onnxruntime-web';
import { env, AutoModel, AutoTokenizer } from '@huggingface/transformers';
env.backends.onnx.wasm.wasmPaths = '/wasm/';
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
@ -237,6 +232,11 @@
//////////////////////
const loadEmbeddingModel = async () => {
const { env, AutoModel, AutoTokenizer } = await import('@huggingface/transformers');
if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = '/wasm/';
}
// Check if the tokenizer and model are already loaded and stored in the window object
if (!window.tokenizer) {
window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
@ -337,7 +337,7 @@
/>
<div
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
<div class=" gap-1">
@ -370,9 +370,7 @@
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm">
{#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto">
@ -386,17 +384,15 @@
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full {loadingLeaderboard
? 'opacity-20'
: ''}"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none w-3"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center">
@ -418,7 +414,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
@ -440,7 +436,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -462,7 +458,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
on:click={() => setSortKey('won')}
>
<div class="flex gap-1.5 items-center justify-end">
@ -484,7 +480,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
on:click={() => setSortKey('lost')}
>
<div class="flex gap-1.5 items-center justify-end">

View file

@ -18,7 +18,7 @@
toggleGlobalById
} from '$lib/apis/functions';
import ArrowDownTray from '../icons/ArrowDownTray.svelte';
import Download from '../icons/Download.svelte';
import Tooltip from '../common/Tooltip.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
import { getModels } from '$lib/apis';
@ -222,7 +222,7 @@
}}
/>
<div class="flex flex-col mt-1.5 mb-0.5">
<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
<div class="flex justify-between items-center mb-1">
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
{$i18n.t('Functions')}
@ -317,7 +317,7 @@
</div>
</div>
<div class="mb-5">
<div class="mb-5 px-[16px]">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
@ -330,14 +330,14 @@
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<div
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
{#if func?.meta?.manifest?.version}
<div
class="text-xs font-bold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
v{func?.meta?.manifest?.version ?? ''}
</div>
@ -482,7 +482,7 @@
)}
</div> -->
<div class=" flex justify-end w-full mb-2">
<div class=" flex justify-end w-full mb-2 px-[16px]">
<div class="flex space-x-2">
<input
id="documents-import-input"
@ -562,7 +562,7 @@
</div>
{#if $config?.features.enable_community_sharing}
<div class=" my-16">
<div class=" my-16 px-[16px]">
<div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by Open WebUI Community')}
</div>

View file

@ -8,7 +8,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Github from '$lib/components/icons/Github.svelte';
@ -41,7 +41,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={-2}
side="bottom"
align="start"

View file

@ -4,7 +4,6 @@
const i18n = getContext('i18n');
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -367,20 +366,22 @@ class Pipe:
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
onChange={(e) => {
_content = e;
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
onChange={(e) => {
_content = e;
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
{/await}
</div>
<div class="pb-3 flex justify-between">

View file

@ -8,7 +8,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
@ -42,7 +42,7 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
class="w-full max-w-[180px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="start"
@ -50,7 +50,7 @@
>
{#if ['filter', 'action'].includes(func.type)}
<div
class="flex gap-2 justify-between items-center px-3 py-2 text-sm font-medium cursor-pointerrounded-md"
class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
>
<div class="flex gap-2 items-center">
<GlobeAlt />
@ -63,11 +63,11 @@
</div>
</div>
<hr class="border-gray-100 dark:border-gray-850 my-1" />
<hr class="border-gray-50 dark:border-gray-850 my-1" />
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
editHandler();
}}
@ -91,7 +91,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
@ -101,7 +101,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
@ -112,20 +112,20 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
>
<ArrowDownTray />
<Download />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-850 my-1" />
<hr class="border-gray-50 dark:border-gray-850 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
deleteHandler();
}}

View file

@ -83,7 +83,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
id="general"
@ -433,7 +433,9 @@
</button>
</div>
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden">
<div
class="flex-1 mt-3 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
>
{#if selectedTab === 'general'}
<General
saveHandler={async () => {

View file

@ -261,10 +261,10 @@
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:url={OPENAI_API_BASE_URLS[idx]}
bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]}
pipeline={pipelineUrls[url] ? true : false}
onSubmit={() => {
updateOpenAIHandler();
}}
@ -326,7 +326,7 @@
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
bind:url={OLLAMA_BASE_URLS[idx]}
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {

View file

@ -10,7 +10,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Download from '$lib/components/icons/Download.svelte';
export let onDelete = () => {};
export let onSubmit = () => {};
@ -71,6 +71,7 @@
class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
bind:value={url}
readonly={true}
/>
</Tooltip>
@ -83,7 +84,7 @@
}}
type="button"
>
<ArrowDownTray />
<Download />
</button>
</Tooltip>

View file

@ -69,6 +69,7 @@
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
readonly={true}
/>
{#if pipeline}
@ -94,13 +95,6 @@
</div>
{/if}
</div>
<SensitiveInput
inputClassName=" outline-hidden bg-transparent w-full"
placeholder={$i18n.t('API Key')}
required={false}
bind:value={key}
/>
</div>
</Tooltip>

View file

@ -143,7 +143,7 @@
</div>
</button>
<hr class="border-gray-100 dark:border-gray-850 my-1" />
<hr class="border-gray-50 dark:border-gray-850 my-1" />
{#if $config?.features.enable_admin_export ?? true}
<div class=" flex w-full justify-between">
@ -233,14 +233,4 @@
{/if}
</div>
</div>
<!-- <div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div> -->
</form>

View file

@ -153,6 +153,7 @@
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
RAGConfig.DOCLING_DO_OCR &&
((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
) {
@ -161,6 +162,14 @@
);
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
RAGConfig.DOCLING_DO_OCR === false &&
RAGConfig.DOCLING_FORCE_OCR === true
) {
toast.error($i18n.t('In order to force OCR, performing OCR must be enabled.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' &&
@ -545,19 +554,91 @@
bind:value={RAGConfig.DOCLING_SERVER_URL}
/>
</div>
<div class="flex w-full mt-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Engine')}
bind:value={RAGConfig.DOCLING_OCR_ENGINE}
/>
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Perform OCR')}
</div>
<div class="flex items-center relative">
<Switch bind:state={RAGConfig.DOCLING_DO_OCR} />
</div>
</div>
</div>
{#if RAGConfig.DOCLING_DO_OCR}
<div class="flex w-full mt-2">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Engine')}
bind:value={RAGConfig.DOCLING_OCR_ENGINE}
/>
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
{/if}
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Force OCR')}
</div>
<div class="flex items-center relative">
<Switch bind:state={RAGConfig.DOCLING_FORCE_OCR} />
</div>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('PDF Backend')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_PDF_BACKEND}
>
<option value="pypdfium2">{$i18n.t('pypdfium2')}</option>
<option value="dlparse_v1">{$i18n.t('dlparse_v1')}</option>
<option value="dlparse_v2">{$i18n.t('dlparse_v2')}</option>
<option value="dlparse_v4">{$i18n.t('dlparse_v4')}</option>
</select>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('Table Mode')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_TABLE_MODE}
>
<option value="fast">{$i18n.t('fast')}</option>
<option value="accurate">{$i18n.t('accurate')}</option>
</select>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('Pipeline')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_PIPELINE}
>
<option value="standard">{$i18n.t('standard')}</option>
<option value="vlm">{$i18n.t('vlm')}</option>
</select>
</div>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
@ -1062,7 +1143,7 @@
<div class=" mb-2.5 py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5'
'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
)}
placement="top-start"
className="inline-tooltip"

View file

@ -293,7 +293,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl />
</div>
</div>

View file

@ -30,7 +30,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Download from '$lib/components/icons/Download.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
@ -265,7 +265,7 @@
showManageModal = true;
}}
>
<ArrowDownTray />
<Download />
</button>
</Tooltip>

View file

@ -11,7 +11,7 @@
import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
import Download from '$lib/components/icons/Download.svelte';
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
import { config } from '$lib/stores';
@ -45,14 +45,14 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[170px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
@ -104,7 +104,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
@ -115,12 +115,12 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
>
<ArrowDownTray />
<Download />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>

View file

@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte';
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
export let saveSettings: Function;
@ -47,7 +47,7 @@
});
</script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form
class="flex flex-col h-full justify-between text-sm"

View file

@ -58,7 +58,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="users-tabs-container"
class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
id="overview"
@ -111,7 +111,7 @@
</button>
</div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
{#if selectedTab === 'overview'}
<UserList />
{:else if selectedTab === 'groups'}

View file

@ -216,7 +216,7 @@
</div>
{:else}
<div>
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-semibold">
<div class="w-full basis-3/5">{$i18n.t('Group')}</div>
<div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div>

View file

@ -1,6 +1,7 @@
<script>
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { page } from '$app/stores';
const i18n = getContext('i18n');
@ -10,7 +11,6 @@
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte';
import { querystringValue } from '$lib/utils';
export let users = [];
export let group = {
@ -47,7 +47,7 @@
};
onMount(() => {
const groupId = querystringValue('id');
const groupId = $page.url.searchParams.get('id');
if (groupId && groupId === group.id) {
showEdit = true;
}

View file

@ -154,7 +154,7 @@
</div>
{:else}
<div
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
>
<div class="flex md:self-center text-lg font-medium px-0.5">
<div class="flex-shrink-0">
@ -219,19 +219,13 @@
</div>
</div>
<div
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('role')}
>
<div class="flex gap-1.5 items-center">
@ -254,7 +248,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('name')}
>
<div class="flex gap-1.5 items-center">
@ -277,7 +271,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('email')}
>
<div class="flex gap-1.5 items-center">
@ -301,7 +295,7 @@
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')}
>
<div class="flex gap-1.5 items-center">
@ -324,7 +318,7 @@
</th>
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('created_at')}
>
<div class="flex gap-1.5 items-center">
@ -347,7 +341,7 @@
<th
scope="col"
class="px-3 py-1.5 cursor-pointer select-none"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
@ -369,7 +363,7 @@
</div>
</th>
<th scope="col" class="px-3 py-2 text-right" />
<th scope="col" class="px-2.5 py-2 text-right" />
</tr>
</thead>
<tbody class="">
@ -508,11 +502,11 @@
> [!NOTE]
> # **Hey there! 👋**
>
> It looks like you have over 50 users that usually falls under organizational usage.
> It looks like you have over 50 users, that usually falls under organizational usage.
>
> Open WebUI is proudly open source and completely free, with no hidden limits — and we'd love to keep it that way. 🌱
> Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱
>
> By supporting the project through sponsorship or an enterprise license, youre not only helping us stay independent, youre also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more all at a fraction of what it would cost to build and maintain internally.
> By supporting the project through sponsorship or an enterprise license, youre not only helping us stay independent, youre also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally.
>
> Your support helps us stay independent and continue building great tools for everyone. 💛
>

View file

@ -43,6 +43,10 @@
let searchDebounceTimeout;
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}

View file

@ -250,6 +250,8 @@
<MessageInput
id="root"
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
{onChange}
onSubmit={submitHandler}
{scrollToBottom}
@ -279,11 +281,12 @@
{/if}
{:else if threadId !== null}
<PaneResizer
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
>
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</PaneResizer>
<Pane defaultSize={50} minSize={30} class="h-full w-full">

File diff suppressed because it is too large Load diff

View file

@ -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 @@
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={15}
alignOffset={-8}
side="top"
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={4}
alignOffset={-6}
side="bottom"
align="start"
transition={flyAndScale}
>
{#if !$mobile}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<CameraSolid />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
>
<DocumentArrowUpSolid />
<Clip />
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
>
<Camera />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -0,0 +1,205 @@
<script lang="ts">
import { getContext, onDestroy, onMount } from 'svelte';
const i18n = getContext('i18n');
import { channels, models, user } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Hashtag from '$lib/components/icons/Hashtag.svelte';
import Lock from '$lib/components/icons/Lock.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { searchUsers } from '$lib/apis/users';
export let query = '';
export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0;
export let label = '';
export let triggerChar = '@';
export let modelSuggestions = false;
export let userSuggestions = false;
export let channelSuggestions = false;
let _models = [];
let _users = [];
let _channels = [];
$: filteredItems = [..._users, ..._models, ..._channels].filter(
(u) =>
u.label.toLowerCase().includes(query.toLowerCase()) ||
u.id.toLowerCase().includes(query.toLowerCase())
);
const getUserList = async () => {
const res = await searchUsers(localStorage.token, query).catch((error) => {
console.error('Error searching users:', error);
return null;
});
if (res) {
_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
a.label.localeCompare(b.label)
);
}
};
$: if (query !== null && userSuggestions) {
getUserList();
}
const select = (index: number) => {
const item = filteredItems[index];
if (!item) return;
// Add the "U:", "M:" or "C:" prefix to the id
// and also append the label after a pipe |
// so that the mention renderer can show the label
if (item)
command({
id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : 'C'}:${item.id}|${item.label}`,
label: item.label
});
};
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') {
selectedIndex = Math.max(0, selectedIndex - 1);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'ArrowDown') {
selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex);
if (event.key === 'Enter') {
event.preventDefault();
}
return true;
}
if (event.key === 'Escape') {
// tell tiptap we handled it (it will close)
return true;
}
return false;
};
// This method will be called from the suggestion renderer
// @ts-ignore
export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event);
}
const keydownListener = (e) => {
// required to prevent the default enter behavior
if (e.key === 'Enter') {
e.preventDefault();
select(selectedIndex);
}
};
onMount(async () => {
window.addEventListener('keydown', keydownListener);
if (channelSuggestions) {
// Add a dummy channel item
_channels = [
...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
];
} else {
if (userSuggestions) {
await getUserList();
}
if (modelSuggestions) {
_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
}
}
});
onDestroy(() => {
window.removeEventListener('keydown', keydownListener);
});
</script>
{#if filteredItems.length}
<div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
id="suggestions-container"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
{#each filteredItems as item, i}
{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'user'}
{$i18n.t('Users')}
{:else if item?.type === 'model'}
{$i18n.t('Models')}
{:else if item?.type === 'channel'}
{$i18n.t('Channels')}
{/if}
</div>
{/if}
<Tooltip content={item?.id} placement="top-start">
<button
type="button"
on:click={() => select(i)}
on:mousemove={() => {
selectedIndex = i;
}}
class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
data-selected={i === selectedIndex}
>
{#if item.type === 'channel'}
<div class=" size-4 justify-center flex items-center mr-0.5">
{#if item?.data?.access_control === null}
<Hashtag className="size-3" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />
{/if}
</div>
{:else if item.type === 'model'}
<img
src={item?.data?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={item?.data?.name ?? item.id}
class="rounded-full size-5 items-center mr-2"
/>
{:else if item.type === 'user'}
<img
src={`${WEBUI_API_BASE_URL}/users/${item.id}/profile/image`}
alt={item?.label ?? item.id}
class="rounded-full size-5 items-center mr-2"
/>
{/if}
<div class="truncate flex-1 pr-2">
{item.label}
</div>
<div class="shrink-0 text-xs text-gray-500">
{#if item.type === 'user'}
{$i18n.t('User')}
{:else if item.type === 'model'}
{$i18n.t('Model')}
{:else if item.type === 'channel'}
{$i18n.t('Channel')}
{/if}
</div>
</button>
</Tooltip>
{/each}
</div>
</div>
{/if}

View file

@ -63,11 +63,7 @@
</div>
</Loader>
{:else if !thread}
<div
class="px-5
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
>
<div class="px-5 max-w-full mx-auto">
{#if channel}
<div class="flex flex-col gap-1.5 pb-5 pt-10">
<div class="text-2xl font-medium capitalize">{channel.name}</div>
@ -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);

View file

@ -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 @@
<div
class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5'
: ''} w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
>
{#if !edit}
<div
@ -138,19 +138,22 @@
id="message-{message.id}"
dir={$settings.chatDirection}
>
<div
class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
>
<div class={`shrink-0 mr-3 w-9`}>
{#if showUserProfile}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ??
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
/>
</ProfilePreview>
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 translate-y-1 ml-0.5'}
/>
</ProfilePreview>
{/if}
{:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
@ -170,7 +173,11 @@
{#if showUserProfile}
<Name>
<div class=" self-end text-base shrink-0 font-medium truncate">
{message?.user?.name}
{#if message?.meta?.model_id}
{message?.meta?.model_name ?? message?.meta?.model_id}
{:else}
{message?.user?.name}
{/if}
</div>
{#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]"
>
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
<span class="line-clamp-1">
{$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')
})}
</span>
</Tooltip>
</div>
{/if}
@ -198,7 +210,7 @@
name={file.name}
type={file.type}
size={file?.size}
colorClassName="bg-white dark:bg-gray-850 "
small={true}
/>
{/if}
</div>
@ -228,7 +240,7 @@
<div class="flex space-x-1.5">
<button
id="close-edit-message-button"
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
on:click={() => {
edit = false;
editedContent = null;
@ -239,7 +251,7 @@
<button
id="confirm-edit-message-button"
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
on:click={async () => {
onEdit(editedContent);
edit = false;
@ -253,12 +265,16 @@
</div>
{:else}
<div class=" min-w-full markdown-prose">
<Markdown
id={message.id}
content={message.content}
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
>(edited)</span
>{/if}
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown
id={message.id}
content={message.content}
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
class="text-gray-500 text-[10px]">({$i18n.t('edited')})</span
>{/if}
{/if}
</div>
{#if (message?.reactions ?? []).length > 0}

View file

@ -1,101 +1,18 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { LinkPreview } from 'bits-ui';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { flyAndScale } from '$lib/utils/transitions';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getUserActiveStatusById } from '$lib/apis/users';
export let side = 'right';
export let align = 'top';
import UserStatus from './UserStatus.svelte';
import UserStatusLinkPreview from './UserStatusLinkPreview.svelte';
export let user = null;
let show = false;
let active = false;
const getActiveStatus = async () => {
const res = await getUserActiveStatusById(localStorage.token, user.id).catch((error) => {
console.error('Error fetching user active status:', error);
});
if (res) {
active = res.active;
} else {
active = false;
}
};
$: if (show) {
getActiveStatus();
}
</script>
<DropdownMenu.Root
bind:open={show}
closeFocus={false}
onOpenChange={(state) => {}}
typeahead={false}
>
<DropdownMenu.Trigger>
<LinkPreview.Root openDelay={0} closeDelay={0}>
<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
<slot />
</DropdownMenu.Trigger>
</LinkPreview.Trigger>
<slot name="content">
<DropdownMenu.Content
class="max-w-full w-[240px] rounded-lg z-9999 bg-white dark:bg-black dark:text-white shadow-lg"
sideOffset={8}
{side}
{align}
transition={flyAndScale}
>
{#if user}
<div class=" flex flex-col gap-2 w-full rounded-lg">
<div class="py-8 relative bg-gray-900 rounded-t-lg">
<img
crossorigin="anonymous"
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
alt="profile"
/>
</div>
<div class=" flex flex-col pt-4 pb-2.5 px-4">
<div class=" -mb-1">
<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
</div>
<div class=" flex items-center gap-2">
{#if active}
<div>
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> {$i18n.t('Active')} </span>
</div>
{:else}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> {$i18n.t('Away')} </span>
</div>
{/if}
</div>
</div>
</div>
{/if}
</DropdownMenu.Content>
</slot>
</DropdownMenu.Root>
<UserStatusLinkPreview id={user?.id} side="right" align="center" sideOffset={8} />
</LinkPreview.Root>

Some files were not shown because too many files have changed in this diff Show more