diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d0f38c2334..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' ---- - -# Bug Report - -## Important Notes - -- **Before submitting a bug report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - -- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours. - -- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - -- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - -Note: Please remove the notes above when submitting your post. Thank you for your understanding and support! - ---- - -## Installation Method - -[Describe the method you used to install the project, e.g., git clone, Docker, pip, etc.] - -## Environment - -- **Open WebUI Version:** [e.g., v0.3.11] -- **Ollama (if applicable):** [e.g., v0.2.0, v0.1.32-rc1] - -- **Operating System:** [e.g., Windows 10, macOS Big Sur, Ubuntu 20.04] -- **Browser (if applicable):** [e.g., Chrome 100.0, Firefox 98.0] - -**Confirmation:** - -- [ ] I have read and followed all the instructions provided in the README.md. -- [ ] I am on the latest version of both Open WebUI and Ollama. -- [ ] I have included the browser console logs. -- [ ] I have included the Docker container logs. -- [ ] I have provided the exact steps to reproduce the bug in the "Steps to Reproduce" section below. - -## Expected Behavior: - -[Describe what you expected to happen.] - -## Actual Behavior: - -[Describe what actually happened.] - -## Description - -**Bug Summary:** -[Provide a brief but clear summary of the bug] - -## Reproduction Details - -**Steps to Reproduce:** -[Outline the steps to reproduce the bug. Be as detailed as possible.] - -## Logs and Screenshots - -**Browser Console Logs:** -[Include relevant browser console logs, if applicable] - -**Docker Container Logs:** -[Include relevant Docker container logs, if applicable] - -**Screenshots/Screen Recordings (if applicable):** -[Attach any relevant screenshots to help illustrate the issue] - -## Additional Information - -[Include any additional details that may help in understanding and reproducing the issue. This could include specific configurations, error messages, or anything else relevant to the bug.] - -## Note - -If the bug report is incomplete or does not follow the provided instructions, it may not be addressed. Please ensure that you have followed the steps outlined in the README.md and troubleshooting.md documents, and provide all necessary information for us to reproduce and address the issue. Thank you! diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000000..a1ea5c8e20 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,167 @@ +name: Bug Report +description: Create a detailed bug report to help us improve Open WebUI. +title: 'issue: ' +labels: ['bug', 'triage'] +assignees: [] +body: + - type: markdown + attributes: + value: | + # Bug Report + + ## 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. + + - **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. + + - **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality. + + - **Bug Reproducibility**: If a bug cannot be reproduced using a `:main` or `:dev` Docker setup or with `pip install` on Python 3.11, community assistance may be required. In such cases, we will move it to the "[Issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section. Your help is appreciated! + + - type: checkboxes + id: issue-check + attributes: + label: Check Existing Issues + description: Confirm that you’ve checked for existing reports before submitting a new one. + options: + - label: I have searched the existing issues and discussions. + required: true + - label: I am using the latest version of Open WebUI. + required: true + + - type: dropdown + id: installation-method + attributes: + label: Installation Method + description: How did you install Open WebUI? + options: + - Git Clone + - Pip Install + - Docker + - Other + validations: + required: true + + - type: input + id: open-webui-version + attributes: + label: Open WebUI Version + description: Specify the version (e.g., v0.3.11) + validations: + required: true + + - type: input + id: ollama-version + attributes: + label: Ollama Version (if applicable) + description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1) + validations: + required: false + + - type: input + id: operating-system + attributes: + label: Operating System + description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04) + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser (if applicable) + description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0) + validations: + required: false + + - type: checkboxes + id: confirmation + attributes: + label: Confirmation + description: Ensure the following prerequisites have been met. + options: + - label: I have read and followed all instructions in `README.md`. + required: true + - label: I am using the latest version of **both** Open WebUI and Ollama. + required: true + - label: I have included the browser console logs. + required: true + - label: I have included the Docker container logs. + required: true + - label: I have **provided every relevant configuration, setting, and environment variable used in my setup.** + required: true + - label: I have clearly **listed every relevant configuration, custom setting, environment variable, and command-line option that influences my setup** (such as Docker Compose overrides, .env values, browser settings, authentication configurations, etc). + required: true + - label: | + I have documented **step-by-step reproduction instructions that are precise, sequential, and leave nothing to interpretation**. My steps: + - Start with the initial platform/version/OS and dependencies used, + - Specify exact install/launch/configure commands, + - List URLs visited, user input (incl. example values/emails/passwords if needed), + - Describe all options and toggles enabled or changed, + - Include any files or environmental changes, + - Identify the expected and actual result at each stage, + - Ensure any reasonably skilled user can follow and hit the same issue. + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: Describe what should have happened. + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: Describe what actually happened. + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: Steps to Reproduce + 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.** + + placeholder: | + Example (include every detail): + 1. Start with a clean Ubuntu 22.04 install. + 2. Install Docker v24.0.5 and start the service. + 3. Clone the Open WebUI repo (git clone ...). + 4. Use the Docker Compose file without modifications. + 5. Open browser Chrome 115.0 in incognito mode. + 6. Go to http://localhost:8080 and log in with user "test@example.com". + 7. Set the language to "English" and theme to "Dark". + 8. Attempt to connect to Ollama at "http://localhost:11434". + 9. Observe that the error message "Connection refused" appears at the top right. + + Please list each step carefully and include all relevant configuration, settings, and options. + validations: + required: true + - type: textarea + id: logs-screenshots + attributes: + label: Logs & Screenshots + description: Include relevant logs, errors, or screenshots to help diagnose the issue. + placeholder: 'Attach logs from the browser console, Docker logs, or error messages.' + validations: + required: true + + - type: textarea + id: additional-info + attributes: + label: Additional Information + description: Provide any extra details that may assist in understanding the issue. + validations: + required: false + + - type: markdown + 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. + Thank you for contributing to Open WebUI! diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..3ba13e0cec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 5d6e9d708d..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' ---- - -# Feature Request - -## Important Notes - -- **Before submitting a report**: Please check the Issues or Discussions section to see if a similar issue or feature request has already been posted. It's likely we're already tracking it! If you’re unsure, start a discussion post first. This will help us efficiently focus on improving the project. - -- **Collaborate respectfully**: We value a constructive attitude, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We’re here to help if you’re open to learning and communicating positively. Remember, Open WebUI is a volunteer-driven project managed by a single maintainer and supported by contributors who also have full-time jobs. We appreciate your time and ask that you respect ours. - -- **Contributing**: If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. - -- **Bug reproducibility**: If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a pip install with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "issues" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! - -Note: Please remove the notes above when submitting your post. Thank you for your understanding and support! - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000000..2a326f65e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,64 @@ +name: Feature Request +description: Suggest an idea for this project +title: 'feat: ' +labels: ['triage'] +body: + - type: markdown + attributes: + value: | + ## Important Notes + ### Before submitting + Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) to see if a similar request has been posted. + It's likely we're already tracking it! If you’re unsure, start a discussion post first. + This will help us efficiently focus on improving the project. + + ### Collaborate respectfully + We value a **constructive attitude**, so please be mindful of your communication. If negativity is part of your approach, our capacity to engage may be limited. We're here to help if you're **open to learning** and **communicating positively**. + + Remember: + - Open WebUI is a **volunteer-driven project** + - It's managed by a **single maintainer** + - It's supported by contributors who also have **full-time jobs** + + We appreciate your time and ask that you **respect ours**. + + + ### Contributing + If you encounter an issue, we highly encourage you to submit a pull request or fork the project. We actively work to prevent contributor burnout to maintain the quality and continuity of Open WebUI. + + ### Bug reproducibility + If a bug cannot be reproduced with a `:main` or `:dev` Docker setup, or a `pip install` with Python 3.11, it may require additional help from the community. In such cases, we will move it to the "[issues](https://github.com/open-webui/open-webui/discussions/categories/issues)" Discussions section due to our limited resources. We encourage the community to assist with these issues. Remember, it’s not that the issue doesn’t exist; we need your help! + + - type: checkboxes + id: existing-issue + attributes: + label: Check Existing Issues + description: Please confirm that you've checked for existing similar requests + options: + - label: I have searched the existing issues and discussions. + required: true + - type: textarea + id: problem-description + attributes: + label: Problem Description + description: Is your feature request related to a problem? Please provide a clear and concise description of what the problem is. + placeholder: "Ex. I'm always frustrated when..." + validations: + required: true + - type: textarea + id: solution-description + attributes: + label: Desired Solution you'd like + description: Clearly describe what you want to happen. + validations: + required: true + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af0a8ed0ee..ed93957ea4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,26 @@ version: 2 updates: + - package-ecosystem: uv + directory: '/' + schedule: + interval: monthly + target-branch: 'dev' + - package-ecosystem: pip directory: '/backend' schedule: interval: monthly target-branch: 'dev' + + - package-ecosystem: npm + directory: '/' + schedule: + interval: monthly + target-branch: 'dev' + - package-ecosystem: 'github-actions' directory: '/' schedule: # Check for updates to GitHub Actions every week interval: monthly + target-branch: 'dev' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2a45c2c16e..7f603cb10c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,9 +9,9 @@ - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. - [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? -- [ ] **Testing:** Have you written and run sufficient tests for validating the changes? +- [ ] **Testing:** Have you written and run sufficient tests to validate the changes? - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? -- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following: +- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following: - **BREAKING CHANGE**: Significant changes that may affect compatibility - **build**: Changes that affect the build system or external dependencies - **ci**: Changes to our continuous integration processes or workflows @@ -22,7 +22,7 @@ - **i18n**: Internationalization or localization changes - **perf**: Performance improvement - **refactor**: Code restructuring for better maintainability, readability, or scalability - - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.) + - **style**: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc.) - **test**: Adding missing tests or correcting existing tests - **WIP**: Work in progress, a temporary label for incomplete or ongoing work @@ -70,3 +70,7 @@ ### Screenshots or Videos - [Attach any relevant screenshots or videos demonstrating the changes] + +### 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. diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 03dcf84556..e61a69f33a 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -14,7 +14,7 @@ env: jobs: build-main-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -111,7 +111,7 @@ jobs: retention-days: 1 build-cuda-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write @@ -211,7 +211,7 @@ jobs: retention-days: 1 build-ollama-image: - runs-on: ubuntu-latest + runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml index 4458766975..1bcdd92c1d 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml @@ -5,10 +5,18 @@ on: branches: - main - dev + paths: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' pull_request: branches: - main - dev + paths: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' jobs: build: @@ -17,7 +25,9 @@ jobs: strategy: matrix: - python-version: [3.11] + python-version: + - 3.11.x + - 3.12.x steps: - uses: actions/checkout@v4 @@ -25,7 +35,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '${{ matrix.python-version }}' - name: Install dependencies run: | diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 53d3aaa5ec..9a007581ff 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.github/workflows/format-build-frontend.yaml @@ -5,10 +5,18 @@ on: branches: - main - dev + paths-ignore: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' pull_request: branches: - main - dev + paths-ignore: + - 'backend/**' + - 'pyproject.toml' + - 'uv.lock' jobs: build: @@ -21,7 +29,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' # Or specify any other version you want to use + node-version: '22' - name: Install Dependencies run: npm install diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index 8a2e3438a6..fd1adab3a9 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -17,9 +17,13 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Git + run: sudo apt-get update && sudo apt-get install -y git - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 - uses: actions/setup-python@v5 with: python-version: 3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ff57384d..5a6e9f0098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,497 @@ 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.13] - 2025-05-30 + +### Added + +- 🟦 **Azure OpenAI Embedding Support**: You can now select Azure OpenAI endpoints for text embeddings, unlocking seamless integration with enterprise-scale Azure AI for powerful RAG and knowledge workflows—no more workarounds, connect and scale effortlessly. +- 🧩 **Smarter Custom Parameter Handling**: Instantly enjoy more flexible model setup—any JSON pasted into custom parameter fields is now parsed automatically, so you can define rich, nested parameters without tedious manual adjustment. This streamlines advanced configuration for all models and accelerates experimentation. +- ⚙️ **General Backend Refactoring**: Significant backend improvements deliver a cleaner codebase for better maintainability, faster performance, and even greater platform reliability—making all your workflows run more smoothly. +- 🌏 **Localization Upgrades**: Experience highly improved user interface translations and clarity in Simplified, Traditional Chinese, Korean, and Finnish, offering a more natural, accurate, and accessible experience for global users. + +### Fixed + +- 🛡️ **Robust Message Handling on Chat Load**: Fixed an issue where chat pages could fail to load if a referenced message was missing or undefined; now, chats always load smoothly and missing IDs no longer disrupt your workflow. +- 📝 **Correct Prompt Access Control**: Ensured that the prompt access controls register properly, restoring reliable permissioning and safeguarding your prompt workflows. +- 🛠 **Open WebUI-Specific Params No Longer Sent to Models**: Fixed a bug that sent internal WebUI parameters to APIs, ensuring only intended model options are transmitted—restoring predictable, error-free model operation. +- 🧠 **Refined Memory Error Handling**: Enhanced stability during memory-related operations, so even uncommon memory errors are gracefully managed without disrupting your session—resulting in a more reliable, worry-free experience. + +## [0.6.12] - 2025-05-29 + +### Added + +- 🧩 **Custom Advanced Model Parameters**: You can now add your own tailor-made advanced parameters to any model, empowering you to fine-tune behavior and unlock greater flexibility beyond just the built-in options—accelerate your experimentation. +- 🪧 **Datalab Marker API Content Extraction Support**: Seamlessly extract content from files and documents using the Datalab Marker API directly in your workflows, enabling more robust structured data extraction for RAG and document processing with just a simple engine switch in the UI. +- ⚡ **Parallelized Base Model Fetching**: Experience noticeably faster startup and model refresh times—base model data now loads in parallel, drastically shortening delays in busy or large-scale deployments. +- 🧠 **Efficient Function Loading and Caching**: Functions are now only reloaded if their content changes, preventing unnecessary duplicate loads, saving bandwidth, and boosting performance. +- 🌍 **Localization & Translation Enhancements**: Improved and expanded Simplified, Traditional Chinese, and Russian translations, providing smoother, more accurate, and context-aware experiences for global users. + +### Fixed + +- 💬 **Stable Message Input Box**: Fixed an issue where the message input box would shift unexpectedly (especially on mobile or with screen reader support), ensuring a smooth and reliable typing experience for every user. +- 🔊 **Reliable Read Aloud (Text-to-Speech)**: Read aloud now works seamlessly across messages, so users depending on TTS for accessibility or multitasking will experience uninterrupted and clear voice playback. +- 🖼 **Image Preview and Download Restored**: Fixed problems with image preview and downloads, ensuring frictionless creation, previewing, and downloading of images in your chats—no more interruptions in creative or documentation workflows. +- 📱 **Improved Mobile Styling for Workspace Capabilities**: Capabilities management is now readable and easy-to-use even on mobile devices, empowering admins and users to manage access quickly on the go. +- 🔁 **/api/v1/retrieval/query/collection Endpoint Reliability**: Queries to retrieval collections now return the expected results, bolstering the reliability of your knowledge workflows and citation-ready responses. + +### Removed + +- 🧹 **Duplicate CSS Elements**: Streamlined the UI by removing redundant CSS, reducing clutter and improving load times for a smoother visual experience. + +## [0.6.11] - 2025-05-27 + +### Added + +- 🟢 **Ollama Model Status Indicator in Model Selector**: Instantly see which Ollama models are currently loaded with a clear indicator in the model selector, helping you stay organized and optimize local model usage. +- 🗑️ **Unload Ollama Model Directly from Model Selector**: Easily release memory and resources by unloading any loaded Ollama model right in the model selector—streamline hardware management without switching pages. +- 🗣️ **User-Configurable Speech-to-Text Language Setting**: Improve transcription accuracy by letting individual users explicitly set their preferred STT language in their settings—ideal for multilingual teams and clear audio capture. +- ⚡ **Granular Audio Playback Speed Control**: Instead of just presets, you can now choose granular audio speed using a numeric input, giving you complete control over playback pace in transcriptions and media reviews. +- 📦 **GZip, Brotli, ZStd Compression Middleware**: Enjoy significantly faster page loads and reduced bandwidth usage with new server-side compression—giving users a snappier, more efficient experience. +- 🏷️ **Configurable Weight for BM25 in Hybrid Search**: Fine-tune search relevance by adjusting the weight for BM25 inside hybrid search from the UI, letting you tailor knowledge search results to your workflow. +- 🧪 **Bypass File Creation with CTRL + SHIFT + V**: When “Paste Large Text as File” is enabled, use CTRL + SHIFT + V to skip the file creation dialog and instantly upload text as a file—perfect for rapid document prep. +- 🌐 **Bypass Web Loader in Web Search**: Choose to bypass web content loading and use snippets directly in web search for faster, more reliable results when page loads are slow or blocked. +- 🚀 **Environment Variable: WEBUI_AUTH_TRUSTED_GROUPS_HEADER**: Now sync and manage user groups directly via trusted HTTP header, unlocking smoother single sign-on and identity integrations for organizations. +- 🏢 **Workspace Models Visibility Controls**: You can now hide workspace-level models from both the model selector and shared environments—keep your team focused and reduce clutter from rarely-used endpoints. +- 🛡️ **Copy Model Link**: You can now copy a direct link to any model—including those hidden from the selector—making sharing and onboarding others more seamless. +- 🔗 **Load Function Directly from URL**: Simplify custom function management—just paste any GitHub function URL into Open WebUI and import new functions in seconds. +- ⚙️ **Custom Name/Description for External Tool Servers**: Personalize and clarify external tool servers by assigning custom names and descriptions, making it easier to manage integrations in large-scale workspaces. +- 🌍 **Custom OpenAPI JSON URL Support for Tool Servers**: Supports specifying any custom OpenAPI JSON URL, unlocking more flexible integration with any backend for tool calls. +- 📊 **Source Field Now Displays in Non-Streaming Responses with Attachments**: When files or knowledge are attached, the "source" field now appears for all responses, even in non-streaming mode—enabling improved citation workflow. +- 🎛 **Pinned Chats**: Reduced payload size on pinned chat requests—leading to faster load times and less data usage, especially on busy warehouses. +- 🛠 **Import/Export Default Prompt Suggestions**: Enjoy one-click import/export of prompt suggestions, making it much easier to share, reuse, and manage best practices across teams or deployments. +- 🍰 **Banners Now Sortable from Admin Settings**: Quickly re-order or prioritize banners, letting you highlight the most critical info for your team. +- 🛠 **Advanced Chat Parameters—Clearer Ollama Support Labels**: Parameters and advanced settings now explicitly indicate if they are Ollama-specific, reducing confusion and improving setup accuracy. +- 🤏 **Scroll Bar Thumb Improved for Better Visibility**: Enhanced scrollbar styling makes navigation more accessible and visually intuitive. +- 🗄️ **Modal Redesign for Archived and User Chat Listings**: Clean, modern modal interface for browsing archived and user-specific chats makes locating conversations faster and more pleasant. +- 📝 **Add/Edit Memory Modal UX**: Memory modals are now larger and have resizable input fields, supporting easier editing of long or complex memory content. +- 🏆 **Translation & Localization Enhancements**: Major upgrades to Chinese (Simplified & Traditional), Korean, Russian, German, Danish, Finnish—not just fixing typos, but consistency, tone, and terminology for a more natural native-language experience. +- ⚡ **General Backend Stability & Security Enhancements**: Various backend refinements ensure a more resilient, reliable, and secure platform for smoother operation and peace of mind. + +### Fixed + +- 🖼️ **Image Generation with Allowed File Extensions Now Works Reliably**: Ensure seamless image generation even when strict file extension rules are set—no more blocked creative workflows due to technical hiccups. +- 🗂 **Remove Leading Dot for File Extension Check**: Fixed an issue where file validation failed because of a leading dot, making file uploads and knowledge management more robust. +- 🏷️ **Correct Local/External Model Classification**: The platform now accurately distinguishes between local and external models—preventing local models from showing up as external (and vice versa)—ensuring seamless setup, clarity, and management of your AI model endpoints. +- 📄 **External Document Loader Now Functions as Intended**: External document loaders are reliably invoked, ensuring smoother knowledge ingestion from external sources—expanding your RAG and knowledge workflows. +- 🎯 **Correct Handling of Toggle Filters**: Toggle filters are now robustly managed, preventing accidental auto-activation and ensuring user preferences are always respected. +- 🗃 **S3 Tagging Character Restrictions Fixed**: Tags for files in S3 now automatically meet Amazon’s allowed character set, avoiding upload errors and ensuring cross-cloud compatibility. +- 🛡️ **Authentication Now Uses Password Hash When Duplicate Emails Exist**: Ensures account security and prevents access issues if duplicate emails are present in your system. + +### Changed + +- 🧩 **Admin Settings: OAuth Redirects Now Use WEBUI_URL**: The OAuth redirect URL is now based on the explicitly set WEBUI_URL, ensuring single sign-on and identity provider integrations always send users to the correct frontend. + +### Removed + +- 💡 **Duplicate/Typo Component Removals**: Obsolete components have been cleaned up, reducing confusion and improving overall code quality for the team. +- 🚫 **Streaming Upsert in Pinecone Removed**: Removed streaming upsert references for better compatibility and future-proofing with latest Pinecone SDK updates. + +## [0.6.10] - 2025-05-19 + +### Added + +- 🧩 **Experimental Azure OpenAI Support**: Instantly connect to Azure OpenAI endpoints by simply pasting your Azure OpenAI URL into the model connections—bringing flexible, enterprise-grade AI integration directly to your workflow. +- 💧 **Watermark AI Responses**: Easily add a visible watermark to AI-generated responses for clear content provenance and compliance with EU AI regulations—perfect for regulated environments and transparent communication. +- 🔍 **Enhanced Search Experience with Dedicated Modal**: Enjoy a modern, powerful search UI in a dedicated modal (open with Ctrl/Cmd + K) accessible from anywhere—quickly find chats, models, or content and boost your productivity. +- 🔲 **"Toggle" Filter Type for Chat Input**: Add interactive toggle filters (e.g. Web Search, Image, Code Interpreter) right into the chat input—giving you one-click control to activate features and simplifying the chat configuration process. +- 🧰 **Granular Model Capabilities Editor**: Define detailed capabilities and feature access for each AI model directly from the model editor—enabling tailored behavior, modular control, and a more customized AI environment for every team or use case. +- 🌐 **Flexible Local and External Connection Support**: Now you can designate any AI connection—whether OpenAI, Ollama, or others—as local or external, enabling seamless setup for on-premise, self-hosted, or cloud configurations and giving you maximum control and flexibility. +- 🗂️ **Allowed File Extensions for RAG**: Gain full control over your Retrieval-Augmented Generation (RAG) workflows by specifying exactly which file extensions are permitted for upload, improving security and relevance of indexed documents. +- 🔊 **Enhanced Audio Transcription Logic**: Experience smoother, more reliable audio transcription—very long audio files are now automatically split and processed in segments, preventing errors and ensuring even challenging files are transcribed seamlessly, all part of a broader stability upgrade for robust media workflows. +- 🦾 **External Document Loader Support**: Enhance knowledge base building by integrating documents using external loaders from a wide range of data sources, expanding what your AI can read and process. +- 📝 **Preview Button for Code Artifacts**: Instantly jump from an HTML code block to its associated artifacts page with the click of a new preview button—speeding up review and streamlining analysis. +- 🦻 **Screen Reader Support for Response Messages**: All chat responses are now fully compatible with screen readers, making the platform more inclusive and accessible for everyone. +- 🧑‍💼 **Customizable Pending User Overlay**: You can now tailor the overlay title and content shown to pending users, ensuring onboarding messaging is perfectly aligned with your organization’s tone and requirements. +- 🔐 **Option to Disable LDAP Certificate Validation**: You can now disable LDAP certificate validation for maximum flexibility in diverse IT environments—making integrations and troubleshooting much easier. +- 🎯 **Workspace Search by Username or Email**: Easily search across workspace pages using any username or email address, streamlining user and resource management. +- 🎨 **High Contrast & Dark Mode Enhancements**: Further improved placeholder, input, suggestion, toast, and model selector contrasts—including a dedicated placeholder dark mode—for more comfortable viewing in all lighting conditions. +- 🛡️ **Refined Security for Pipelines & Model Uploads**: Strengthened safeguards against path traversal vulnerabilities during uploads—ensuring your platform’s document and model management remains secure. +- 🌏 **Major Localization Upgrades**: Comprehensive translation updates and improvements across Korean, Bulgarian, Catalan, Japanese, Italian, Traditional Chinese, and Spanish—including more accurate AI terminology for clarity; your experience is now more natural, inclusive, and professional everywhere. +- 🦾 **General Backend Stability & Security**: Multiple backend improvements (including file upload, command navigation, and logging refactorings) deliver increased resilience, better error handling, and a more robust platform for all users. + +### Fixed + +- ✅ **Evaluation Feedback Endpoint Reliability**: Addressed issues with feedback submission endpoints to ensure seamless user feedback collection on model responses. +- 🫰 **Model List State Fixes**: Resolved issues where model status toggles in the workspace page might inadvertently switch or confuse state, making the management of active/inactive models more dependable. +- ✍️ **Admin Signup Logic**: Admin self-signup experience and validation flow is smoother and more robust. +- 🔁 **Signout Redirect Flow Improved**: Logging out now redirects more reliably, reducing confusion and making session management seamless. + +## [0.6.9] - 2025-05-10 + +### Added + +- 📝 **Edit Attached Images/Files in Messages**: You can now easily edit your sent messages by removing attached files—streamlining document management, correcting mistakes on the fly, and keeping your chats clutter-free. +- 🚨 **Clear Alerts for Private Task Models**: When interacting with private task models, the UI now clearly alerts you—making it easier to understand resource availability and access, reducing confusion during workflow setup. + +### Fixed + +- 🛡️ **Confirm Dialog Focus Trap Reliability**: The focus now stays correctly within confirmation dialogs, ensuring keyboard navigation and accessibility is seamless and preventing accidental operations—especially helpful during critical or rapid workflows. +- 💬 **Temporary Chat Admin Controls & Session Cleanliness**: Admins are now able to properly enable temporary chat mode without errors, and previous session prompts or tool selections no longer carry over—delivering a fresh, predictable, and consistent temporary chat experience every time. +- 🤖 **External Reranker Integration Functionality Restored**: External reranker integrations now work correctly, allowing you to fully leverage advanced ranking services for sharper, more relevant search results in your RAG and knowledge base workflows. + +## [0.6.8] - 2025-05-10 + +### Added + +- 🏆 **External Reranker Support for Knowledge Base Search**: Supercharge your Retrieval-Augmented Generation (RAG) workflows with the new External Reranker integration; easily plug in advanced reranking services via the UI to deliver sharper and more relevant search results, accelerating research and insight discovery. +- 📤 **Unstylized PDF Export Option (Reduced File Size)**: When exporting chat transcripts or documents, you can now choose an unstylized PDF export for snappier downloads, minimal file size, and clean data archiving—perfect for large-scale storage or sharing. +- 📝 **Vazirmatn Font for Persian & Arabic**: Arabic and Persian users will now see their text beautifully rendered with the specialized Vazirmatn font for an improved localized reading experience. +- 🏷️ **SharePoint Tenant ID Support for OneDrive**: You can now specify a SharePoint tenant ID in OneDrive settings for seamless authentication and granular enterprise integration. +- 👤 **Refresh OAuth Profile Picture**: Your OAuth profile picture now updates in real-time, ensuring your presence and avatar always match your latest identity across integrated platforms. +- 🔧 **Milvus Configuration Improvements**: Configure index and metric types for Milvus directly within settings; take full control of your vector database for more accurate and robust AI search experiences. +- 🛡️ **S3 Tagging Toggle for Compatibility**: Optional S3 tagging via an environment toggle grants full compatibility with all storage backends—including those that don’t support tagging like Cloudflare R2—ensuring error-free attachment and document management. +- 👨‍🦯 **Icon Button Accessibility Improvements**: Key interactive icon-buttons now include aria-labels and ARIA descriptions, so screen readers provide precise guidance about what action each button performs for improved accessibility. +- ♿ **Enhanced Accessibility with Modal Focus Trap**: Modal dialogs and pop-ups now feature a focus trap and improved ARIA roles, ensuring seamless navigation and screen reader support—making the interface friendlier for everyone, including keyboard and assistive tech users. +- 🏃 **Improved Admin User List Loading Indicator**: The user list loading experience is now clearer and more responsive in the admin panel. +- 🧑‍🤝‍🧑 **Larger Admin User List Page Size**: Admins can now manage up to 30 users per page in the admin interface, drastically reducing pagination and making large user teams easier and faster to manage. +- 🌠 **Default Code Interpreter Prompt Clarified**: The built-in code interpreter prompt is now more explicit, preventing AI from wrapping code in Markdown blocks when not needed—ensuring properly formatted code runs as intended every time. +- 🧾 **Improved Default Title Generation Prompt Template**: Title generation now uses a robust template for reliable JSON output, improving chat organization and searchability. +- 🔗 **Support Jupyter Notebooks with Non-Root Base URLs**: Notebook-based code execution now supports non-root deployed Jupyter servers, granting full flexibility for hybrid or multi-user setups. +- 📰 **UI Scrollbar Always Visible for Overflow Tools**: When available tools overflow the display, the scrollbar is now always visible and there’s a handy "show all" toggle, making navigation of large toolsets snappier and more intuitive. +- 🛠️ **General Backend Refactoring for Stability**: Multiple under-the-hood improvements have been made across backend components, ensuring smoother performance, fewer errors, and a more reliable overall experience for all users. +- 🚀 **Optimized Web Search for Faster Results**: Web search speed and performance have been significantly enhanced, delivering answers and sources in record time to accelerate your research-heavy workflows. +- 💡 **More Supported Languages**: Expanded language support ensures an even wider range of users can enjoy an intuitive and natural interface in their native tongue. + +### Fixed + +- 🏃‍♂️ **Exhausting Workers in Nginx Reverse Proxy Due to Websocket Fix**: Websocket sessions are now fully compatible behind Nginx, eliminating worker exhaustion and restoring 24/7 reliability for real-time chats even in complex deployments. +- 🎤 **Audio Transcription Issue with OpenAI Resolved**: OpenAI-based audio transcription now handles WebM and newer formats without error, ensuring seamless voice-to-text workflows every time. +- 👉 **Message Input RTL Issue Fixed**: The chat message input now displays correctly for right-to-left languages, creating a flawless typing and reading experience for Arabic, Hebrew, and more. +- 🀄 **Katex: Proper Rendering of Chinese Characters Next to Math**: Math formulas now render perfectly even when directly adjacent to Chinese (CJK) characters, improving visual clarity for multilingual teams and cross-language documents. +- 🔂 **Duplicate Web Search URLs Eliminated**: Search results now reliably filter out URL duplicates, so your knowledge and search citations are always clean, trimmed, and easy to review. +- 📄 **Markdown Rendering Fixed in Knowledge Bases**: Markdown is now displayed correctly within knowledge bases, enabling better formatting and clarity of information-rich files. +- 🗂️ **LDAP Import/Loading Issue Resolved**: LDAP user imports process correctly, ensuring smooth onboarding and access without interruption. +- 🌎 **Pinecone Batch Operations and Async Safety**: All Pinecone operations (batch insert, upsert, delete) now run efficiently and safely in an async environment, boosting performance and preventing slowdowns in large-scale RAG jobs. + +## [0.6.7] - 2025-05-07 + +### Added + +- 🌐 **Custom Azure TTS API URL Support Added**: You can now define a custom Azure Text-to-Speech endpoint—enabling flexibility for enterprise deployments and regional compliance. +- ⚙️ **TOOL_SERVER_CONNECTIONS Environment Variable Suppor**: Easily configure and deploy tool servers via environment variables, streamlining setup and enabling faster enterprise provisioning. +- 👥 **Enhanced OAuth Group Handling as String or List**: OAuth group data can now be passed as either a list or a comma-separated string, improving compatibility with varied identity provider formats and reducing onboarding friction. + +### Fixed + +- 🧠 **Embedding with Ollama Proxy Endpoints Restored**: Fixed an issue where missing API config broke embedding for proxied Ollama models—ensuring consistent performance and compatibility. +- 🔐 **OIDC OAuth Login Issue Resolved**: Users can once again sign in seamlessly using OpenID Connect-based OAuth, eliminating login interruptions and improving reliability. +- 📝 **Notes Feature Access Fixed for Non-Admins**: Fixed an issue preventing non-admin users from accessing the Notes feature, restoring full cross-role collaboration capabilities. +- 🖼️ **Tika Loader Image Extraction Problem Resolved**: Ensured TikaLoader now processes 'extract_images' parameter correctly, restoring complete file extraction functionality in document workflows. +- 🎨 **Automatic1111 Image Model Setting Applied Properly**: Fixed an issue where switching to a specific image model via the UI wasn’t reflected in generation, re-enabling full visual creativity control. +- 🏷️ **Multiple XML Tags in Messages Now Parsed Correctly**: Fixed parsing issues when messages included multiple XML-style tags, ensuring clean and unbroken rendering of rich content in chats. +- 🖌️ **OpenAI Image Generation Issues Resolved**: Resolved broken image output when using OpenAI’s image generation, ensuring fully functional visual creation workflows. +- 🔎 **Tool Server Settings UI Privacy Restored**: Prevented restricted users from accessing tool server settings via search—restoring tight permissions control and safeguarding sensitive configurations. +- 🎧 **WebM Audio Transcription Now Supported**: Fixed an issue where WebM files failed during audio transcription—these formats are now fully supported, ensuring smoother voice note workflows and broader file compatibility. + +## [0.6.6] - 2025-05-05 + +### Added + +- 📝 **AI-Enhanced Notes (With Audio Transcription)**: Effortlessly create notes, attach meeting or voice audio, and let the AI instantly enhance, summarize, or refine your notes using audio transcriptions—making your documentation smarter, cleaner, and more insightful with minimal effort. +- 🔊 **Meeting Audio Recording & Import**: Seamlessly record audio from your meetings or capture screen audio and attach it to your notes—making it easier to revisit, annotate, and extract insights from important discussions. +- 📁 **Import Markdown Notes Effortlessly**: Bring your existing knowledge library into Open WebUI by importing your Markdown notes, so you can leverage all advanced note management and AI features right away. +- 👥 **Notes Permissions by User Group**: Fine-tune access and editing rights for notes based on user roles or groups, so you can delegate writing or restrict sensitive information as needed. +- ☁️ **OneDrive & SharePoint Integration**: Keep your content in sync by connecting notes and files directly with OneDrive or SharePoint—unlocking fast enterprise import/export and seamless collaboration with your existing workflows. +- 🗂️ **Paginated User List in Admin Panel**: Effortlessly manage and search through large teams via the new paginated user list—saving time and streamlining user administration in big organizations. +- 🕹️ **Granular Chat Share & Export Permissions**: Enjoy enhanced control over who can share or export chats, enabling tighter governance and privacy in team and enterprise settings. +- 🛑 **User Role Change Confirmation Dialog**: Reduce accidental privilege changes with a required confirmation step before updating user roles—improving security and preventing costly mistakes in team management. +- 🚨 **Audit Log for Failed Login Attempts**: Quickly detect unauthorized access attempts or troubleshoot user login problems with detailed logs of failed authentication right in the audit trail. +- 💡 **Dedicated 'Generate Title' Button for Chats**: Swiftly organize every conversation—tap the new button to let AI create relevant, clear titles for all your chats, saving time and reducing clutter. +- 💬 **Notification Sound Always-On Option**: Take control of your notifications by setting sound alerts to always play—helping you stay on top of important updates in busy environments. +- 🆔 **S3 File Tagging Support**: Uploaded files to S3 now include tags for better organization, searching, and integration with your file management policies. +- 🛡️ **OAuth Blocked Groups Support**: Gain more control over group-based access by explicitly blocking specified OAuth groups—ideal for complex identity or security requirements. +- 🚀 **Optimized Faster Web Search & Multi-Threaded Queries**: Enjoy dramatically faster web search and RAG (retrieval augmented generation) with revamped multi-threaded search—get richer, more accurate results in less time. +- 🔍 **All-Knowledge Parallel Search**: Searches across your entire knowledge base now happen in parallel even in non-hybrid mode, speeding up responses and improving knowledge accuracy for every question. +- 🌐 **New Firecrawl & Yacy Web Search Integrations**: Expand your world of information with two new advanced search engines—Firecrawl for deeper web insight and Yacy for decentralized, privacy-friendly search capabilities. +- 🧠 **Configurable Docling OCR Engine & Language**: Use environment variables to fine-tune Docling OCR engine and supported languages for smarter, more tailored document extraction and RAG workflows. +- 🗝️ **Enhanced Sentence Transformers Configuration**: Added new environment variables for easier set up and advanced customization of Sentence Transformers—ensuring best fit for your embedding needs. +- 🌲 **Pinecone Vector Database Integration**: Index, search, and manage knowledge at enterprise scale with full native support for Pinecone as your vector database—effortlessly handle even the biggest document sets. +- 🔄 **Automatic Requirements Installation for Tools & Functions**: Never worry about lost dependencies on restart—external function and tool requirements are now auto-installed at boot, ensuring tools always “just work.” +- 🔒 **Automatic Sign-Out on Token Expiry**: Security is smarter—users are now automatically logged out if their authentication token expires, protecting sensitive content and ensuring compliance without disruption. +- 🎬 **Automatic YouTube Embed Detection**: Paste YouTube links and see instant in-chat video embeds—no more manual embedding, making knowledge sharing and media consumption even easier for every team. +- 🔄 **Expanded Language & Locale Support**: Translations for Danish, French, Russian, Traditional Chinese, Simplified Chinese, Thai, Catalan, German, and Korean have been upgraded, offering smoother, more natural user experiences across the platform. + +### Fixed + +- 🔒 **Tighter HTML Token Security**: HTML rendering is now restricted to admin-uploaded tokens only, reducing any risk of XSS and keeping your data safe. +- 🔐 **Refined HTML Security and Token Handling**: Further hardened how HTML tokens and content are handled, guaranteeing even stronger resistance to security vulnerabilities and attacks. +- 🔏 **Correct Model Usage with Ollama Proxy Prefixes**: Enhanced model reference handling so proxied models in Ollama always download and run correctly—even when using custom prefixes. +- 📥 **Video File Upload Handling**: Prevented video files from being misclassified as text, fixing bugs with uploads and ensuring media files work as expected. +- 🔄 **No More Dependent WebSocket Sequential Delays**: Streamlined WebSocket operation to prevent delays and maintain snappy real-time collaboration, especially in multi-user environments. +- 🛠️ **More Robust Action Module Execution**: Multiple actions in a module now trigger as designed, increasing automation and scripting flexibility. +- 📧 **Notification Webhooks**: Ensured that notification webhooks are always sent for user events, even when the user isn’t currently active. +- 🗂️ **Smarter Knowledge Base Reindexing**: Knowledge reindexing continues even when corrupt or missing collections are encountered, keeping your search features running reliably. +- 🏷️ **User Import with Profile Images**: When importing users, their profile images now come along—making onboarding and collaboration visually clearer from day one. +- 💬 **OpenAI o-Series Universal Support**: All OpenAI o-series models are now seamlessly recognized and supported, unlocking more advanced capabilities and model choices for every workflow. + +### Changed + +- 📜 **Custom License Update & Contributor Agreement**: Open WebUI now operates under a custom license with Contributor License Agreement required by default—see https://docs.openwebui.com/license/ for details, ensuring sustainable open innovation for the community. +- 🔨 **CUDA Docker Images Updated to 12.8**: Upgraded CUDA image support for faster, more compatible model inference and futureproof GPU performance in your AI infrastructure. +- 🧱 **General Backend Refactoring for Reliability**: Continuous stability improvements streamline backend logic, reduce errors, and lay a stronger foundation for the next wave of feature releases—all under the hood for a more dependable WebUI. + +## [0.6.5] - 2025-04-14 + +### Added + +- 🛂 **Granular Voice Feature Permissions Per User Group**: Admins can now separately manage access to Speech-to-Text (record voice), Text-to-Speech (read aloud), and Tool Calls for each user group—giving teams tighter control over voice features and enhanced governance across roles. +- 🗣️ **Toggle Voice Activity Detection (VAD) for Whisper STT**: New environment variable lets you enable/disable VAD filtering with built-in Whisper speech-to-text, giving you flexibility to optimize for different audio quality and response accuracy levels. +- 📋 **Copy Formatted Response Mode**: You can now enable “Copy Formatted” in Settings > Interface to copy AI responses exactly as styled (with rich formatting, links, and structure preserved), making it faster and cleaner to paste into documents, emails, or reports. +- ⚙️ **Backend Stability and Performance Enhancements**: General backend refactoring improves system resilience, consistency, and overall reliability—offering smoother performance across workflows whether chatting, generating media, or using external tools. +- 🌎 **Translation Refinements Across Multiple Languages**: Updated translations deliver smoother language localization, clearer labels, and improved international usability throughout the UI—ensuring a better experience for non-English speakers. + +### Fixed + +- 🛠️ **LDAP Login Reliability Restored**: Resolved a critical issue where some LDAP setups failed due to attribute parsing—ensuring consistent, secure, and seamless user authentication across enterprise deployments. +- 🖼️ **Image Generation in Temporary Chats Now Works Properly**: Fixed a bug where image outputs weren’t generated during temporary chats—visual content can now be used reliably in all chat modes without interruptions. + +## [0.6.4] - 2025-04-12 + +### Fixed + +- 🛠️ **RAG_TEMPLATE Display Issue Resolved**: Fixed a formatting problem where the custom RAG_TEMPLATE wasn't correctly rendered in the interface—ensuring that custom retrieval prompts now appear exactly as intended for more reliable prompt engineering. + +## [0.6.3] - 2025-04-12 + +### Added + +- 🧪 **Auto-Artifact Detection Toggle**: Automatically detects artifacts in results—but now you can disable this behavior under advanced settings for full control. +- 🖼️ **Widescreen Mode for Shared Chats**: Shared link conversations now support widescreen layouts—perfect for presentations or easier review across wider displays. +- 🔁 **Reindex Knowledge Files on Demand**: Admins can now trigger reindexing of all knowledge files after changing embeddings—ensuring immediate alignment with new models for optimal RAG performance. +- 📄 **OpenAPI YAML Format Support**: External tools can now use YAML-format OpenAPI specs—making integration simpler for developers familiar with YAML-based configurations. +- 💬 **Message Content Copy Behavior**: Copy action now excludes 'details' tags—streamlining clipboard content when sharing or pasting summaries elsewhere. +- 🧭 **Sougou Web Search Integration**: New search engine option added—enhancing global relevance and diversity of search sources for multilingual users. +- 🧰 **Frontend Web Loader Engine Configuration**: Admins can now set preferred web loader engine for RAG workflows directly from the frontend—offering more control across setups. +- 👥 **Multi-Model Chat Permission Control**: Admins can manage access to multi-model chats per user group—allowing tighter governance in team environments. +- 🧱 **Persistent Configuration Can Be Disabled**: New environment variable lets advanced users and hosts turn off persistent configs—ideal for volatile or stateless deployments. +- 🧠 **Elixir Code Highlighting Support**: Elixir syntax is now beautifully rendered in code blocks—perfect for developers using this language in AI or automation projects. +- 🌐 **PWA External Manifest URL Support**: You can now define an external manifest.json—integrate Open WebUI seamlessly in managed or proxy-based PWA environments like Cloudflare Zero Trust. +- 🧪 **Azure AI Speech-to-Text Provider Integration**: Easily transcribe large audio files (up to 200MB) with high accuracy using Microsoft's Azure STT—fully configurable in Audio Settings. +- 🔏 **PKCE (Code Challenge Method) Support for OIDC**: Enhance your OIDC login security with Proof Key for Code Exchange—ideal for zero-trust and native client apps. +- ✨ **General UI/UX Enhancements**: Numerous refinements across layout, styling, and tool interactions—reducing visual noise and improving overall usability across key workflows. +- 🌍 **Translation Updates Across Multiple Languages**: Refined Catalan, Russian, Chinese (Simplified & Traditional), Hungarian, and Spanish translations for clearer navigation and instructions globally. + +### Fixed + +- 💥 **Chat Completion Error with Missing Models Resolved**: Fixed internal server error when referencing a model that doesn’t exist—ensuring graceful fallback and clear error guidance. +- 🔧 **Correct Knowledge Base Citations Restored**: Citations generated by RAG workflows now show accurate references—ensuring verifiability in outputs from sourced content. +- 🎙️ **Broken OGG/WebM Audio Upload Handling for OpenAI Fixed**: Uploading OGG or WebM files now converts properly to WAV before transcription—restoring accurate AI speech recognition workflows. +- 🔐 **Tool Server 'Session' Authentication Restored**: Previously broken session auth on external tool servers is now fully functional—ensuring secure and seamless access to connected tools. +- 🌐 **Folder-Based Chat Rename Now Updates Correctly**: Renaming chats in folders now reflects instantly everywhere—improving chat organization and clarity. +- 📜 **KaTeX Overflow Displays Fixed**: Math expressions now stay neatly within message bounds—preserving layout consistency even with long formulas. +- 🚫 **Stopping Ongoing Chat Fixed**: You can now return to an active (ongoing) chat and stop generation at any time—ensuring full control over sessions. +- 🔧 **TOOL_SERVERS / TOOL_SERVER_CONNECTIONS Indexing Issue Fixed**: Fixed a mismatch between tool lists and their access paths—restoring full function and preventing confusion in tool management. +- 🔐 **LDAP Login Handles Multiple Emails**: When LDAP returns multiple email attributes, the first valid one is now used—ensuring login success and account consistency. +- 🧩 **Model Visibility Toggle Fix**: Toggling model visibility now works even for untouched models—letting admins smoothly manage user access across base models. +- ⚙️ **Cross-Origin manifest.json Now Loads Properly**: Compatibility issues with Cloudflare Zero Trust (and others) resolved, allowing manifest.json to load behind authenticated proxies. + +### Changed + +- 🔒 **Default Access Scopes Set to Private for All Resources**: Models, tools, and knowledge are now private by default when created—ensuring better baseline security and visibility controls. +- 🧱 **General Backend Refactoring for Stability**: Numerous invisible improvements enhance backend scalability, security, and maintainability—powering upcoming features with a stronger foundation. +- 🧩 **Stable Dependency Upgrades**: Updated key platform libraries—Chromadb (0.6.3), pgvector (0.4.0), Azure Identity (1.21.0), and Youtube Transcript API (1.0.3)—for improved compatibility, functionality, and security. + +## [0.6.2] - 2025-04-06 + +### Added + +- 🌍 **Improved Global Language Support**: Expanded and refined translations across multiple languages to enhance clarity and consistency for international users. + +### Fixed + +- 🛠️ **Accurate Tool Descriptions from OpenAPI Servers**: External tools now use full endpoint descriptions instead of summaries when generating tool specifications—helping AI models understand tool purpose more precisely and choose the right tool more accurately in tool workflows. +- 🔧 **Precise Web Results Source Attribution**: Fixed a key issue where all web search results showed the same source ID—now each result gets its correct and distinct source, ensuring accurate citations and traceability. +- 🔍 **Clean Web Search Retrieval**: Web search now retains only results from URLs where real content was successfully fetched—improving accuracy and removing empty or broken links from citations. +- 🎵 **Audio File Upload Response Restored**: Resolved an issue where uploading audio files did not return valid responses, restoring smooth file handling for transcription and audio-based workflows. + +### Changed + +- 🧰 **General Backend Refactoring**: Multiple behind-the-scenes improvements streamline backend performance, reduce complexity, and ensure a more stable, maintainable system overall—making everything smoother without changing your workflow. + +## [0.6.1] - 2025-04-05 + +### Added + +- 🛠️ **Global Tool Servers Configuration**: Admins can now centrally configure global external tool servers from Admin Settings > Tools, allowing seamless sharing of tool integrations across all users without manual setup per user. +- 🔐 **Direct Tool Usage Permission for Users**: Introduced a new user-level permission toggle that grants non-admin users access to direct external tools, empowering broader team collaboration while maintaining control. +- 🧠 **Mistral OCR Content Extraction Support**: Added native support for Mistral OCR as a high-accuracy document loader, drastically improving text extraction from scanned documents in RAG workflows. +- 🖼️ **Tools Indicator UI Redesign**: Enhanced message input now smartly displays both built-in and external tools via a unified dropdown, making it simpler and more intuitive to activate tools during conversations. +- 📄 **RAG Prompt Improved and More Coherent**: Default RAG system prompt has been revised to be more clear and citation-focused—admins can leave the template field empty to use this new gold-standard prompt. +- 🧰 **Performance & Developer Improvements**: Major internal restructuring of several tool-related components, simplifying styling and merging external/internal handling logic, resulting in better maintainability and performance. +- 🌍 **Improved Translations**: Updated translations for Tibetan, Polish, Chinese (Simplified & Traditional), Arabic, Russian, Ukrainian, Dutch, Finnish, and French to improve clarity and consistency across the interface. + +### Fixed + +- 🔑 **External Tool Server API Key Bug Resolved**: Fixed a critical issue where authentication headers were not being sent when calling tools from external OpenAPI tool servers, ensuring full security and smooth tool operations. +- 🚫 **Conditional Export Button Visibility**: UI now gracefully hides export buttons when there's nothing to export in models, prompts, tools, or functions, improving visual clarity and reducing confusion. +- 🧪 **Hybrid Search Failure Recovery**: Resolved edge case in parallel hybrid search where empty or unindexed collections caused backend crashes—these are now cleanly skipped to ensure system stability. +- 📂 **Admin Folder Deletion Fix**: Addressed an issue where folders created in the admin workspace couldn't be deleted, restoring full organizational flexibility for admins. +- 🔐 **Improved Generic Error Feedback on Login**: Authentication errors now show simplified, non-revealing messages for privacy and improved UX, especially with federated logins. +- 📝 **Tool Message with Images Improved**: Enhanced how tool-generated messages with image outputs are shown in chat, making them more readable and consistent with the overall UI design. +- ⚙️ **Auto-Exclusion for Broken RAG Collections**: Auto-skips document collections that fail to fetch data or return "None", preventing silent errors and streamlining retrieval workflows. +- 📝 **Docling Text File Handling Fix**: Fixed file parsing inconsistency that broke docling-based RAG functionality for certain plain text files, ensuring wider file compatibility. + +## [0.6.0] - 2025-03-31 + +### Added + +- 🧩 **External Tool Server Support via OpenAPI**: Connect Open WebUI to any OpenAPI-compatible REST server instantly—offering immediate integration with thousands of developer tools, SDKs, and SaaS systems for powerful extensibility. Learn more: https://github.com/open-webui/openapi-servers +- 🛠️ **MCP Server Support via MCPO**: You can now convert and expose your internal MCP tools as interoperable OpenAPI HTTP servers within Open WebUI for seamless, plug-n-play AI toolchain creation. Learn more: https://github.com/open-webui/mcpo +- 📨 **/messages Chat API Endpoint Support**: For power users building external AI systems, new endpoints allow precise control of messages asynchronously—feed long-running external responses into Open WebUI chats without coupling with the frontend. +- 📝 **Client-Side PDF Generation**: PDF exports are now generated fully client-side for drastically improved output quality—perfect for saving conversations or documents. +- 💼 **Enforced Temporary Chats Mode**: Admins can now enforce temporary chat sessions by default to align with stringent data retention and compliance requirements. +- 🌍 **Public Resource Sharing Permission Controls**: Fine-grained user group permissions now allow enabling/disabling public sharing for models, knowledge, prompts, and tools—ideal for privacy, team control, and internal deployments. +- 📦 **Custom pip Options for Tools/Functions**: You can now specify custom pip installation options with "PIP_OPTIONS", "PIP_PACKAGE_INDEX_OPTIONS" environment variables—improving compatibility, support for private indexes, and better control over Python environments. +- 🔢 **Editable Message Counter**: You can now double-click the message count number and jump straight to editing the index—quickly navigate complex chats or regenerate specific messages precisely. +- 🧠 **Embedding Prefix Support Added**: Add custom prefixes to your embeddings for instruct-style tokens, enabling stronger model alignment and more consistent RAG performance. +- 🙈 **Ability to Hide Base Models**: Optionally hide base models from the UI, helping users streamline model visibility and limit access to only usable endpoints.. +- 📚 **Docling Content Extraction Support**: Open WebUI now supports Docling as a content extraction engine, enabling smarter and more accurate parsing of complex file formats—ideal for advanced document understanding and Retrieval-Augmented Generation (RAG) workflows. +- 🗃️ **Redis Sentinel Support Added**: Enhance deployment redundancy with support for Redis Sentinel for highly available, failover-safe Redis-based caching or pub/sub. +- 📚 **JSON Schema Format for Ollama**: Added support for defining the format using JSON schema in Ollama-compatible models, improving flexibility and validation of model outputs. +- 🔍 **Chat Sidebar Search "Clear” Button**: Quickly clear search filters in chat sidebar using the new ✖️ button—streamline your chat navigation with one click. +- 🗂️ **Auto-Focus + Enter Submit for Folder Name**: When creating a new folder, the system automatically enters rename mode with name preselected—simplifying your org workflow. +- 🧱 **Markdown Alerts Rendering**: Blockquotes with syntax hinting (e.g. ⚠️, ℹ️, ✅) now render styled Markdown alert banners, making messages and documentation more visually structured. +- 🔁 **Hybrid Search Runs in Parallel Now**: Hybrid (BM25 + embedding) search components now run in parallel—dramatically reducing response times and speeding up document retrieval. +- 📋 **Cleaner UI for Tool Call Display**: Optimized the visual layout of called tools inside chat messages for better clarity and reduced visual clutter. +- 🧪 **Playwright Timeout Now Configurable**: Default timeout for Playwright processes is now shorter and adjustable via environment variables—making web scraping more robust and tunable to environments. +- 📈 **OpenTelemetry Support for Observability**: Open WebUI now integrates with OpenTelemetry, allowing you to connect with tools like Grafana, Jaeger, or Prometheus for detailed performance insights and real-time visibility—entirely opt-in and fully self-hosted. Even if enabled, no data is ever sent to us, ensuring your privacy and ownership over all telemetry data. +- 🛠 **General UI Enhancements & UX Polish**: Numerous refinements across sidebar, code blocks, modal interactions, button alignment, scrollbar visibility, and folder behavior improve overall fluidity and usability of the interface. +- 🧱 **General Backend Refactoring**: Numerous backend components have been refactored to improve stability, maintainability, and performance—ensuring a more consistent and reliable system across all features. +- 🌍 **Internationalization Language Support Updates**: Added Estonian and Galician languages, improved Spanish (fully revised), Traditional Chinese, Simplified Chinese, Turkish, Catalan, Ukrainian, and German for a more localized and inclusive interface. + +### Fixed + +- 🧑‍💻 **Firefox Input Height Bug**: Text input in Firefox now maintains proper height, ensuring message boxes look consistent and behave predictably. +- 🧾 **Tika Blank Line Bug**: PDFs processed with Apache Tika 3.1.0.0 no longer introduce excessive blank lines—improving RAG output quality and visual cleanliness. +- 🧪 **CSV Loader Encoding Issues**: CSV files with unknown encodings now automatically detect character sets, resolving import errors in non-UTF-8 datasets. +- ✅ **LDAP Auth Config Fix**: Path to certificate file is now optional for LDAP setups, fixing authentication trouble for users without preconfigured cert paths. +- 📥 **File Deletion in Bypass Mode**: Resolved issue where files couldn’t be deleted from knowledge when “bypass embedding” mode was enabled. +- 🧩 **Hybrid Search Result Sorting & Deduplication Fixed**: Fixed citation and sorting issues in RAG hybrid and reranker modes, ensuring retrieved documents are shown in correct order per score. +- 🧷 **Model Export/Import Broken for a Single Model**: Fixed bug where individual models couldn’t be exported or re-imported, restoring full portability. +- 📫 **Auth Redirect Fix**: Logged-in users are now routed properly without unnecessary login prompts when already authenticated. + +### Changed + +- 🧠 **Prompt Autocompletion Disabled By Default**: Autocomplete suggestions while typing are now disabled unless explicitly re-enabled in user preferences—reduces distractions while composing prompts for advanced users. +- 🧾 **Normalize Citation Numbering**: Source citations now properly begin from "1" instead of "0"—improving consistency and professional presentation in AI outputs. +- 📚 **Improved Error Handling from Pipelines**: Pipelines now show the actual returned error message from failed tasks rather than generic "Connection closed"—making debugging far more user-friendly. + +### Removed + +- 🧾 **ENABLE_AUDIT_LOGS Setting Removed**: Deprecated setting “ENABLE_AUDIT_LOGS” has been fully removed—now controlled via “AUDIT_LOG_LEVEL” instead. + +## [0.5.20] - 2025-03-05 + +### Added + +- **⚡ Toggle Code Execution On/Off**: You can now enable or disable code execution, providing more control over security, ensuring a safer and more customizable experience. + +### Fixed + +- **📜 Pinyin Keyboard Enter Key Now Works Properly**: Resolved an issue where the Enter key for Pinyin keyboards was not functioning as expected, ensuring seamless input for Chinese users. +- **🖼️ Web Manifest Loading Issue Fixed**: Addressed inconsistencies with 'site.webmanifest', guaranteeing proper loading and representation of the app across different browsers and devices. +- **📦 Non-Root Container Issue Resolved**: Fixed a critical issue where the UI failed to load correctly in non-root containers, ensuring reliable deployment in various environments. + +## [0.5.19] - 2025-03-04 + +### Added + +- **📊 Logit Bias Parameter Support**: Fine-tune conversation dynamics by adjusting the Logit Bias parameter directly in chat settings, giving you more control over model responses. +- **⌨️ Customizable Enter Behavior**: You can now configure Enter to send messages only when combined with Ctrl (Ctrl+Enter) via Settings > Interface, preventing accidental message sends. +- **📝 Collapsible Code Blocks**: Easily collapse long code blocks to declutter your chat, making it easier to focus on important details. +- **🏷️ Tag Selector in Model Selector**: Quickly find and categorize models with the new tag filtering system in the Model Selector, streamlining model discovery. +- **📈 Experimental Elasticsearch Vector DB Support**: Now supports Elasticsearch as a vector database, offering more flexibility for data retrieval in Retrieval-Augmented Generation (RAG) workflows. +- **⚙️ General Reliability Enhancements**: Various stability improvements across the WebUI, ensuring a smoother, more consistent experience. +- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages. + +### Fixed + +- **🔄 "Stream" Hook Activation**: Fixed an issue where the "Stream" hook only worked when globally enabled, ensuring reliable real-time filtering. +- **📧 LDAP Email Case Sensitivity**: Resolved an issue where LDAP login failed due to email case sensitivity mismatches, improving authentication reliability. +- **💬 WebSocket Chat Event Registration**: Fixed a bug preventing chat event listeners from being registered upon sign-in, ensuring real-time updates work properly. + +## [0.5.18] - 2025-02-27 + +### Fixed + +- **🌐 Open WebUI Now Works Over LAN in Insecure Context**: Resolved an issue preventing Open WebUI from functioning when accessed over a local network in an insecure context, ensuring seamless connectivity. +- **🔄 UI Now Reflects Deleted Connections Instantly**: Fixed an issue where deleting a connection did not update the UI in real time, ensuring accurate system state visibility. +- **🛠️ Models Now Display Correctly with ENABLE_FORWARD_USER_INFO_HEADERS**: Addressed a bug where models were not visible when ENABLE_FORWARD_USER_INFO_HEADERS was set, restoring proper model listing. + +## [0.5.17] - 2025-02-27 + +### Added + +- **🚀 Instant Document Upload with Bypass Embedding & Retrieval**: Admins can now enable "Bypass Embedding & Retrieval" in Admin Settings > Documents, significantly speeding up document uploads and ensuring full document context is retained without chunking. +- **🔎 "Stream" Hook for Real-Time Filtering**: The new "stream" hook allows dynamic real-time message filtering. Learn more in our documentation (https://docs.openwebui.com/features/plugin/functions/filter). +- **☁️ OneDrive Integration**: Early support for OneDrive storage integration has been introduced, expanding file import options. +- **📈 Enhanced Logging with Loguru**: Backend logging has been improved with Loguru, making debugging and issue tracking far more efficient. +- **⚙️ General Stability Enhancements**: Backend and frontend refactoring improves performance, ensuring a smoother and more reliable user experience. +- **🌍 Updated Translations**: Refined multilingual support for better localization and accuracy across various languages. + +### Fixed + +- **🔄 Reliable Model Imports from the Community Platform**: Resolved import failures, allowing seamless integration of community-shared models without errors. +- **📊 OpenAI Usage Statistics Restored**: Fixed an issue where OpenAI usage metrics were not displaying correctly, ensuring accurate tracking of usage data. +- **🗂️ Deduplication for Retrieved Documents**: Documents retrieved during searches are now intelligently deduplicated, meaning no more redundant results—helping to keep information concise and relevant. + +### Changed + +- **📝 "Full Context Mode" Renamed for Clarity**: The "Full Context Mode" toggle in Web Search settings is now labeled "Bypass Embedding & Retrieval" for consistency across the UI. + +## [0.5.16] - 2025-02-20 + +### Fixed + +- **🔍 Web Search Retrieval Restored**: Resolved a critical issue that broke web search retrieval by reverting deduplication changes, ensuring complete and accurate search results once again. + +## [0.5.15] - 2025-02-20 + +### Added + +- **📄 Full Context Mode for Local Document Search (RAG)**: Toggle full context mode from Admin Settings > Documents to inject entire document content into context, improving accuracy for models with large context windows—ideal for deep context understanding. +- **🌍 Smarter Web Search with Agentic Workflows**: Web searches now intelligently gather and refine multiple relevant terms, similar to RAG handling, delivering significantly better search results for more accurate information retrieval. +- **🔎 Experimental Playwright Support for Web Loader**: Web content retrieval is taken to the next level with Playwright-powered scraping for enhanced accuracy in extracted web data. +- **☁️ Experimental Azure Storage Provider**: Early-stage support for Azure Storage allows more cloud storage flexibility directly within Open WebUI. +- **📊 Improved Jupyter Code Execution with Plots**: Interactive coding now properly displays inline plots, making data visualization more seamless inside chat interactions. +- **⏳ Adjustable Execution Timeout for Jupyter Interpreter**: Customize execution timeout (default: 60s) for Jupyter-based code execution, allowing longer or more constrained execution based on your needs. +- **▶️ "Running..." Indicator for Jupyter Code Execution**: A visual indicator now appears while code execution is in progress, providing real-time status updates on ongoing computations. +- **⚙️ General Backend & Frontend Stability Enhancements**: Extensive refactoring improves reliability, performance, and overall user experience for a more seamless Open WebUI. +- **🌍 Translation Updates**: Various international translation refinements ensure better localization and a more natural user interface experience. + +### Fixed + +- **📱 Mobile Hover Issue Resolved**: Users can now edit responses smoothly on mobile without interference, fixing a longstanding hover issue. +- **🔄 Temporary Chat Message Duplication Fixed**: Eliminated buggy behavior where messages were being unnecessarily repeated in temporary chat mode, ensuring a smooth and consistent conversation flow. + +## [0.5.14] - 2025-02-17 + +### Fixed + +- **🔧 Critical Import Error Resolved**: Fixed a circular import issue preventing 'override_static' from being correctly imported in 'open_webui.config', ensuring smooth system initialization and stability. + +## [0.5.13] - 2025-02-17 + +### Added + +- **🌐 Full Context Mode for Web Search**: Enable highly accurate web searches by utilizing full context mode—ideal for models with large context windows, ensuring more precise and insightful results. +- **⚡ Optimized Asynchronous Web Search**: Web searches now load significantly faster with optimized async support, providing users with quicker, more efficient information retrieval. +- **🔄 Auto Text Direction for RTL Languages**: Automatic text alignment based on language input, ensuring seamless conversation flow for Arabic, Hebrew, and other right-to-left scripts. +- **🚀 Jupyter Notebook Support for Code Execution**: The "Run" button in code blocks can now use Jupyter for execution, offering a powerful, dynamic coding experience directly in the chat. +- **🗑️ Message Delete Confirmation Dialog**: Prevent accidental deletions with a new confirmation prompt before removing messages, adding an additional layer of security to your chat history. +- **📥 Download Button for SVG Diagrams**: SVG diagrams generated within chat can now be downloaded instantly, making it easier to save and share complex visual data. +- **✨ General UI/UX Improvements and Backend Stability**: A refined interface with smoother interactions, improved layouts, and backend stability enhancements for a more reliable, polished experience. + +### Fixed + +- **🛠️ Temporary Chat Message Continue Button Fixed**: The "Continue Response" button for temporary chats now works as expected, ensuring an uninterrupted conversation flow. + +### Changed + +- **📝 Prompt Variable Update**: Deprecated square bracket '[]' indicators for prompt variables; now requires double curly brackets '{{}}' for consistency and clarity. +- **🔧 Stability Enhancements**: Error handling improved in chat history, ensuring smoother operations when reviewing previous messages. + ## [0.5.12] - 2025-02-13 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index eb54b48947..59285aa429 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,13 +2,13 @@ ## Our Pledge -As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. +As members, contributors, and leaders of this community, we pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct. ## Why These Standards Are Important -Open-source projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved. +Projects rely on a community of volunteers dedicating their time, expertise, and effort toward a shared goal. These projects are inherently collaborative but also fragile, as the success of the project depends on the goodwill, energy, and productivity of those involved. Maintaining a positive and respectful environment is essential to safeguarding the integrity of this project and protecting contributors' efforts. Behavior that disrupts this atmosphere—whether through hostility, entitlement, or unprofessional conduct—can severely harm the morale and productivity of the community. **Strict enforcement of these standards ensures a safe and supportive space for meaningful collaboration.** @@ -79,7 +79,7 @@ This approach ensures that disruptive behaviors are addressed swiftly and decisi ## Why Zero Tolerance Is Necessary -Open-source projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do. +Projects thrive on collaboration, goodwill, and mutual respect. Toxic behaviors—such as entitlement, hostility, or persistent negativity—threaten not just individual contributors but the health of the project as a whole. Allowing such behaviors to persist robs contributors of their time, energy, and enthusiasm for the work they do. By enforcing a zero-tolerance policy, we ensure that the community remains a safe, welcoming space for all participants. These measures are not about harshness—they are about protecting contributors and fostering a productive environment where innovation can thrive. diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT b/CONTRIBUTOR_LICENSE_AGREEMENT new file mode 100644 index 0000000000..ca9f48b02e --- /dev/null +++ b/CONTRIBUTOR_LICENSE_AGREEMENT @@ -0,0 +1,7 @@ +# Open WebUI Contributor License Agreement + +By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community. + +Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible. + +**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._** \ No newline at end of file diff --git a/Caddyfile.localhost b/Caddyfile.localhost deleted file mode 100644 index 80728eedf6..0000000000 --- a/Caddyfile.localhost +++ /dev/null @@ -1,64 +0,0 @@ -# Run with -# caddy run --envfile ./example.env --config ./Caddyfile.localhost -# -# This is configured for -# - Automatic HTTPS (even for localhost) -# - Reverse Proxying to Ollama API Base URL (http://localhost:11434/api) -# - CORS -# - HTTP Basic Auth API Tokens (uncomment basicauth section) - - -# CORS Preflight (OPTIONS) + Request (GET, POST, PATCH, PUT, DELETE) -(cors-api) { - @match-cors-api-preflight method OPTIONS - handle @match-cors-api-preflight { - header { - Access-Control-Allow-Origin "{http.request.header.origin}" - Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" - Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With" - Access-Control-Allow-Credentials "true" - Access-Control-Max-Age "3600" - defer - } - respond "" 204 - } - - @match-cors-api-request { - not { - header Origin "{http.request.scheme}://{http.request.host}" - } - header Origin "{http.request.header.origin}" - } - handle @match-cors-api-request { - header { - Access-Control-Allow-Origin "{http.request.header.origin}" - Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" - Access-Control-Allow-Headers "Origin, Accept, Authorization, Content-Type, X-Requested-With" - Access-Control-Allow-Credentials "true" - Access-Control-Max-Age "3600" - defer - } - } -} - -# replace localhost with example.com or whatever -localhost { - ## HTTP Basic Auth - ## (uncomment to enable) - # basicauth { - # # see .example.env for how to generate tokens - # {env.OLLAMA_API_ID} {env.OLLAMA_API_TOKEN_DIGEST} - # } - - handle /api/* { - # Comment to disable CORS - import cors-api - - reverse_proxy localhost:11434 - } - - # Same-Origin Static Web Server - file_server { - root ./build/ - } -} diff --git a/Dockerfile b/Dockerfile index 274e23dbfc..d7de72f015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG USE_CUDA=false ARG USE_OLLAMA=false # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) -ARG USE_CUDA_VER=cu121 +ARG USE_CUDA_VER=cu128 # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers # Leaderboard: https://huggingface.co/spaces/mteb/leaderboard # for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB) @@ -26,6 +26,9 @@ ARG BUILD_HASH WORKDIR /app +# to store git revision in build +RUN apk add --no-cache git + COPY package.json package-lock.json ./ RUN npm ci @@ -132,7 +135,7 @@ RUN if [ "$USE_OLLAMA" = "true" ]; then \ # install python dependencies COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt -RUN pip3 install uv && \ +RUN pip3 install --no-cache-dir uv && \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ diff --git a/LICENSE b/LICENSE index 89109d7516..3991050972 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2023-2025 Timothy Jaeryang Baek +Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI) All rights reserved. Redistribution and use in source and binary forms, with or without @@ -15,6 +15,12 @@ modification, are permitted provided that the following conditions are met: contributors may be used to endorse or promote products derived from this software without specific prior written permission. +4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. + +5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. + +6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE diff --git a/README.md b/README.md index 56ab09b05d..2ad208c3f7 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,20 @@ ![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui) ![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui) ![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red) -![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false) [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) **Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**. -For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/). - ![Open WebUI Demo](./demo.gif) +> [!TIP] +> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](mailto:sales@openwebui.com)** +> +> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!** + +For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/). + ## Key Features of Open WebUI ⭐ - 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images. @@ -57,9 +61,36 @@ For more information, be sure to check out our [Open WebUI Documentation](https: Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview! -## 🔗 Also Check Out Open WebUI Community! +## Sponsors 🙌 -Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀 +#### Emerald + + + + + + + + + + +
+ + n8n + + + N8N • Does your interface have a backend yet?
Try n8n +
+ + n8n + + + Warp • The intelligent terminal for developers +
+ +--- + +We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you! ## How to Install 🚀 @@ -201,7 +232,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http ## License 📜 -This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄 +This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄 ## Support 💬 diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index d85be48da3..967a49de8f 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -73,8 +73,15 @@ def serve( os.environ["LD_LIBRARY_PATH"] = ":".join(LD_LIBRARY_PATH) import open_webui.main # we need set environment variables before importing main + from open_webui.env import UVICORN_WORKERS # Import the workers setting - uvicorn.run(open_webui.main.app, host=host, port=port, forwarded_allow_ips="*") + uvicorn.run( + "open_webui.main:app", + host=host, + port=port, + forwarded_allow_ips="*", + workers=UVICORN_WORKERS, + ) @app.command() diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index adfdcfec87..0f49483610 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2,12 +2,14 @@ import json import logging import os import shutil +import base64 +import redis + from datetime import datetime from pathlib import Path from typing import Generic, Optional, TypeVar from urllib.parse import urlparse -import chromadb import requests from pydantic import BaseModel from sqlalchemy import JSON, Column, DateTime, Integer, func @@ -16,6 +18,9 @@ from open_webui.env import ( DATA_DIR, DATABASE_URL, ENV, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, FRONTEND_BUILD_DIR, OFFLINE_MODE, OPEN_WEBUI_DIR, @@ -25,6 +30,7 @@ from open_webui.env import ( log, ) from open_webui.internal.db import Base, get_db +from open_webui.utils.redis import get_redis_connection class EndpointFilter(logging.Filter): @@ -42,7 +48,7 @@ logging.getLogger("uvicorn.access").addFilter(EndpointFilter()) # Function to run the alembic migrations def run_migrations(): - print("Running migrations") + log.info("Running migrations") try: from alembic import command from alembic.config import Config @@ -55,7 +61,7 @@ def run_migrations(): command.upgrade(alembic_cfg, "head") except Exception as e: - print(f"Error: {e}") + log.exception(f"Error running migrations: {e}") run_migrations() @@ -103,54 +109,7 @@ if os.path.exists(f"{DATA_DIR}/config.json"): DEFAULT_CONFIG = { "version": 0, - "ui": { - "default_locale": "", - "prompt_suggestions": [ - { - "title": [ - "Help me study", - "vocabulary for a college entrance exam", - ], - "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", - }, - { - "title": [ - "Give me ideas", - "for what to do with my kids' art", - ], - "content": "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.", - }, - { - "title": ["Tell me a fun fact", "about the Roman Empire"], - "content": "Tell me a random fun fact about the Roman Empire", - }, - { - "title": [ - "Show me a code snippet", - "of a website's sticky header", - ], - "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", - }, - { - "title": [ - "Explain options trading", - "if I'm familiar with buying and selling stocks", - ], - "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", - }, - { - "title": ["Overcome procrastination", "give me tips"], - "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", - }, - { - "title": [ - "Grammar check", - "rewrite it for better readability ", - ], - "content": 'Check the following sentence for grammar and clarity: "[sentence]". Rewrite it for better readability while maintaining its original meaning.', - }, - ], - }, + "ui": {}, } @@ -195,6 +154,10 @@ def save_config(config): T = TypeVar("T") +ENABLE_PERSISTENT_CONFIG = ( + os.environ.get("ENABLE_PERSISTENT_CONFIG", "True").lower() == "true" +) + class PersistentConfig(Generic[T]): def __init__(self, env_name: str, config_path: str, env_value: T): @@ -202,7 +165,7 @@ class PersistentConfig(Generic[T]): self.config_path = config_path self.env_value = env_value self.config_value = get_config_value(config_path) - if self.config_value is not None: + if self.config_value is not None and ENABLE_PERSISTENT_CONFIG: log.info(f"'{env_name}' loaded from the latest database entry") self.value = self.config_value else: @@ -247,9 +210,17 @@ class PersistentConfig(Generic[T]): class AppConfig: _state: dict[str, PersistentConfig] + _redis: Optional[redis.Redis] = None - def __init__(self): + def __init__( + self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [] + ): super().__setattr__("_state", {}) + if redis_url: + super().__setattr__( + "_redis", + get_redis_connection(redis_url, redis_sentinels, decode_responses=True), + ) def __setattr__(self, key, value): if isinstance(value, PersistentConfig): @@ -258,7 +229,31 @@ class AppConfig: self._state[key].value = value self._state[key].save() + if self._redis: + redis_key = f"open-webui:config:{key}" + self._redis.set(redis_key, json.dumps(self._state[key].value)) + def __getattr__(self, key): + if key not in self._state: + raise AttributeError(f"Config key '{key}' not found") + + # If Redis is available, check for an updated value + if self._redis: + redis_key = f"open-webui:config:{key}" + redis_value = self._redis.get(redis_key) + + if redis_value is not None: + try: + decoded_value = json.loads(redis_value) + + # Update the in-memory value if different + if self._state[key].value != decoded_value: + self._state[key].value = decoded_value + log.info(f"Updated {key} from Redis: {decoded_value}") + + except json.JSONDecodeError: + log.error(f"Invalid JSON format in Redis for {key}: {redis_value}") + return self._state[key].value @@ -293,12 +288,14 @@ JWT_EXPIRES_IN = PersistentConfig( # OAuth config #################################### + ENABLE_OAUTH_SIGNUP = PersistentConfig( "ENABLE_OAUTH_SIGNUP", "oauth.enable_signup", os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true", ) + OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig( "OAUTH_MERGE_ACCOUNTS_BY_EMAIL", "oauth.merge_accounts_by_email", @@ -416,6 +413,12 @@ OAUTH_SCOPES = PersistentConfig( os.environ.get("OAUTH_SCOPES", "openid email profile"), ) +OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig( + "OAUTH_CODE_CHALLENGE_METHOD", + "oauth.oidc.code_challenge_method", + os.environ.get("OAUTH_CODE_CHALLENGE_METHOD", None), +) + OAUTH_PROVIDER_NAME = PersistentConfig( "OAUTH_PROVIDER_NAME", "oauth.oidc.provider_name", @@ -428,6 +431,7 @@ OAUTH_USERNAME_CLAIM = PersistentConfig( os.environ.get("OAUTH_USERNAME_CLAIM", "name"), ) + OAUTH_PICTURE_CLAIM = PersistentConfig( "OAUTH_PICTURE_CLAIM", "oauth.oidc.avatar_claim", @@ -458,6 +462,19 @@ ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig( os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true", ) +ENABLE_OAUTH_GROUP_CREATION = PersistentConfig( + "ENABLE_OAUTH_GROUP_CREATION", + "oauth.enable_group_creation", + os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true", +) + + +OAUTH_BLOCKED_GROUPS = PersistentConfig( + "OAUTH_BLOCKED_GROUPS", + "oauth.blocked_groups", + os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"), +) + OAUTH_ROLES_CLAIM = PersistentConfig( "OAUTH_ROLES_CLAIM", "oauth.roles_claim", @@ -488,6 +505,12 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig( ], ) +OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig( + "OAUTH_UPDATE_PICTURE_ON_LOGIN", + "oauth.update_picture_on_login", + os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true", +) + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -519,7 +542,7 @@ def load_oauth_providers(): name="microsoft", client_id=MICROSOFT_CLIENT_ID.value, client_secret=MICROSOFT_CLIENT_SECRET.value, - server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration", + server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}", client_kwargs={ "scope": MICROSOFT_OAUTH_SCOPE.value, }, @@ -560,14 +583,27 @@ def load_oauth_providers(): ): def oidc_oauth_register(client): + client_kwargs = { + "scope": OAUTH_SCOPES.value, + } + + if ( + OAUTH_CODE_CHALLENGE_METHOD.value + and OAUTH_CODE_CHALLENGE_METHOD.value == "S256" + ): + client_kwargs["code_challenge_method"] = "S256" + elif OAUTH_CODE_CHALLENGE_METHOD.value: + raise Exception( + 'Code challenge methods other than "%s" not supported. Given: "%s"' + % ("S256", OAUTH_CODE_CHALLENGE_METHOD.value) + ) + client.register( name="oidc", client_id=OAUTH_CLIENT_ID.value, client_secret=OAUTH_CLIENT_SECRET.value, server_metadata_url=OPENID_PROVIDER_URL.value, - client_kwargs={ - "scope": OAUTH_SCOPES.value, - }, + client_kwargs=client_kwargs, redirect_uri=OPENID_REDIRECT_URI.value, ) @@ -586,6 +622,17 @@ load_oauth_providers() STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve() +for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"): + if file_path.is_file(): + target_path = STATIC_DIR / file_path.relative_to( + (FRONTEND_BUILD_DIR / "static") + ) + target_path.parent.mkdir(parents=True, exist_ok=True) + try: + shutil.copyfile(file_path, target_path) + except Exception as e: + logging.error(f"An error occurred: {e}") + frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png" if frontend_favicon.exists(): @@ -593,8 +640,6 @@ if frontend_favicon.exists(): shutil.copyfile(frontend_favicon, STATIC_DIR / "favicon.png") except Exception as e: logging.error(f"An error occurred: {e}") -else: - logging.warning(f"Frontend favicon not found at {frontend_favicon}") frontend_splash = FRONTEND_BUILD_DIR / "static" / "splash.png" @@ -603,12 +648,18 @@ if frontend_splash.exists(): shutil.copyfile(frontend_splash, STATIC_DIR / "splash.png") except Exception as e: logging.error(f"An error occurred: {e}") -else: - logging.warning(f"Frontend splash not found at {frontend_splash}") + +frontend_loader = FRONTEND_BUILD_DIR / "static" / "loader.js" + +if frontend_loader.exists(): + try: + shutil.copyfile(frontend_loader, STATIC_DIR / "loader.js") + except Exception as e: + logging.error(f"An error occurred: {e}") #################################### -# CUSTOM_NAME +# CUSTOM_NAME (Legacy) #################################### CUSTOM_NAME = os.environ.get("CUSTOM_NAME", "") @@ -650,6 +701,12 @@ if CUSTOM_NAME: pass +#################################### +# LICENSE_KEY +#################################### + +LICENSE_KEY = os.environ.get("LICENSE_KEY", "") + #################################### # STORAGE PROVIDER #################################### @@ -662,26 +719,35 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None) S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None) S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) +S3_USE_ACCELERATE_ENDPOINT = ( + os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "false").lower() == "true" +) +S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None) +S3_ENABLE_TAGGING = os.getenv("S3_ENABLE_TAGGING", "false").lower() == "true" GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None) GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get( "GOOGLE_APPLICATION_CREDENTIALS_JSON", None ) +AZURE_STORAGE_ENDPOINT = os.environ.get("AZURE_STORAGE_ENDPOINT", None) +AZURE_STORAGE_CONTAINER_NAME = os.environ.get("AZURE_STORAGE_CONTAINER_NAME", None) +AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None) + #################################### # File Upload DIR #################################### -UPLOAD_DIR = f"{DATA_DIR}/uploads" -Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True) +UPLOAD_DIR = DATA_DIR / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) #################################### # Cache DIR #################################### -CACHE_DIR = f"{DATA_DIR}/cache" -Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) +CACHE_DIR = DATA_DIR / "cache" +CACHE_DIR.mkdir(parents=True, exist_ok=True) #################################### @@ -767,6 +833,9 @@ ENABLE_OPENAI_API = PersistentConfig( OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "") +GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") +GEMINI_API_BASE_URL = os.environ.get("GEMINI_API_BASE_URL", "") + if OPENAI_API_BASE_URL == "": OPENAI_API_BASE_URL = "https://api.openai.com/v1" @@ -808,6 +877,25 @@ except Exception: pass OPENAI_API_BASE_URL = "https://api.openai.com/v1" +#################################### +# TOOL_SERVERS +#################################### + +try: + tool_server_connections = json.loads( + os.environ.get("TOOL_SERVER_CONNECTIONS", "[]") + ) +except Exception as e: + log.exception(f"Error loading TOOL_SERVER_CONNECTIONS: {e}") + tool_server_connections = [] + + +TOOL_SERVER_CONNECTIONS = PersistentConfig( + "TOOL_SERVER_CONNECTIONS", + "tool_server.connections", + tool_server_connections, +) + #################################### # WEBUI #################################### @@ -845,10 +933,15 @@ DEFAULT_MODELS = PersistentConfig( "DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None) ) -DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( - "DEFAULT_PROMPT_SUGGESTIONS", - "ui.prompt_suggestions", - [ +try: + default_prompt_suggestions = json.loads( + os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]") + ) +except Exception as e: + log.exception(f"Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}") + default_prompt_suggestions = [] +if default_prompt_suggestions == []: + default_prompt_suggestions = [ { "title": ["Help me study", "vocabulary for a college entrance exam"], "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", @@ -876,7 +969,12 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( "title": ["Overcome procrastination", "give me tips"], "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", }, - ], + ] + +DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( + "DEFAULT_PROMPT_SUGGESTIONS", + "ui.prompt_suggestions", + default_prompt_suggestions, ) MODEL_ORDER_LIST = PersistentConfig( @@ -891,6 +989,26 @@ DEFAULT_USER_ROLE = PersistentConfig( os.getenv("DEFAULT_USER_ROLE", "pending"), ) +PENDING_USER_OVERLAY_TITLE = PersistentConfig( + "PENDING_USER_OVERLAY_TITLE", + "ui.pending_user_overlay_title", + os.environ.get("PENDING_USER_OVERLAY_TITLE", ""), +) + +PENDING_USER_OVERLAY_CONTENT = PersistentConfig( + "PENDING_USER_OVERLAY_CONTENT", + "ui.pending_user_overlay_content", + os.environ.get("PENDING_USER_OVERLAY_CONTENT", ""), +) + + +RESPONSE_WATERMARK = PersistentConfig( + "RESPONSE_WATERMARK", + "ui.watermark", + os.environ.get("RESPONSE_WATERMARK", ""), +) + + USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = ( os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS", "False").lower() == "true" @@ -910,6 +1028,35 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = ( os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true" ) +USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + + USER_PERMISSIONS_CHAT_CONTROLS = ( os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" ) @@ -926,10 +1073,45 @@ USER_PERMISSIONS_CHAT_EDIT = ( os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_SHARE = ( + os.environ.get("USER_PERMISSIONS_CHAT_SHARE", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_EXPORT = ( + os.environ.get("USER_PERMISSIONS_CHAT_EXPORT", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_STT = ( + os.environ.get("USER_PERMISSIONS_CHAT_STT", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_TTS = ( + os.environ.get("USER_PERMISSIONS_CHAT_TTS", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_CALL = ( + os.environ.get("USER_PERMISSIONS_CHAT_CALL", "True").lower() == "true" +) + +USER_PERMISSIONS_CHAT_MULTIPLE_MODELS = ( + os.environ.get("USER_PERMISSIONS_CHAT_MULTIPLE_MODELS", "True").lower() == "true" +) + USER_PERMISSIONS_CHAT_TEMPORARY = ( os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true" ) +USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = ( + os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower() + == "true" +) + + +USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS = ( + os.environ.get("USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS", "False").lower() + == "true" +) + USER_PERMISSIONS_FEATURES_WEB_SEARCH = ( os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true" ) @@ -944,6 +1126,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = ( == "true" ) +USER_PERMISSIONS_FEATURES_NOTES = ( + os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" +) + DEFAULT_USER_PERMISSIONS = { "workspace": { @@ -952,17 +1138,32 @@ DEFAULT_USER_PERMISSIONS = { "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS, "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS, }, + "sharing": { + "public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING, + "public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING, + "public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, + "public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, + }, "chat": { "controls": USER_PERMISSIONS_CHAT_CONTROLS, "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, "delete": USER_PERMISSIONS_CHAT_DELETE, "edit": USER_PERMISSIONS_CHAT_EDIT, + "share": USER_PERMISSIONS_CHAT_SHARE, + "export": USER_PERMISSIONS_CHAT_EXPORT, + "stt": USER_PERMISSIONS_CHAT_STT, + "tts": USER_PERMISSIONS_CHAT_TTS, + "call": USER_PERMISSIONS_CHAT_CALL, + "multiple_models": USER_PERMISSIONS_CHAT_MULTIPLE_MODELS, "temporary": USER_PERMISSIONS_CHAT_TEMPORARY, + "temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, }, "features": { + "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, + "notes": USER_PERMISSIONS_FEATURES_NOTES, }, } @@ -978,6 +1179,11 @@ ENABLE_CHANNELS = PersistentConfig( os.environ.get("ENABLE_CHANNELS", "False").lower() == "true", ) +ENABLE_NOTES = PersistentConfig( + "ENABLE_NOTES", + "notes.enable", + os.environ.get("ENABLE_NOTES", "True").lower() == "true", +) ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig( "ENABLE_EVALUATION_ARENA_MODELS", @@ -1022,6 +1228,24 @@ ENABLE_MESSAGE_RATING = PersistentConfig( os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true", ) +ENABLE_USER_WEBHOOKS = PersistentConfig( + "ENABLE_USER_WEBHOOKS", + "ui.enable_user_webhooks", + os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true", +) + +# FastAPI / AnyIO settings +THREAD_POOL_SIZE = os.getenv("THREAD_POOL_SIZE", None) + +if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): + try: + THREAD_POOL_SIZE = int(THREAD_POOL_SIZE) + except ValueError: + log.warning( + f"THREAD_POOL_SIZE is not a valid integer: {THREAD_POOL_SIZE}. Defaulting to None." + ) + THREAD_POOL_SIZE = None + def validate_cors_origins(origins): for origin in origins: @@ -1048,7 +1272,9 @@ def validate_cors_origin(origin): # To test CORS_ALLOW_ORIGIN locally, you can set something like # CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080 # in your .env file depending on your frontend port, 5173 in this case. -CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";") +CORS_ALLOW_ORIGIN = os.environ.get( + "CORS_ALLOW_ORIGIN", "*;http://localhost:5173;http://localhost:8080" +).split(";") if "*" in CORS_ALLOW_ORIGIN: log.warning( @@ -1071,7 +1297,7 @@ try: banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]")) banners = [BannerModel(**banner) for banner in banners] except Exception as e: - print(f"Error loading WEBUI_BANNERS: {e}") + log.exception(f"Error loading WEBUI_BANNERS: {e}") banners = [] WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners) @@ -1120,6 +1346,9 @@ Generate a concise, 3-5 word title with an emoji summarizing the chat history. - Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting. - Write the title in the chat's primary language; default to English if multilingual. - Prioritize accuracy over excessive creativity; keep it clear and simple. +- Your entire response must consist solely of the JSON object, without any introductory or concluding text. +- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text. +- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure. ### Output: JSON format: { "title": "your concise title here" } ### Examples: @@ -1243,7 +1472,7 @@ Strictly return in JSON format: ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig( "ENABLE_AUTOCOMPLETE_GENERATION", "task.autocomplete.enable", - os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "True").lower() == "true", + os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true", ) AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( @@ -1347,6 +1576,49 @@ Responses from models: {{responses}}""" # Code Interpreter #################################### +ENABLE_CODE_EXECUTION = PersistentConfig( + "ENABLE_CODE_EXECUTION", + "code_execution.enable", + os.environ.get("ENABLE_CODE_EXECUTION", "True").lower() == "true", +) + +CODE_EXECUTION_ENGINE = PersistentConfig( + "CODE_EXECUTION_ENGINE", + "code_execution.engine", + os.environ.get("CODE_EXECUTION_ENGINE", "pyodide"), +) + +CODE_EXECUTION_JUPYTER_URL = PersistentConfig( + "CODE_EXECUTION_JUPYTER_URL", + "code_execution.jupyter.url", + os.environ.get("CODE_EXECUTION_JUPYTER_URL", ""), +) + +CODE_EXECUTION_JUPYTER_AUTH = PersistentConfig( + "CODE_EXECUTION_JUPYTER_AUTH", + "code_execution.jupyter.auth", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""), +) + +CODE_EXECUTION_JUPYTER_AUTH_TOKEN = PersistentConfig( + "CODE_EXECUTION_JUPYTER_AUTH_TOKEN", + "code_execution.jupyter.auth_token", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""), +) + + +CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = PersistentConfig( + "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", + "code_execution.jupyter.auth_password", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""), +) + +CODE_EXECUTION_JUPYTER_TIMEOUT = PersistentConfig( + "CODE_EXECUTION_JUPYTER_TIMEOUT", + "code_execution.jupyter.timeout", + int(os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60")), +) + ENABLE_CODE_INTERPRETER = PersistentConfig( "ENABLE_CODE_INTERPRETER", "code_interpreter.enable", @@ -1368,26 +1640,48 @@ CODE_INTERPRETER_PROMPT_TEMPLATE = PersistentConfig( CODE_INTERPRETER_JUPYTER_URL = PersistentConfig( "CODE_INTERPRETER_JUPYTER_URL", "code_interpreter.jupyter.url", - os.environ.get("CODE_INTERPRETER_JUPYTER_URL", ""), + os.environ.get( + "CODE_INTERPRETER_JUPYTER_URL", os.environ.get("CODE_EXECUTION_JUPYTER_URL", "") + ), ) CODE_INTERPRETER_JUPYTER_AUTH = PersistentConfig( "CODE_INTERPRETER_JUPYTER_AUTH", "code_interpreter.jupyter.auth", - os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH", ""), + os.environ.get( + "CODE_INTERPRETER_JUPYTER_AUTH", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""), + ), ) CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = PersistentConfig( "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", "code_interpreter.jupyter.auth_token", - os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", ""), + os.environ.get( + "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""), + ), ) CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = PersistentConfig( "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", "code_interpreter.jupyter.auth_password", - os.environ.get("CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", ""), + os.environ.get( + "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", + os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""), + ), +) + +CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig( + "CODE_INTERPRETER_JUPYTER_TIMEOUT", + "code_interpreter.jupyter.timeout", + int( + os.environ.get( + "CODE_INTERPRETER_JUPYTER_TIMEOUT", + os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60"), + ) + ), ) @@ -1397,7 +1691,8 @@ DEFAULT_CODE_INTERPRETER_PROMPT = """ 1. **Code Interpreter**: `` - You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response. - The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**. - - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. Do NOT use triple backticks. + - To use it, **you must enclose your code within `` XML tags** and stop right away. If you don't, the code won't execute. + - When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code. - When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user. - After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.** - If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary. @@ -1415,21 +1710,27 @@ VECTOR_DB = os.environ.get("VECTOR_DB", "chroma") # Chroma CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" -CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) -CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) -CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") -CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) -CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") -CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get("CHROMA_CLIENT_AUTH_CREDENTIALS", "") -# Comma-separated list of header=value pairs -CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") -if CHROMA_HTTP_HEADERS: - CHROMA_HTTP_HEADERS = dict( - [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] + +if VECTOR_DB == "chroma": + import chromadb + + CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) + CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) + CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") + CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) + CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") + CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get( + "CHROMA_CLIENT_AUTH_CREDENTIALS", "" ) -else: - CHROMA_HTTP_HEADERS = None -CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" + # Comma-separated list of header=value pairs + CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") + if CHROMA_HTTP_HEADERS: + CHROMA_HTTP_HEADERS = dict( + [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] + ) + else: + CHROMA_HTTP_HEADERS = None + CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) # Milvus @@ -1438,17 +1739,42 @@ MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_DB = os.environ.get("MILVUS_DB", "default") MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None) +MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW") +MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE") +MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16")) +MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100")) +MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128")) + # Qdrant QDRANT_URI = os.environ.get("QDRANT_URI", None) QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None) +QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true" +QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true" +QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334")) +ENABLE_QDRANT_MULTITENANCY_MODE = ( + os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true" +) # OpenSearch OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") -OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", True) -OPENSEARCH_CERT_VERIFY = os.environ.get("OPENSEARCH_CERT_VERIFY", False) +OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true" +OPENSEARCH_CERT_VERIFY = ( + os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true" +) OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None) OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None) +# ElasticSearch +ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200") +ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None) +ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None) +ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None) +ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None) +ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None) +SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None) +ELASTICSEARCH_INDEX_PREFIX = os.environ.get( + "ELASTICSEARCH_INDEX_PREFIX", "open_webui_collections" +) # Pgvector PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL) if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"): @@ -1459,6 +1785,14 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536") ) +# Pinecone +PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None) +PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None) +PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "open-webui-index") +PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024, 768 +PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine") +PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure" + #################################### # Information Retrieval (RAG) #################################### @@ -1483,6 +1817,30 @@ GOOGLE_DRIVE_API_KEY = PersistentConfig( os.environ.get("GOOGLE_DRIVE_API_KEY", ""), ) +ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( + "ENABLE_ONEDRIVE_INTEGRATION", + "onedrive.enable", + os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", +) + +ONEDRIVE_CLIENT_ID = PersistentConfig( + "ONEDRIVE_CLIENT_ID", + "onedrive.client_id", + os.environ.get("ONEDRIVE_CLIENT_ID", ""), +) + +ONEDRIVE_SHAREPOINT_URL = PersistentConfig( + "ONEDRIVE_SHAREPOINT_URL", + "onedrive.sharepoint_url", + os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""), +) + +ONEDRIVE_SHAREPOINT_TENANT_ID = PersistentConfig( + "ONEDRIVE_SHAREPOINT_TENANT_ID", + "onedrive.sharepoint_tenant_id", + os.environ.get("ONEDRIVE_SHAREPOINT_TENANT_ID", ""), +) + # RAG Content Extraction CONTENT_EXTRACTION_ENGINE = PersistentConfig( "CONTENT_EXTRACTION_ENGINE", @@ -1490,20 +1848,146 @@ CONTENT_EXTRACTION_ENGINE = PersistentConfig( os.environ.get("CONTENT_EXTRACTION_ENGINE", "").lower(), ) +DATALAB_MARKER_API_KEY = PersistentConfig( + "DATALAB_MARKER_API_KEY", + "rag.datalab_marker_api_key", + os.environ.get("DATALAB_MARKER_API_KEY", ""), +) + +DATALAB_MARKER_LANGS = PersistentConfig( + "DATALAB_MARKER_LANGS", + "rag.datalab_marker_langs", + os.environ.get("DATALAB_MARKER_LANGS", ""), +) + +DATALAB_MARKER_USE_LLM = PersistentConfig( + "DATALAB_MARKER_USE_LLM", + "rag.DATALAB_MARKER_USE_LLM", + os.environ.get("DATALAB_MARKER_USE_LLM", "false").lower() == "true", +) + +DATALAB_MARKER_SKIP_CACHE = PersistentConfig( + "DATALAB_MARKER_SKIP_CACHE", + "rag.datalab_marker_skip_cache", + os.environ.get("DATALAB_MARKER_SKIP_CACHE", "false").lower() == "true", +) + +DATALAB_MARKER_FORCE_OCR = PersistentConfig( + "DATALAB_MARKER_FORCE_OCR", + "rag.datalab_marker_force_ocr", + os.environ.get("DATALAB_MARKER_FORCE_OCR", "false").lower() == "true", +) + +DATALAB_MARKER_PAGINATE = PersistentConfig( + "DATALAB_MARKER_PAGINATE", + "rag.datalab_marker_paginate", + os.environ.get("DATALAB_MARKER_PAGINATE", "false").lower() == "true", +) + +DATALAB_MARKER_STRIP_EXISTING_OCR = PersistentConfig( + "DATALAB_MARKER_STRIP_EXISTING_OCR", + "rag.datalab_marker_strip_existing_ocr", + os.environ.get("DATALAB_MARKER_STRIP_EXISTING_OCR", "false").lower() == "true", +) + +DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig( + "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", + "rag.datalab_marker_disable_image_extraction", + os.environ.get("DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", "false").lower() + == "true", +) + +DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig( + "DATALAB_MARKER_OUTPUT_FORMAT", + "rag.datalab_marker_output_format", + os.environ.get("DATALAB_MARKER_OUTPUT_FORMAT", "markdown"), +) + +EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig( + "EXTERNAL_DOCUMENT_LOADER_URL", + "rag.external_document_loader_url", + os.environ.get("EXTERNAL_DOCUMENT_LOADER_URL", ""), +) + +EXTERNAL_DOCUMENT_LOADER_API_KEY = PersistentConfig( + "EXTERNAL_DOCUMENT_LOADER_API_KEY", + "rag.external_document_loader_api_key", + os.environ.get("EXTERNAL_DOCUMENT_LOADER_API_KEY", ""), +) + TIKA_SERVER_URL = PersistentConfig( "TIKA_SERVER_URL", "rag.tika_server_url", os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment ) +DOCLING_SERVER_URL = PersistentConfig( + "DOCLING_SERVER_URL", + "rag.docling_server_url", + os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), +) + +DOCLING_OCR_ENGINE = PersistentConfig( + "DOCLING_OCR_ENGINE", + "rag.docling_ocr_engine", + os.getenv("DOCLING_OCR_ENGINE", "tesseract"), +) + +DOCLING_OCR_LANG = PersistentConfig( + "DOCLING_OCR_LANG", + "rag.docling_ocr_lang", + os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"), +) + +DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig( + "DOCLING_DO_PICTURE_DESCRIPTION", + "rag.docling_do_picture_description", + os.getenv("DOCLING_DO_PICTURE_DESCRIPTION", "False").lower() == "true", +) + +DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( + "DOCUMENT_INTELLIGENCE_ENDPOINT", + "rag.document_intelligence_endpoint", + os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", ""), +) + +DOCUMENT_INTELLIGENCE_KEY = PersistentConfig( + "DOCUMENT_INTELLIGENCE_KEY", + "rag.document_intelligence_key", + os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""), +) + +MISTRAL_OCR_API_KEY = PersistentConfig( + "MISTRAL_OCR_API_KEY", + "rag.mistral_ocr_api_key", + os.getenv("MISTRAL_OCR_API_KEY", ""), +) + +BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig( + "BYPASS_EMBEDDING_AND_RETRIEVAL", + "rag.bypass_embedding_and_retrieval", + os.environ.get("BYPASS_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", +) + + RAG_TOP_K = PersistentConfig( "RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3")) ) +RAG_TOP_K_RERANKER = PersistentConfig( + "RAG_TOP_K_RERANKER", + "rag.top_k_reranker", + int(os.environ.get("RAG_TOP_K_RERANKER", "3")), +) RAG_RELEVANCE_THRESHOLD = PersistentConfig( "RAG_RELEVANCE_THRESHOLD", "rag.relevance_threshold", float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")), ) +RAG_HYBRID_BM25_WEIGHT = PersistentConfig( + "RAG_HYBRID_BM25_WEIGHT", + "rag.hybrid_bm25_weight", + float(os.environ.get("RAG_HYBRID_BM25_WEIGHT", "0.5")), +) ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( "ENABLE_RAG_HYBRID_SEARCH", @@ -1511,6 +1995,12 @@ ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true", ) +RAG_FULL_CONTEXT = PersistentConfig( + "RAG_FULL_CONTEXT", + "rag.full_context", + os.getenv("RAG_FULL_CONTEXT", "False").lower() == "true", +) + RAG_FILE_MAX_COUNT = PersistentConfig( "RAG_FILE_MAX_COUNT", "rag.file.max_count", @@ -1531,10 +2021,14 @@ RAG_FILE_MAX_SIZE = PersistentConfig( ), ) -ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( - "ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", - "rag.enable_web_loader_ssl_verification", - os.environ.get("ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION", "True").lower() == "true", +RAG_ALLOWED_FILE_EXTENSIONS = PersistentConfig( + "RAG_ALLOWED_FILE_EXTENSIONS", + "rag.file.allowed_extensions", + [ + ext.strip() + for ext in os.environ.get("RAG_ALLOWED_FILE_EXTENSIONS", "").split(",") + if ext.strip() + ], ) RAG_EMBEDDING_ENGINE = PersistentConfig( @@ -1574,6 +2068,20 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig( ), ) +RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None) + +RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None) + +RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get( + "RAG_EMBEDDING_PREFIX_FIELD_NAME", None +) + +RAG_RERANKING_ENGINE = PersistentConfig( + "RAG_RERANKING_ENGINE", + "rag.reranking_engine", + os.environ.get("RAG_RERANKING_ENGINE", ""), +) + RAG_RERANKING_MODEL = PersistentConfig( "RAG_RERANKING_MODEL", "rag.reranking_model", @@ -1582,6 +2090,7 @@ RAG_RERANKING_MODEL = PersistentConfig( if RAG_RERANKING_MODEL.value != "": log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}") + RAG_RERANKING_MODEL_AUTO_UPDATE = ( not OFFLINE_MODE and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true" @@ -1591,6 +2100,18 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "True").lower() == "true" ) +RAG_EXTERNAL_RERANKER_URL = PersistentConfig( + "RAG_EXTERNAL_RERANKER_URL", + "rag.external_reranker_url", + os.environ.get("RAG_EXTERNAL_RERANKER_URL", ""), +) + +RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig( + "RAG_EXTERNAL_RERANKER_API_KEY", + "rag.external_reranker_api_key", + os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""), +) + RAG_TEXT_SPLITTER = PersistentConfig( "RAG_TEXT_SPLITTER", @@ -1617,7 +2138,7 @@ CHUNK_OVERLAP = PersistentConfig( ) DEFAULT_RAG_TEMPLATE = """### Task: -Respond to the user query using the provided context, incorporating inline citations in the format [source_id] **only when the tag is explicitly provided** in the context. +Respond to the user query using the provided context, incorporating inline citations in the format [id] **only when the tag includes an explicit id attribute** (e.g., ). ### Guidelines: - If you don't know the answer, clearly state that. @@ -1625,18 +2146,17 @@ Respond to the user query using the provided context, incorporating inline citat - Respond in the same language as the user's query. - If the context is unreadable or of poor quality, inform the user and provide the best possible answer. - If the answer isn't present in the context but you possess the knowledge, explain this to the user and provide the answer using your own understanding. -- **Only include inline citations using [source_id] when a tag is explicitly provided in the context.** -- Do not cite if the tag is not provided in the context. +- **Only include inline citations using [id] (e.g., [1], [2]) when the tag includes an id attribute.** +- Do not cite if the tag does not contain an id attribute. - Do not use XML tags in your response. - Ensure citations are concise and directly related to the information provided. ### Example of Citation: -If the user asks about a specific topic and the information is found in "whitepaper.pdf" with a provided , the response should include the citation like so: -* "According to the study, the proposed method increases efficiency by 20% [whitepaper.pdf]." -If no is present, the response should omit the citation. +If the user asks about a specific topic and the information is found in a source with a provided id attribute, the response should include the citation like in the following example: +* "According to the study, the proposed method increases efficiency by 20% [1]." ### Output: -Provide a clear and direct response to the user's query, including inline citations in the format [source_id] only when the tag is present in the context. +Provide a clear and direct response to the user's query, including inline citations in the format [id] only when the tag with id attribute is present in the context. {{CONTEXT}} @@ -1664,6 +2184,22 @@ RAG_OPENAI_API_KEY = PersistentConfig( os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY), ) +RAG_AZURE_OPENAI_BASE_URL = PersistentConfig( + "RAG_AZURE_OPENAI_BASE_URL", + "rag.azure_openai.base_url", + os.getenv("RAG_AZURE_OPENAI_BASE_URL", ""), +) +RAG_AZURE_OPENAI_API_KEY = PersistentConfig( + "RAG_AZURE_OPENAI_API_KEY", + "rag.azure_openai.api_key", + os.getenv("RAG_AZURE_OPENAI_API_KEY", ""), +) +RAG_AZURE_OPENAI_API_VERSION = PersistentConfig( + "RAG_AZURE_OPENAI_API_VERSION", + "rag.azure_openai.api_version", + os.getenv("RAG_AZURE_OPENAI_API_VERSION", ""), +) + RAG_OLLAMA_BASE_URL = PersistentConfig( "RAG_OLLAMA_BASE_URL", "rag.ollama.url", @@ -1694,22 +2230,46 @@ YOUTUBE_LOADER_PROXY_URL = PersistentConfig( ) -ENABLE_RAG_WEB_SEARCH = PersistentConfig( - "ENABLE_RAG_WEB_SEARCH", +#################################### +# Web Search (RAG) +#################################### + +ENABLE_WEB_SEARCH = PersistentConfig( + "ENABLE_WEB_SEARCH", "rag.web.search.enable", - os.getenv("ENABLE_RAG_WEB_SEARCH", "False").lower() == "true", + os.getenv("ENABLE_WEB_SEARCH", "False").lower() == "true", ) -RAG_WEB_SEARCH_ENGINE = PersistentConfig( - "RAG_WEB_SEARCH_ENGINE", +WEB_SEARCH_ENGINE = PersistentConfig( + "WEB_SEARCH_ENGINE", "rag.web.search.engine", - os.getenv("RAG_WEB_SEARCH_ENGINE", ""), + os.getenv("WEB_SEARCH_ENGINE", ""), ) +BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig( + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", + "rag.web.search.bypass_embedding_and_retrieval", + os.getenv("BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", +) + + +BYPASS_WEB_SEARCH_WEB_LOADER = PersistentConfig( + "BYPASS_WEB_SEARCH_WEB_LOADER", + "rag.web.search.bypass_web_loader", + os.getenv("BYPASS_WEB_SEARCH_WEB_LOADER", "False").lower() == "true", +) + +WEB_SEARCH_RESULT_COUNT = PersistentConfig( + "WEB_SEARCH_RESULT_COUNT", + "rag.web.search.result_count", + int(os.getenv("WEB_SEARCH_RESULT_COUNT", "3")), +) + + # You can provide a list of your own websites to filter after performing a web search. # This ensures the highest level of safety and reliability of the information sources. -RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( - "RAG_WEB_SEARCH_DOMAIN_FILTER_LIST", +WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( + "WEB_SEARCH_DOMAIN_FILTER_LIST", "rag.web.search.domain.filter_list", [ # "wikipedia.com", @@ -1718,6 +2278,31 @@ RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( ], ) +WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( + "WEB_SEARCH_CONCURRENT_REQUESTS", + "rag.web.search.concurrent_requests", + int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")), +) + + +WEB_LOADER_ENGINE = PersistentConfig( + "WEB_LOADER_ENGINE", + "rag.web.loader.engine", + os.environ.get("WEB_LOADER_ENGINE", ""), +) + +ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( + "ENABLE_WEB_LOADER_SSL_VERIFICATION", + "rag.web.loader.ssl_verification", + os.environ.get("ENABLE_WEB_LOADER_SSL_VERIFICATION", "True").lower() == "true", +) + +WEB_SEARCH_TRUST_ENV = PersistentConfig( + "WEB_SEARCH_TRUST_ENV", + "rag.web.search.trust_env", + os.getenv("WEB_SEARCH_TRUST_ENV", "False").lower() == "true", +) + SEARXNG_QUERY_URL = PersistentConfig( "SEARXNG_QUERY_URL", @@ -1725,6 +2310,24 @@ SEARXNG_QUERY_URL = PersistentConfig( os.getenv("SEARXNG_QUERY_URL", ""), ) +YACY_QUERY_URL = PersistentConfig( + "YACY_QUERY_URL", + "rag.web.search.yacy_query_url", + os.getenv("YACY_QUERY_URL", ""), +) + +YACY_USERNAME = PersistentConfig( + "YACY_USERNAME", + "rag.web.search.yacy_username", + os.getenv("YACY_USERNAME", ""), +) + +YACY_PASSWORD = PersistentConfig( + "YACY_PASSWORD", + "rag.web.search.yacy_password", + os.getenv("YACY_PASSWORD", ""), +) + GOOGLE_PSE_API_KEY = PersistentConfig( "GOOGLE_PSE_API_KEY", "rag.web.search.google_pse_api_key", @@ -1785,12 +2388,6 @@ SERPLY_API_KEY = PersistentConfig( os.getenv("SERPLY_API_KEY", ""), ) -TAVILY_API_KEY = PersistentConfig( - "TAVILY_API_KEY", - "rag.web.search.tavily_api_key", - os.getenv("TAVILY_API_KEY", ""), -) - JINA_API_KEY = PersistentConfig( "JINA_API_KEY", "rag.web.search.jina_api_key", @@ -1841,18 +2438,83 @@ EXA_API_KEY = PersistentConfig( os.getenv("EXA_API_KEY", ""), ) -RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig( - "RAG_WEB_SEARCH_RESULT_COUNT", - "rag.web.search.result_count", - int(os.getenv("RAG_WEB_SEARCH_RESULT_COUNT", "3")), +PERPLEXITY_API_KEY = PersistentConfig( + "PERPLEXITY_API_KEY", + "rag.web.search.perplexity_api_key", + os.getenv("PERPLEXITY_API_KEY", ""), ) -RAG_WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( - "RAG_WEB_SEARCH_CONCURRENT_REQUESTS", - "rag.web.search.concurrent_requests", - int(os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10")), +SOUGOU_API_SID = PersistentConfig( + "SOUGOU_API_SID", + "rag.web.search.sougou_api_sid", + os.getenv("SOUGOU_API_SID", ""), ) +SOUGOU_API_SK = PersistentConfig( + "SOUGOU_API_SK", + "rag.web.search.sougou_api_sk", + os.getenv("SOUGOU_API_SK", ""), +) + +TAVILY_API_KEY = PersistentConfig( + "TAVILY_API_KEY", + "rag.web.search.tavily_api_key", + os.getenv("TAVILY_API_KEY", ""), +) + +TAVILY_EXTRACT_DEPTH = PersistentConfig( + "TAVILY_EXTRACT_DEPTH", + "rag.web.search.tavily_extract_depth", + os.getenv("TAVILY_EXTRACT_DEPTH", "basic"), +) + +PLAYWRIGHT_WS_URL = PersistentConfig( + "PLAYWRIGHT_WS_URL", + "rag.web.loader.playwright_ws_url", + os.environ.get("PLAYWRIGHT_WS_URL", ""), +) + +PLAYWRIGHT_TIMEOUT = PersistentConfig( + "PLAYWRIGHT_TIMEOUT", + "rag.web.loader.playwright_timeout", + int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10000")), +) + +FIRECRAWL_API_KEY = PersistentConfig( + "FIRECRAWL_API_KEY", + "rag.web.loader.firecrawl_api_key", + os.environ.get("FIRECRAWL_API_KEY", ""), +) + +FIRECRAWL_API_BASE_URL = PersistentConfig( + "FIRECRAWL_API_BASE_URL", + "rag.web.loader.firecrawl_api_url", + os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"), +) + +EXTERNAL_WEB_SEARCH_URL = PersistentConfig( + "EXTERNAL_WEB_SEARCH_URL", + "rag.web.search.external_web_search_url", + os.environ.get("EXTERNAL_WEB_SEARCH_URL", ""), +) + +EXTERNAL_WEB_SEARCH_API_KEY = PersistentConfig( + "EXTERNAL_WEB_SEARCH_API_KEY", + "rag.web.search.external_web_search_api_key", + os.environ.get("EXTERNAL_WEB_SEARCH_API_KEY", ""), +) + +EXTERNAL_WEB_LOADER_URL = PersistentConfig( + "EXTERNAL_WEB_LOADER_URL", + "rag.web.loader.external_web_loader_url", + os.environ.get("EXTERNAL_WEB_LOADER_URL", ""), +) + +EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig( + "EXTERNAL_WEB_LOADER_API_KEY", + "rag.web.loader.external_web_loader_api_key", + os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""), +) #################################### # Images @@ -2064,6 +2726,17 @@ IMAGES_OPENAI_API_KEY = PersistentConfig( os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY), ) +IMAGES_GEMINI_API_BASE_URL = PersistentConfig( + "IMAGES_GEMINI_API_BASE_URL", + "image_generation.gemini.api_base_url", + os.getenv("IMAGES_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL), +) +IMAGES_GEMINI_API_KEY = PersistentConfig( + "IMAGES_GEMINI_API_KEY", + "image_generation.gemini.api_key", + os.getenv("IMAGES_GEMINI_API_KEY", GEMINI_API_KEY), +) + IMAGE_SIZE = PersistentConfig( "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") ) @@ -2095,6 +2768,14 @@ WHISPER_MODEL_AUTO_UPDATE = ( and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true" ) +WHISPER_VAD_FILTER = PersistentConfig( + "WHISPER_VAD_FILTER", + "audio.stt.whisper_vad_filter", + os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true", +) + +WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None + # Add Deepgram configuration DEEPGRAM_API_KEY = PersistentConfig( "DEEPGRAM_API_KEY", @@ -2102,6 +2783,7 @@ DEEPGRAM_API_KEY = PersistentConfig( os.getenv("DEEPGRAM_API_KEY", ""), ) + AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig( "AUDIO_STT_OPENAI_API_BASE_URL", "audio.stt.openai.api_base_url", @@ -2126,6 +2808,36 @@ AUDIO_STT_MODEL = PersistentConfig( os.getenv("AUDIO_STT_MODEL", ""), ) +AUDIO_STT_AZURE_API_KEY = PersistentConfig( + "AUDIO_STT_AZURE_API_KEY", + "audio.stt.azure.api_key", + os.getenv("AUDIO_STT_AZURE_API_KEY", ""), +) + +AUDIO_STT_AZURE_REGION = PersistentConfig( + "AUDIO_STT_AZURE_REGION", + "audio.stt.azure.region", + os.getenv("AUDIO_STT_AZURE_REGION", ""), +) + +AUDIO_STT_AZURE_LOCALES = PersistentConfig( + "AUDIO_STT_AZURE_LOCALES", + "audio.stt.azure.locales", + os.getenv("AUDIO_STT_AZURE_LOCALES", ""), +) + +AUDIO_STT_AZURE_BASE_URL = PersistentConfig( + "AUDIO_STT_AZURE_BASE_URL", + "audio.stt.azure.base_url", + os.getenv("AUDIO_STT_AZURE_BASE_URL", ""), +) + +AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig( + "AUDIO_STT_AZURE_MAX_SPEAKERS", + "audio.stt.azure.max_speakers", + os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""), +) + AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig( "AUDIO_TTS_OPENAI_API_BASE_URL", "audio.tts.openai.api_base_url", @@ -2171,7 +2883,13 @@ AUDIO_TTS_SPLIT_ON = PersistentConfig( AUDIO_TTS_AZURE_SPEECH_REGION = PersistentConfig( "AUDIO_TTS_AZURE_SPEECH_REGION", "audio.tts.azure.speech_region", - os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", "eastus"), + os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", ""), +) + +AUDIO_TTS_AZURE_SPEECH_BASE_URL = PersistentConfig( + "AUDIO_TTS_AZURE_SPEECH_BASE_URL", + "audio.tts.azure.speech_base_url", + os.getenv("AUDIO_TTS_AZURE_SPEECH_BASE_URL", ""), ) AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig( @@ -2240,7 +2958,7 @@ LDAP_SEARCH_BASE = PersistentConfig( LDAP_SEARCH_FILTERS = PersistentConfig( "LDAP_SEARCH_FILTER", "ldap.server.search_filter", - os.environ.get("LDAP_SEARCH_FILTER", ""), + os.environ.get("LDAP_SEARCH_FILTER", os.environ.get("LDAP_SEARCH_FILTERS", "")), ) LDAP_USE_TLS = PersistentConfig( @@ -2255,6 +2973,12 @@ LDAP_CA_CERT_FILE = PersistentConfig( os.environ.get("LDAP_CA_CERT_FILE", ""), ) +LDAP_VALIDATE_CERT = PersistentConfig( + "LDAP_VALIDATE_CERT", + "ldap.server.validate_cert", + os.environ.get("LDAP_VALIDATE_CERT", "True").lower() == "true", +) + LDAP_CIPHERS = PersistentConfig( "LDAP_CIPHERS", "ldap.server.ciphers", os.environ.get("LDAP_CIPHERS", "ALL") ) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 86d87a2c38..95c54a0d27 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -31,6 +31,7 @@ class ERROR_MESSAGES(str, Enum): USERNAME_TAKEN = ( "Uh-oh! This username is already registered. Please choose another username." ) + PASSWORD_TOO_LONG = "Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long." COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string." FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file." diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 0be3887f82..fcfccaedf5 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -65,10 +65,8 @@ except Exception: # LOGGING #################################### -log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] - GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() -if GLOBAL_LOG_LEVEL in log_levels: +if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping(): logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) else: GLOBAL_LOG_LEVEL = "INFO" @@ -78,6 +76,7 @@ log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") if "cuda_error" in locals(): log.exception(cuda_error) + del cuda_error log_sources = [ "AUDIO", @@ -100,19 +99,19 @@ SRC_LOG_LEVELS = {} for source in log_sources: log_env_var = source + "_LOG_LEVEL" SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper() - if SRC_LOG_LEVELS[source] not in log_levels: + if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping(): SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}") log.setLevel(SRC_LOG_LEVELS["CONFIG"]) - WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") if WEBUI_NAME != "Open WebUI": WEBUI_NAME += " (Open WebUI)" WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" +TRUSTED_SIGNATURE_KEY = os.environ.get("TRUSTED_SIGNATURE_KEY", "") #################################### # ENV (dev,test,prod) @@ -130,7 +129,6 @@ else: except Exception: PACKAGE_DATA = {"version": "0.0.0"} - VERSION = PACKAGE_DATA["version"] @@ -161,7 +159,6 @@ try: except Exception: changelog_content = (pkgutil.get_data("open_webui", "CHANGELOG.md") or b"").decode() - # Convert markdown content to HTML html_content = markdown.markdown(changelog_content) @@ -192,7 +189,6 @@ for version in soup.find_all("h2"): changelog_json[version_number] = version_data - CHANGELOG = changelog_json #################################### @@ -209,7 +205,6 @@ ENABLE_FORWARD_USER_INFO_HEADERS = ( os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true" ) - #################################### # WEBUI_BUILD_HASH #################################### @@ -244,7 +239,6 @@ if FROM_INIT_PY: DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) - STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")) FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) @@ -256,7 +250,6 @@ if FROM_INIT_PY: os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend") ).resolve() - #################################### # Database #################################### @@ -321,7 +314,6 @@ RESET_CONFIG_ON_START = ( os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" ) - ENABLE_REALTIME_CHAT_SAVE = ( os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true" ) @@ -330,7 +322,23 @@ ENABLE_REALTIME_CHAT_SAVE = ( # REDIS #################################### -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") +REDIS_URL = os.environ.get("REDIS_URL", "") +REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") +REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") + +#################################### +# UVICORN WORKERS +#################################### + +# Number of uvicorn worker processes for handling requests +UVICORN_WORKERS = os.environ.get("UVICORN_WORKERS", "1") +try: + UVICORN_WORKERS = int(UVICORN_WORKERS) + if UVICORN_WORKERS < 1: + UVICORN_WORKERS = 1 +except ValueError: + UVICORN_WORKERS = 1 + log.info(f"Invalid UVICORN_WORKERS value, defaulting to {UVICORN_WORKERS}") #################################### # WEBUI_AUTH (Required for security) @@ -341,11 +349,19 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None ) WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) +WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get( + "WEBUI_AUTH_TRUSTED_GROUPS_HEADER", None +) + BYPASS_MODEL_ACCESS_CONTROL = ( os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true" ) +WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get( + "WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None +) + #################################### # WEBUI_SECRET_KEY #################################### @@ -385,6 +401,11 @@ ENABLE_WEBSOCKET_SUPPORT = ( WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) +WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60) + +WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "") + +WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379") AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") @@ -396,19 +417,88 @@ else: except Exception: AIOHTTP_CLIENT_TIMEOUT = 300 -AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "" + +AIOHTTP_CLIENT_SESSION_SSL = ( + os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true" ) -if AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST == "": - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = None +AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( + "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", + os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"), +) + +if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "": + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None else: try: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = int( - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = int(AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) + except Exception: + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10 + + +AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = os.environ.get( + "AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA", "10" +) + +if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA == "": + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = None +else: + try: + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = int( + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA ) except Exception: - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST = 5 + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10 + + +AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = ( + os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true" +) + + +#################################### +# SENTENCE TRANSFORMERS +#################################### + + +SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "") +if SENTENCE_TRANSFORMERS_BACKEND == "": + SENTENCE_TRANSFORMERS_BACKEND = "torch" + + +SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get( + "SENTENCE_TRANSFORMERS_MODEL_KWARGS", "" +) +if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "": + SENTENCE_TRANSFORMERS_MODEL_KWARGS = None +else: + try: + SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads( + SENTENCE_TRANSFORMERS_MODEL_KWARGS + ) + except Exception: + SENTENCE_TRANSFORMERS_MODEL_KWARGS = None + + +SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get( + "SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", "" +) +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "": + SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch" + + +SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get( + "SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", "" +) +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "": + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None +else: + try: + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads( + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS + ) + except Exception: + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None #################################### # OFFLINE_MODE @@ -418,3 +508,56 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true" if OFFLINE_MODE: os.environ["HF_HUB_OFFLINE"] = "1" + + +#################################### +# AUDIT LOGGING +#################################### +# Where to store log file +AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log" +# Maximum size of a file before rotating into a new log file +AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB") +# METADATA | REQUEST | REQUEST_RESPONSE +AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper() +try: + MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048) +except ValueError: + MAX_BODY_LOG_SIZE = 2048 + +# Comma separated list for urls to exclude from audit +AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders").split( + "," +) +AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS] +AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS] + + +#################################### +# OPENTELEMETRY +#################################### + +ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true" +OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get( + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317" +) +OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui") +OTEL_RESOURCE_ATTRIBUTES = os.environ.get( + "OTEL_RESOURCE_ATTRIBUTES", "" +) # e.g. key1=val1,key2=val2 +OTEL_TRACES_SAMPLER = os.environ.get( + "OTEL_TRACES_SAMPLER", "parentbased_always_on" +).lower() + +#################################### +# TOOLS/FUNCTIONS PIP OPTIONS +#################################### + +PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split() +PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split() + + +#################################### +# PROGRESSIVE WEB APP OPTIONS +#################################### + +EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL") diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 274be56ec0..20fabb2dc7 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -2,6 +2,7 @@ import logging import sys import inspect import json +import asyncio from pydantic import BaseModel from typing import AsyncGenerator, Generator, Iterator @@ -27,7 +28,10 @@ from open_webui.socket.main import ( from open_webui.models.functions import Functions from open_webui.models.models import Models -from open_webui.utils.plugin import load_function_module_by_id +from open_webui.utils.plugin import ( + load_function_module_by_id, + get_function_module_from_cache, +) from open_webui.utils.tools import get_tools from open_webui.utils.access_control import has_access @@ -52,12 +56,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"]) def get_function_module_by_id(request: Request, pipe_id: str): - # Check if function is already loaded - if pipe_id not in request.app.state.FUNCTIONS: - function_module, _, _ = load_function_module_by_id(pipe_id) - request.app.state.FUNCTIONS[pipe_id] = function_module - else: - function_module = request.app.state.FUNCTIONS[pipe_id] + function_module, _, _ = get_function_module_from_cache(request, pipe_id) if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): valves = Functions.get_function_valves_by_id(pipe_id) @@ -76,11 +75,13 @@ async def get_function_models(request): if hasattr(function_module, "pipes"): sub_pipes = [] - # Check if pipes is a function or a list - + # Handle pipes being a list, sync function, or async function try: if callable(function_module.pipes): - sub_pipes = 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: @@ -220,6 +221,9 @@ async def generate_function_chat_completion( extra_params = { "__event_emitter__": __event_emitter__, "__event_call__": __event_call__, + "__chat_id__": metadata.get("chat_id", None), + "__session_id__": metadata.get("session_id", None), + "__message_id__": metadata.get("message_id", None), "__task__": __task__, "__task_body__": __task_body__, "__files__": files, @@ -249,8 +253,13 @@ async def generate_function_chat_completion( form_data["model"] = model_info.base_model_id params = model_info.params.model_dump() - form_data = apply_model_params_to_body_openai(params, form_data) - form_data = apply_model_system_prompt_to_body(params, form_data, metadata, user) + + if params: + system = params.pop("system", None) + form_data = apply_model_params_to_body_openai(params, form_data) + form_data = apply_model_system_prompt_to_body( + system, form_data, metadata, user + ) pipe_id = get_pipe_id(form_data) function_module = get_function_module_by_id(request, pipe_id) diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py index ccc62b9a57..5cf3529302 100644 --- a/backend/open_webui/internal/wrappers.py +++ b/backend/open_webui/internal/wrappers.py @@ -43,7 +43,7 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase): def register_connection(db_url): - db = connect(db_url, unquote_password=True) + db = connect(db_url, unquote_user=True, unquote_password=True) if isinstance(db, PostgresqlDatabase): # Enable autoconnect for SQLite databases, managed by Peewee db.autoconnect = True @@ -51,7 +51,7 @@ def register_connection(db_url): log.info("Connected to PostgreSQL database") # Get the connection details - connection = parse(db_url, unquote_password=True) + connection = parse(db_url, unquote_user=True, unquote_password=True) # Use our custom database class that supports reconnection db = ReconnectingPostgresqlDatabase(**connection) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 79d56795d4..a629478e4a 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -17,6 +17,7 @@ from sqlalchemy import text from typing import Optional from aiocache import cached import aiohttp +import anyio.to_thread import requests @@ -39,12 +40,17 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles +from starlette_compress import CompressMiddleware + from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import Response, StreamingResponse +from open_webui.utils import logger +from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware +from open_webui.utils.logger import start_logger from open_webui.socket.main import ( app as socket_app, periodic_usage_pool_cleanup, @@ -60,6 +66,7 @@ from open_webui.routers import ( auths, channels, chats, + notes, folders, configs, groups, @@ -81,25 +88,41 @@ from open_webui.routers.retrieval import ( get_rf, ) -from open_webui.internal.db import Session +from open_webui.internal.db import Session, engine from open_webui.models.functions import Functions from open_webui.models.models import Models from open_webui.models.users import UserModel, Users +from open_webui.models.chats import Chats from open_webui.config import ( + LICENSE_KEY, # Ollama ENABLE_OLLAMA_API, OLLAMA_BASE_URLS, 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, # Direct Connections ENABLE_DIRECT_CONNECTIONS, - # Code Interpreter + # Thread pool size for FastAPI/AnyIO + THREAD_POOL_SIZE, + # Tool Server Configs + TOOL_SERVER_CONNECTIONS, + # Code Execution + ENABLE_CODE_EXECUTION, + CODE_EXECUTION_ENGINE, + CODE_EXECUTION_JUPYTER_URL, + CODE_EXECUTION_JUPYTER_AUTH, + CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + CODE_EXECUTION_JUPYTER_TIMEOUT, ENABLE_CODE_INTERPRETER, CODE_INTERPRETER_ENGINE, CODE_INTERPRETER_PROMPT_TEMPLATE, @@ -107,6 +130,7 @@ from open_webui.config import ( CODE_INTERPRETER_JUPYTER_AUTH, CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + CODE_INTERPRETER_JUPYTER_TIMEOUT, # Image AUTOMATIC1111_API_AUTH, AUTOMATIC1111_BASE_URL, @@ -125,11 +149,18 @@ from open_webui.config import ( IMAGE_STEPS, IMAGES_OPENAI_API_BASE_URL, IMAGES_OPENAI_API_KEY, + IMAGES_GEMINI_API_BASE_URL, + IMAGES_GEMINI_API_KEY, # Audio AUDIO_STT_ENGINE, AUDIO_STT_MODEL, AUDIO_STT_OPENAI_API_BASE_URL, AUDIO_STT_OPENAI_API_KEY, + AUDIO_STT_AZURE_API_KEY, + AUDIO_STT_AZURE_REGION, + AUDIO_STT_AZURE_LOCALES, + AUDIO_STT_AZURE_BASE_URL, + AUDIO_STT_AZURE_MAX_SPEAKERS, AUDIO_TTS_API_KEY, AUDIO_TTS_ENGINE, AUDIO_TTS_MODEL, @@ -138,59 +169,107 @@ from open_webui.config import ( AUDIO_TTS_SPLIT_ON, AUDIO_TTS_VOICE, AUDIO_TTS_AZURE_SPEECH_REGION, + AUDIO_TTS_AZURE_SPEECH_BASE_URL, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, + PLAYWRIGHT_WS_URL, + PLAYWRIGHT_TIMEOUT, + FIRECRAWL_API_BASE_URL, + FIRECRAWL_API_KEY, + WEB_LOADER_ENGINE, WHISPER_MODEL, + WHISPER_VAD_FILTER, + WHISPER_LANGUAGE, DEEPGRAM_API_KEY, WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_DIR, # Retrieval RAG_TEMPLATE, DEFAULT_RAG_TEMPLATE, + RAG_FULL_CONTEXT, + BYPASS_EMBEDDING_AND_RETRIEVAL, RAG_EMBEDDING_MODEL, RAG_EMBEDDING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + RAG_RERANKING_ENGINE, RAG_RERANKING_MODEL, + RAG_EXTERNAL_RERANKER_URL, + RAG_EXTERNAL_RERANKER_API_KEY, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_BATCH_SIZE, + RAG_TOP_K, + RAG_TOP_K_RERANKER, RAG_RELEVANCE_THRESHOLD, + RAG_HYBRID_BM25_WEIGHT, + RAG_ALLOWED_FILE_EXTENSIONS, RAG_FILE_MAX_COUNT, RAG_FILE_MAX_SIZE, RAG_OPENAI_API_BASE_URL, RAG_OPENAI_API_KEY, + RAG_AZURE_OPENAI_BASE_URL, + RAG_AZURE_OPENAI_API_KEY, + RAG_AZURE_OPENAI_API_VERSION, RAG_OLLAMA_BASE_URL, RAG_OLLAMA_API_KEY, CHUNK_OVERLAP, CHUNK_SIZE, CONTENT_EXTRACTION_ENGINE, + DATALAB_MARKER_API_KEY, + DATALAB_MARKER_LANGS, + DATALAB_MARKER_SKIP_CACHE, + DATALAB_MARKER_FORCE_OCR, + DATALAB_MARKER_PAGINATE, + DATALAB_MARKER_STRIP_EXISTING_OCR, + DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + DATALAB_MARKER_OUTPUT_FORMAT, + DATALAB_MARKER_USE_LLM, + EXTERNAL_DOCUMENT_LOADER_URL, + EXTERNAL_DOCUMENT_LOADER_API_KEY, TIKA_SERVER_URL, - RAG_TOP_K, + DOCLING_SERVER_URL, + DOCLING_OCR_ENGINE, + DOCLING_OCR_LANG, + DOCLING_DO_PICTURE_DESCRIPTION, + DOCUMENT_INTELLIGENCE_ENDPOINT, + DOCUMENT_INTELLIGENCE_KEY, + MISTRAL_OCR_API_KEY, RAG_TEXT_SPLITTER, TIKTOKEN_ENCODING_NAME, PDF_EXTRACT_IMAGES, YOUTUBE_LOADER_LANGUAGE, YOUTUBE_LOADER_PROXY_URL, # Retrieval (Web Search) - RAG_WEB_SEARCH_ENGINE, - RAG_WEB_SEARCH_RESULT_COUNT, - RAG_WEB_SEARCH_CONCURRENT_REQUESTS, - RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + ENABLE_WEB_SEARCH, + WEB_SEARCH_ENGINE, + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + BYPASS_WEB_SEARCH_WEB_LOADER, + WEB_SEARCH_RESULT_COUNT, + WEB_SEARCH_CONCURRENT_REQUESTS, + WEB_SEARCH_TRUST_ENV, + WEB_SEARCH_DOMAIN_FILTER_LIST, JINA_API_KEY, SEARCHAPI_API_KEY, SEARCHAPI_ENGINE, SERPAPI_API_KEY, SERPAPI_ENGINE, SEARXNG_QUERY_URL, + YACY_QUERY_URL, + YACY_USERNAME, + YACY_PASSWORD, SERPER_API_KEY, SERPLY_API_KEY, SERPSTACK_API_KEY, SERPSTACK_HTTPS, TAVILY_API_KEY, + TAVILY_EXTRACT_DEPTH, BING_SEARCH_V7_ENDPOINT, BING_SEARCH_V7_SUBSCRIPTION_KEY, BRAVE_SEARCH_API_KEY, EXA_API_KEY, + PERPLEXITY_API_KEY, + SOUGOU_API_SID, + SOUGOU_API_SK, KAGI_SEARCH_API_KEY, MOJEEK_SEARCH_API_KEY, BOCHA_SEARCH_API_KEY, @@ -198,12 +277,19 @@ from open_webui.config import ( GOOGLE_PSE_ENGINE_ID, GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_API_KEY, + ONEDRIVE_CLIENT_ID, + ONEDRIVE_SHAREPOINT_URL, + ONEDRIVE_SHAREPOINT_TENANT_ID, ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_LOCAL_WEB_FETCH, - ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - ENABLE_RAG_WEB_SEARCH, + ENABLE_WEB_LOADER_SSL_VERIFICATION, ENABLE_GOOGLE_DRIVE_INTEGRATION, + ENABLE_ONEDRIVE_INTEGRATION, UPLOAD_DIR, + EXTERNAL_WEB_SEARCH_URL, + EXTERNAL_WEB_SEARCH_API_KEY, + EXTERNAL_WEB_LOADER_URL, + EXTERNAL_WEB_LOADER_API_KEY, # WebUI WEBUI_AUTH, WEBUI_NAME, @@ -218,11 +304,15 @@ from open_webui.config import ( ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, API_KEY_ALLOWED_ENDPOINTS, ENABLE_CHANNELS, + ENABLE_NOTES, ENABLE_COMMUNITY_SHARING, ENABLE_MESSAGE_RATING, + ENABLE_USER_WEBHOOKS, ENABLE_EVALUATION_ARENA_MODELS, USER_PERMISSIONS, DEFAULT_USER_ROLE, + PENDING_USER_OVERLAY_CONTENT, + PENDING_USER_OVERLAY_TITLE, DEFAULT_PROMPT_SUGGESTIONS, DEFAULT_MODELS, DEFAULT_ARENA_MODEL, @@ -249,6 +339,7 @@ from open_webui.config import ( LDAP_APP_PASSWORD, LDAP_USE_TLS, LDAP_CA_CERT_FILE, + LDAP_VALIDATE_CERT, LDAP_CIPHERS, # Misc ENV, @@ -259,6 +350,7 @@ from open_webui.config import ( DEFAULT_LOCALE, OAUTH_PROVIDERS, WEBUI_URL, + RESPONSE_WATERMARK, # Admin ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT, @@ -281,8 +373,14 @@ from open_webui.config import ( reset_config, ) from open_webui.env import ( + AUDIT_EXCLUDED_PATHS, + AUDIT_LOG_LEVEL, CHANGELOG, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, GLOBAL_LOG_LEVEL, + MAX_BODY_LOG_SIZE, SAFE_MODE, SRC_LOG_LEVELS, VERSION, @@ -292,10 +390,14 @@ from open_webui.env import ( WEBUI_SESSION_COOKIE_SECURE, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, + WEBUI_AUTH_SIGNOUT_REDIRECT_URL, ENABLE_WEBSOCKET_SUPPORT, BYPASS_MODEL_ACCESS_CONTROL, RESET_CONFIG_ON_START, OFFLINE_MODE, + ENABLE_OTEL, + EXTERNAL_PWA_MANIFEST_URL, + AIOHTTP_CLIENT_SESSION_SSL, ) @@ -313,14 +415,24 @@ from open_webui.utils.middleware import process_chat_payload, process_chat_respo from open_webui.utils.access_control import has_access from open_webui.utils.auth import ( + get_license_data, + get_http_authorization_cred, decode_token, get_admin_user, get_verified_user, ) -from open_webui.utils.oauth import oauth_manager +from open_webui.utils.plugin import install_tool_and_function_dependencies +from open_webui.utils.oauth import OAuthManager from open_webui.utils.security_headers import SecurityHeadersMiddleware -from open_webui.tasks import stop_task, list_tasks # Import from tasks.py +from open_webui.tasks import ( + list_task_ids_by_chat_id, + stop_task, + list_tasks, +) # Import from tasks.py + +from open_webui.utils.redis import get_sentinels_from_env + if SAFE_MODE: print("SAFE MODE ENABLED") @@ -348,15 +460,15 @@ class SPAStaticFiles(StaticFiles): print( rf""" - ___ __ __ _ _ _ ___ - / _ \ _ __ ___ _ __ \ \ / /__| |__ | | | |_ _| -| | | | '_ \ / _ \ '_ \ \ \ /\ / / _ \ '_ \| | | || | -| |_| | |_) | __/ | | | \ V V / __/ |_) | |_| || | - \___/| .__/ \___|_| |_| \_/\_/ \___|_.__/ \___/|___| - |_| + ██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗ +██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║ +██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║ +██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║ ██║███╗██║██╔══╝ ██╔══██╗██║ ██║██║ +╚██████╔╝██║ ███████╗██║ ╚████║ ╚███╔███╔╝███████╗██████╔╝╚██████╔╝██║ + ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ -v{VERSION} - building the best open-source AI user interface. +v{VERSION} - building the best AI user interface. {f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""} https://github.com/open-webui/open-webui """ @@ -365,21 +477,56 @@ https://github.com/open-webui/open-webui @asynccontextmanager async def lifespan(app: FastAPI): + start_logger() if RESET_CONFIG_ON_START: reset_config() + if LICENSE_KEY: + get_license_data(app, LICENSE_KEY) + + # This should be blocking (sync) so functions are not deactivated on first /get_models calls + # when the first user lands on the / route. + log.info("Installing external dependencies of functions and tools...") + install_tool_and_function_dependencies() + + if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: + limiter = anyio.to_thread.current_default_thread_limiter() + limiter.total_tokens = THREAD_POOL_SIZE + asyncio.create_task(periodic_usage_pool_cleanup()) + yield app = FastAPI( + title="Open WebUI", docs_url="/docs" if ENV == "dev" else None, openapi_url="/openapi.json" if ENV == "dev" else None, redoc_url=None, lifespan=lifespan, ) -app.state.config = AppConfig() +oauth_manager = OAuthManager(app) + +app.state.config = AppConfig( + redis_url=REDIS_URL, + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), +) + +app.state.WEBUI_NAME = WEBUI_NAME +app.state.LICENSE_METADATA = None + + +######################################## +# +# OPENTELEMETRY +# +######################################## + +if ENABLE_OTEL: + from open_webui.utils.telemetry.setup import setup as setup_opentelemetry + + setup_opentelemetry(app=app, db_engine=engine) ######################################## @@ -408,6 +555,15 @@ app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS app.state.OPENAI_MODELS = {} +######################################## +# +# TOOL SERVERS +# +######################################## + +app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS +app.state.TOOL_SERVERS = [] + ######################################## # # DIRECT CONNECTIONS @@ -442,6 +598,11 @@ app.state.config.DEFAULT_MODELS = DEFAULT_MODELS app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE +app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT +app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE + +app.state.config.RESPONSE_WATERMARK = RESPONSE_WATERMARK + app.state.config.USER_PERMISSIONS = USER_PERMISSIONS app.state.config.WEBHOOK_URL = WEBHOOK_URL app.state.config.BANNERS = WEBUI_BANNERS @@ -449,8 +610,10 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS +app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING +app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS @@ -476,15 +639,22 @@ app.state.config.LDAP_SEARCH_BASE = LDAP_SEARCH_BASE app.state.config.LDAP_SEARCH_FILTERS = LDAP_SEARCH_FILTERS app.state.config.LDAP_USE_TLS = LDAP_USE_TLS app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE +app.state.config.LDAP_VALIDATE_CERT = LDAP_VALIDATE_CERT app.state.config.LDAP_CIPHERS = LDAP_CIPHERS app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER +app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL +app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL + +app.state.USER_COUNT = None app.state.TOOLS = {} -app.state.FUNCTIONS = {} +app.state.TOOL_CONTENTS = {} +app.state.FUNCTIONS = {} +app.state.FUNCTION_CONTENTS = {} ######################################## # @@ -494,17 +664,41 @@ app.state.FUNCTIONS = {} app.state.config.TOP_K = RAG_TOP_K +app.state.config.TOP_K_RERANKER = RAG_TOP_K_RERANKER app.state.config.RELEVANCE_THRESHOLD = RAG_RELEVANCE_THRESHOLD +app.state.config.HYBRID_BM25_WEIGHT = RAG_HYBRID_BM25_WEIGHT +app.state.config.ALLOWED_FILE_EXTENSIONS = RAG_ALLOWED_FILE_EXTENSIONS app.state.config.FILE_MAX_SIZE = RAG_FILE_MAX_SIZE app.state.config.FILE_MAX_COUNT = RAG_FILE_MAX_COUNT + +app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT +app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = BYPASS_EMBEDDING_AND_RETRIEVAL app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH -app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( - ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION -) +app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERIFICATION app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE +app.state.config.DATALAB_MARKER_API_KEY = DATALAB_MARKER_API_KEY +app.state.config.DATALAB_MARKER_LANGS = DATALAB_MARKER_LANGS +app.state.config.DATALAB_MARKER_SKIP_CACHE = DATALAB_MARKER_SKIP_CACHE +app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR +app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE +app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTING_OCR +app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( + DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION +) +app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM +app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT +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_OCR_ENGINE = DOCLING_OCR_ENGINE +app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG +app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION +app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT +app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY +app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME @@ -515,12 +709,21 @@ app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE + +app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL +app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL +app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY + app.state.config.RAG_TEMPLATE = RAG_TEMPLATE app.state.config.RAG_OPENAI_API_BASE_URL = RAG_OPENAI_API_BASE_URL app.state.config.RAG_OPENAI_API_KEY = RAG_OPENAI_API_KEY +app.state.config.RAG_AZURE_OPENAI_BASE_URL = RAG_AZURE_OPENAI_BASE_URL +app.state.config.RAG_AZURE_OPENAI_API_KEY = RAG_AZURE_OPENAI_API_KEY +app.state.config.RAG_AZURE_OPENAI_API_VERSION = RAG_AZURE_OPENAI_API_VERSION + app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY @@ -530,12 +733,24 @@ app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL -app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH -app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE -app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST +app.state.config.ENABLE_WEB_SEARCH = ENABLE_WEB_SEARCH +app.state.config.WEB_SEARCH_ENGINE = WEB_SEARCH_ENGINE +app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = WEB_SEARCH_DOMAIN_FILTER_LIST +app.state.config.WEB_SEARCH_RESULT_COUNT = WEB_SEARCH_RESULT_COUNT +app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS +app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE +app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV +app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL +) +app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION +app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL +app.state.config.YACY_QUERY_URL = YACY_QUERY_URL +app.state.config.YACY_USERNAME = YACY_USERNAME +app.state.config.YACY_PASSWORD = YACY_PASSWORD app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY @@ -555,9 +770,20 @@ app.state.config.JINA_API_KEY = JINA_API_KEY app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY app.state.config.EXA_API_KEY = EXA_API_KEY +app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY +app.state.config.SOUGOU_API_SID = SOUGOU_API_SID +app.state.config.SOUGOU_API_SK = SOUGOU_API_SK +app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL +app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY +app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL +app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY -app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT -app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS + +app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL +app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT +app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL +app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY +app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH app.state.EMBEDDING_FUNCTION = None app.state.ef = None @@ -574,7 +800,10 @@ try: ) app.state.rf = get_rf( + app.state.config.RAG_RERANKING_ENGINE, app.state.config.RAG_RERANKING_MODEL, + app.state.config.RAG_EXTERNAL_RERANKER_URL, + app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, RAG_RERANKING_MODEL_AUTO_UPDATE, ) except Exception as e: @@ -589,22 +818,45 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function( ( app.state.config.RAG_OPENAI_API_BASE_URL if app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else app.state.config.RAG_OLLAMA_BASE_URL + else ( + app.state.config.RAG_OLLAMA_BASE_URL + if app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else app.state.config.RAG_AZURE_OPENAI_BASE_URL + ) ), ( app.state.config.RAG_OPENAI_API_KEY if app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else app.state.config.RAG_OLLAMA_API_KEY + else ( + app.state.config.RAG_OLLAMA_API_KEY + if app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else app.state.config.RAG_AZURE_OPENAI_API_KEY + ) ), app.state.config.RAG_EMBEDDING_BATCH_SIZE, + azure_api_version=( + app.state.config.RAG_AZURE_OPENAI_API_VERSION + if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + else None + ), ) ######################################## # -# CODE INTERPRETER +# CODE EXECUTION # ######################################## +app.state.config.ENABLE_CODE_EXECUTION = ENABLE_CODE_EXECUTION +app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE +app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL +app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH +app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = CODE_EXECUTION_JUPYTER_AUTH_TOKEN +app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = ( + CODE_EXECUTION_JUPYTER_AUTH_PASSWORD +) +app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = CODE_EXECUTION_JUPYTER_TIMEOUT + app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER app.state.config.CODE_INTERPRETER_ENGINE = CODE_INTERPRETER_ENGINE app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = CODE_INTERPRETER_PROMPT_TEMPLATE @@ -617,6 +869,7 @@ app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = ( app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD ) +app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIMEOUT ######################################## # @@ -631,6 +884,9 @@ app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY +app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL +app.state.config.IMAGES_GEMINI_API_KEY = IMAGES_GEMINI_API_KEY + app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL @@ -659,8 +915,15 @@ app.state.config.STT_ENGINE = AUDIO_STT_ENGINE app.state.config.STT_MODEL = AUDIO_STT_MODEL app.state.config.WHISPER_MODEL = WHISPER_MODEL +app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY +app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY +app.state.config.AUDIO_STT_AZURE_REGION = AUDIO_STT_AZURE_REGION +app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES +app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL +app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS + app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE @@ -671,6 +934,7 @@ app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON app.state.config.TTS_AZURE_SPEECH_REGION = AUDIO_TTS_AZURE_SPEECH_REGION +app.state.config.TTS_AZURE_SPEECH_BASE_URL = AUDIO_TTS_AZURE_SPEECH_BASE_URL app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT @@ -733,7 +997,8 @@ class RedirectMiddleware(BaseHTTPMiddleware): # Check for the specific watch path and the presence of 'v' parameter if path.endswith("/watch") and "v" in query_params: - video_id = query_params["v"][0] # Extract the first 'v' parameter + # Extract the first 'v' parameter + video_id = query_params["v"][0] encoded_video_id = urlencode({"youtube": video_id}) redirect_url = f"/?{encoded_video_id}" return RedirectResponse(url=redirect_url) @@ -744,6 +1009,7 @@ class RedirectMiddleware(BaseHTTPMiddleware): # Add the middleware to the app +app.add_middleware(CompressMiddleware) app.add_middleware(RedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) @@ -759,6 +1025,10 @@ async def commit_session_after_request(request: Request, call_next): @app.middleware("http") async def check_url(request: Request, call_next): start_time = int(time.time()) + request.state.token = get_http_authorization_cred( + request.headers.get("Authorization") + ) + request.state.enable_api_key = app.state.config.ENABLE_API_KEY response = await call_next(request) process_time = int(time.time()) - start_time @@ -815,6 +1085,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"]) app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"]) app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"]) +app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"]) + app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) @@ -832,6 +1104,19 @@ app.include_router( app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +try: + audit_level = AuditLevel(AUDIT_LOG_LEVEL) +except ValueError as e: + logger.error(f"Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}") + audit_level = AuditLevel.NONE + +if audit_level != AuditLevel.NONE: + app.add_middleware( + AuditLoggingMiddleware, + audit_level=audit_level, + excluded_paths=AUDIT_EXCLUDED_PATHS, + max_body_size=MAX_BODY_LOG_SIZE, + ) ################################## # # Chat Endpoints @@ -864,14 +1149,29 @@ async def get_models(request: Request, user=Depends(get_verified_user)): return filtered_models - models = await get_all_models(request) + all_models = await get_all_models(request, user=user) - # Filter out filter pipelines - models = [ - model - for model in models - if "pipeline" not in model or model["pipeline"].get("type", None) != "filter" - ] + models = [] + for model in all_models: + # Filter out filter pipelines + if "pipeline" in model and model["pipeline"].get("type", None) == "filter": + continue + + try: + model_tags = [ + tag.get("name") + for tag in model.get("info", {}).get("meta", {}).get("tags", []) + ] + tags = [tag.get("name") for tag in model.get("tags", [])] + + tags = list(set(model_tags + tags)) + model["tags"] = [{"name": tag} for tag in tags] + except Exception as e: + log.debug(f"Error processing model tags: {e}") + model["tags"] = [] + pass + + models.append(model) model_order_list = request.app.state.config.MODEL_ORDER_LIST if model_order_list: @@ -893,7 +1193,7 @@ async def get_models(request: Request, user=Depends(get_verified_user)): @app.get("/api/models/base") async def get_base_models(request: Request, user=Depends(get_admin_user)): - models = await get_all_base_models(request) + models = await get_all_base_models(request, user=user) return {"data": models} @@ -904,11 +1204,12 @@ async def chat_completion( user=Depends(get_verified_user), ): if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) model_item = form_data.pop("model_item", {}) tasks = form_data.pop("background_tasks", None) + metadata = {} try: if not model_item.get("direct", False): model_id = form_data.get("model", None) @@ -936,11 +1237,13 @@ async def chat_completion( "chat_id": form_data.pop("chat_id", None), "message_id": form_data.pop("id", None), "session_id": form_data.pop("session_id", None), + "filter_ids": form_data.pop("filter_ids", []), "tool_ids": form_data.get("tool_ids", None), + "tool_servers": form_data.pop("tool_servers", None), "files": form_data.get("files", None), - "features": form_data.get("features", None), - "variables": form_data.get("variables", None), - "model": model_info, + "features": form_data.get("features", {}), + "variables": form_data.get("variables", {}), + "model": model, "direct": model_item.get("direct", False), **( {"function_calling": "native"} @@ -958,11 +1261,21 @@ async def chat_completion( form_data["metadata"] = metadata form_data, metadata, events = await process_chat_payload( - request, form_data, metadata, user, model + request, form_data, user, metadata, model ) except Exception as e: log.debug(f"Error processing chat payload: {e}") + if metadata.get("chat_id") and metadata.get("message_id"): + # Update the chat message with the error + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "error": {"content": str(e)}, + }, + ) + raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), @@ -972,7 +1285,7 @@ async def chat_completion( response = await chat_completion_handler(request, form_data, user) return await process_chat_response( - request, response, form_data, user, events, metadata, tasks + request, response, form_data, user, metadata, model, events, tasks ) except Exception as e: raise HTTPException( @@ -1027,7 +1340,7 @@ async def chat_action( @app.post("/api/tasks/stop/{task_id}") async def stop_task_endpoint(task_id: str, user=Depends(get_verified_user)): try: - result = await stop_task(task_id) # Use the function from tasks.py + result = await stop_task(task_id) return result except ValueError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -1035,7 +1348,19 @@ async def stop_task_endpoint(task_id: str, user=Depends(get_verified_user)): @app.get("/api/tasks") async def list_tasks_endpoint(user=Depends(get_verified_user)): - return {"tasks": list_tasks()} # Use the function from tasks.py + return {"tasks": list_tasks()} + + +@app.get("/api/tasks/chat/{chat_id}") +async def list_tasks_by_chat_id_endpoint(chat_id: str, user=Depends(get_verified_user)): + chat = Chats.get_chat_by_id(chat_id) + if chat is None or chat.user_id != user.id: + return {"task_ids": []} + + task_ids = list_task_ids_by_chat_id(chat_id) + + print(f"Task IDs for chat {chat_id}: {task_ids}") + return {"task_ids": task_ids} @app.post("/api/embeddings") @@ -1066,15 +1391,16 @@ async def get_app_config(request: Request): if data is not None and "id" in data: user = Users.get_user_by_id(data["id"]) + user_count = Users.get_num_users() onboarding = False + if user is None: - user_count = Users.get_num_users() onboarding = user_count == 0 return { **({"onboarding": True} if onboarding else {}), "status": True, - "name": WEBUI_NAME, + "name": app.state.WEBUI_NAME, "version": VERSION, "default_locale": str(DEFAULT_LOCALE), "oauth": { @@ -1095,15 +1421,19 @@ async def get_app_config(request: Request): { "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_channels": app.state.config.ENABLE_CHANNELS, - "enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH, + "enable_notes": app.state.config.ENABLE_NOTES, + "enable_web_search": app.state.config.ENABLE_WEB_SEARCH, + "enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION, "enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER, "enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION, "enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, + "enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS, "enable_admin_export": ENABLE_ADMIN_EXPORT, "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, } if user is not None else {} @@ -1113,6 +1443,10 @@ async def get_app_config(request: Request): { "default_models": app.state.config.DEFAULT_MODELS, "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, + "user_count": user_count, + "code": { + "engine": app.state.config.CODE_EXECUTION_ENGINE, + }, "audio": { "tts": { "engine": app.state.config.TTS_ENGINE, @@ -1132,6 +1466,24 @@ async def get_app_config(request: Request): "client_id": GOOGLE_DRIVE_CLIENT_ID.value, "api_key": GOOGLE_DRIVE_API_KEY.value, }, + "onedrive": { + "client_id": ONEDRIVE_CLIENT_ID.value, + "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, + "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, + }, + "ui": { + "pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE, + "pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT, + "response_watermark": app.state.config.RESPONSE_WATERMARK, + }, + "license_metadata": app.state.LICENSE_METADATA, + **( + { + "active_entries": app.state.USER_COUNT, + } + if user.role == "admin" + else {} + ), } if user is not None else {} @@ -1175,7 +1527,8 @@ async def get_app_latest_release_version(user=Depends(get_verified_user)): timeout = aiohttp.ClientTimeout(total=1) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( - "https://api.github.com/repos/open-webui/open-webui/releases/latest" + "https://api.github.com/repos/open-webui/open-webui/releases/latest", + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() data = await response.json() @@ -1209,7 +1562,7 @@ if len(OAUTH_PROVIDERS) > 0: @app.get("/oauth/{provider}/login") async def oauth_login(provider: str, request: Request): - return await oauth_manager.handle_login(provider, request) + return await oauth_manager.handle_login(request, provider) # OAuth login logic is as follows: @@ -1220,42 +1573,45 @@ async def oauth_login(provider: str, request: Request): # - Email addresses are considered unique, so we fail registration if the email address is already taken @app.get("/oauth/{provider}/callback") async def oauth_callback(provider: str, request: Request, response: Response): - return await oauth_manager.handle_callback(provider, request, response) + return await oauth_manager.handle_callback(request, provider, response) @app.get("/manifest.json") async def get_manifest_json(): - return { - "name": WEBUI_NAME, - "short_name": WEBUI_NAME, - "description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.", - "start_url": "/", - "display": "standalone", - "background_color": "#343541", - "orientation": "natural", - "icons": [ - { - "src": "/static/logo.png", - "type": "image/png", - "sizes": "500x500", - "purpose": "any", - }, - { - "src": "/static/logo.png", - "type": "image/png", - "sizes": "500x500", - "purpose": "maskable", - }, - ], - } + if app.state.EXTERNAL_PWA_MANIFEST_URL: + return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json() + else: + return { + "name": app.state.WEBUI_NAME, + "short_name": app.state.WEBUI_NAME, + "description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.", + "start_url": "/", + "display": "standalone", + "background_color": "#343541", + "orientation": "any", + "icons": [ + { + "src": "/static/logo.png", + "type": "image/png", + "sizes": "500x500", + "purpose": "any", + }, + { + "src": "/static/logo.png", + "type": "image/png", + "sizes": "500x500", + "purpose": "maskable", + }, + ], + } @app.get("/opensearch.xml") async def get_opensearch_xml(): xml_content = rf""" - {WEBUI_NAME} - Search {WEBUI_NAME} + {app.state.WEBUI_NAME} + Search {app.state.WEBUI_NAME} UTF-8 {app.state.config.WEBUI_URL}/static/favicon.png diff --git a/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py new file mode 100644 index 0000000000..8e983a2cff --- /dev/null +++ b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py @@ -0,0 +1,33 @@ +"""Add note table + +Revision ID: 9f0c9cd09105 +Revises: 3781e22d8b01 +Create Date: 2025-05-03 03:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "9f0c9cd09105" +down_revision = "3781e22d8b01" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "note", + sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column("user_id", sa.Text(), nullable=True), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("access_control", sa.JSON(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=True), + sa.Column("updated_at", sa.BigInteger(), nullable=True), + ) + + +def downgrade(): + op.drop_table("note") diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index f07c36c734..3ad88bc119 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -129,12 +129,16 @@ class AuthsTable: def authenticate_user(self, email: str, password: str) -> Optional[UserModel]: log.info(f"authenticate_user: {email}") + + user = Users.get_user_by_email(email) + if not user: + return None + try: with get_db() as db: - auth = db.query(Auth).filter_by(email=email, active=True).first() + auth = db.query(Auth).filter_by(id=user.id, active=True).first() if auth: if verify_password(password, auth.password): - user = Users.get_user_by_id(auth.id) return user else: return None @@ -155,8 +159,8 @@ class AuthsTable: except Exception: return False - def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]: - log.info(f"authenticate_user_by_trusted_header: {email}") + def authenticate_user_by_email(self, email: str) -> Optional[UserModel]: + log.info(f"authenticate_user_by_email: {email}") try: with get_db() as db: auth = db.query(Auth).filter_by(email=email, active=True).first() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 9e0a5865e9..0ac53a0233 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1,3 +1,4 @@ +import logging import json import time import uuid @@ -5,7 +6,7 @@ from typing import Optional from open_webui.internal.db import Base, get_db from open_webui.models.tags import TagModel, Tag, Tags - +from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON @@ -16,6 +17,9 @@ from sqlalchemy.sql import exists # Chat DB Schema #################### +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + class Chat(Base): __tablename__ = "chat" @@ -373,22 +377,47 @@ class ChatTable: return False def get_archived_chat_list_by_user_id( - self, user_id: str, skip: int = 0, limit: int = 50 + self, + user_id: str, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 50, ) -> list[ChatModel]: + with get_db() as db: - all_chats = ( - db.query(Chat) - .filter_by(user_id=user_id, archived=True) - .order_by(Chat.updated_at.desc()) - # .limit(limit).offset(skip) - .all() - ) + query = db.query(Chat).filter_by(user_id=user_id, archived=True) + + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter(Chat.title.ilike(f"%{query_key}%")) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by and direction and getattr(Chat, order_by): + if direction.lower() == "asc": + query = query.order_by(getattr(Chat, order_by).asc()) + elif direction.lower() == "desc": + query = query.order_by(getattr(Chat, order_by).desc()) + else: + raise ValueError("Invalid direction for ordering") + else: + query = query.order_by(Chat.updated_at.desc()) + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + all_chats = query.all() return [ChatModel.model_validate(chat) for chat in all_chats] def get_chat_list_by_user_id( self, user_id: str, include_archived: bool = False, + filter: Optional[dict] = None, skip: int = 0, limit: int = 50, ) -> list[ChatModel]: @@ -397,7 +426,23 @@ class ChatTable: if not include_archived: query = query.filter_by(archived=False) - query = query.order_by(Chat.updated_at.desc()) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter(Chat.title.ilike(f"%{query_key}%")) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by and direction and getattr(Chat, order_by): + if direction.lower() == "asc": + query = query.order_by(getattr(Chat, order_by).asc()) + elif direction.lower() == "desc": + query = query.order_by(getattr(Chat, order_by).desc()) + else: + raise ValueError("Invalid direction for ordering") + else: + query = query.order_by(Chat.updated_at.desc()) if skip: query = query.offset(skip) @@ -432,7 +477,7 @@ class ChatTable: all_chats = query.all() - # result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. + # result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass. return [ ChatTitleIdResponse.model_validate( { @@ -538,7 +583,9 @@ class ChatTable: search_text = search_text.lower().strip() if not search_text: - return self.get_chat_list_by_user_id(user_id, include_archived, skip, limit) + return self.get_chat_list_by_user_id( + user_id, include_archived, filter={}, skip=skip, limit=limit + ) search_text_words = search_text.split(" ") @@ -670,7 +717,7 @@ class ChatTable: # Perform pagination at the SQL level all_chats = query.offset(skip).limit(limit).all() - print(len(all_chats)) + log.info(f"The number of chats: {len(all_chats)}") # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] @@ -731,7 +778,7 @@ class ChatTable: query = db.query(Chat).filter_by(user_id=user_id) tag_id = tag_name.replace(" ", "_").lower() - print(db.bind.dialect.name) + log.info(f"DB dialect name: {db.bind.dialect.name}") if db.bind.dialect.name == "sqlite": # SQLite JSON1 querying for tags within the meta JSON field query = query.filter( @@ -752,7 +799,7 @@ class ChatTable: ) all_chats = query.all() - print("all_chats", all_chats) + log.debug(f"all_chats: {all_chats}") return [ChatModel.model_validate(chat) for chat in all_chats] def add_chat_tag_by_id_and_user_id_and_tag_name( @@ -810,7 +857,7 @@ class ChatTable: count = query.count() # Debugging output for inspection - print(f"Count of chats for tag '{tag_name}':", count) + log.info(f"Count of chats for tag '{tag_name}': {count}") return count diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 7ff5c45408..215e36aa24 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -118,7 +118,7 @@ class FeedbackTable: else: return None except Exception as e: - print(e) + log.exception(f"Error creating a new feedback: {e}") return None def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 91dea54443..6f1511cd13 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -119,7 +119,7 @@ class FilesTable: else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error inserting a new file: {e}") return None def get_file_by_id(self, id: str) -> Optional[FileModel]: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 040774196b..1c97de26c9 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -9,6 +9,8 @@ from open_webui.models.chats import Chats from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, JSON, Boolean +from open_webui.utils.access_control import get_permissions + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -82,7 +84,7 @@ class FolderTable: else: return None except Exception as e: - print(e) + log.exception(f"Error inserting a new folder: {e}") return None def get_folder_by_id_and_user_id( @@ -234,15 +236,18 @@ class FolderTable: log.error(f"update_folder: {e}") return - def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> bool: + def delete_folder_by_id_and_user_id( + self, id: str, user_id: str, delete_chats=True + ) -> bool: try: with get_db() as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: return False - # Delete all chats in the folder - Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) + if delete_chats: + # Delete all chats in the folder + Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id) # Delete all children folders def delete_children(folder): @@ -250,9 +255,11 @@ class FolderTable: folder.id, user_id ) for folder_child in folder_children: - Chats.delete_chats_by_user_id_and_folder_id( - user_id, folder_child.id - ) + if delete_chats: + Chats.delete_chats_by_user_id_and_folder_id( + user_id, folder_child.id + ) + delete_children(folder_child) folder = db.query(Folder).filter_by(id=folder_child.id).first() diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 6c6aed8623..e98771fa02 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -105,9 +105,57 @@ class FunctionsTable: else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error creating a new function: {e}") return None + def sync_functions( + self, user_id: str, functions: list[FunctionModel] + ) -> list[FunctionModel]: + # 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: + # Get existing functions + existing_functions = db.query(Function).all() + existing_ids = {func.id for func in existing_functions} + + # Prepare a set of new function IDs + new_function_ids = {func.id for func in functions} + + # Update or insert functions + for func in functions: + if func.id in existing_ids: + db.query(Function).filter_by(id=func.id).update( + { + **func.model_dump(), + "user_id": user_id, + "updated_at": int(time.time()), + } + ) + else: + new_func = Function( + **{ + **func.model_dump(), + "user_id": user_id, + "updated_at": int(time.time()), + } + ) + db.add(new_func) + + # Remove functions that are no longer present + for func in existing_functions: + if func.id not in new_function_ids: + db.delete(func) + + db.commit() + + return [ + FunctionModel.model_validate(func) + for func in db.query(Function).all() + ] + except Exception as e: + log.exception(f"Error syncing functions for user {user_id}: {e}") + return [] + def get_function_by_id(self, id: str) -> Optional[FunctionModel]: try: with get_db() as db: @@ -170,7 +218,7 @@ class FunctionsTable: function = db.get(Function, id) return function.valves if function.valves else {} except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Error getting function valves by id {id}: {e}") return None def update_function_valves_by_id( @@ -202,7 +250,9 @@ class FunctionsTable: return user_settings["functions"]["valves"].get(id, {}) except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error getting user values by id {id} and user id {user_id}: {e}" + ) return None def update_user_valves_by_id_and_user_id( @@ -225,7 +275,9 @@ class FunctionsTable: return user_settings["functions"]["valves"][id] except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error updating user valves by id {id} and user_id {user_id}: {e}" + ) return None def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]: diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 763340fbcb..df79284cfa 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -207,5 +207,43 @@ class GroupTable: except Exception: return False + def sync_user_groups_by_group_names( + self, user_id: str, group_names: list[str] + ) -> bool: + with get_db() as db: + try: + groups = db.query(Group).filter(Group.name.in_(group_names)).all() + group_ids = [group.id for group in groups] + + # Remove user from groups not in the new list + existing_groups = self.get_groups_by_member_id(user_id) + + for group in existing_groups: + if group.id not in group_ids: + group.user_ids.remove(user_id) + db.query(Group).filter_by(id=group.id).update( + { + "user_ids": group.user_ids, + "updated_at": int(time.time()), + } + ) + + # Add user to new groups + for group in groups: + if user_id not in group.user_ids: + group.user_ids.append(user_id) + db.query(Group).filter_by(id=group.id).update( + { + "user_ids": group.user_ids, + "updated_at": int(time.time()), + } + ) + + db.commit() + return True + except Exception as e: + log.exception(e) + return False + Groups = GroupTable() diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index c8dae97267..8b10a77cf9 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -63,14 +63,15 @@ class MemoriesTable: else: return None - def update_memory_by_id( + def update_memory_by_id_and_user_id( self, id: str, + user_id: str, content: str, ) -> Optional[MemoryModel]: with get_db() as db: try: - db.query(Memory).filter_by(id=id).update( + db.query(Memory).filter_by(id=id, user_id=user_id).update( {"content": content, "updated_at": int(time.time())} ) db.commit() diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py old mode 100644 new mode 100755 index f2f59d7c49..7df8d8656b --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -166,7 +166,7 @@ class ModelsTable: else: return None except Exception as e: - print(e) + log.exception(f"Failed to insert a new model: {e}") return None def get_all_models(self) -> list[ModelModel]: @@ -246,8 +246,7 @@ class ModelsTable: db.refresh(model) return ModelModel.model_validate(model) except Exception as e: - print(e) - + log.exception(f"Failed to update the model by id {id}: {e}") return None def delete_model_by_id(self, id: str) -> bool: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py new file mode 100644 index 0000000000..114ccdc574 --- /dev/null +++ b/backend/open_webui/models/notes.py @@ -0,0 +1,135 @@ +import json +import time +import uuid +from typing import Optional + +from open_webui.internal.db import Base, get_db +from open_webui.utils.access_control import has_access +from open_webui.models.users import Users, UserResponse + + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON +from sqlalchemy import or_, func, select, and_, text +from sqlalchemy.sql import exists + +#################### +# Note DB Schema +#################### + + +class Note(Base): + __tablename__ = "note" + + id = Column(Text, primary_key=True) + user_id = Column(Text) + + title = Column(Text) + data = Column(JSON, nullable=True) + meta = Column(JSON, nullable=True) + + access_control = Column(JSON, nullable=True) + + created_at = Column(BigInteger) + updated_at = Column(BigInteger) + + +class NoteModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + + title: str + data: Optional[dict] = None + meta: Optional[dict] = None + + access_control: Optional[dict] = None + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + +#################### +# Forms +#################### + + +class NoteForm(BaseModel): + title: str + data: Optional[dict] = None + meta: Optional[dict] = None + access_control: Optional[dict] = None + + +class NoteUserResponse(NoteModel): + user: Optional[UserResponse] = None + + +class NoteTable: + def insert_new_note( + self, + form_data: NoteForm, + user_id: str, + ) -> Optional[NoteModel]: + with get_db() as db: + note = NoteModel( + **{ + "id": str(uuid.uuid4()), + "user_id": user_id, + **form_data.model_dump(), + "created_at": int(time.time_ns()), + "updated_at": int(time.time_ns()), + } + ) + + new_note = Note(**note.model_dump()) + + db.add(new_note) + db.commit() + return note + + def get_notes(self) -> list[NoteModel]: + with get_db() as db: + notes = db.query(Note).order_by(Note.updated_at.desc()).all() + return [NoteModel.model_validate(note) for note in notes] + + def get_notes_by_user_id( + self, user_id: str, permission: str = "write" + ) -> list[NoteModel]: + notes = self.get_notes() + return [ + note + for note in notes + if note.user_id == user_id + or has_access(user_id, permission, note.access_control) + ] + + def get_note_by_id(self, id: str) -> Optional[NoteModel]: + with get_db() as db: + note = db.query(Note).filter(Note.id == id).first() + return NoteModel.model_validate(note) if note else None + + def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]: + with get_db() as db: + note = db.query(Note).filter(Note.id == id).first() + if not note: + return None + + note.title = form_data.title + note.data = form_data.data + note.meta = form_data.meta + note.access_control = form_data.access_control + note.updated_at = int(time.time_ns()) + + db.commit() + return NoteModel.model_validate(note) if note else None + + def delete_note_by_id(self, id: str): + with get_db() as db: + db.query(Note).filter(Note.id == id).delete() + db.commit() + return True + + +Notes = NoteTable() diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 3e812db95d..279dc624d5 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -61,7 +61,7 @@ class TagTable: else: return None except Exception as e: - print(e) + log.exception(f"Error inserting a new tag: {e}") return None def get_tag_by_name_and_user_id( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index a5f13ebb71..68a83ea42c 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -131,7 +131,7 @@ class ToolsTable: else: return None except Exception as e: - print(f"Error creating tool: {e}") + log.exception(f"Error creating a new tool: {e}") return None def get_tool_by_id(self, id: str) -> Optional[ToolModel]: @@ -175,7 +175,7 @@ class ToolsTable: tool = db.get(Tool, id) return tool.valves if tool.valves else {} except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Error getting tool valves by id {id}: {e}") return None def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]: @@ -204,7 +204,9 @@ class ToolsTable: return user_settings["tools"]["valves"].get(id, {}) except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error getting user values by id {id} and user_id {user_id}: {e}" + ) return None def update_user_valves_by_id_and_user_id( @@ -227,7 +229,9 @@ class ToolsTable: return user_settings["tools"]["valves"][id] except Exception as e: - print(f"An error occurred: {e}") + log.exception( + f"Error updating user valves by id {id} and user_id {user_id}: {e}" + ) return None def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 605299528d..3222aa27a6 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -10,6 +10,8 @@ from open_webui.models.groups import Groups from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text +from sqlalchemy import or_ + #################### # User DB Schema @@ -67,6 +69,11 @@ class UserModel(BaseModel): #################### +class UserListResponse(BaseModel): + users: list[UserModel] + total: int + + class UserResponse(BaseModel): id: str name: str @@ -160,11 +167,63 @@ class UsersTable: return None def get_users( - self, skip: Optional[int] = None, limit: Optional[int] = None - ) -> list[UserModel]: + self, + filter: Optional[dict] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + ) -> UserListResponse: with get_db() as db: + query = db.query(User) - query = db.query(User).order_by(User.created_at.desc()) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + User.name.ilike(f"%{query_key}%"), + User.email.ilike(f"%{query_key}%"), + ) + ) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(User.name.asc()) + else: + query = query.order_by(User.name.desc()) + elif order_by == "email": + if direction == "asc": + query = query.order_by(User.email.asc()) + else: + query = query.order_by(User.email.desc()) + + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(User.created_at.asc()) + else: + query = query.order_by(User.created_at.desc()) + + elif order_by == "last_active_at": + if direction == "asc": + query = query.order_by(User.last_active_at.asc()) + else: + query = query.order_by(User.last_active_at.desc()) + + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(User.updated_at.asc()) + else: + query = query.order_by(User.updated_at.desc()) + elif order_by == "role": + if direction == "asc": + query = query.order_by(User.role.asc()) + else: + query = query.order_by(User.role.desc()) + + else: + query = query.order_by(User.created_at.desc()) if skip: query = query.offset(skip) @@ -172,8 +231,10 @@ class UsersTable: query = query.limit(limit) users = query.all() - - return [UserModel.model_validate(user) for user in users] + return { + "users": [UserModel.model_validate(user) for user in users], + "total": db.query(User).count(), + } def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]: with get_db() as db: @@ -330,5 +391,13 @@ class UsersTable: users = db.query(User).filter(User.id.in_(user_ids)).all() return [user.id for user in users] + def get_super_admin_user(self) -> Optional[UserModel]: + with get_db() as db: + user = db.query(User).filter_by(role="admin").first() + if user: + return UserModel.model_validate(user) + else: + return None + Users = UsersTable() diff --git a/backend/open_webui/retrieval/loaders/datalab_marker.py b/backend/open_webui/retrieval/loaders/datalab_marker.py new file mode 100644 index 0000000000..104c2830df --- /dev/null +++ b/backend/open_webui/retrieval/loaders/datalab_marker.py @@ -0,0 +1,251 @@ +import os +import time +import requests +import logging +import json +from typing import List, Optional +from langchain_core.documents import Document +from fastapi import HTTPException, status + +log = logging.getLogger(__name__) + + +class DatalabMarkerLoader: + def __init__( + self, + file_path: str, + api_key: str, + langs: Optional[str] = None, + use_llm: bool = False, + skip_cache: bool = False, + force_ocr: bool = False, + paginate: bool = False, + strip_existing_ocr: bool = False, + disable_image_extraction: bool = False, + output_format: str = None, + ): + self.file_path = file_path + self.api_key = api_key + self.langs = langs + self.use_llm = use_llm + self.skip_cache = skip_cache + self.force_ocr = force_ocr + self.paginate = paginate + self.strip_existing_ocr = strip_existing_ocr + self.disable_image_extraction = disable_image_extraction + self.output_format = output_format + + def _get_mime_type(self, filename: str) -> str: + ext = filename.rsplit(".", 1)[-1].lower() + mime_map = { + "pdf": "application/pdf", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "odt": "application/vnd.oasis.opendocument.text", + "ppt": "application/vnd.ms-powerpoint", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "odp": "application/vnd.oasis.opendocument.presentation", + "html": "text/html", + "epub": "application/epub+zip", + "png": "image/png", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "webp": "image/webp", + "gif": "image/gif", + "tiff": "image/tiff", + } + return mime_map.get(ext, "application/octet-stream") + + def check_marker_request_status(self, request_id: str) -> dict: + url = f"https://www.datalab.to/api/v1/marker/{request_id}" + headers = {"X-Api-Key": self.api_key} + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + result = response.json() + log.info(f"Marker API status check for request {request_id}: {result}") + return result + except requests.HTTPError as e: + log.error(f"Error checking Marker request status: {e}") + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to check Marker request: {e}", + ) + except ValueError as e: + log.error(f"Invalid JSON checking Marker request: {e}") + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON: {e}" + ) + + def load(self) -> List[Document]: + url = "https://www.datalab.to/api/v1/marker" + filename = os.path.basename(self.file_path) + mime_type = self._get_mime_type(filename) + headers = {"X-Api-Key": self.api_key} + + form_data = { + "langs": self.langs, + "use_llm": str(self.use_llm).lower(), + "skip_cache": str(self.skip_cache).lower(), + "force_ocr": str(self.force_ocr).lower(), + "paginate": str(self.paginate).lower(), + "strip_existing_ocr": str(self.strip_existing_ocr).lower(), + "disable_image_extraction": str(self.disable_image_extraction).lower(), + "output_format": self.output_format, + } + + log.info( + f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}" + ) + + try: + with open(self.file_path, "rb") as f: + files = {"file": (filename, f, mime_type)} + response = requests.post( + url, data=form_data, files=files, headers=headers + ) + response.raise_for_status() + result = response.json() + except FileNotFoundError: + raise HTTPException( + status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}" + ) + except requests.HTTPError as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Datalab Marker request failed: {e}", + ) + except ValueError as e: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON response: {e}" + ) + except Exception as e: + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + if not result.get("success"): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Datalab Marker request failed: {result.get('error', 'Unknown error')}", + ) + + check_url = result.get("request_check_url") + request_id = result.get("request_id") + if not check_url: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, detail="No request_check_url returned." + ) + + for _ in range(300): # Up to 10 minutes + time.sleep(2) + try: + poll_response = requests.get(check_url, headers=headers) + poll_response.raise_for_status() + poll_result = poll_response.json() + except (requests.HTTPError, ValueError) as e: + raw_body = poll_response.text + log.error(f"Polling error: {e}, response body: {raw_body}") + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}" + ) + + status_val = poll_result.get("status") + success_val = poll_result.get("success") + + if status_val == "complete": + summary = { + k: poll_result.get(k) + for k in ( + "status", + "output_format", + "success", + "error", + "page_count", + "total_cost", + ) + } + log.info( + f"Marker processing completed successfully: {json.dumps(summary, indent=2)}" + ) + break + + if status_val == "failed" or success_val is False: + log.error( + f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}" + ) + error_msg = ( + poll_result.get("error") + or "Marker returned failure without error message" + ) + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Marker processing failed: {error_msg}", + ) + else: + raise HTTPException( + status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out" + ) + + if not poll_result.get("success", False): + error_msg = poll_result.get("error") or "Unknown processing error" + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Final processing failed: {error_msg}", + ) + + content_key = self.output_format.lower() + raw_content = poll_result.get(content_key) + + if content_key == "json": + full_text = json.dumps(raw_content, indent=2) + elif content_key in {"markdown", "html"}: + full_text = str(raw_content).strip() + else: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported output format: {self.output_format}", + ) + + if not full_text: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="Datalab Marker returned empty content", + ) + + marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output") + os.makedirs(marker_output_dir, exist_ok=True) + + file_ext_map = {"markdown": "md", "json": "json", "html": "html"} + file_ext = file_ext_map.get(content_key, "txt") + output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}" + output_path = os.path.join(marker_output_dir, output_filename) + + try: + with open(output_path, "w", encoding="utf-8") as f: + f.write(full_text) + log.info(f"Saved Marker output to: {output_path}") + except Exception as e: + log.warning(f"Failed to write marker output to disk: {e}") + + metadata = { + "source": filename, + "output_format": poll_result.get("output_format", self.output_format), + "page_count": poll_result.get("page_count", 0), + "processed_with_llm": self.use_llm, + "request_id": request_id or "", + } + + images = poll_result.get("images", {}) + if images: + metadata["image_count"] = len(images) + metadata["images"] = json.dumps(list(images.keys())) + + for k, v in metadata.items(): + if isinstance(v, (dict, list)): + metadata[k] = json.dumps(v) + elif v is None: + metadata[k] = "" + + return [Document(page_content=full_text, metadata=metadata)] diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py new file mode 100644 index 0000000000..6119da3791 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -0,0 +1,58 @@ +import requests +import logging +from typing import Iterator, List, Union + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ExternalDocumentLoader(BaseLoader): + def __init__( + self, + file_path, + url: str, + api_key: str, + mime_type=None, + **kwargs, + ) -> None: + self.url = url + self.api_key = api_key + + self.file_path = file_path + self.mime_type = mime_type + + def load(self) -> list[Document]: + with open(self.file_path, "rb") as f: + data = f.read() + + headers = {} + if self.mime_type is not None: + headers["Content-Type"] = self.mime_type + + if self.api_key is not None: + headers["Authorization"] = f"Bearer {self.api_key}" + + url = self.url + if url.endswith("/"): + url = url[:-1] + + r = requests.put(f"{url}/process", data=data, headers=headers) + + if r.ok: + res = r.json() + + if res: + return [ + Document( + page_content=res.get("page_content"), + metadata=res.get("metadata"), + ) + ] + else: + raise Exception("Error loading document: No content returned") + else: + raise Exception(f"Error loading document: {r.status_code} {r.text}") diff --git a/backend/open_webui/retrieval/loaders/external_web.py b/backend/open_webui/retrieval/loaders/external_web.py new file mode 100644 index 0000000000..68ed66162b --- /dev/null +++ b/backend/open_webui/retrieval/loaders/external_web.py @@ -0,0 +1,53 @@ +import requests +import logging +from typing import Iterator, List, Union + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ExternalWebLoader(BaseLoader): + def __init__( + self, + web_paths: Union[str, List[str]], + external_url: str, + external_api_key: str, + continue_on_failure: bool = True, + **kwargs, + ) -> None: + self.external_url = external_url + self.external_api_key = external_api_key + self.urls = web_paths if isinstance(web_paths, list) else [web_paths] + self.continue_on_failure = continue_on_failure + + def lazy_load(self) -> Iterator[Document]: + batch_size = 20 + for i in range(0, len(self.urls), batch_size): + urls = self.urls[i : i + batch_size] + try: + response = requests.post( + self.external_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader", + "Authorization": f"Bearer {self.external_api_key}", + }, + json={ + "urls": urls, + }, + ) + response.raise_for_status() + results = response.json() + for result in results: + yield Document( + page_content=result.get("page_content", ""), + metadata=result.get("metadata", {}), + ) + except Exception as e: + if self.continue_on_failure: + log.error(f"Error extracting content from batch {urls}: {e}") + else: + raise e diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index a9372f65a6..0d0ff851b7 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -4,6 +4,7 @@ import ftfy import sys from langchain_community.document_loaders import ( + AzureAIDocumentIntelligenceLoader, BSHTMLLoader, CSVLoader, Docx2txtLoader, @@ -19,6 +20,13 @@ from langchain_community.document_loaders import ( YoutubeLoader, ) from langchain_core.documents import Document + +from open_webui.retrieval.loaders.external_document import ExternalDocumentLoader + +from open_webui.retrieval.loaders.mistral import MistralLoader +from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader + + from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -76,15 +84,18 @@ known_source_ext = [ "jsx", "hs", "lhs", + "json", ] class TikaLoader: - def __init__(self, url, file_path, mime_type=None): + def __init__(self, url, file_path, mime_type=None, extract_images=None): self.url = url self.file_path = file_path self.mime_type = mime_type + self.extract_images = extract_images + def load(self) -> list[Document]: with open(self.file_path, "rb") as f: data = f.read() @@ -94,6 +105,9 @@ class TikaLoader: else: headers = {} + if self.extract_images == True: + headers["X-Tika-PDFextractInlineImages"] = "true" + endpoint = self.url if not endpoint.endswith("/"): endpoint += "/" @@ -103,7 +117,7 @@ class TikaLoader: if r.ok: raw_metadata = r.json() - text = raw_metadata.get("X-TIKA:content", "") + text = raw_metadata.get("X-TIKA:content", "").strip() if "Content-Type" in raw_metadata: headers["Content-Type"] = raw_metadata["Content-Type"] @@ -115,6 +129,68 @@ class TikaLoader: raise Exception(f"Error calling Tika: {r.reason}") +class DoclingLoader: + def __init__(self, url, file_path=None, mime_type=None, params=None): + self.url = url.rstrip("/") + self.file_path = file_path + self.mime_type = mime_type + + self.params = params or {} + + def load(self) -> list[Document]: + with open(self.file_path, "rb") as f: + files = { + "files": ( + self.file_path, + f, + self.mime_type or "application/octet-stream", + ) + } + + params = { + "image_export_mode": "placeholder", + "table_mode": "accurate", + } + + if self.params: + if self.params.get("do_picture_classification"): + params["do_picture_classification"] = self.params.get( + "do_picture_classification" + ) + + if self.params.get("ocr_engine") and self.params.get("ocr_lang"): + params["ocr_engine"] = self.params.get("ocr_engine") + params["ocr_lang"] = [ + lang.strip() + for lang in self.params.get("ocr_lang").split(",") + if lang.strip() + ] + + endpoint = f"{self.url}/v1alpha/convert/file" + r = requests.post(endpoint, files=files, data=params) + + if r.ok: + result = r.json() + document_data = result.get("document", {}) + text = document_data.get("md_content", "") + + metadata = {"Content-Type": self.mime_type} if self.mime_type else {} + + log.debug("Docling extracted text: %s", text) + + return [Document(page_content=text, metadata=metadata)] + else: + error_msg = f"Error calling Docling API: {r.reason}" + if r.text: + try: + error_data = r.json() + if "detail" in error_data: + error_msg += f" - {error_data['detail']}" + except Exception: + error_msg += f" - {r.text}" + raise Exception(f"Error calling Docling: {error_msg}") + + class Loader: def __init__(self, engine: str = "", **kwargs): self.engine = engine @@ -133,27 +209,140 @@ class Loader: for doc in docs ] + def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: + return file_ext in known_source_ext or ( + file_content_type and file_content_type.find("text/") >= 0 + ) + def _get_loader(self, filename: str, file_content_type: str, file_path: str): file_ext = filename.split(".")[-1].lower() - if self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): - if file_ext in known_source_ext or ( - file_content_type and file_content_type.find("text/") >= 0 - ): + if ( + self.engine == "external" + and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL") + and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY") + ): + loader = ExternalDocumentLoader( + file_path=file_path, + url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"), + api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"), + mime_type=file_content_type, + ) + elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): + if self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: loader = TikaLoader( url=self.kwargs.get("TIKA_SERVER_URL"), file_path=file_path, mime_type=file_content_type, + extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"), ) + elif ( + self.engine == "datalab_marker" + and self.kwargs.get("DATALAB_MARKER_API_KEY") + and file_ext + in [ + "pdf", + "xls", + "xlsx", + "ods", + "doc", + "docx", + "odt", + "ppt", + "pptx", + "odp", + "html", + "epub", + "png", + "jpeg", + "jpg", + "webp", + "gif", + "tiff", + ] + ): + loader = DatalabMarkerLoader( + file_path=file_path, + api_key=self.kwargs["DATALAB_MARKER_API_KEY"], + langs=self.kwargs.get("DATALAB_MARKER_LANGS"), + use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False), + skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False), + force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False), + paginate=self.kwargs.get("DATALAB_MARKER_PAGINATE", False), + strip_existing_ocr=self.kwargs.get( + "DATALAB_MARKER_STRIP_EXISTING_OCR", False + ), + disable_image_extraction=self.kwargs.get( + "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False + ), + output_format=self.kwargs.get( + "DATALAB_MARKER_OUTPUT_FORMAT", "markdown" + ), + ) + elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"): + if self._is_text_file(file_ext, file_content_type): + loader = TextLoader(file_path, autodetect_encoding=True) + else: + loader = DoclingLoader( + url=self.kwargs.get("DOCLING_SERVER_URL"), + file_path=file_path, + mime_type=file_content_type, + params={ + "ocr_engine": self.kwargs.get("DOCLING_OCR_ENGINE"), + "ocr_lang": self.kwargs.get("DOCLING_OCR_LANG"), + "do_picture_classification": self.kwargs.get( + "DOCLING_DO_PICTURE_DESCRIPTION" + ), + }, + ) + elif ( + self.engine == "document_intelligence" + and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != "" + and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "" + and ( + file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"] + or file_content_type + in [ + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ] + ) + ): + loader = AzureAIDocumentIntelligenceLoader( + file_path=file_path, + api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), + api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"), + ) + elif ( + self.engine == "mistral_ocr" + and self.kwargs.get("MISTRAL_OCR_API_KEY") != "" + and file_ext + in ["pdf"] # Mistral OCR currently only supports PDF and images + ): + loader = MistralLoader( + api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path + ) + elif ( + self.engine == "external" + and self.kwargs.get("MISTRAL_OCR_API_KEY") != "" + and file_ext + in ["pdf"] # Mistral OCR currently only supports PDF and images + ): + loader = MistralLoader( + api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path + ) else: if file_ext == "pdf": loader = PyPDFLoader( file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES") ) elif file_ext == "csv": - loader = CSVLoader(file_path) + loader = CSVLoader(file_path, autodetect_encoding=True) elif file_ext == "rst": loader = UnstructuredRSTLoader(file_path, mode="elements") elif file_ext == "xml": @@ -182,9 +371,7 @@ class Loader: loader = UnstructuredPowerPointLoader(file_path) elif file_ext == "msg": loader = OutlookMessageLoader(file_path) - elif file_ext in known_source_ext or ( - file_content_type and file_content_type.find("text/") >= 0 - ): + elif self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: loader = TextLoader(file_path, autodetect_encoding=True) diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py new file mode 100644 index 0000000000..67641d0509 --- /dev/null +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -0,0 +1,633 @@ +import requests +import aiohttp +import asyncio +import logging +import os +import sys +import time +from typing import List, Dict, Any +from contextlib import asynccontextmanager + +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL + +logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class MistralLoader: + """ + Enhanced Mistral OCR loader with both sync and async support. + Loads documents by processing them through the Mistral OCR API. + """ + + BASE_API_URL = "https://api.mistral.ai/v1" + + def __init__( + self, + api_key: str, + file_path: str, + timeout: int = 300, # 5 minutes default + max_retries: int = 3, + enable_debug_logging: bool = False, + ): + """ + Initializes the loader with enhanced features. + + Args: + api_key: Your Mistral API key. + file_path: The local path to the PDF file to process. + timeout: Request timeout in seconds. + max_retries: Maximum number of retry attempts. + enable_debug_logging: Enable detailed debug logs. + """ + if not api_key: + raise ValueError("API key cannot be empty.") + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found at {file_path}") + + self.api_key = api_key + self.file_path = file_path + self.timeout = timeout + self.max_retries = max_retries + self.debug = enable_debug_logging + + # Pre-compute file info for performance + self.file_name = os.path.basename(file_path) + self.file_size = os.path.getsize(file_path) + + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "User-Agent": "OpenWebUI-MistralLoader/2.0", + } + + def _debug_log(self, message: str, *args) -> None: + """Conditional debug logging for performance.""" + if self.debug: + log.debug(message, *args) + + def _handle_response(self, response: requests.Response) -> Dict[str, Any]: + """Checks response status and returns JSON content.""" + try: + response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) + # Handle potential empty responses for certain successful requests (e.g., DELETE) + if response.status_code == 204 or not response.content: + return {} # Return empty dict if no content + return response.json() + except requests.exceptions.HTTPError as http_err: + log.error(f"HTTP error occurred: {http_err} - Response: {response.text}") + raise + except requests.exceptions.RequestException as req_err: + log.error(f"Request exception occurred: {req_err}") + raise + except ValueError as json_err: # Includes JSONDecodeError + log.error(f"JSON decode error: {json_err} - Response: {response.text}") + raise # Re-raise after logging + + async def _handle_response_async( + self, response: aiohttp.ClientResponse + ) -> Dict[str, Any]: + """Async version of response handling with better error info.""" + try: + response.raise_for_status() + + # Check content type + content_type = response.headers.get("content-type", "") + if "application/json" not in content_type: + if response.status == 204: + return {} + text = await response.text() + raise ValueError( + f"Unexpected content type: {content_type}, body: {text[:200]}..." + ) + + return await response.json() + + except aiohttp.ClientResponseError as e: + error_text = await response.text() if response else "No response" + log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}") + raise + except aiohttp.ClientError as e: + log.error(f"Client error: {e}") + raise + except Exception as e: + log.error(f"Unexpected error processing response: {e}") + raise + + def _retry_request_sync(self, request_func, *args, **kwargs): + """Synchronous retry logic with exponential backoff.""" + for attempt in range(self.max_retries): + try: + return request_func(*args, **kwargs) + except (requests.exceptions.RequestException, Exception) as e: + if attempt == self.max_retries - 1: + raise + + wait_time = (2**attempt) + 0.5 + log.warning( + f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..." + ) + time.sleep(wait_time) + + async def _retry_request_async(self, request_func, *args, **kwargs): + """Async retry logic with exponential backoff.""" + for attempt in range(self.max_retries): + try: + return await request_func(*args, **kwargs) + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + if attempt == self.max_retries - 1: + raise + + wait_time = (2**attempt) + 0.5 + log.warning( + f"Request failed (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s..." + ) + await asyncio.sleep(wait_time) + + def _upload_file(self) -> str: + """Uploads the file to Mistral for OCR processing (sync version).""" + log.info("Uploading file to Mistral API") + url = f"{self.BASE_API_URL}/files" + file_name = os.path.basename(self.file_path) + + def upload_request(): + with open(self.file_path, "rb") as f: + files = {"file": (file_name, f, "application/pdf")} + data = {"purpose": "ocr"} + + response = requests.post( + url, + headers=self.headers, + files=files, + data=data, + timeout=self.timeout, + ) + + return self._handle_response(response) + + try: + response_data = self._retry_request_sync(upload_request) + file_id = response_data.get("id") + if not file_id: + raise ValueError("File ID not found in upload response.") + log.info(f"File uploaded successfully. File ID: {file_id}") + return file_id + except Exception as e: + log.error(f"Failed to upload file: {e}") + raise + + async def _upload_file_async(self, session: aiohttp.ClientSession) -> str: + """Async file upload with streaming for better memory efficiency.""" + url = f"{self.BASE_API_URL}/files" + + async def upload_request(): + # Create multipart writer for streaming upload + writer = aiohttp.MultipartWriter("form-data") + + # Add purpose field + purpose_part = writer.append("ocr") + purpose_part.set_content_disposition("form-data", name="purpose") + + # Add file part with streaming + file_part = writer.append_payload( + aiohttp.streams.FilePayload( + self.file_path, + filename=self.file_name, + content_type="application/pdf", + ) + ) + file_part.set_content_disposition( + "form-data", name="file", filename=self.file_name + ) + + self._debug_log( + f"Uploading file: {self.file_name} ({self.file_size:,} bytes)" + ) + + async with session.post( + url, + data=writer, + headers=self.headers, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) as response: + return await self._handle_response_async(response) + + response_data = await self._retry_request_async(upload_request) + + file_id = response_data.get("id") + if not file_id: + raise ValueError("File ID not found in upload response.") + + log.info(f"File uploaded successfully. File ID: {file_id}") + return file_id + + def _get_signed_url(self, file_id: str) -> str: + """Retrieves a temporary signed URL for the uploaded file (sync version).""" + log.info(f"Getting signed URL for file ID: {file_id}") + url = f"{self.BASE_API_URL}/files/{file_id}/url" + params = {"expiry": 1} + signed_url_headers = {**self.headers, "Accept": "application/json"} + + def url_request(): + response = requests.get( + url, headers=signed_url_headers, params=params, timeout=self.timeout + ) + return self._handle_response(response) + + try: + response_data = self._retry_request_sync(url_request) + signed_url = response_data.get("url") + if not signed_url: + raise ValueError("Signed URL not found in response.") + log.info("Signed URL received.") + return signed_url + except Exception as e: + log.error(f"Failed to get signed URL: {e}") + raise + + async def _get_signed_url_async( + self, session: aiohttp.ClientSession, file_id: str + ) -> str: + """Async signed URL retrieval.""" + url = f"{self.BASE_API_URL}/files/{file_id}/url" + params = {"expiry": 1} + + headers = {**self.headers, "Accept": "application/json"} + + async def url_request(): + self._debug_log(f"Getting signed URL for file ID: {file_id}") + async with session.get( + url, + headers=headers, + params=params, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) as response: + return await self._handle_response_async(response) + + response_data = await self._retry_request_async(url_request) + + signed_url = response_data.get("url") + if not signed_url: + raise ValueError("Signed URL not found in response.") + + self._debug_log("Signed URL received successfully") + return signed_url + + def _process_ocr(self, signed_url: str) -> Dict[str, Any]: + """Sends the signed URL to the OCR endpoint for processing (sync version).""" + log.info("Processing OCR via Mistral API") + url = f"{self.BASE_API_URL}/ocr" + ocr_headers = { + **self.headers, + "Content-Type": "application/json", + "Accept": "application/json", + } + payload = { + "model": "mistral-ocr-latest", + "document": { + "type": "document_url", + "document_url": signed_url, + }, + "include_image_base64": False, + } + + def ocr_request(): + response = requests.post( + url, headers=ocr_headers, json=payload, timeout=self.timeout + ) + return self._handle_response(response) + + try: + ocr_response = self._retry_request_sync(ocr_request) + log.info("OCR processing done.") + self._debug_log("OCR response: %s", ocr_response) + return ocr_response + except Exception as e: + log.error(f"Failed during OCR processing: {e}") + raise + + async def _process_ocr_async( + self, session: aiohttp.ClientSession, signed_url: str + ) -> Dict[str, Any]: + """Async OCR processing with timing metrics.""" + url = f"{self.BASE_API_URL}/ocr" + + headers = { + **self.headers, + "Content-Type": "application/json", + "Accept": "application/json", + } + + payload = { + "model": "mistral-ocr-latest", + "document": { + "type": "document_url", + "document_url": signed_url, + }, + "include_image_base64": False, + } + + async def ocr_request(): + log.info("Starting OCR processing via Mistral API") + start_time = time.time() + + async with session.post( + url, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) as response: + ocr_response = await self._handle_response_async(response) + + processing_time = time.time() - start_time + log.info(f"OCR processing completed in {processing_time:.2f}s") + + return ocr_response + + return await self._retry_request_async(ocr_request) + + def _delete_file(self, file_id: str) -> None: + """Deletes the file from Mistral storage (sync version).""" + log.info(f"Deleting uploaded file ID: {file_id}") + url = f"{self.BASE_API_URL}/files/{file_id}" + + try: + response = requests.delete(url, headers=self.headers, timeout=30) + delete_response = self._handle_response(response) + log.info(f"File deleted successfully: {delete_response}") + except Exception as e: + # Log error but don't necessarily halt execution if deletion fails + log.error(f"Failed to delete file ID {file_id}: {e}") + + async def _delete_file_async( + self, session: aiohttp.ClientSession, file_id: str + ) -> None: + """Async file deletion with error tolerance.""" + try: + + async def delete_request(): + self._debug_log(f"Deleting file ID: {file_id}") + async with session.delete( + url=f"{self.BASE_API_URL}/files/{file_id}", + headers=self.headers, + timeout=aiohttp.ClientTimeout( + total=30 + ), # Shorter timeout for cleanup + ) as response: + return await self._handle_response_async(response) + + await self._retry_request_async(delete_request) + self._debug_log(f"File {file_id} deleted successfully") + + except Exception as e: + # Don't fail the entire process if cleanup fails + log.warning(f"Failed to delete file ID {file_id}: {e}") + + @asynccontextmanager + async def _get_session(self): + """Context manager for HTTP session with optimized settings.""" + connector = aiohttp.TCPConnector( + limit=10, # Total connection limit + limit_per_host=5, # Per-host connection limit + ttl_dns_cache=300, # DNS cache TTL + use_dns_cache=True, + keepalive_timeout=30, + enable_cleanup_closed=True, + ) + + async with aiohttp.ClientSession( + connector=connector, + timeout=aiohttp.ClientTimeout(total=self.timeout), + headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"}, + ) as session: + yield session + + def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: + """Process OCR results into Document objects with enhanced metadata.""" + pages_data = ocr_response.get("pages") + if not pages_data: + log.warning("No pages found in OCR response.") + return [ + Document( + page_content="No text content found", metadata={"error": "no_pages"} + ) + ] + + documents = [] + total_pages = len(pages_data) + skipped_pages = 0 + + for page_data in pages_data: + page_content = page_data.get("markdown") + page_index = page_data.get("index") # API uses 0-based index + + if page_content is not None and page_index is not None: + # Clean up content efficiently + cleaned_content = ( + page_content.strip() + if isinstance(page_content, str) + else str(page_content) + ) + + if cleaned_content: # Only add non-empty pages + documents.append( + Document( + page_content=cleaned_content, + metadata={ + "page": page_index, # 0-based index from API + "page_label": page_index + + 1, # 1-based label for convenience + "total_pages": total_pages, + "file_name": self.file_name, + "file_size": self.file_size, + "processing_engine": "mistral-ocr", + }, + ) + ) + else: + skipped_pages += 1 + self._debug_log(f"Skipping empty page {page_index}") + else: + skipped_pages += 1 + self._debug_log( + f"Skipping page due to missing 'markdown' or 'index'. Data: {page_data}" + ) + + if skipped_pages > 0: + log.info( + f"Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages" + ) + + if not documents: + # Case where pages existed but none had valid markdown/index + log.warning( + "OCR response contained pages, but none had valid content/index." + ) + return [ + Document( + page_content="No valid text content found in document", + metadata={"error": "no_valid_pages", "total_pages": total_pages}, + ) + ] + + return documents + + def load(self) -> List[Document]: + """ + Executes the full OCR workflow: upload, get URL, process OCR, delete file. + Synchronous version for backward compatibility. + + Returns: + A list of Document objects, one for each page processed. + """ + file_id = None + start_time = time.time() + + try: + # 1. Upload file + file_id = self._upload_file() + + # 2. Get Signed URL + signed_url = self._get_signed_url(file_id) + + # 3. Process OCR + ocr_response = self._process_ocr(signed_url) + + # 4. Process results + documents = self._process_results(ocr_response) + + total_time = time.time() - start_time + log.info( + f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents" + ) + + return documents + + except Exception as e: + total_time = time.time() - start_time + log.error( + f"An error occurred during the loading process after {total_time:.2f}s: {e}" + ) + # Return an error document on failure + return [ + Document( + page_content=f"Error during processing: {e}", + metadata={ + "error": "processing_failed", + "file_name": self.file_name, + }, + ) + ] + finally: + # 5. Delete file (attempt even if prior steps failed after upload) + if file_id: + try: + self._delete_file(file_id) + except Exception as del_e: + # Log deletion error, but don't overwrite original error if one occurred + log.error( + f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}" + ) + + async def load_async(self) -> List[Document]: + """ + Asynchronous OCR workflow execution with optimized performance. + + Returns: + A list of Document objects, one for each page processed. + """ + file_id = None + start_time = time.time() + + try: + async with self._get_session() as session: + # 1. Upload file with streaming + file_id = await self._upload_file_async(session) + + # 2. Get signed URL + signed_url = await self._get_signed_url_async(session, file_id) + + # 3. Process OCR + ocr_response = await self._process_ocr_async(session, signed_url) + + # 4. Process results + documents = self._process_results(ocr_response) + + total_time = time.time() - start_time + log.info( + f"Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents" + ) + + return documents + + except Exception as e: + total_time = time.time() - start_time + log.error(f"Async OCR workflow failed after {total_time:.2f}s: {e}") + return [ + Document( + page_content=f"Error during OCR processing: {e}", + metadata={ + "error": "processing_failed", + "file_name": self.file_name, + }, + ) + ] + finally: + # 5. Cleanup - always attempt file deletion + if file_id: + try: + async with self._get_session() as session: + await self._delete_file_async(session, file_id) + except Exception as cleanup_error: + log.error(f"Cleanup failed for file ID {file_id}: {cleanup_error}") + + @staticmethod + async def load_multiple_async( + loaders: List["MistralLoader"], + ) -> List[List[Document]]: + """ + Process multiple files concurrently for maximum performance. + + Args: + loaders: List of MistralLoader instances + + Returns: + List of document lists, one for each loader + """ + if not loaders: + return [] + + log.info(f"Starting concurrent processing of {len(loaders)} files") + start_time = time.time() + + # Process all files concurrently + tasks = [loader.load_async() for loader in loaders] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Handle any exceptions in results + processed_results = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + log.error(f"File {i} failed: {result}") + processed_results.append( + [ + Document( + page_content=f"Error processing file: {result}", + metadata={ + "error": "batch_processing_failed", + "file_index": i, + }, + ) + ] + ) + else: + processed_results.append(result) + + total_time = time.time() - start_time + total_docs = sum(len(docs) for docs in processed_results) + log.info( + f"Batch processing completed in {total_time:.2f}s, produced {total_docs} total documents" + ) + + return processed_results diff --git a/backend/open_webui/retrieval/loaders/tavily.py b/backend/open_webui/retrieval/loaders/tavily.py new file mode 100644 index 0000000000..15a3d7f97f --- /dev/null +++ b/backend/open_webui/retrieval/loaders/tavily.py @@ -0,0 +1,93 @@ +import requests +import logging +from typing import Iterator, List, Literal, Union + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class TavilyLoader(BaseLoader): + """Extract web page content from URLs using Tavily Extract API. + + This is a LangChain document loader that uses Tavily's Extract API to + retrieve content from web pages and return it as Document objects. + + Args: + urls: URL or list of URLs to extract content from. + api_key: The Tavily API key. + extract_depth: Depth of extraction, either "basic" or "advanced". + continue_on_failure: Whether to continue if extraction of a URL fails. + """ + + def __init__( + self, + urls: Union[str, List[str]], + api_key: str, + extract_depth: Literal["basic", "advanced"] = "basic", + continue_on_failure: bool = True, + ) -> None: + """Initialize Tavily Extract client. + + Args: + urls: URL or list of URLs to extract content from. + api_key: The Tavily API key. + include_images: Whether to include images in the extraction. + extract_depth: Depth of extraction, either "basic" or "advanced". + advanced extraction retrieves more data, including tables and + embedded content, with higher success but may increase latency. + basic costs 1 credit per 5 successful URL extractions, + advanced costs 2 credits per 5 successful URL extractions. + continue_on_failure: Whether to continue if extraction of a URL fails. + """ + if not urls: + raise ValueError("At least one URL must be provided.") + + self.api_key = api_key + self.urls = urls if isinstance(urls, list) else [urls] + self.extract_depth = extract_depth + self.continue_on_failure = continue_on_failure + self.api_url = "https://api.tavily.com/extract" + + def lazy_load(self) -> Iterator[Document]: + """Extract and yield documents from the URLs using Tavily Extract API.""" + batch_size = 20 + for i in range(0, len(self.urls), batch_size): + batch_urls = self.urls[i : i + batch_size] + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + # Use string for single URL, array for multiple URLs + urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls + payload = {"urls": urls_param, "extract_depth": self.extract_depth} + # Make the API call + response = requests.post(self.api_url, headers=headers, json=payload) + response.raise_for_status() + response_data = response.json() + # Process successful results + for result in response_data.get("results", []): + url = result.get("url", "") + content = result.get("raw_content", "") + if not content: + log.warning(f"No content extracted from {url}") + continue + # Add URLs as metadata + metadata = {"source": url} + yield Document( + page_content=content, + metadata=metadata, + ) + for failed in response_data.get("failed_results", []): + url = failed.get("url", "") + error = failed.get("error", "Unknown error") + log.error(f"Failed to extract content from {url}: {error}") + except Exception as e: + if self.continue_on_failure: + log.error(f"Error extracting content from batch {batch_urls}: {e}") + else: + raise e diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index 8eb48488b2..d908cc8cb5 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -62,12 +62,17 @@ class YoutubeLoader: _video_id = _parse_video_id(video_id) self.video_id = _video_id if _video_id is not None else video_id self._metadata = {"source": video_id} - self.language = language self.proxy_url = proxy_url + + # Ensure language is a list if isinstance(language, str): self.language = [language] else: - self.language = language + self.language = list(language) + + # Add English as fallback if not already in the list + if "en" not in self.language: + self.language.append("en") def load(self) -> List[Document]: """Load YouTube transcripts into `Document` objects.""" @@ -101,17 +106,31 @@ class YoutubeLoader: log.exception("Loading YouTube transcript failed") return [] - try: - transcript = transcript_list.find_transcript(self.language) - except NoTranscriptFound: - transcript = transcript_list.find_transcript(["en"]) + # Try each language in order of priority + for lang in self.language: + try: + transcript = transcript_list.find_transcript([lang]) + log.debug(f"Found transcript for language '{lang}'") + transcript_pieces: List[Dict[str, Any]] = transcript.fetch() + transcript_text = " ".join( + map( + lambda transcript_piece: transcript_piece.text.strip(" "), + transcript_pieces, + ) + ) + return [Document(page_content=transcript_text, metadata=self._metadata)] + except NoTranscriptFound: + log.debug(f"No transcript found for language '{lang}'") + continue + except Exception as e: + log.info(f"Error finding transcript for language '{lang}'") + raise e - transcript_pieces: List[Dict[str, Any]] = transcript.fetch() - - transcript = " ".join( - map( - lambda transcript_piece: transcript_piece["text"].strip(" "), - transcript_pieces, - ) + # If we get here, all languages failed + languages_tried = ", ".join(self.language) + log.warning( + f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed." + ) + raise NoTranscriptFound( + f"No transcript found for any supported language. Verify if the video has transcripts, add more languages if needed." ) - return [Document(page_content=transcript, metadata=self._metadata)] diff --git a/backend/open_webui/retrieval/models/base_reranker.py b/backend/open_webui/retrieval/models/base_reranker.py new file mode 100644 index 0000000000..6be7a5649b --- /dev/null +++ b/backend/open_webui/retrieval/models/base_reranker.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod +from typing import Optional, List, Tuple + + +class BaseReranker(ABC): + @abstractmethod + def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]: + pass diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index ea3204cb8b..7ec888437a 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -1,13 +1,21 @@ import os +import logging import torch import numpy as np from colbert.infra import ColBERTConfig from colbert.modeling.checkpoint import Checkpoint +from open_webui.env import SRC_LOG_LEVELS -class ColBERT: +from open_webui.retrieval.models.base_reranker import BaseReranker + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ColBERT(BaseReranker): def __init__(self, name, **kwargs) -> None: - print("ColBERT: Loading model", name) + log.info("ColBERT: Loading model", name) self.device = "cuda" if torch.cuda.is_available() else "cpu" DOCKER = kwargs.get("env") == "docker" diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py new file mode 100644 index 0000000000..5ebc3e52ea --- /dev/null +++ b/backend/open_webui/retrieval/models/external.py @@ -0,0 +1,60 @@ +import logging +import requests +from typing import Optional, List, Tuple + +from open_webui.env import SRC_LOG_LEVELS +from open_webui.retrieval.models.base_reranker import BaseReranker + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class ExternalReranker(BaseReranker): + def __init__( + self, + api_key: str, + url: str = "http://localhost:8080/v1/rerank", + model: str = "reranker", + ): + self.api_key = api_key + self.url = url + self.model = model + + def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]: + query = sentences[0][0] + docs = [i[1] for i in sentences] + + payload = { + "model": self.model, + "query": query, + "documents": docs, + "top_n": len(docs), + } + + try: + log.info(f"ExternalReranker:predict:model {self.model}") + log.info(f"ExternalReranker:predict:query {query}") + + r = requests.post( + f"{self.url}", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + }, + json=payload, + ) + + r.raise_for_status() + data = r.json() + + if "results" in data: + sorted_results = sorted(data["results"], key=lambda x: x["index"]) + return [result["relevance_score"] for result in sorted_results] + else: + log.error("No results found in external reranking response") + return None + + except Exception as e: + log.exception(f"Error in external reranking: {e}") + return None diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index b7da20ad5f..683f42819b 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1,27 +1,36 @@ import logging import os -import uuid from typing import Optional, Union -import asyncio import requests +import hashlib +from concurrent.futures import ThreadPoolExecutor +import time from huggingface_hub import snapshot_download from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain_community.retrievers import BM25Retriever from langchain_core.documents import Document - from open_webui.config import VECTOR_DB -from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT -from open_webui.utils.misc import get_last_user_message +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.retrieval.vector.main import GetResult + from open_webui.env import ( SRC_LOG_LEVELS, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, ) +from open_webui.config import ( + RAG_EMBEDDING_QUERY_PREFIX, + RAG_EMBEDDING_CONTENT_PREFIX, + RAG_EMBEDDING_PREFIX_FIELD_NAME, +) log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -46,7 +55,7 @@ class VectorSearchRetriever(BaseRetriever): ) -> list[Document]: result = VECTOR_DB_CLIENT.search( collection_name=self.collection_name, - vectors=[self.embedding_function(query)], + vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)], limit=self.top_k, ) @@ -69,6 +78,7 @@ def query_doc( collection_name: str, query_embedding: list[float], k: int, user: UserModel = None ): try: + log.debug(f"query_doc:doc {collection_name}") result = VECTOR_DB_CLIENT.search( collection_name=collection_name, vectors=[query_embedding], @@ -80,24 +90,40 @@ def query_doc( return result except Exception as e: - print(e) + log.exception(f"Error querying doc {collection_name} with limit {k}: {e}") + raise e + + +def get_doc(collection_name: str, user: UserModel = None): + try: + log.debug(f"get_doc:doc {collection_name}") + result = VECTOR_DB_CLIENT.get(collection_name=collection_name) + + if result: + log.info(f"query_doc:result {result.ids} {result.metadatas}") + + return result + except Exception as e: + log.exception(f"Error getting doc {collection_name}: {e}") raise e def query_doc_with_hybrid_search( collection_name: str, + collection_result: GetResult, query: str, embedding_function, k: int, reranking_function, + k_reranker: int, r: float, + hybrid_bm25_weight: float, ) -> dict: try: - result = VECTOR_DB_CLIENT.get(collection_name=collection_name) - + log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") bm25_retriever = BM25Retriever.from_texts( - texts=result.documents[0], - metadatas=result.metadatas[0], + texts=collection_result.documents[0], + metadatas=collection_result.metadatas[0], ) bm25_retriever.k = k @@ -107,12 +133,23 @@ def query_doc_with_hybrid_search( top_k=k, ) - ensemble_retriever = EnsembleRetriever( - retrievers=[bm25_retriever, vector_search_retriever], weights=[0.5, 0.5] - ) + if hybrid_bm25_weight <= 0: + ensemble_retriever = EnsembleRetriever( + retrievers=[vector_search_retriever], weights=[1.0] + ) + elif hybrid_bm25_weight >= 1: + ensemble_retriever = EnsembleRetriever( + retrievers=[bm25_retriever], weights=[1.0] + ) + else: + ensemble_retriever = EnsembleRetriever( + retrievers=[bm25_retriever, vector_search_retriever], + weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight], + ) + compressor = RerankCompressor( embedding_function=embedding_function, - top_n=k, + top_n=k_reranker, reranking_function=reranking_function, r_score=r, ) @@ -122,10 +159,23 @@ def query_doc_with_hybrid_search( ) result = compression_retriever.invoke(query) + + distances = [d.metadata.get("score") for d in result] + documents = [d.page_content for d in result] + metadatas = [d.metadata for d in result] + + # retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker + if k < k_reranker: + sorted_items = sorted( + zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True + ) + sorted_items = sorted_items[:k] + distances, documents, metadatas = map(list, zip(*sorted_items)) + result = { - "distances": [[d.metadata.get("score") for d in result]], - "documents": [[d.page_content for d in result]], - "metadatas": [[d.metadata for d in result]], + "distances": [distances], + "documents": [documents], + "metadatas": [metadatas], } log.info( @@ -134,52 +184,88 @@ def query_doc_with_hybrid_search( ) return result except Exception as e: + log.exception(f"Error querying doc {collection_name} with hybrid search: {e}") raise e -def merge_and_sort_query_results( - query_results: list[dict], k: int, reverse: bool = False -) -> list[dict]: +def merge_get_results(get_results: list[dict]) -> dict: # Initialize lists to store combined data - combined_distances = [] combined_documents = [] combined_metadatas = [] + combined_ids = [] - for data in query_results: - combined_distances.extend(data["distances"][0]) + for data in get_results: combined_documents.extend(data["documents"][0]) combined_metadatas.extend(data["metadatas"][0]) - - # Create a list of tuples (distance, document, metadata) - combined = list(zip(combined_distances, combined_documents, combined_metadatas)) - - # Sort the list based on distances - combined.sort(key=lambda x: x[0], reverse=reverse) - - # We don't have anything :-( - if not combined: - sorted_distances = [] - sorted_documents = [] - sorted_metadatas = [] - else: - # Unzip the sorted list - sorted_distances, sorted_documents, sorted_metadatas = zip(*combined) - - # Slicing the lists to include only k elements - sorted_distances = list(sorted_distances)[:k] - sorted_documents = list(sorted_documents)[:k] - sorted_metadatas = list(sorted_metadatas)[:k] + combined_ids.extend(data["ids"][0]) # Create the output dictionary result = { - "distances": [sorted_distances], - "documents": [sorted_documents], - "metadatas": [sorted_metadatas], + "documents": [combined_documents], + "metadatas": [combined_metadatas], + "ids": [combined_ids], } return result +def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: + # Initialize lists to store combined data + combined = dict() # To store documents with unique document hashes + + for data in query_results: + distances = data["distances"][0] + documents = data["documents"][0] + metadatas = data["metadatas"][0] + + for distance, document, metadata in zip(distances, documents, metadatas): + if isinstance(document, str): + doc_hash = hashlib.sha256( + document.encode() + ).hexdigest() # Compute a hash for uniqueness + + if doc_hash not in combined.keys(): + combined[doc_hash] = (distance, document, metadata) + continue # if doc is new, no further comparison is needed + + # if doc is alredy in, but new distance is better, update + if distance > combined[doc_hash][0]: + combined[doc_hash] = (distance, document, metadata) + + combined = list(combined.values()) + # Sort the list based on distances + combined.sort(key=lambda x: x[0], reverse=True) + + # Slice to keep only the top k elements + sorted_distances, sorted_documents, sorted_metadatas = ( + zip(*combined[:k]) if combined else ([], [], []) + ) + + # Create and return the output dictionary + return { + "distances": [list(sorted_distances)], + "documents": [list(sorted_documents)], + "metadatas": [list(sorted_metadatas)], + } + + +def get_all_items_from_collections(collection_names: list[str]) -> dict: + results = [] + + for collection_name in collection_names: + if collection_name: + try: + result = get_doc(collection_name=collection_name) + if result is not None: + results.append(result.model_dump()) + except Exception as e: + log.exception(f"Error when querying the collection: {e}") + else: + pass + + return merge_get_results(results) + + def query_collection( collection_names: list[str], queries: list[str], @@ -187,29 +273,49 @@ def query_collection( k: int, ) -> dict: results = [] - for query in queries: - query_embedding = embedding_function(query) - for collection_name in collection_names: - if collection_name: - try: - result = query_doc( - collection_name=collection_name, - k=k, - query_embedding=query_embedding, - ) - if result is not None: - results.append(result.model_dump()) - except Exception as e: - log.exception(f"Error when querying the collection: {e}") - else: - pass + error = False - if VECTOR_DB == "chroma": - # Chroma uses unconventional cosine similarity, so we don't need to reverse the results - # https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections - return merge_and_sort_query_results(results, k=k, reverse=False) - else: - return merge_and_sort_query_results(results, k=k, reverse=True) + def process_query_collection(collection_name, query_embedding): + try: + if collection_name: + result = query_doc( + collection_name=collection_name, + k=k, + query_embedding=query_embedding, + ) + if result is not None: + return result.model_dump(), None + return None, None + except Exception as e: + log.exception(f"Error when querying the collection: {e}") + return None, e + + # Generate all query embeddings (in one call) + query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) + log.debug( + f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections" + ) + + with ThreadPoolExecutor() as executor: + future_results = [] + for query_embedding in query_embeddings: + for collection_name in collection_names: + result = executor.submit( + process_query_collection, collection_name, query_embedding + ) + future_results.append(result) + task_results = [future.result() for future in future_results] + + for result, err in task_results: + if err is not None: + error = True + elif result is not None: + results.append(result) + + if error and not results: + log.warning("All collection queries failed. No results returned.") + + return merge_and_sort_query_results(results, k=k) def query_collection_with_hybrid_search( @@ -218,39 +324,74 @@ def query_collection_with_hybrid_search( embedding_function, k: int, reranking_function, + k_reranker: int, r: float, + hybrid_bm25_weight: float, ) -> dict: results = [] error = False + # Fetch collection data once per collection sequentially + # Avoid fetching the same data multiple times later + collection_results = {} for collection_name in collection_names: try: - for query in queries: - result = query_doc_with_hybrid_search( - collection_name=collection_name, - query=query, - embedding_function=embedding_function, - k=k, - reranking_function=reranking_function, - r=r, - ) - results.append(result) - except Exception as e: - log.exception( - "Error when querying the collection with " f"hybrid_search: {e}" + log.debug( + f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}" ) - error = True + collection_results[collection_name] = VECTOR_DB_CLIENT.get( + collection_name=collection_name + ) + except Exception as e: + log.exception(f"Failed to fetch collection {collection_name}: {e}") + collection_results[collection_name] = None - if error: + log.info( + f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." + ) + + def process_query(collection_name, query): + try: + result = query_doc_with_hybrid_search( + collection_name=collection_name, + collection_result=collection_results[collection_name], + query=query, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + k_reranker=k_reranker, + r=r, + hybrid_bm25_weight=hybrid_bm25_weight, + ) + return result, None + except Exception as e: + log.exception(f"Error when querying the collection with hybrid_search: {e}") + return None, e + + # Prepare tasks for all collections and queries + # Avoid running any tasks for collections that failed to fetch data (have assigned None) + tasks = [ + (cn, q) + for cn in collection_names + if collection_results[cn] is not None + for q in queries + ] + + with ThreadPoolExecutor() as executor: + future_results = [executor.submit(process_query, cn, q) for cn, q in tasks] + task_results = [future.result() for future in future_results] + + for result, err in task_results: + if err is not None: + error = True + elif result is not None: + results.append(result) + + if error and not results: raise Exception( - "Hybrid search failed for all collections. Using Non hybrid search as fallback." + "Hybrid search failed for all collections. Using Non-hybrid search as fallback." ) - if VECTOR_DB == "chroma": - # Chroma uses unconventional cosine similarity, so we don't need to reverse the results - # https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections - return merge_and_sort_query_results(results, k=k, reverse=False) - else: - return merge_and_sort_query_results(results, k=k, reverse=True) + return merge_and_sort_query_results(results, k=k) def get_embedding_function( @@ -260,58 +401,132 @@ def get_embedding_function( url, key, embedding_batch_size, + azure_api_version=None, ): if embedding_engine == "": - return lambda query, user=None: embedding_function.encode(query).tolist() - elif embedding_engine in ["ollama", "openai"]: - func = lambda query, user=None: generate_embeddings( + return lambda query, prefix=None, user=None: embedding_function.encode( + query, **({"prompt": prefix} if prefix else {}) + ).tolist() + elif embedding_engine in ["ollama", "openai", "azure_openai"]: + func = lambda query, prefix=None, user=None: generate_embeddings( engine=embedding_engine, model=embedding_model, text=query, + prefix=prefix, url=url, key=key, user=user, + azure_api_version=azure_api_version, ) - def generate_multiple(query, user, func): + def generate_multiple(query, prefix, user, func): if isinstance(query, list): embeddings = [] for i in range(0, len(query), embedding_batch_size): embeddings.extend( - func(query[i : i + embedding_batch_size], user=user) + func( + query[i : i + embedding_batch_size], + prefix=prefix, + user=user, + ) ) return embeddings else: - return func(query, user) + return func(query, prefix, user) - return lambda query, user=None: generate_multiple(query, user, func) + return lambda query, prefix=None, user=None: generate_multiple( + query, prefix, user, func + ) else: raise ValueError(f"Unknown embedding engine: {embedding_engine}") def get_sources_from_files( + request, files, queries, embedding_function, k, reranking_function, + k_reranker, r, + hybrid_bm25_weight, hybrid_search, + full_context=False, ): - log.debug(f"files: {files} {queries} {embedding_function} {reranking_function}") + log.debug( + f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}" + ) extracted_collections = [] relevant_contexts = [] for file in files: - if file.get("context") == "full": + + context = None + if file.get("docs"): + # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL + context = { + "documents": [[doc.get("content") for doc in file.get("docs")]], + "metadatas": [[doc.get("metadata") for doc in file.get("docs")]], + } + elif file.get("context") == "full": + # Manual Full Mode Toggle context = { "documents": [[file.get("file").get("data", {}).get("content")]], "metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]], } - else: - context = None + elif ( + file.get("type") != "web_search" + and request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL + ): + # BYPASS_EMBEDDING_AND_RETRIEVAL + if file.get("type") == "collection": + file_ids = file.get("data", {}).get("file_ids", []) + documents = [] + metadatas = [] + for file_id in file_ids: + file_object = Files.get_file_by_id(file_id) + + if file_object: + documents.append(file_object.data.get("content", "")) + metadatas.append( + { + "file_id": file_id, + "name": file_object.filename, + "source": file_object.filename, + } + ) + + context = { + "documents": [documents], + "metadatas": [metadatas], + } + + elif file.get("id"): + file_object = Files.get_file_by_id(file.get("id")) + if file_object: + context = { + "documents": [[file_object.data.get("content", "")]], + "metadatas": [ + [ + { + "file_id": file.get("id"), + "name": file_object.filename, + "source": file_object.filename, + } + ] + ], + } + elif file.get("file").get("data"): + context = { + "documents": [[file.get("file").get("data", {}).get("content")]], + "metadatas": [ + [file.get("file").get("data", {}).get("metadata", {})] + ], + } + else: collection_names = [] if file.get("type") == "collection": if file.get("legacy"): @@ -331,42 +546,52 @@ def get_sources_from_files( log.debug(f"skipping {file} as it has already been extracted") continue - try: - context = None - if file.get("type") == "text": - context = file["content"] - else: - if hybrid_search: - try: - context = query_collection_with_hybrid_search( + if full_context: + try: + context = get_all_items_from_collections(collection_names) + except Exception as e: + log.exception(e) + + else: + try: + context = None + if file.get("type") == "text": + context = file["content"] + else: + if hybrid_search: + try: + context = query_collection_with_hybrid_search( + collection_names=collection_names, + queries=queries, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + k_reranker=k_reranker, + r=r, + hybrid_bm25_weight=hybrid_bm25_weight, + ) + except Exception as e: + log.debug( + "Error when using hybrid search, using" + " non hybrid search as fallback." + ) + + if (not hybrid_search) or (context is None): + context = query_collection( collection_names=collection_names, queries=queries, embedding_function=embedding_function, k=k, - reranking_function=reranking_function, - r=r, ) - except Exception as e: - log.debug( - "Error when using hybrid search, using" - " non hybrid search as fallback." - ) - - if (not hybrid_search) or (context is None): - context = query_collection( - collection_names=collection_names, - queries=queries, - embedding_function=embedding_function, - k=k, - ) - except Exception as e: - log.exception(e) + except Exception as e: + log.exception(e) extracted_collections.extend(collection_names) if context: if "data" in file: del file["data"] + relevant_contexts.append({**context, "file": file}) sources = [] @@ -435,9 +660,17 @@ def generate_openai_batch_embeddings( texts: list[str], url: str = "https://api.openai.com/v1", key: str = "", + prefix: str = None, user: UserModel = None, ) -> Optional[list[list[float]]]: try: + log.debug( + f"generate_openai_batch_embeddings:model {model} batch size: {len(texts)}" + ) + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + r = requests.post( f"{url}/embeddings", headers={ @@ -454,7 +687,7 @@ def generate_openai_batch_embeddings( else {} ), }, - json={"input": texts, "model": model}, + json=json_data, ) r.raise_for_status() data = r.json() @@ -463,14 +696,80 @@ def generate_openai_batch_embeddings( else: raise "Something went wrong :/" except Exception as e: - print(e) + log.exception(f"Error generating openai batch embeddings: {e}") + return None + + +def generate_azure_openai_batch_embeddings( + model: str, + texts: list[str], + url: str, + key: str = "", + version: str = "", + prefix: str = None, + user: UserModel = None, +) -> Optional[list[list[float]]]: + try: + log.debug( + f"generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}" + ) + json_data = {"input": texts} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + + url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}" + + for _ in range(5): + r = requests.post( + url, + headers={ + "Content-Type": "application/json", + "api-key": key, + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + }, + json=json_data, + ) + if r.status_code == 429: + retry = float(r.headers.get("Retry-After", "1")) + time.sleep(retry) + continue + r.raise_for_status() + data = r.json() + if "data" in data: + return [elem["embedding"] for elem in data["data"]] + else: + raise Exception("Something went wrong :/") + return None + except Exception as e: + log.exception(f"Error generating azure openai batch embeddings: {e}") return None def generate_ollama_batch_embeddings( - model: str, texts: list[str], url: str, key: str = "", user: UserModel = None + model: str, + texts: list[str], + url: str, + key: str = "", + prefix: str = None, + user: UserModel = None, ) -> Optional[list[list[float]]]: try: + log.debug( + f"generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}" + ) + json_data = {"input": texts, "model": model} + if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): + json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix + r = requests.post( f"{url}/api/embed", headers={ @@ -487,7 +786,7 @@ def generate_ollama_batch_embeddings( else {} ), }, - json={"input": texts, "model": model}, + json=json_data, ) r.raise_for_status() data = r.json() @@ -497,37 +796,55 @@ def generate_ollama_batch_embeddings( else: raise "Something went wrong :/" except Exception as e: - print(e) + log.exception(f"Error generating ollama batch embeddings: {e}") return None -def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs): +def generate_embeddings( + engine: str, + model: str, + text: Union[str, list[str]], + prefix: Union[str, None] = None, + **kwargs, +): url = kwargs.get("url", "") key = kwargs.get("key", "") user = kwargs.get("user") - if engine == "ollama": + if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None: if isinstance(text, list): - embeddings = generate_ollama_batch_embeddings( - **{"model": model, "texts": text, "url": url, "key": key, "user": user} - ) + text = [f"{prefix}{text_element}" for text_element in text] else: - embeddings = generate_ollama_batch_embeddings( - **{ - "model": model, - "texts": [text], - "url": url, - "key": key, - "user": user, - } - ) + text = f"{prefix}{text}" + + if engine == "ollama": + embeddings = generate_ollama_batch_embeddings( + **{ + "model": model, + "texts": text if isinstance(text, list) else [text], + "url": url, + "key": key, + "prefix": prefix, + "user": user, + } + ) return embeddings[0] if isinstance(text, str) else embeddings elif engine == "openai": - if isinstance(text, list): - embeddings = generate_openai_batch_embeddings(model, text, url, key, user) - else: - embeddings = generate_openai_batch_embeddings(model, [text], url, key, user) - + embeddings = generate_openai_batch_embeddings( + model, text if isinstance(text, list) else [text], url, key, prefix, user + ) + return embeddings[0] if isinstance(text, str) else embeddings + elif engine == "azure_openai": + azure_api_version = kwargs.get("azure_api_version", "") + embeddings = generate_azure_openai_batch_embeddings( + model, + text if isinstance(text, list) else [text], + url, + key, + azure_api_version, + prefix, + user, + ) return embeddings[0] if isinstance(text, str) else embeddings @@ -563,13 +880,15 @@ class RerankCompressor(BaseDocumentCompressor): else: from sentence_transformers import util - query_embedding = self.embedding_function(query) + query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) document_embedding = self.embedding_function( - [doc.page_content for doc in documents] + [doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX ) scores = util.cos_sim(query_embedding, document_embedding)[0] - docs_with_scores = list(zip(documents, scores.tolist())) + docs_with_scores = list( + zip(documents, scores.tolist() if not isinstance(scores, list) else scores) + ) if self.r_score: docs_with_scores = [ (d, s) for d, s in docs_with_scores if s >= self.r_score diff --git a/backend/open_webui/retrieval/vector/connector.py b/backend/open_webui/retrieval/vector/connector.py deleted file mode 100644 index bf97bc7b1d..0000000000 --- a/backend/open_webui/retrieval/vector/connector.py +++ /dev/null @@ -1,22 +0,0 @@ -from open_webui.config import VECTOR_DB - -if VECTOR_DB == "milvus": - from open_webui.retrieval.vector.dbs.milvus import MilvusClient - - VECTOR_DB_CLIENT = MilvusClient() -elif VECTOR_DB == "qdrant": - from open_webui.retrieval.vector.dbs.qdrant import QdrantClient - - VECTOR_DB_CLIENT = QdrantClient() -elif VECTOR_DB == "opensearch": - from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient - - VECTOR_DB_CLIENT = OpenSearchClient() -elif VECTOR_DB == "pgvector": - from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient - - VECTOR_DB_CLIENT = PgvectorClient() -else: - from open_webui.retrieval.vector.dbs.chroma import ChromaClient - - VECTOR_DB_CLIENT = ChromaClient() diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py old mode 100644 new mode 100755 index c40618fcc5..f9adc9c95f --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -1,10 +1,16 @@ import chromadb +import logging from chromadb import Settings from chromadb.utils.batch_utils import create_batches from typing import Optional -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( CHROMA_DATA_PATH, CHROMA_HTTP_HOST, @@ -16,9 +22,13 @@ from open_webui.config import ( CHROMA_CLIENT_AUTH_PROVIDER, CHROMA_CLIENT_AUTH_CREDENTIALS, ) +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) -class ChromaClient: +class ChromaClient(VectorDBBase): def __init__(self): settings_dict = { "allow_reset": True, @@ -70,10 +80,16 @@ class ChromaClient: n_results=limit, ) + # chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1 + # https://docs.trychroma.com/docs/collections/configure cosine equation + distances: list = result["distances"][0] + distances = [2 - dist for dist in distances] + distances = [[dist / 2 for dist in distances]] + return SearchResult( **{ "ids": result["ids"], - "distances": result["distances"], + "distances": distances, "documents": result["documents"], "metadatas": result["metadatas"], } @@ -102,8 +118,7 @@ class ChromaClient: } ) return None - except Exception as e: - print(e) + except: return None def get(self, collection_name: str) -> Optional[GetResult]: @@ -162,12 +177,19 @@ class ChromaClient: filter: Optional[dict] = None, ): # Delete the items from the collection based on the ids. - collection = self.client.get_collection(name=collection_name) - if collection: - if ids: - collection.delete(ids=ids) - elif filter: - collection.delete(where=filter) + try: + collection = self.client.get_collection(name=collection_name) + if collection: + if ids: + collection.delete(ids=ids) + elif filter: + collection.delete(where=filter) + except Exception as e: + # If collection doesn't exist, that's fine - nothing to delete + log.debug( + f"Attempted to delete from non-existent collection {collection_name}. Ignoring." + ) + pass def reset(self): # Resets the database. This will delete all collections and item entries. diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py new file mode 100644 index 0000000000..18a915e381 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -0,0 +1,300 @@ +from elasticsearch import Elasticsearch, BadRequestError +from typing import Optional +import ssl +from elasticsearch.helpers import bulk, scan +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + ELASTICSEARCH_URL, + ELASTICSEARCH_CA_CERTS, + ELASTICSEARCH_API_KEY, + ELASTICSEARCH_USERNAME, + ELASTICSEARCH_PASSWORD, + ELASTICSEARCH_CLOUD_ID, + ELASTICSEARCH_INDEX_PREFIX, + SSL_ASSERT_FINGERPRINT, +) + + +class ElasticsearchClient(VectorDBBase): + """ + Important: + in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating + an index for each file but store it as a text field, while seperating to different index + baesd on the embedding length. + """ + + def __init__(self): + self.index_prefix = ELASTICSEARCH_INDEX_PREFIX + self.client = Elasticsearch( + hosts=[ELASTICSEARCH_URL], + ca_certs=ELASTICSEARCH_CA_CERTS, + api_key=ELASTICSEARCH_API_KEY, + cloud_id=ELASTICSEARCH_CLOUD_ID, + basic_auth=( + (ELASTICSEARCH_USERNAME, ELASTICSEARCH_PASSWORD) + if ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD + else None + ), + ssl_assert_fingerprint=SSL_ASSERT_FINGERPRINT, + ) + + # Status: works + def _get_index_name(self, dimension: int) -> str: + return f"{self.index_prefix}_d{str(dimension)}" + + # Status: works + def _scan_result_to_get_result(self, result) -> GetResult: + if not result: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_get_result(self, result) -> GetResult: + if not result["hits"]["hits"]: + return None + ids = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) + + # Status: works + def _result_to_search_result(self, result) -> SearchResult: + ids = [] + distances = [] + documents = [] + metadatas = [] + + for hit in result["hits"]["hits"]: + ids.append(hit["_id"]) + distances.append(hit["_score"]) + documents.append(hit["_source"].get("text")) + metadatas.append(hit["_source"].get("metadata")) + + return SearchResult( + ids=[ids], + distances=[distances], + documents=[documents], + metadatas=[metadatas], + ) + + # Status: works + def _create_index(self, dimension: int): + body = { + "mappings": { + "dynamic_templates": [ + { + "strings": { + "match_mapping_type": "string", + "mapping": {"type": "keyword"}, + } + } + ], + "properties": { + "collection": {"type": "keyword"}, + "id": {"type": "keyword"}, + "vector": { + "type": "dense_vector", + "dims": dimension, # Adjust based on your vector dimensions + "index": True, + "similarity": "cosine", + }, + "text": {"type": "text"}, + "metadata": {"type": "object"}, + }, + } + } + self.client.indices.create(index=self._get_index_name(dimension), body=body) + + # Status: works + + def _create_batches(self, items: list[VectorItem], batch_size=100): + for i in range(0, len(items), batch_size): + yield items[i : min(i + batch_size, len(items))] + + # Status: works + def has_collection(self, collection_name) -> bool: + query_body = {"query": {"bool": {"filter": []}}} + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + + try: + result = self.client.count(index=f"{self.index_prefix}*", body=query_body) + + return result.body["count"] > 0 + except Exception as e: + return None + + def delete_collection(self, collection_name: str): + query = {"query": {"term": {"collection": collection_name}}} + self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + + # Status: works + def search( + self, collection_name: str, vectors: list[list[float]], limit: int + ) -> Optional[SearchResult]: + query = { + "size": limit, + "_source": ["text", "metadata"], + "query": { + "script_score": { + "query": { + "bool": {"filter": [{"term": {"collection": collection_name}}]} + }, + "script": { + "source": "cosineSimilarity(params.vector, 'vector') + 1.0", + "params": { + "vector": vectors[0] + }, # Assuming single query vector + }, + } + }, + } + + result = self.client.search( + index=self._get_index_name(len(vectors[0])), body=query + ) + + return self._result_to_search_result(result) + + # Status: only tested halfwat + def query( + self, collection_name: str, filter: dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + if not self.has_collection(collection_name): + return None + + query_body = { + "query": {"bool": {"filter": []}}, + "_source": ["text", "metadata"], + } + + for field, value in filter.items(): + query_body["query"]["bool"]["filter"].append({"term": {field: value}}) + query_body["query"]["bool"]["filter"].append( + {"term": {"collection": collection_name}} + ) + size = limit if limit else 10 + + try: + result = self.client.search( + index=f"{self.index_prefix}*", + body=query_body, + size=size, + ) + + return self._result_to_get_result(result) + + except Exception as e: + return None + + # Status: works + def _has_index(self, dimension: int): + return self.client.indices.exists( + index=self._get_index_name(dimension=dimension) + ) + + def get_or_create_index(self, dimension: int): + if not self._has_index(dimension=dimension): + self._create_index(dimension=dimension) + + # Status: works + def get(self, collection_name: str) -> Optional[GetResult]: + # Get all the items in the collection. + query = { + "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}, + "_source": ["text", "metadata"], + } + results = list(scan(self.client, index=f"{self.index_prefix}*", query=query)) + + return self._scan_result_to_get_result(results) + + # Status: works + def insert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(dimension=len(items[0]["vector"])) + + for batch in self._create_batches(items): + actions = [ + { + "_index": self._get_index_name(dimension=len(items[0]["vector"])), + "_id": item["id"], + "_source": { + "collection": collection_name, + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + } + for item in batch + ] + bulk(self.client, actions) + + # Upsert documents using the update API with doc_as_upsert=True. + def upsert(self, collection_name: str, items: list[VectorItem]): + if not self._has_index(dimension=len(items[0]["vector"])): + self._create_index(dimension=len(items[0]["vector"])) + for batch in self._create_batches(items): + actions = [ + { + "_op_type": "update", + "_index": self._get_index_name(dimension=len(item["vector"])), + "_id": item["id"], + "doc": { + "collection": collection_name, + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + "doc_as_upsert": True, + } + for item in batch + ] + bulk(self.client, actions) + + # Delete specific documents from a collection by filtering on both collection and document IDs. + def delete( + self, + collection_name: str, + ids: Optional[list[str]] = None, + filter: Optional[dict] = None, + ): + + query = { + "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}} + } + # logic based on chromaDB + if ids: + query["query"]["bool"]["filter"].append({"terms": {"_id": ids}}) + elif filter: + for field, value in filter.items(): + query["query"]["bool"]["filter"].append( + {"term": {f"metadata.{field}": value}} + ) + + self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + + def reset(self): + indices = self.client.indices.get(index=f"{self.index_prefix}*") + for index in indices: + self.client.indices.delete(index=index) diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 43c3f3d1a1..a4bad13d00 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -1,30 +1,42 @@ from pymilvus import MilvusClient as Client from pymilvus import FieldSchema, DataType import json - +import logging from typing import Optional - -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( MILVUS_URI, MILVUS_DB, MILVUS_TOKEN, + MILVUS_INDEX_TYPE, + MILVUS_METRIC_TYPE, + MILVUS_HNSW_M, + MILVUS_HNSW_EFCONSTRUCTION, + MILVUS_IVF_FLAT_NLIST, ) +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) -class MilvusClient: +class MilvusClient(VectorDBBase): def __init__(self): self.collection_prefix = "open_webui" if MILVUS_TOKEN is None: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB) else: - self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN) + self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB, token=MILVUS_TOKEN) def _result_to_get_result(self, result) -> GetResult: ids = [] documents = [] metadatas = [] - for match in result: _ids = [] _documents = [] @@ -33,11 +45,9 @@ class MilvusClient: _ids.append(item.get("id")) _documents.append(item.get("data", {}).get("text")) _metadatas.append(item.get("metadata")) - ids.append(_ids) documents.append(_documents) metadatas.append(_metadatas) - return GetResult( **{ "ids": ids, @@ -51,24 +61,23 @@ class MilvusClient: distances = [] documents = [] metadatas = [] - for match in result: _ids = [] _distances = [] _documents = [] _metadatas = [] - for item in match: _ids.append(item.get("id")) - _distances.append(item.get("distance")) + # normalize milvus score from [-1, 1] to [0, 1] range + # https://milvus.io/docs/de/metric.md + _dist = (item.get("distance") + 1.0) / 2.0 + _distances.append(_dist) _documents.append(item.get("entity", {}).get("data", {}).get("text")) _metadatas.append(item.get("entity", {}).get("metadata")) - ids.append(_ids) distances.append(_distances) documents.append(_documents) metadatas.append(_metadatas) - return SearchResult( **{ "ids": ids, @@ -101,11 +110,39 @@ class MilvusClient: ) index_params = self.client.prepare_index_params() + + # Use configurations from config.py + index_type = MILVUS_INDEX_TYPE.upper() + metric_type = MILVUS_METRIC_TYPE.upper() + + log.info(f"Using Milvus index type: {index_type}, metric type: {metric_type}") + + index_creation_params = {} + if index_type == "HNSW": + index_creation_params = { + "M": MILVUS_HNSW_M, + "efConstruction": MILVUS_HNSW_EFCONSTRUCTION, + } + log.info(f"HNSW params: {index_creation_params}") + elif index_type == "IVF_FLAT": + index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST} + log.info(f"IVF_FLAT params: {index_creation_params}") + elif index_type in ["FLAT", "AUTOINDEX"]: + log.info(f"Using {index_type} index with no specific build-time params.") + else: + log.warning( + f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. " + f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. " + f"Milvus will use its default for the collection if this type is not directly supported for index creation." + ) + # For unsupported types, pass the type directly to Milvus; it might handle it or use a default. + # If Milvus errors out, the user needs to correct the MILVUS_INDEX_TYPE env var. + index_params.add_index( field_name="vector", - index_type="HNSW", - metric_type="COSINE", - params={"M": 16, "efConstruction": 100}, + index_type=index_type, + metric_type=metric_type, + params=index_creation_params, ) self.client.create_collection( @@ -113,6 +150,9 @@ class MilvusClient: schema=schema, index_params=index_params, ) + log.info( + f"Successfully created collection '{self.collection_prefix}_{collection_name}' with index type '{index_type}' and metric '{metric_type}'." + ) def has_collection(self, collection_name: str) -> bool: # Check if the collection exists based on the collection name. @@ -133,82 +173,113 @@ class MilvusClient: ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. collection_name = collection_name.replace("-", "_") + # For some index types like IVF_FLAT, search params like nprobe can be set. + # Example: search_params = {"nprobe": 10} if using IVF_FLAT + # For simplicity, not adding configurable search_params here, but could be extended. result = self.client.search( collection_name=f"{self.collection_prefix}_{collection_name}", data=vectors, limit=limit, output_fields=["data", "metadata"], + # search_params=search_params # Potentially add later if needed ) - return self._result_to_search_result(result) def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): # Construct the filter string for querying collection_name = collection_name.replace("-", "_") if not self.has_collection(collection_name): + log.warning( + f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}" + ) return None - filter_string = " && ".join( [ f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items() ] ) - max_limit = 16383 # The maximum number of records per request all_results = [] - if limit is None: - limit = float("inf") # Use infinity as a placeholder for no limit + # Milvus default limit for query if not specified is 16384, but docs mention iteration. + # Let's set a practical high number if "all" is intended, or handle true pagination. + # For now, if limit is None, we'll fetch in batches up to a very large number. + # This part could be refined based on expected use cases for "get all". + # For this function signature, None implies "as many as possible" up to Milvus limits. + limit = ( + 16384 * 10 + ) # A large number to signify fetching many, will be capped by actual data or max_limit per call. + log.info( + f"Limit not specified for query, fetching up to {limit} results in batches." + ) # Initialize offset and remaining to handle pagination offset = 0 remaining = limit try: + log.info( + f"Querying collection {self.collection_prefix}_{collection_name} with filter: '{filter_string}', limit: {limit}" + ) # Loop until there are no more items to fetch or the desired limit is reached while remaining > 0: - print("remaining", remaining) current_fetch = min( - max_limit, remaining - ) # Determine how many items to fetch in this iteration + max_limit, remaining if isinstance(remaining, int) else max_limit + ) + log.debug( + f"Querying with offset: {offset}, current_fetch: {current_fetch}" + ) results = self.client.query( collection_name=f"{self.collection_prefix}_{collection_name}", filter=filter_string, - output_fields=["*"], + output_fields=[ + "id", + "data", + "metadata", + ], # Explicitly list needed fields. Vector not usually needed in query. limit=current_fetch, offset=offset, ) if not results: + log.debug("No more results from query.") break all_results.extend(results) results_count = len(results) - remaining -= ( - results_count # Decrease remaining by the number of items fetched - ) + log.debug(f"Fetched {results_count} results in this batch.") + + if isinstance(remaining, int): + remaining -= results_count + offset += results_count - # Break the loop if the results returned are less than the requested fetch count + # Break the loop if the results returned are less than the requested fetch count (means end of data) if results_count < current_fetch: + log.debug( + "Fetched less than requested, assuming end of results for this query." + ) break - print(all_results) + log.info(f"Total results from query: {len(all_results)}") return self._result_to_get_result([all_results]) except Exception as e: - print(e) + log.exception( + f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}" + ) return None def get(self, collection_name: str) -> Optional[GetResult]: - # Get all the items in the collection. + # Get all the items in the collection. This can be very resource-intensive for large collections. collection_name = collection_name.replace("-", "_") - result = self.client.query( - collection_name=f"{self.collection_prefix}_{collection_name}", - filter='id != ""', + log.warning( + f"Fetching ALL items from collection '{self.collection_prefix}_{collection_name}'. This might be slow for large collections." ) - return self._result_to_get_result([result]) + # Using query with a trivial filter to get all items. + # This will use the paginated query logic. + return self.query(collection_name=collection_name, filter={}, limit=None) def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. @@ -216,10 +287,23 @@ class MilvusClient: if not self.client.has_collection( collection_name=f"{self.collection_prefix}_{collection_name}" ): + log.info( + f"Collection {self.collection_prefix}_{collection_name} does not exist. Creating now." + ) + if not items: + log.error( + f"Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension." + ) + raise ValueError( + "Cannot create Milvus collection without items to determine vector dimension." + ) self._create_collection( collection_name=collection_name, dimension=len(items[0]["vector"]) ) + log.info( + f"Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." + ) return self.client.insert( collection_name=f"{self.collection_prefix}_{collection_name}", data=[ @@ -239,10 +323,23 @@ class MilvusClient: if not self.client.has_collection( collection_name=f"{self.collection_prefix}_{collection_name}" ): + log.info( + f"Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now." + ) + if not items: + log.error( + f"Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension." + ) + raise ValueError( + "Cannot create Milvus collection for upsert without items to determine vector dimension." + ) self._create_collection( collection_name=collection_name, dimension=len(items[0]["vector"]) ) + log.info( + f"Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." + ) return self.client.upsert( collection_name=f"{self.collection_prefix}_{collection_name}", data=[ @@ -262,30 +359,55 @@ class MilvusClient: ids: Optional[list[str]] = None, filter: Optional[dict] = None, ): - # Delete the items from the collection based on the ids. + # Delete the items from the collection based on the ids or filter. collection_name = collection_name.replace("-", "_") + if not self.has_collection(collection_name): + log.warning( + f"Delete attempted on non-existent collection: {self.collection_prefix}_{collection_name}" + ) + return None + if ids: + log.info( + f"Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}" + ) return self.client.delete( collection_name=f"{self.collection_prefix}_{collection_name}", ids=ids, ) elif filter: - # Convert the filter dictionary to a string using JSON_CONTAINS. filter_string = " && ".join( [ f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items() ] ) - + log.info( + f"Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}" + ) return self.client.delete( collection_name=f"{self.collection_prefix}_{collection_name}", filter=filter_string, ) + else: + log.warning( + f"Delete operation on {self.collection_prefix}_{collection_name} called without IDs or filter. No action taken." + ) + return None def reset(self): - # Resets the database. This will delete all collections and item entries. + # Resets the database. This will delete all collections and item entries that match the prefix. + log.warning( + f"Resetting Milvus: Deleting all collections with prefix '{self.collection_prefix}'." + ) collection_names = self.client.list_collections() - for collection_name in collection_names: - if collection_name.startswith(self.collection_prefix): - self.client.drop_collection(collection_name=collection_name) + deleted_collections = [] + for collection_name_full in collection_names: + if collection_name_full.startswith(self.collection_prefix): + try: + self.client.drop_collection(collection_name=collection_name_full) + deleted_collections.append(collection_name_full) + log.info(f"Deleted collection: {collection_name_full}") + except Exception as e: + log.error(f"Error deleting collection {collection_name_full}: {e}") + log.info(f"Milvus reset complete. Deleted collections: {deleted_collections}") diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index b8186b3f93..60ef2d906c 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -1,7 +1,13 @@ from opensearchpy import OpenSearch +from opensearchpy.helpers import bulk from typing import Optional -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import ( OPENSEARCH_URI, OPENSEARCH_SSL, @@ -11,7 +17,7 @@ from open_webui.config import ( ) -class OpenSearchClient: +class OpenSearchClient(VectorDBBase): def __init__(self): self.index_prefix = "open_webui" self.client = OpenSearch( @@ -21,7 +27,13 @@ class OpenSearchClient: http_auth=(OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD), ) + def _get_index_name(self, collection_name: str) -> str: + return f"{self.index_prefix}_{collection_name}" + def _result_to_get_result(self, result) -> GetResult: + if not result["hits"]["hits"]: + return None + ids = [] documents = [] metadatas = [] @@ -31,9 +43,12 @@ class OpenSearchClient: documents.append(hit["_source"].get("text")) metadatas.append(hit["_source"].get("metadata")) - return GetResult(ids=ids, documents=documents, metadatas=metadatas) + return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) def _result_to_search_result(self, result) -> SearchResult: + if not result["hits"]["hits"]: + return None + ids = [] distances = [] documents = [] @@ -46,72 +61,88 @@ class OpenSearchClient: metadatas.append(hit["_source"].get("metadata")) return SearchResult( - ids=ids, distances=distances, documents=documents, metadatas=metadatas + ids=[ids], + distances=[distances], + documents=[documents], + metadatas=[metadatas], ) - def _create_index(self, index_name: str, dimension: int): + def _create_index(self, collection_name: str, dimension: int): body = { + "settings": {"index": {"knn": True}}, "mappings": { "properties": { "id": {"type": "keyword"}, "vector": { - "type": "dense_vector", - "dims": dimension, # Adjust based on your vector dimensions - "index": true, + "type": "knn_vector", + "dimension": dimension, # Adjust based on your vector dimensions + "index": True, "similarity": "faiss", "method": { "name": "hnsw", - "space_type": "ip", # Use inner product to approximate cosine similarity + "space_type": "innerproduct", # Use inner product to approximate cosine similarity "engine": "faiss", - "ef_construction": 128, - "m": 16, + "parameters": { + "ef_construction": 128, + "m": 16, + }, }, }, "text": {"type": "text"}, "metadata": {"type": "object"}, } - } + }, } - self.client.indices.create(index=f"{self.index_prefix}_{index_name}", body=body) + self.client.indices.create( + index=self._get_index_name(collection_name), body=body + ) def _create_batches(self, items: list[VectorItem], batch_size=100): for i in range(0, len(items), batch_size): yield items[i : i + batch_size] - def has_collection(self, index_name: str) -> bool: + def has_collection(self, collection_name: str) -> bool: # has_collection here means has index. # We are simply adapting to the norms of the other DBs. - return self.client.indices.exists(index=f"{self.index_prefix}_{index_name}") + return self.client.indices.exists(index=self._get_index_name(collection_name)) - def delete_colleciton(self, index_name: str): + def delete_collection(self, collection_name: str): # delete_collection here means delete index. # We are simply adapting to the norms of the other DBs. - self.client.indices.delete(index=f"{self.index_prefix}_{index_name}") + self.client.indices.delete(index=self._get_index_name(collection_name)) def search( - self, index_name: str, vectors: list[list[float]], limit: int + self, collection_name: str, vectors: list[list[float | int]], limit: int ) -> Optional[SearchResult]: - query = { - "size": limit, - "_source": ["text", "metadata"], - "query": { - "script_score": { - "query": {"match_all": {}}, - "script": { - "source": "cosineSimilarity(params.vector, 'vector') + 1.0", - "params": { - "vector": vectors[0] - }, # Assuming single query vector - }, - } - }, - } + try: + if not self.has_collection(collection_name): + return None - result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query - ) + query = { + "size": limit, + "_source": ["text", "metadata"], + "query": { + "script_score": { + "query": {"match_all": {}}, + "script": { + "source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0", + "params": { + "field": "vector", + "query_value": vectors[0], + }, # Assuming single query vector + }, + } + }, + } - return self._result_to_search_result(result) + result = self.client.search( + index=self._get_index_name(collection_name), body=query + ) + + return self._result_to_search_result(result) + + except Exception as e: + return None def query( self, collection_name: str, filter: dict, limit: Optional[int] = None @@ -125,13 +156,15 @@ class OpenSearchClient: } for field, value in filter.items(): - query_body["query"]["bool"]["filter"].append({"term": {field: value}}) + query_body["query"]["bool"]["filter"].append( + {"match": {"metadata." + str(field): value}} + ) size = limit if limit else 10 try: result = self.client.search( - index=f"{self.index_prefix}_{collection_name}", + index=self._get_index_name(collection_name), body=query_body, size=size, ) @@ -141,64 +174,88 @@ class OpenSearchClient: except Exception as e: return None - def get_or_create_index(self, index_name: str, dimension: int): - if not self.has_index(index_name): - self._create_index(index_name, dimension) + def _create_index_if_not_exists(self, collection_name: str, dimension: int): + if not self.has_collection(collection_name): + self._create_index(collection_name, dimension) - def get(self, index_name: str) -> Optional[GetResult]: + def get(self, collection_name: str) -> Optional[GetResult]: query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]} result = self.client.search( - index=f"{self.index_prefix}_{index_name}", body=query + index=self._get_index_name(collection_name), body=query ) return self._result_to_get_result(result) - def insert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def insert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ { - "index": { - "_id": item["id"], - "_source": { - "vector": item["vector"], - "text": item["text"], - "metadata": item["metadata"], - }, - } + "_op_type": "index", + "_index": self._get_index_name(collection_name), + "_id": item["id"], + "_source": { + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, } for item in batch ] - self.client.bulk(actions) + bulk(self.client, actions) - def upsert(self, index_name: str, items: list[VectorItem]): - if not self.has_index(index_name): - self._create_index(index_name, dimension=len(items[0]["vector"])) + def upsert(self, collection_name: str, items: list[VectorItem]): + self._create_index_if_not_exists( + collection_name=collection_name, dimension=len(items[0]["vector"]) + ) for batch in self._create_batches(items): actions = [ { - "index": { - "_id": item["id"], - "_source": { - "vector": item["vector"], - "text": item["text"], - "metadata": item["metadata"], - }, - } + "_op_type": "update", + "_index": self._get_index_name(collection_name), + "_id": item["id"], + "doc": { + "vector": item["vector"], + "text": item["text"], + "metadata": item["metadata"], + }, + "doc_as_upsert": True, } for item in batch ] - self.client.bulk(actions) + bulk(self.client, actions) - def delete(self, index_name: str, ids: list[str]): - actions = [ - {"delete": {"_index": f"{self.index_prefix}_{index_name}", "_id": id}} - for id in ids - ] - self.client.bulk(body=actions) + def delete( + self, + collection_name: str, + ids: Optional[list[str]] = None, + filter: Optional[dict] = None, + ): + if ids: + actions = [ + { + "_op_type": "delete", + "_index": self._get_index_name(collection_name), + "_id": id, + } + for id in ids + ] + bulk(self.client, actions) + elif filter: + query_body = { + "query": {"bool": {"filter": []}}, + } + for field, value in filter.items(): + query_body["query"]["bool"]["filter"].append( + {"match": {"metadata." + str(field): value}} + ) + self.client.delete_by_query( + index=self._get_index_name(collection_name), body=query_body + ) def reset(self): indices = self.client.indices.get(index=f"{self.index_prefix}_*") diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 341b3056fa..b6cb2a4e25 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -1,4 +1,5 @@ from typing import Optional, List, Dict, Any +import logging from sqlalchemy import ( cast, column, @@ -21,12 +22,22 @@ from pgvector.sqlalchemy import Vector from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.exc import NoSuchTableError -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH +from open_webui.env import SRC_LOG_LEVELS + VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH Base = declarative_base() +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + class DocumentChunk(Base): __tablename__ = "document_chunk" @@ -38,7 +49,7 @@ class DocumentChunk(Base): vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True) -class PgvectorClient: +class PgvectorClient(VectorDBBase): def __init__(self) -> None: # if no pgvector uri, use the existing database connection @@ -82,10 +93,10 @@ class PgvectorClient: ) ) self.session.commit() - print("Initialization complete.") + log.info("Initialization complete.") except Exception as e: self.session.rollback() - print(f"Error during initialization: {e}") + log.exception(f"Error during initialization: {e}") raise def check_vector_length(self) -> None: @@ -130,9 +141,8 @@ class PgvectorClient: # Pad the vector with zeros vector += [0.0] * (VECTOR_LENGTH - current_length) elif current_length > VECTOR_LENGTH: - raise Exception( - f"Vector length {current_length} not supported. Max length must be <= {VECTOR_LENGTH}" - ) + # Truncate the vector to VECTOR_LENGTH + vector = vector[:VECTOR_LENGTH] return vector def insert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -150,12 +160,12 @@ class PgvectorClient: new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - print( + log.info( f"Inserted {len(new_items)} items into collection '{collection_name}'." ) except Exception as e: self.session.rollback() - print(f"Error during insert: {e}") + log.exception(f"Error during insert: {e}") raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -184,10 +194,12 @@ class PgvectorClient: ) self.session.add(new_chunk) self.session.commit() - print(f"Upserted {len(items)} items into collection '{collection_name}'.") + log.info( + f"Upserted {len(items)} items into collection '{collection_name}'." + ) except Exception as e: self.session.rollback() - print(f"Error during upsert: {e}") + log.exception(f"Error during upsert: {e}") raise def search( @@ -270,7 +282,9 @@ class PgvectorClient: for row in results: qid = int(row.qid) ids[qid].append(row.id) - distances[qid].append(row.distance) + # normalize and re-orders pgvec distance from [2, 0] to [0, 1] score range + # https://github.com/pgvector/pgvector?tab=readme-ov-file#querying + distances[qid].append((2.0 - row.distance) / 2.0) documents[qid].append(row.text) metadatas[qid].append(row.vmetadata) @@ -278,7 +292,7 @@ class PgvectorClient: ids=ids, distances=distances, documents=documents, metadatas=metadatas ) except Exception as e: - print(f"Error during search: {e}") + log.exception(f"Error during search: {e}") return None def query( @@ -310,7 +324,7 @@ class PgvectorClient: metadatas=metadatas, ) except Exception as e: - print(f"Error during query: {e}") + log.exception(f"Error during query: {e}") return None def get( @@ -334,7 +348,7 @@ class PgvectorClient: return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: - print(f"Error during get: {e}") + log.exception(f"Error during get: {e}") return None def delete( @@ -356,22 +370,22 @@ class PgvectorClient: ) deleted = query.delete(synchronize_session=False) self.session.commit() - print(f"Deleted {deleted} items from collection '{collection_name}'.") + log.info(f"Deleted {deleted} items from collection '{collection_name}'.") except Exception as e: self.session.rollback() - print(f"Error during delete: {e}") + log.exception(f"Error during delete: {e}") raise def reset(self) -> None: try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - print( + log.info( f"Reset complete. Deleted {deleted} items from 'document_chunk' table." ) except Exception as e: self.session.rollback() - print(f"Error during reset: {e}") + log.exception(f"Error during reset: {e}") raise def close(self) -> None: @@ -387,9 +401,9 @@ class PgvectorClient: ) return exists except Exception as e: - print(f"Error checking collection existence: {e}") + log.exception(f"Error checking collection existence: {e}") return False def delete_collection(self, collection_name: str) -> None: self.delete(collection_name) - print(f"Collection '{collection_name}' deleted.") + log.info(f"Collection '{collection_name}' deleted.") diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py new file mode 100644 index 0000000000..9f8abf4609 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -0,0 +1,508 @@ +from typing import Optional, List, Dict, Any, Union +import logging +import time # for measuring elapsed time +from pinecone import Pinecone, ServerlessSpec + +import asyncio # for async upserts +import functools # for partial binding in async tasks + +import concurrent.futures # for parallel batch upserts + +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + PINECONE_API_KEY, + PINECONE_ENVIRONMENT, + PINECONE_INDEX_NAME, + PINECONE_DIMENSION, + PINECONE_METRIC, + PINECONE_CLOUD, +) +from open_webui.env import SRC_LOG_LEVELS + +NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system +BATCH_SIZE = 100 # Recommended batch size for Pinecone operations + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class PineconeClient(VectorDBBase): + def __init__(self): + self.collection_prefix = "open-webui" + + # Validate required configuration + self._validate_config() + + # Store configuration values + self.api_key = PINECONE_API_KEY + self.environment = PINECONE_ENVIRONMENT + self.index_name = PINECONE_INDEX_NAME + self.dimension = PINECONE_DIMENSION + self.metric = PINECONE_METRIC + self.cloud = PINECONE_CLOUD + + # Initialize Pinecone client for improved performance + self.client = Pinecone(api_key=self.api_key) + + # Persistent executor for batch operations + self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + + # Create index if it doesn't exist + self._initialize_index() + + def _validate_config(self) -> None: + """Validate that all required configuration variables are set.""" + missing_vars = [] + if not PINECONE_API_KEY: + missing_vars.append("PINECONE_API_KEY") + if not PINECONE_ENVIRONMENT: + missing_vars.append("PINECONE_ENVIRONMENT") + if not PINECONE_INDEX_NAME: + missing_vars.append("PINECONE_INDEX_NAME") + if not PINECONE_DIMENSION: + missing_vars.append("PINECONE_DIMENSION") + if not PINECONE_CLOUD: + missing_vars.append("PINECONE_CLOUD") + + if missing_vars: + raise ValueError( + f"Required configuration missing: {', '.join(missing_vars)}" + ) + + def _initialize_index(self) -> None: + """Initialize the Pinecone index.""" + try: + # Check if index exists + if self.index_name not in self.client.list_indexes().names(): + log.info(f"Creating Pinecone index '{self.index_name}'...") + self.client.create_index( + name=self.index_name, + dimension=self.dimension, + metric=self.metric, + spec=ServerlessSpec(cloud=self.cloud, region=self.environment), + ) + log.info(f"Successfully created Pinecone index '{self.index_name}'") + else: + log.info(f"Using existing Pinecone index '{self.index_name}'") + + # Connect to the index + self.index = self.client.Index(self.index_name) + + except Exception as e: + log.error(f"Failed to initialize Pinecone index: {e}") + raise RuntimeError(f"Failed to initialize Pinecone index: {e}") + + def _create_points( + self, items: List[VectorItem], collection_name_with_prefix: str + ) -> List[Dict[str, Any]]: + """Convert VectorItem objects to Pinecone point format.""" + points = [] + for item in items: + # Start with any existing metadata or an empty dict + metadata = item.get("metadata", {}).copy() if item.get("metadata") else {} + + # Add text to metadata if available + if "text" in item: + metadata["text"] = item["text"] + + # Always add collection_name to metadata for filtering + metadata["collection_name"] = collection_name_with_prefix + + point = { + "id": item["id"], + "values": item["vector"], + "metadata": metadata, + } + points.append(point) + return points + + def _get_collection_name_with_prefix(self, collection_name: str) -> str: + """Get the collection name with prefix.""" + return f"{self.collection_prefix}_{collection_name}" + + def _normalize_distance(self, score: float) -> float: + """Normalize distance score based on the metric used.""" + if self.metric.lower() == "cosine": + # Cosine similarity ranges from -1 to 1, normalize to 0 to 1 + return (score + 1.0) / 2.0 + elif self.metric.lower() in ["euclidean", "dotproduct"]: + # These are already suitable for ranking (smaller is better for Euclidean) + return score + else: + # For other metrics, use as is + return score + + def _result_to_get_result(self, matches: list) -> GetResult: + """Convert Pinecone matches to GetResult format.""" + ids = [] + documents = [] + metadatas = [] + + for match in matches: + metadata = getattr(match, "metadata", {}) or {} + ids.append(match.id if hasattr(match, "id") else match["id"]) + documents.append(metadata.get("text", "")) + metadatas.append(metadata) + + return GetResult( + **{ + "ids": [ids], + "documents": [documents], + "metadatas": [metadatas], + } + ) + + def has_collection(self, collection_name: str) -> bool: + """Check if a collection exists by searching for at least one item.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + # Search for at least 1 item with this collection name in metadata + response = self.index.query( + vector=[0.0] * self.dimension, # dummy vector + top_k=1, + filter={"collection_name": collection_name_with_prefix}, + include_metadata=False, + ) + matches = getattr(response, "matches", []) or [] + return len(matches) > 0 + except Exception as e: + log.exception( + f"Error checking collection '{collection_name_with_prefix}': {e}" + ) + return False + + def delete_collection(self, collection_name: str) -> None: + """Delete a collection by removing all vectors with the collection name in metadata.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + try: + self.index.delete(filter={"collection_name": collection_name_with_prefix}) + log.info( + f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)." + ) + except Exception as e: + log.warning( + f"Failed to delete collection '{collection_name_with_prefix}': {e}" + ) + raise + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert vectors into a collection.""" + if not items: + log.warning("No items to insert") + return + + start_time = time.time() + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Parallelize batch inserts for performance + executor = self._executor + futures = [] + for i in range(0, len(points), BATCH_SIZE): + batch = points[i : i + BATCH_SIZE] + futures.append(executor.submit(self.index.upsert, vectors=batch)) + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + log.error(f"Error inserting batch: {e}") + raise + elapsed = time.time() - start_time + log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds") + log.info( + f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + ) + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """Upsert (insert or update) vectors into a collection.""" + if not items: + log.warning("No items to upsert") + return + + start_time = time.time() + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Parallelize batch upserts for performance + executor = self._executor + futures = [] + for i in range(0, len(points), BATCH_SIZE): + batch = points[i : i + BATCH_SIZE] + futures.append(executor.submit(self.index.upsert, vectors=batch)) + for future in concurrent.futures.as_completed(futures): + try: + future.result() + except Exception as e: + log.error(f"Error upserting batch: {e}") + raise + elapsed = time.time() - start_time + log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds") + log.info( + f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" + ) + + async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None: + """Async version of insert using asyncio and run_in_executor for improved performance.""" + if not items: + log.warning("No items to insert") + return + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Create batches + batches = [ + points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) + ] + loop = asyncio.get_event_loop() + tasks = [ + loop.run_in_executor( + None, functools.partial(self.index.upsert, vectors=batch) + ) + for batch in batches + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + log.error(f"Error in async insert batch: {result}") + raise result + log.info( + f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'" + ) + + async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None: + """Async version of upsert using asyncio and run_in_executor for improved performance.""" + if not items: + log.warning("No items to upsert") + return + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + points = self._create_points(items, collection_name_with_prefix) + + # Create batches + batches = [ + points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) + ] + loop = asyncio.get_event_loop() + tasks = [ + loop.run_in_executor( + None, functools.partial(self.index.upsert, vectors=batch) + ) + for batch in batches + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + log.error(f"Error in async upsert batch: {result}") + raise result + log.info( + f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'" + ) + + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """Search for similar vectors in a collection.""" + if not vectors or not vectors[0]: + log.warning("No vectors provided for search") + return None + + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + if limit is None or limit <= 0: + limit = NO_LIMIT + + try: + # Search using the first vector (assuming this is the intended behavior) + query_vector = vectors[0] + + # Perform the search + query_response = self.index.query( + vector=query_vector, + top_k=limit, + include_metadata=True, + filter={"collection_name": collection_name_with_prefix}, + ) + + matches = getattr(query_response, "matches", []) or [] + if not matches: + # Return empty result if no matches + return SearchResult( + ids=[[]], + documents=[[]], + metadatas=[[]], + distances=[[]], + ) + + # Convert to GetResult format + get_result = self._result_to_get_result(matches) + + # Calculate normalized distances based on metric + distances = [ + [ + self._normalize_distance(getattr(match, "score", 0.0)) + for match in matches + ] + ] + + return SearchResult( + ids=get_result.ids, + documents=get_result.documents, + metadatas=get_result.metadatas, + distances=distances, + ) + except Exception as e: + log.error(f"Error searching in '{collection_name_with_prefix}': {e}") + return None + + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """Query vectors by metadata filter.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + if limit is None or limit <= 0: + limit = NO_LIMIT + + try: + # Create a zero vector for the dimension as Pinecone requires a vector + zero_vector = [0.0] * self.dimension + + # Combine user filter with collection_name + pinecone_filter = {"collection_name": collection_name_with_prefix} + if filter: + pinecone_filter.update(filter) + + # Perform metadata-only query + query_response = self.index.query( + vector=zero_vector, + filter=pinecone_filter, + top_k=limit, + include_metadata=True, + ) + + matches = getattr(query_response, "matches", []) or [] + return self._result_to_get_result(matches) + + except Exception as e: + log.error(f"Error querying collection '{collection_name}': {e}") + return None + + def get(self, collection_name: str) -> Optional[GetResult]: + """Get all vectors in a collection.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + # Use a zero vector for fetching all entries + zero_vector = [0.0] * self.dimension + + # Add filter to only get vectors for this collection + query_response = self.index.query( + vector=zero_vector, + top_k=NO_LIMIT, + include_metadata=True, + filter={"collection_name": collection_name_with_prefix}, + ) + + matches = getattr(query_response, "matches", []) or [] + return self._result_to_get_result(matches) + + except Exception as e: + log.error(f"Error getting collection '{collection_name}': {e}") + return None + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + """Delete vectors by IDs or filter.""" + collection_name_with_prefix = self._get_collection_name_with_prefix( + collection_name + ) + + try: + if ids: + # Delete by IDs (in batches for large deletions) + for i in range(0, len(ids), BATCH_SIZE): + batch_ids = ids[i : i + BATCH_SIZE] + # Note: When deleting by ID, we can't filter by collection_name + # This is a limitation of Pinecone - be careful with ID uniqueness + self.index.delete(ids=batch_ids) + log.debug( + f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'" + ) + log.info( + f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'" + ) + + elif filter: + # Combine user filter with collection_name + pinecone_filter = {"collection_name": collection_name_with_prefix} + if filter: + pinecone_filter.update(filter) + # Delete by metadata filter + self.index.delete(filter=pinecone_filter) + log.info( + f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'" + ) + + else: + log.warning("No ids or filter provided for delete operation") + + except Exception as e: + log.error(f"Error deleting from collection '{collection_name}': {e}") + raise + + def reset(self) -> None: + """Reset the database by deleting all collections.""" + try: + self.index.delete(delete_all=True) + log.info("All vectors successfully deleted from the index.") + except Exception as e: + log.error(f"Failed to reset Pinecone index: {e}") + raise + + def close(self): + """Shut down resources.""" + try: + # The new Pinecone client doesn't need explicit closing + pass + except Exception as e: + log.warning(f"Failed to clean up Pinecone resources: {e}") + self._executor.shutdown(wait=True) + + def __enter__(self): + """Enter context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager, ensuring resources are cleaned up.""" + self.close() diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index f077ae45ac..dfe2979076 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -1,25 +1,60 @@ from typing import Optional +import logging +from urllib.parse import urlparse from qdrant_client import QdrantClient as Qclient from qdrant_client.http.models import PointStruct from qdrant_client.models import models -from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult -from open_webui.config import QDRANT_URI, QDRANT_API_KEY +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + QDRANT_URI, + QDRANT_API_KEY, + QDRANT_ON_DISK, + QDRANT_GRPC_PORT, + QDRANT_PREFER_GRPC, +) +from open_webui.env import SRC_LOG_LEVELS NO_LIMIT = 999999999 +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) -class QdrantClient: + +class QdrantClient(VectorDBBase): def __init__(self): self.collection_prefix = "open-webui" self.QDRANT_URI = QDRANT_URI self.QDRANT_API_KEY = QDRANT_API_KEY - self.client = ( - Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) - if self.QDRANT_URI - else None - ) + self.QDRANT_ON_DISK = QDRANT_ON_DISK + self.PREFER_GRPC = QDRANT_PREFER_GRPC + self.GRPC_PORT = QDRANT_GRPC_PORT + + if not self.QDRANT_URI: + self.client = None + return + + # Unified handling for either scheme + parsed = urlparse(self.QDRANT_URI) + host = parsed.hostname or self.QDRANT_URI + http_port = parsed.port or 6333 # default REST port + + if self.PREFER_GRPC: + self.client = Qclient( + host=host, + port=http_port, + grpc_port=self.GRPC_PORT, + prefer_grpc=self.PREFER_GRPC, + api_key=self.QDRANT_API_KEY, + ) + else: + self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) def _result_to_get_result(self, points) -> GetResult: ids = [] @@ -45,11 +80,13 @@ class QdrantClient: self.client.create_collection( collection_name=collection_name_with_prefix, vectors_config=models.VectorParams( - size=dimension, distance=models.Distance.COSINE + size=dimension, + distance=models.Distance.COSINE, + on_disk=self.QDRANT_ON_DISK, ), ) - print(f"collection {collection_name_with_prefix} successfully created!") + log.info(f"collection {collection_name_with_prefix} successfully created!") def _create_collection_if_not_exists(self, collection_name, dimension): if not self.has_collection(collection_name=collection_name): @@ -94,7 +131,8 @@ class QdrantClient: ids=get_result.ids, documents=get_result.documents, metadatas=get_result.metadatas, - distances=[[point.score for point in query_response.points]], + # qdrant distance is [-1, 1], normalize to [0, 1] + distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]], ) def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): @@ -120,7 +158,7 @@ class QdrantClient: ) return self._result_to_get_result(points.points) except Exception as e: - print(e) + log.exception(f"Error querying a collection '{collection_name}': {e}") return None def get(self, collection_name: str) -> Optional[GetResult]: diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py new file mode 100644 index 0000000000..e83c437ef7 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -0,0 +1,712 @@ +import logging +from typing import Optional, Tuple +from urllib.parse import urlparse + +import grpc +from open_webui.config import ( + QDRANT_API_KEY, + QDRANT_GRPC_PORT, + QDRANT_ON_DISK, + QDRANT_PREFER_GRPC, + QDRANT_URI, +) +from open_webui.env import SRC_LOG_LEVELS +from open_webui.retrieval.vector.main import ( + GetResult, + SearchResult, + VectorDBBase, + VectorItem, +) +from qdrant_client import QdrantClient as Qclient +from qdrant_client.http.exceptions import UnexpectedResponse +from qdrant_client.http.models import PointStruct +from qdrant_client.models import models + +NO_LIMIT = 999999999 + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class QdrantClient(VectorDBBase): + def __init__(self): + self.collection_prefix = "open-webui" + self.QDRANT_URI = QDRANT_URI + self.QDRANT_API_KEY = QDRANT_API_KEY + self.QDRANT_ON_DISK = QDRANT_ON_DISK + self.PREFER_GRPC = QDRANT_PREFER_GRPC + self.GRPC_PORT = QDRANT_GRPC_PORT + + if not self.QDRANT_URI: + self.client = None + return + + # Unified handling for either scheme + parsed = urlparse(self.QDRANT_URI) + host = parsed.hostname or self.QDRANT_URI + http_port = parsed.port or 6333 # default REST port + + if self.PREFER_GRPC: + self.client = Qclient( + host=host, + port=http_port, + grpc_port=self.GRPC_PORT, + prefer_grpc=self.PREFER_GRPC, + api_key=self.QDRANT_API_KEY, + ) + else: + self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY) + + # Main collection types for multi-tenancy + self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories" + self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge" + self.FILE_COLLECTION = f"{self.collection_prefix}_files" + self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web-search" + self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based" + + def _result_to_get_result(self, points) -> GetResult: + ids = [] + documents = [] + metadatas = [] + + for point in points: + payload = point.payload + ids.append(point.id) + documents.append(payload["text"]) + metadatas.append(payload["metadata"]) + + return GetResult( + **{ + "ids": [ids], + "documents": [documents], + "metadatas": [metadatas], + } + ) + + def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]: + """ + Maps the traditional collection name to multi-tenant collection and tenant ID. + + Returns: + tuple: (collection_name, tenant_id) + """ + # Check for user memory collections + tenant_id = collection_name + + if collection_name.startswith("user-memory-"): + return self.MEMORY_COLLECTION, tenant_id + + # Check for file collections + elif collection_name.startswith("file-"): + return self.FILE_COLLECTION, tenant_id + + # Check for web search collections + elif collection_name.startswith("web-search-"): + return self.WEB_SEARCH_COLLECTION, tenant_id + + # Handle hash-based collections (YouTube and web URLs) + elif len(collection_name) == 63 and all( + c in "0123456789abcdef" for c in collection_name + ): + return self.HASH_BASED_COLLECTION, tenant_id + + else: + return self.KNOWLEDGE_COLLECTION, tenant_id + + def _extract_error_message(self, exception): + """ + Extract error message from either HTTP or gRPC exceptions + + Returns: + tuple: (status_code, error_message) + """ + # Check if it's an HTTP exception + if isinstance(exception, UnexpectedResponse): + try: + error_data = exception.structured() + error_msg = error_data.get("status", {}).get("error", "") + return exception.status_code, error_msg + except Exception as inner_e: + log.error(f"Failed to parse HTTP error: {inner_e}") + return exception.status_code, str(exception) + + # Check if it's a gRPC exception + elif isinstance(exception, grpc.RpcError): + # Extract status code from gRPC error + status_code = None + if hasattr(exception, "code") and callable(exception.code): + status_code = exception.code().value[0] + + # Extract error message + error_msg = str(exception) + if "details =" in error_msg: + # Parse the details line which contains the actual error message + try: + details_line = [ + line.strip() + for line in error_msg.split("\n") + if "details =" in line + ][0] + error_msg = details_line.split("details =")[1].strip(' "') + except (IndexError, AttributeError): + # Fall back to full message if parsing fails + pass + + return status_code, error_msg + + # For any other type of exception + return None, str(exception) + + def _is_collection_not_found_error(self, exception): + """ + Check if the exception is due to collection not found, supporting both HTTP and gRPC + """ + status_code, error_msg = self._extract_error_message(exception) + + # HTTP error (404) + if ( + status_code == 404 + and "Collection" in error_msg + and "doesn't exist" in error_msg + ): + return True + + # gRPC error (NOT_FOUND status) + if ( + isinstance(exception, grpc.RpcError) + and exception.code() == grpc.StatusCode.NOT_FOUND + ): + return True + + return False + + def _is_dimension_mismatch_error(self, exception): + """ + Check if the exception is due to dimension mismatch, supporting both HTTP and gRPC + """ + status_code, error_msg = self._extract_error_message(exception) + + # Common patterns in both HTTP and gRPC + return ( + "Vector dimension error" in error_msg + or "dimensions mismatch" in error_msg + or "invalid vector size" in error_msg + ) + + def _create_multi_tenant_collection_if_not_exists( + self, mt_collection_name: str, dimension: int = 384 + ): + """ + Creates a collection with multi-tenancy configuration if it doesn't exist. + Default dimension is set to 384 which corresponds to 'sentence-transformers/all-MiniLM-L6-v2'. + When creating collections dynamically (insert/upsert), the actual vector dimensions will be used. + """ + try: + # Try to create the collection directly - will fail if it already exists + self.client.create_collection( + collection_name=mt_collection_name, + vectors_config=models.VectorParams( + size=dimension, + distance=models.Distance.COSINE, + on_disk=self.QDRANT_ON_DISK, + ), + hnsw_config=models.HnswConfigDiff( + payload_m=16, # Enable per-tenant indexing + m=0, + on_disk=self.QDRANT_ON_DISK, + ), + ) + + # Create tenant ID payload index + self.client.create_payload_index( + collection_name=mt_collection_name, + field_name="tenant_id", + field_schema=models.KeywordIndexParams( + type=models.KeywordIndexType.KEYWORD, + is_tenant=True, + on_disk=self.QDRANT_ON_DISK, + ), + wait=True, + ) + + log.info( + f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!" + ) + except (UnexpectedResponse, grpc.RpcError) as e: + # Check for the specific error indicating collection already exists + status_code, error_msg = self._extract_error_message(e) + + # HTTP status code 409 or gRPC ALREADY_EXISTS + if (isinstance(e, UnexpectedResponse) and status_code == 409) or ( + isinstance(e, grpc.RpcError) + and e.code() == grpc.StatusCode.ALREADY_EXISTS + ): + if "already exists" in error_msg: + log.debug(f"Collection {mt_collection_name} already exists") + return + # If it's not an already exists error, re-raise + raise e + except Exception as e: + raise e + + def _create_points(self, items: list[VectorItem], tenant_id: str): + """ + Create point structs from vector items with tenant ID. + """ + return [ + PointStruct( + id=item["id"], + vector=item["vector"], + payload={ + "text": item["text"], + "metadata": item["metadata"], + "tenant_id": tenant_id, + }, + ) + for item in items + ] + + def has_collection(self, collection_name: str) -> bool: + """ + Check if a logical collection exists by checking for any points with the tenant ID. + """ + if not self.client: + return False + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Create tenant filter + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + try: + # Try directly querying - most of the time collection should exist + response = self.client.query_points( + collection_name=mt_collection, + query_filter=models.Filter(must=[tenant_filter]), + limit=1, + ) + + # Collection exists with this tenant ID if there are points + return len(response.points) > 0 + except (UnexpectedResponse, grpc.RpcError) as e: + if self._is_collection_not_found_error(e): + log.debug(f"Collection {mt_collection} doesn't exist") + return False + else: + # For other API errors, log and return False + _, error_msg = self._extract_error_message(e) + log.warning(f"Unexpected Qdrant error: {error_msg}") + return False + except Exception as e: + # For any other errors, log and return False + log.debug(f"Error checking collection {mt_collection}: {e}") + return False + + def delete( + self, + collection_name: str, + ids: Optional[list[str]] = None, + filter: Optional[dict] = None, + ): + """ + Delete vectors by ID or filter from a collection with tenant isolation. + """ + if not self.client: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Create tenant filter + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + must_conditions = [tenant_filter] + should_conditions = [] + + if ids: + for id_value in ids: + should_conditions.append( + models.FieldCondition( + key="metadata.id", + match=models.MatchValue(value=id_value), + ), + ) + elif filter: + for key, value in filter.items(): + must_conditions.append( + models.FieldCondition( + key=f"metadata.{key}", + match=models.MatchValue(value=value), + ), + ) + + try: + # Try to delete directly - most of the time collection should exist + update_result = self.client.delete( + collection_name=mt_collection, + points_selector=models.FilterSelector( + filter=models.Filter(must=must_conditions, should=should_conditions) + ), + ) + + return update_result + except (UnexpectedResponse, grpc.RpcError) as e: + if self._is_collection_not_found_error(e): + log.debug( + f"Collection {mt_collection} doesn't exist, nothing to delete" + ) + return None + else: + # For other API errors, log and re-raise + _, error_msg = self._extract_error_message(e) + log.warning(f"Unexpected Qdrant error: {error_msg}") + raise + except Exception as e: + # For non-Qdrant exceptions, re-raise + raise + + def search( + self, collection_name: str, vectors: list[list[float | int]], limit: int + ) -> Optional[SearchResult]: + """ + Search for the nearest neighbor items based on the vectors with tenant isolation. + """ + if not self.client: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Get the vector dimension from the query vector + dimension = len(vectors[0]) if vectors and len(vectors) > 0 else None + + try: + # Try the search operation directly - most of the time collection should exist + + # Create tenant filter + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + # Ensure vector dimensions match the collection + collection_dim = self.client.get_collection( + mt_collection + ).config.params.vectors.size + + if collection_dim != dimension: + if collection_dim < dimension: + vectors = [vector[:collection_dim] for vector in vectors] + else: + vectors = [ + vector + [0] * (collection_dim - dimension) + for vector in vectors + ] + + # Search with tenant filter + prefetch_query = models.Prefetch( + filter=models.Filter(must=[tenant_filter]), + limit=NO_LIMIT, + ) + query_response = self.client.query_points( + collection_name=mt_collection, + query=vectors[0], + prefetch=prefetch_query, + limit=limit, + ) + + get_result = self._result_to_get_result(query_response.points) + return SearchResult( + ids=get_result.ids, + documents=get_result.documents, + metadatas=get_result.metadatas, + # qdrant distance is [-1, 1], normalize to [0, 1] + distances=[ + [(point.score + 1.0) / 2.0 for point in query_response.points] + ], + ) + except (UnexpectedResponse, grpc.RpcError) as e: + if self._is_collection_not_found_error(e): + log.debug( + f"Collection {mt_collection} doesn't exist, search returns None" + ) + return None + else: + # For other API errors, log and re-raise + _, error_msg = self._extract_error_message(e) + log.warning(f"Unexpected Qdrant error during search: {error_msg}") + raise + except Exception as e: + # For non-Qdrant exceptions, log and return None + log.exception(f"Error searching collection '{collection_name}': {e}") + return None + + def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): + """ + Query points with filters and tenant isolation. + """ + if not self.client: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Set default limit if not provided + if limit is None: + limit = NO_LIMIT + + # Create tenant filter + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + # Create metadata filters + field_conditions = [] + for key, value in filter.items(): + field_conditions.append( + models.FieldCondition( + key=f"metadata.{key}", match=models.MatchValue(value=value) + ) + ) + + # Combine tenant filter with metadata filters + combined_filter = models.Filter(must=[tenant_filter, *field_conditions]) + + try: + # Try the query directly - most of the time collection should exist + points = self.client.query_points( + collection_name=mt_collection, + query_filter=combined_filter, + limit=limit, + ) + + return self._result_to_get_result(points.points) + except (UnexpectedResponse, grpc.RpcError) as e: + if self._is_collection_not_found_error(e): + log.debug( + f"Collection {mt_collection} doesn't exist, query returns None" + ) + return None + else: + # For other API errors, log and re-raise + _, error_msg = self._extract_error_message(e) + log.warning(f"Unexpected Qdrant error during query: {error_msg}") + raise + except Exception as e: + # For non-Qdrant exceptions, log and re-raise + log.exception(f"Error querying collection '{collection_name}': {e}") + return None + + def get(self, collection_name: str) -> Optional[GetResult]: + """ + Get all items in a collection with tenant isolation. + """ + if not self.client: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Create tenant filter + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + try: + # Try to get points directly - most of the time collection should exist + points = self.client.query_points( + collection_name=mt_collection, + query_filter=models.Filter(must=[tenant_filter]), + limit=NO_LIMIT, + ) + + return self._result_to_get_result(points.points) + except (UnexpectedResponse, grpc.RpcError) as e: + if self._is_collection_not_found_error(e): + log.debug(f"Collection {mt_collection} doesn't exist, get returns None") + return None + else: + # For other API errors, log and re-raise + _, error_msg = self._extract_error_message(e) + log.warning(f"Unexpected Qdrant error during get: {error_msg}") + raise + except Exception as e: + # For non-Qdrant exceptions, log and return None + log.exception(f"Error getting collection '{collection_name}': {e}") + return None + + def _handle_operation_with_error_retry( + self, operation_name, mt_collection, points, dimension + ): + """ + Private helper to handle common error cases for insert and upsert operations. + + Args: + operation_name: 'insert' or 'upsert' + mt_collection: The multi-tenant collection name + points: The vector points to insert/upsert + dimension: The dimension of the vectors + + Returns: + The operation result (for upsert) or None (for insert) + """ + try: + if operation_name == "insert": + self.client.upload_points(mt_collection, points) + return None + else: # upsert + return self.client.upsert(mt_collection, points) + except (UnexpectedResponse, grpc.RpcError) as e: + # Handle collection not found + if self._is_collection_not_found_error(e): + log.info( + f"Collection {mt_collection} doesn't exist. Creating it with dimension {dimension}." + ) + # Create collection with correct dimensions from our vectors + self._create_multi_tenant_collection_if_not_exists( + mt_collection_name=mt_collection, dimension=dimension + ) + # Try operation again - no need for dimension adjustment since we just created with correct dimensions + if operation_name == "insert": + self.client.upload_points(mt_collection, points) + return None + else: # upsert + return self.client.upsert(mt_collection, points) + + # Handle dimension mismatch + elif self._is_dimension_mismatch_error(e): + # For dimension errors, the collection must exist, so get its configuration + mt_collection_info = self.client.get_collection(mt_collection) + existing_size = mt_collection_info.config.params.vectors.size + + log.info( + f"Dimension mismatch: Collection {mt_collection} expects {existing_size}, got {dimension}" + ) + + if existing_size < dimension: + # Truncate vectors to fit + log.info( + f"Truncating vectors from {dimension} to {existing_size} dimensions" + ) + points = [ + PointStruct( + id=point.id, + vector=point.vector[:existing_size], + payload=point.payload, + ) + for point in points + ] + elif existing_size > dimension: + # Pad vectors with zeros + log.info( + f"Padding vectors from {dimension} to {existing_size} dimensions with zeros" + ) + points = [ + PointStruct( + id=point.id, + vector=point.vector + + [0] * (existing_size - len(point.vector)), + payload=point.payload, + ) + for point in points + ] + # Try operation again with adjusted dimensions + if operation_name == "insert": + self.client.upload_points(mt_collection, points) + return None + else: # upsert + return self.client.upsert(mt_collection, points) + else: + # Not a known error we can handle, log and re-raise + _, error_msg = self._extract_error_message(e) + log.warning(f"Unhandled Qdrant error: {error_msg}") + raise + except Exception as e: + # For non-Qdrant exceptions, re-raise + raise + + def insert(self, collection_name: str, items: list[VectorItem]): + """ + Insert items with tenant ID. + """ + if not self.client or not items: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Get dimensions from the actual vectors + dimension = len(items[0]["vector"]) if items else None + + # Create points with tenant ID + points = self._create_points(items, tenant_id) + + # Handle the operation with error retry + return self._handle_operation_with_error_retry( + "insert", mt_collection, points, dimension + ) + + def upsert(self, collection_name: str, items: list[VectorItem]): + """ + Upsert items with tenant ID. + """ + if not self.client or not items: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + # Get dimensions from the actual vectors + dimension = len(items[0]["vector"]) if items else None + + # Create points with tenant ID + points = self._create_points(items, tenant_id) + + # Handle the operation with error retry + return self._handle_operation_with_error_retry( + "upsert", mt_collection, points, dimension + ) + + def reset(self): + """ + Reset the database by deleting all collections. + """ + if not self.client: + return None + + collection_names = self.client.get_collections().collections + for collection_name in collection_names: + if collection_name.name.startswith(self.collection_prefix): + self.client.delete_collection(collection_name=collection_name.name) + + def delete_collection(self, collection_name: str): + """ + Delete a collection. + """ + if not self.client: + return None + + # Map to multi-tenant collection and tenant ID + mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) + + tenant_filter = models.FieldCondition( + key="tenant_id", match=models.MatchValue(value=tenant_id) + ) + + field_conditions = [tenant_filter] + + update_result = self.client.delete( + collection_name=mt_collection, + points_selector=models.FilterSelector( + filter=models.Filter(must=field_conditions) + ), + ) + + if self.client.get_collection(mt_collection).points_count == 0: + self.client.delete_collection(mt_collection) + + return update_result diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py new file mode 100644 index 0000000000..72a3f6cebe --- /dev/null +++ b/backend/open_webui/retrieval/vector/factory.py @@ -0,0 +1,55 @@ +from open_webui.retrieval.vector.main import VectorDBBase +from open_webui.retrieval.vector.type import VectorType +from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE + + +class Vector: + + @staticmethod + def get_vector(vector_type: str) -> VectorDBBase: + """ + get vector db instance by vector type + """ + match vector_type: + case VectorType.MILVUS: + from open_webui.retrieval.vector.dbs.milvus import MilvusClient + + return MilvusClient() + case VectorType.QDRANT: + if ENABLE_QDRANT_MULTITENANCY_MODE: + from open_webui.retrieval.vector.dbs.qdrant_multitenancy import ( + QdrantClient, + ) + + return QdrantClient() + else: + from open_webui.retrieval.vector.dbs.qdrant import QdrantClient + + return QdrantClient() + case VectorType.PINECONE: + from open_webui.retrieval.vector.dbs.pinecone import PineconeClient + + return PineconeClient() + case VectorType.OPENSEARCH: + from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient + + return OpenSearchClient() + case VectorType.PGVECTOR: + from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient + + return PgvectorClient() + case VectorType.ELASTICSEARCH: + from open_webui.retrieval.vector.dbs.elasticsearch import ( + ElasticsearchClient, + ) + + return ElasticsearchClient() + case VectorType.CHROMA: + from open_webui.retrieval.vector.dbs.chroma import ChromaClient + + return ChromaClient() + case _: + raise ValueError(f"Unsupported vector type: {vector_type}") + + +VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB) diff --git a/backend/open_webui/retrieval/vector/main.py b/backend/open_webui/retrieval/vector/main.py index f0cf0c0387..53f752f579 100644 --- a/backend/open_webui/retrieval/vector/main.py +++ b/backend/open_webui/retrieval/vector/main.py @@ -1,5 +1,6 @@ from pydantic import BaseModel -from typing import Optional, List, Any +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Union class VectorItem(BaseModel): @@ -17,3 +18,69 @@ class GetResult(BaseModel): class SearchResult(GetResult): distances: Optional[List[List[float | int]]] + + +class VectorDBBase(ABC): + """ + Abstract base class for all vector database backends. + + Implementations of this class provide methods for collection management, + vector insertion, deletion, similarity search, and metadata filtering. + + Any custom vector database integration must inherit from this class and + implement all abstract methods. + """ + + @abstractmethod + def has_collection(self, collection_name: str) -> bool: + """Check if the collection exists in the vector DB.""" + pass + + @abstractmethod + def delete_collection(self, collection_name: str) -> None: + """Delete a collection from the vector DB.""" + pass + + @abstractmethod + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert a list of vector items into a collection.""" + pass + + @abstractmethod + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + """Insert or update vector items in a collection.""" + pass + + @abstractmethod + def search( + self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + ) -> Optional[SearchResult]: + """Search for similar vectors in a collection.""" + pass + + @abstractmethod + def query( + self, collection_name: str, filter: Dict, limit: Optional[int] = None + ) -> Optional[GetResult]: + """Query vectors from a collection using metadata filter.""" + pass + + @abstractmethod + def get(self, collection_name: str) -> Optional[GetResult]: + """Retrieve all vectors from a collection.""" + pass + + @abstractmethod + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict] = None, + ) -> None: + """Delete vectors by ID or filter from a collection.""" + pass + + @abstractmethod + def reset(self) -> None: + """Reset the vector database by removing all collections or those matching a condition.""" + pass diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py new file mode 100644 index 0000000000..b03bcb4828 --- /dev/null +++ b/backend/open_webui/retrieval/vector/type.py @@ -0,0 +1,11 @@ +from enum import StrEnum + + +class VectorType(StrEnum): + MILVUS = "milvus" + QDRANT = "qdrant" + CHROMA = "chroma" + PINECONE = "pinecone" + ELASTICSEARCH = "elasticsearch" + OPENSEARCH = "opensearch" + PGVECTOR = "pgvector" diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index 7c0c3f1c20..bf8ae6880b 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -3,6 +3,7 @@ from typing import Optional from open_webui.retrieval.web.main import SearchResult, get_filtered_results from duckduckgo_search import DDGS +from duckduckgo_search.exceptions import RatelimitException from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) @@ -22,29 +23,24 @@ def search_duckduckgo( list[SearchResult]: A list of search results """ # Use the DDGS context manager to create a DDGS object + search_results = [] with DDGS() as ddgs: # Use the ddgs.text() method to perform the search - ddgs_gen = ddgs.text( - query, safesearch="moderate", max_results=count, backend="api" - ) - # Check if there are search results - if ddgs_gen: - # Convert the search results into a list - search_results = [r for r in ddgs_gen] - - # Create an empty list to store the SearchResult objects - results = [] - # Iterate over each search result - for result in search_results: - # Create a SearchResult object and append it to the results list - results.append( - SearchResult( - link=result["href"], - title=result.get("title"), - snippet=result.get("body"), + try: + search_results = ddgs.text( + query, safesearch="moderate", max_results=count, backend="lite" ) - ) + except RatelimitException as e: + log.error(f"RatelimitException: {e}") if filter_list: - results = get_filtered_results(results, filter_list) + search_results = get_filtered_results(search_results, filter_list) + # Return the list of search results - return results + return [ + SearchResult( + link=result["href"], + title=result.get("title"), + snippet=result.get("body"), + ) + for result in search_results + ] diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py new file mode 100644 index 0000000000..a5c8003e47 --- /dev/null +++ b/backend/open_webui/retrieval/web/external.py @@ -0,0 +1,47 @@ +import logging +from typing import Optional, List + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_external( + external_url: str, + external_api_key: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, +) -> List[SearchResult]: + try: + response = requests.post( + external_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Authorization": f"Bearer {external_api_key}", + }, + json={ + "query": query, + "count": count, + }, + ) + response.raise_for_status() + results = response.json() + if filter_list: + results = get_filtered_results(results, filter_list) + results = [ + SearchResult( + link=result.get("link"), + title=result.get("title"), + snippet=result.get("snippet"), + ) + for result in results[:count] + ] + log.info(f"External search results: {results}") + return results + except Exception as e: + log.error(f"Error in External search: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py new file mode 100644 index 0000000000..a85fc51fbd --- /dev/null +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -0,0 +1,49 @@ +import logging +from typing import Optional, List +from urllib.parse import urljoin + +import requests +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_firecrawl( + firecrawl_url: str, + firecrawl_api_key: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, +) -> List[SearchResult]: + try: + firecrawl_search_url = urljoin(firecrawl_url, "/v1/search") + response = requests.post( + firecrawl_search_url, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Authorization": f"Bearer {firecrawl_api_key}", + }, + json={ + "query": query, + "limit": count, + }, + ) + response.raise_for_status() + results = response.json().get("data", []) + if filter_list: + results = get_filtered_results(results, filter_list) + results = [ + SearchResult( + link=result.get("url"), + title=result.get("title"), + snippet=result.get("description"), + ) + for result in results[:count] + ] + log.info(f"External search results: {results}") + return results + except Exception as e: + log.error(f"Error in External search: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py new file mode 100644 index 0000000000..e5314eb1f7 --- /dev/null +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -0,0 +1,87 @@ +import logging +from typing import Optional, List +import requests + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_perplexity( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """Search using Perplexity API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A Perplexity API key + query (str): The query to search for + count (int): Maximum number of results to return + + """ + + # Handle PersistentConfig object + if hasattr(api_key, "__str__"): + api_key = str(api_key) + + try: + url = "https://api.perplexity.ai/chat/completions" + + # Create payload for the API call + payload = { + "model": "sonar", + "messages": [ + { + "role": "system", + "content": "You are a search assistant. Provide factual information with citations.", + }, + {"role": "user", "content": query}, + ], + "temperature": 0.2, # Lower temperature for more factual responses + "stream": False, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # Make the API request + response = requests.request("POST", url, json=payload, headers=headers) + + # Parse the JSON response + json_response = response.json() + + # Extract citations from the response + citations = json_response.get("citations", []) + + # Create search results from citations + results = [] + for i, citation in enumerate(citations[:count]): + # Extract content from the response to use as snippet + content = "" + if "choices" in json_response and json_response["choices"]: + if i == 0: + content = json_response["choices"][0]["message"]["content"] + + result = {"link": citation, "title": f"Source {i+1}", "snippet": content} + results.append(result) + + if filter_list: + + results = get_filtered_results(results, filter_list) + + return [ + SearchResult( + link=result["link"], title=result["title"], snippet=result["snippet"] + ) + for result in results[:count] + ] + + except Exception as e: + log.error(f"Error searching with Perplexity API: {e}") + return [] diff --git a/backend/open_webui/retrieval/web/searchapi.py b/backend/open_webui/retrieval/web/searchapi.py index 38bc0b5742..d7704638c2 100644 --- a/backend/open_webui/retrieval/web/searchapi.py +++ b/backend/open_webui/retrieval/web/searchapi.py @@ -42,7 +42,9 @@ def search_searchapi( results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], title=result["title"], snippet=result["snippet"] + link=result["link"], + title=result.get("title"), + snippet=result.get("snippet"), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/serpapi.py b/backend/open_webui/retrieval/web/serpapi.py index 028b6bcfe1..8762210bfd 100644 --- a/backend/open_webui/retrieval/web/serpapi.py +++ b/backend/open_webui/retrieval/web/serpapi.py @@ -42,7 +42,9 @@ def search_serpapi( results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], title=result["title"], snippet=result["snippet"] + link=result["link"], + title=result.get("title"), + snippet=result.get("snippet"), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/sougou.py b/backend/open_webui/retrieval/web/sougou.py new file mode 100644 index 0000000000..af7957c4fc --- /dev/null +++ b/backend/open_webui/retrieval/web/sougou.py @@ -0,0 +1,60 @@ +import logging +import json +from typing import Optional, List + + +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_sougou( + sougou_api_sid: str, + sougou_api_sk: str, + query: str, + count: int, + filter_list: Optional[List[str]] = None, +) -> List[SearchResult]: + from tencentcloud.common.common_client import CommonClient + from tencentcloud.common import credential + from tencentcloud.common.exception.tencent_cloud_sdk_exception import ( + TencentCloudSDKException, + ) + from tencentcloud.common.profile.client_profile import ClientProfile + from tencentcloud.common.profile.http_profile import HttpProfile + + try: + cred = credential.Credential(sougou_api_sid, sougou_api_sk) + http_profile = HttpProfile() + http_profile.endpoint = "tms.tencentcloudapi.com" + client_profile = ClientProfile() + client_profile.http_profile = http_profile + params = json.dumps({"Query": query, "Cnt": 20}) + common_client = CommonClient( + "tms", "2020-12-29", cred, "", profile=client_profile + ) + results = [ + json.loads(page) + for page in common_client.call_json("SearchPro", json.loads(params))[ + "Response" + ]["Pages"] + ] + sorted_results = sorted( + results, key=lambda x: x.get("scour", 0.0), reverse=True + ) + if filter_list: + sorted_results = get_filtered_results(sorted_results, filter_list) + + return [ + SearchResult( + link=result.get("url"), + title=result.get("title"), + snippet=result.get("passage"), + ) + for result in sorted_results[:count] + ] + except TencentCloudSDKException as err: + log.error(f"Error in Sougou search: {err}") + return [] diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index cc468725d9..bfd102afa6 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -1,32 +1,45 @@ import logging +from typing import Optional import requests -from open_webui.retrieval.web.main import SearchResult +from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -def search_tavily(api_key: str, query: str, count: int) -> list[SearchResult]: +def search_tavily( + api_key: str, + query: str, + count: int, + filter_list: Optional[list[str]] = None, + # **kwargs, +) -> list[SearchResult]: """Search using Tavily's Search API and return the results as a list of SearchResult objects. Args: api_key (str): A Tavily Search API key query (str): The query to search for + count (int): The maximum number of results to return Returns: list[SearchResult]: A list of search results """ url = "https://api.tavily.com/search" - data = {"query": query, "api_key": api_key} - - response = requests.post(url, json=data) + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + data = {"query": query, "max_results": count} + response = requests.post(url, headers=headers, json=data) response.raise_for_status() json_response = response.json() - raw_search_results = json_response.get("results", []) + results = json_response.get("results", []) + if filter_list: + results = get_filtered_results(results, filter_list) return [ SearchResult( @@ -34,5 +47,5 @@ def search_tavily(api_key: str, query: str, count: int) -> list[SearchResult]: title=result.get("title", ""), snippet=result.get("content"), ) - for result in raw_search_results[:count] + for result in results ] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 3a73444b3d..5a90a86e0f 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -1,19 +1,45 @@ -import socket -import urllib.parse -import validators -from typing import Union, Sequence, Iterator - -from langchain_community.document_loaders import ( - WebBaseLoader, -) -from langchain_core.documents import Document - - -from open_webui.constants import ERROR_MESSAGES -from open_webui.config import ENABLE_RAG_LOCAL_WEB_FETCH -from open_webui.env import SRC_LOG_LEVELS - +import asyncio import logging +import socket +import ssl +import urllib.parse +import urllib.request +from collections import defaultdict +from datetime import datetime, time, timedelta +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + List, + Optional, + Sequence, + Union, + Literal, +) +import aiohttp +import certifi +import validators +from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader +from langchain_community.document_loaders.firecrawl import FireCrawlLoader +from langchain_community.document_loaders.base import BaseLoader +from langchain_core.documents import Document +from open_webui.retrieval.loaders.tavily import TavilyLoader +from open_webui.retrieval.loaders.external_web import ExternalWebLoader +from open_webui.constants import ERROR_MESSAGES +from open_webui.config import ( + ENABLE_RAG_LOCAL_WEB_FETCH, + PLAYWRIGHT_WS_URL, + PLAYWRIGHT_TIMEOUT, + WEB_LOADER_ENGINE, + FIRECRAWL_API_BASE_URL, + FIRECRAWL_API_KEY, + TAVILY_API_KEY, + TAVILY_EXTRACT_DEPTH, + EXTERNAL_WEB_LOADER_URL, + EXTERNAL_WEB_LOADER_API_KEY, +) +from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -65,9 +91,472 @@ def resolve_hostname(hostname): return ipv4_addresses, ipv6_addresses +def extract_metadata(soup, url): + metadata = {"source": url} + if title := soup.find("title"): + metadata["title"] = title.get_text() + if description := soup.find("meta", attrs={"name": "description"}): + metadata["description"] = description.get("content", "No description found.") + if html := soup.find("html"): + metadata["language"] = html.get("lang", "No language found.") + return metadata + + +def verify_ssl_cert(url: str) -> bool: + """Verify SSL certificate for the given URL.""" + if not url.startswith("https://"): + return True + + try: + hostname = url.split("://")[-1].split("/")[0] + context = ssl.create_default_context(cafile=certifi.where()) + with context.wrap_socket(ssl.socket(), server_hostname=hostname) as s: + s.connect((hostname, 443)) + return True + except ssl.SSLError: + return False + except Exception as e: + log.warning(f"SSL verification failed for {url}: {str(e)}") + return False + + +class RateLimitMixin: + async def _wait_for_rate_limit(self): + """Wait to respect the rate limit if specified.""" + if self.requests_per_second and self.last_request_time: + min_interval = timedelta(seconds=1.0 / self.requests_per_second) + time_since_last = datetime.now() - self.last_request_time + if time_since_last < min_interval: + await asyncio.sleep((min_interval - time_since_last).total_seconds()) + self.last_request_time = datetime.now() + + def _sync_wait_for_rate_limit(self): + """Synchronous version of rate limit wait.""" + if self.requests_per_second and self.last_request_time: + min_interval = timedelta(seconds=1.0 / self.requests_per_second) + time_since_last = datetime.now() - self.last_request_time + if time_since_last < min_interval: + time.sleep((min_interval - time_since_last).total_seconds()) + self.last_request_time = datetime.now() + + +class URLProcessingMixin: + def _verify_ssl_cert(self, url: str) -> bool: + """Verify SSL certificate for a URL.""" + return verify_ssl_cert(url) + + async def _safe_process_url(self, url: str) -> bool: + """Perform safety checks before processing a URL.""" + if self.verify_ssl and not self._verify_ssl_cert(url): + raise ValueError(f"SSL certificate verification failed for {url}") + await self._wait_for_rate_limit() + return True + + def _safe_process_url_sync(self, url: str) -> bool: + """Synchronous version of safety checks.""" + if self.verify_ssl and not self._verify_ssl_cert(url): + raise ValueError(f"SSL certificate verification failed for {url}") + self._sync_wait_for_rate_limit() + return True + + +class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): + def __init__( + self, + web_paths, + verify_ssl: bool = True, + trust_env: bool = False, + requests_per_second: Optional[float] = None, + continue_on_failure: bool = True, + api_key: Optional[str] = None, + api_url: Optional[str] = None, + mode: Literal["crawl", "scrape", "map"] = "scrape", + proxy: Optional[Dict[str, str]] = None, + params: Optional[Dict] = None, + ): + """Concurrent document loader for FireCrawl operations. + + Executes multiple FireCrawlLoader instances concurrently using thread pooling + to improve bulk processing efficiency. + Args: + web_paths: List of URLs/paths to process. + verify_ssl: If True, verify SSL certificates. + trust_env: If True, use proxy settings from environment variables. + requests_per_second: Number of requests per second to limit to. + continue_on_failure (bool): If True, continue loading other URLs on failure. + api_key: API key for FireCrawl service. Defaults to None + (uses FIRE_CRAWL_API_KEY environment variable if not provided). + api_url: Base URL for FireCrawl API. Defaults to official API endpoint. + mode: Operation mode selection: + - 'crawl': Website crawling mode (default) + - 'scrape': Direct page scraping + - 'map': Site map generation + proxy: Proxy override settings for the FireCrawl API. + params: The parameters to pass to the Firecrawl API. + Examples include crawlerOptions. + For more details, visit: https://github.com/mendableai/firecrawl-py + """ + proxy_server = proxy.get("server") if proxy else None + if trust_env and not proxy_server: + env_proxies = urllib.request.getproxies() + env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + if env_proxy_server: + if proxy: + proxy["server"] = env_proxy_server + else: + proxy = {"server": env_proxy_server} + self.web_paths = web_paths + self.verify_ssl = verify_ssl + self.requests_per_second = requests_per_second + self.last_request_time = None + self.trust_env = trust_env + self.continue_on_failure = continue_on_failure + self.api_key = api_key + self.api_url = api_url + self.mode = mode + self.params = params + + def lazy_load(self) -> Iterator[Document]: + """Load documents concurrently using FireCrawl.""" + for url in self.web_paths: + try: + self._safe_process_url_sync(url) + loader = FireCrawlLoader( + url=url, + api_key=self.api_key, + api_url=self.api_url, + mode=self.mode, + params=self.params, + ) + for document in loader.lazy_load(): + if not document.metadata.get("source"): + document.metadata["source"] = document.metadata.get("sourceURL") + yield document + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading {url}: {e}") + continue + raise e + + async def alazy_load(self): + """Async version of lazy_load.""" + for url in self.web_paths: + try: + await self._safe_process_url(url) + loader = FireCrawlLoader( + url=url, + api_key=self.api_key, + api_url=self.api_url, + mode=self.mode, + params=self.params, + ) + async for document in loader.alazy_load(): + if not document.metadata.get("source"): + document.metadata["source"] = document.metadata.get("sourceURL") + yield document + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading {url}: {e}") + continue + raise e + + +class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin): + def __init__( + self, + web_paths: Union[str, List[str]], + api_key: str, + extract_depth: Literal["basic", "advanced"] = "basic", + continue_on_failure: bool = True, + requests_per_second: Optional[float] = None, + verify_ssl: bool = True, + trust_env: bool = False, + proxy: Optional[Dict[str, str]] = None, + ): + """Initialize SafeTavilyLoader with rate limiting and SSL verification support. + + Args: + web_paths: List of URLs/paths to process. + api_key: The Tavily API key. + extract_depth: Depth of extraction ("basic" or "advanced"). + continue_on_failure: Whether to continue if extraction of a URL fails. + requests_per_second: Number of requests per second to limit to. + verify_ssl: If True, verify SSL certificates. + trust_env: If True, use proxy settings from environment variables. + proxy: Optional proxy configuration. + """ + # Initialize proxy configuration if using environment variables + proxy_server = proxy.get("server") if proxy else None + if trust_env and not proxy_server: + env_proxies = urllib.request.getproxies() + env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + if env_proxy_server: + if proxy: + proxy["server"] = env_proxy_server + else: + proxy = {"server": env_proxy_server} + + # Store parameters for creating TavilyLoader instances + self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] + self.api_key = api_key + self.extract_depth = extract_depth + self.continue_on_failure = continue_on_failure + self.verify_ssl = verify_ssl + self.trust_env = trust_env + self.proxy = proxy + + # Add rate limiting + self.requests_per_second = requests_per_second + self.last_request_time = None + + def lazy_load(self) -> Iterator[Document]: + """Load documents with rate limiting support, delegating to TavilyLoader.""" + valid_urls = [] + for url in self.web_paths: + try: + self._safe_process_url_sync(url) + valid_urls.append(url) + except Exception as e: + log.warning(f"SSL verification failed for {url}: {str(e)}") + if not self.continue_on_failure: + raise e + if not valid_urls: + if self.continue_on_failure: + log.warning("No valid URLs to process after SSL verification") + return + raise ValueError("No valid URLs to process after SSL verification") + try: + loader = TavilyLoader( + urls=valid_urls, + api_key=self.api_key, + extract_depth=self.extract_depth, + continue_on_failure=self.continue_on_failure, + ) + yield from loader.lazy_load() + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error extracting content from URLs: {e}") + else: + raise e + + async def alazy_load(self) -> AsyncIterator[Document]: + """Async version with rate limiting and SSL verification.""" + valid_urls = [] + for url in self.web_paths: + try: + await self._safe_process_url(url) + valid_urls.append(url) + except Exception as e: + log.warning(f"SSL verification failed for {url}: {str(e)}") + if not self.continue_on_failure: + raise e + + if not valid_urls: + if self.continue_on_failure: + log.warning("No valid URLs to process after SSL verification") + return + raise ValueError("No valid URLs to process after SSL verification") + + try: + loader = TavilyLoader( + urls=valid_urls, + api_key=self.api_key, + extract_depth=self.extract_depth, + continue_on_failure=self.continue_on_failure, + ) + async for document in loader.alazy_load(): + yield document + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading URLs: {e}") + else: + raise e + + +class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessingMixin): + """Load HTML pages safely with Playwright, supporting SSL verification, rate limiting, and remote browser connection. + + Attributes: + web_paths (List[str]): List of URLs to load. + verify_ssl (bool): If True, verify SSL certificates. + trust_env (bool): If True, use proxy settings from environment variables. + requests_per_second (Optional[float]): Number of requests per second to limit to. + continue_on_failure (bool): If True, continue loading other URLs on failure. + headless (bool): If True, the browser will run in headless mode. + proxy (dict): Proxy override settings for the Playwright session. + playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection. + playwright_timeout (Optional[int]): Maximum operation time in milliseconds. + """ + + def __init__( + self, + web_paths: List[str], + verify_ssl: bool = True, + trust_env: bool = False, + requests_per_second: Optional[float] = None, + continue_on_failure: bool = True, + headless: bool = True, + remove_selectors: Optional[List[str]] = None, + proxy: Optional[Dict[str, str]] = None, + playwright_ws_url: Optional[str] = None, + playwright_timeout: Optional[int] = 10000, + ): + """Initialize with additional safety parameters and remote browser support.""" + + proxy_server = proxy.get("server") if proxy else None + if trust_env and not proxy_server: + env_proxies = urllib.request.getproxies() + env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + if env_proxy_server: + if proxy: + proxy["server"] = env_proxy_server + else: + proxy = {"server": env_proxy_server} + + # We'll set headless to False if using playwright_ws_url since it's handled by the remote browser + super().__init__( + urls=web_paths, + continue_on_failure=continue_on_failure, + headless=headless if playwright_ws_url is None else False, + remove_selectors=remove_selectors, + proxy=proxy, + ) + self.verify_ssl = verify_ssl + self.requests_per_second = requests_per_second + self.last_request_time = None + self.playwright_ws_url = playwright_ws_url + self.trust_env = trust_env + self.playwright_timeout = playwright_timeout + + def lazy_load(self) -> Iterator[Document]: + """Safely load URLs synchronously with support for remote browser.""" + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + # Use remote browser if ws_endpoint is provided, otherwise use local browser + if self.playwright_ws_url: + browser = p.chromium.connect(self.playwright_ws_url) + else: + browser = p.chromium.launch(headless=self.headless, proxy=self.proxy) + + for url in self.urls: + try: + self._safe_process_url_sync(url) + page = browser.new_page() + response = page.goto(url, timeout=self.playwright_timeout) + if response is None: + raise ValueError(f"page.goto() returned None for url {url}") + + text = self.evaluator.evaluate(page, browser, response) + metadata = {"source": url} + yield Document(page_content=text, metadata=metadata) + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading {url}: {e}") + continue + raise e + browser.close() + + async def alazy_load(self) -> AsyncIterator[Document]: + """Safely load URLs asynchronously with support for remote browser.""" + from playwright.async_api import async_playwright + + async with async_playwright() as p: + # Use remote browser if ws_endpoint is provided, otherwise use local browser + if self.playwright_ws_url: + browser = await p.chromium.connect(self.playwright_ws_url) + else: + browser = await p.chromium.launch( + headless=self.headless, proxy=self.proxy + ) + + for url in self.urls: + try: + await self._safe_process_url(url) + page = await browser.new_page() + response = await page.goto(url, timeout=self.playwright_timeout) + if response is None: + raise ValueError(f"page.goto() returned None for url {url}") + + text = await self.evaluator.evaluate_async(page, browser, response) + metadata = {"source": url} + yield Document(page_content=text, metadata=metadata) + except Exception as e: + if self.continue_on_failure: + log.exception(f"Error loading {url}: {e}") + continue + raise e + await browser.close() + + class SafeWebBaseLoader(WebBaseLoader): """WebBaseLoader with enhanced error handling for URLs.""" + def __init__(self, trust_env: bool = False, *args, **kwargs): + """Initialize SafeWebBaseLoader + Args: + trust_env (bool, optional): set to True if using proxy to make web requests, for example + using http(s)_proxy environment variables. Defaults to False. + """ + super().__init__(*args, **kwargs) + self.trust_env = trust_env + + async def _fetch( + self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5 + ) -> str: + async with aiohttp.ClientSession(trust_env=self.trust_env) as session: + for i in range(retries): + try: + kwargs: Dict = dict( + headers=self.session.headers, + cookies=self.session.cookies.get_dict(), + ) + if not self.session.verify: + kwargs["ssl"] = False + + async with session.get( + url, + **(self.requests_kwargs | kwargs), + ) as response: + if self.raise_for_status: + response.raise_for_status() + return await response.text() + except aiohttp.ClientConnectionError as e: + if i == retries - 1: + raise + else: + log.warning( + f"Error fetching {url} with attempt " + f"{i + 1}/{retries}: {e}. Retrying..." + ) + await asyncio.sleep(cooldown * backoff**i) + raise ValueError("retry count exceeded") + + def _unpack_fetch_results( + self, results: Any, urls: List[str], parser: Union[str, None] = None + ) -> List[Any]: + """Unpack fetch results into BeautifulSoup objects.""" + from bs4 import BeautifulSoup + + final_results = [] + for i, result in enumerate(results): + url = urls[i] + if parser is None: + if url.endswith(".xml"): + parser = "xml" + else: + parser = self.default_parser + self._check_parser(parser) + final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs)) + return final_results + + async def ascrape_all( + self, urls: List[str], parser: Union[str, None] = None + ) -> List[Any]: + """Async fetch all urls, then return soups for all results.""" + results = await self.fetch_all(urls) + return self._unpack_fetch_results(results, urls, parser=parser) + def lazy_load(self) -> Iterator[Document]: """Lazy load text from the url(s) in web_path with error handling.""" for path in self.web_paths: @@ -76,33 +565,86 @@ class SafeWebBaseLoader(WebBaseLoader): text = soup.get_text(**self.bs_get_text_kwargs) # Build metadata - metadata = {"source": path} - if title := soup.find("title"): - metadata["title"] = title.get_text() - if description := soup.find("meta", attrs={"name": "description"}): - metadata["description"] = description.get( - "content", "No description found." - ) - if html := soup.find("html"): - metadata["language"] = html.get("lang", "No language found.") + metadata = extract_metadata(soup, path) yield Document(page_content=text, metadata=metadata) except Exception as e: # Log the error and continue with the next URL - log.error(f"Error loading {path}: {e}") + log.exception(f"Error loading {path}: {e}") + + async def alazy_load(self) -> AsyncIterator[Document]: + """Async lazy load text from the url(s) in web_path.""" + results = await self.ascrape_all(self.web_paths) + for path, soup in zip(self.web_paths, results): + text = soup.get_text(**self.bs_get_text_kwargs) + metadata = {"source": path} + if title := soup.find("title"): + metadata["title"] = title.get_text() + if description := soup.find("meta", attrs={"name": "description"}): + metadata["description"] = description.get( + "content", "No description found." + ) + if html := soup.find("html"): + metadata["language"] = html.get("lang", "No language found.") + yield Document(page_content=text, metadata=metadata) + + async def aload(self) -> list[Document]: + """Load data into Document objects.""" + return [document async for document in self.alazy_load()] def get_web_loader( urls: Union[str, Sequence[str]], verify_ssl: bool = True, requests_per_second: int = 2, + trust_env: bool = False, ): # Check if the URLs are valid safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) - return SafeWebBaseLoader( - safe_urls, - verify_ssl=verify_ssl, - requests_per_second=requests_per_second, - continue_on_failure=True, - ) + web_loader_args = { + "web_paths": safe_urls, + "verify_ssl": verify_ssl, + "requests_per_second": requests_per_second, + "continue_on_failure": True, + "trust_env": trust_env, + } + + if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web": + WebLoaderClass = SafeWebBaseLoader + if WEB_LOADER_ENGINE.value == "playwright": + WebLoaderClass = SafePlaywrightURLLoader + web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000 + if PLAYWRIGHT_WS_URL.value: + web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value + + if WEB_LOADER_ENGINE.value == "firecrawl": + WebLoaderClass = SafeFireCrawlLoader + web_loader_args["api_key"] = FIRECRAWL_API_KEY.value + web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value + + if WEB_LOADER_ENGINE.value == "tavily": + WebLoaderClass = SafeTavilyLoader + web_loader_args["api_key"] = TAVILY_API_KEY.value + web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value + + if WEB_LOADER_ENGINE.value == "external": + WebLoaderClass = ExternalWebLoader + web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value + web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value + + if WebLoaderClass: + web_loader = WebLoaderClass(**web_loader_args) + + log.debug( + "Using WEB_LOADER_ENGINE %s for %s URLs", + web_loader.__class__.__name__, + len(safe_urls), + ) + + return web_loader + else: + raise ValueError( + f"Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. " + "Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'." + ) diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py new file mode 100644 index 0000000000..bc61425cbc --- /dev/null +++ b/backend/open_webui/retrieval/web/yacy.py @@ -0,0 +1,87 @@ +import logging +from typing import Optional + +import requests +from requests.auth import HTTPDigestAuth +from open_webui.retrieval.web.main import SearchResult, get_filtered_results +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_yacy( + query_url: str, + username: Optional[str], + password: Optional[str], + query: str, + count: int, + filter_list: Optional[list[str]] = None, +) -> list[SearchResult]: + """ + Search a Yacy instance for a given query and return the results as a list of SearchResult objects. + + The function accepts username and password for authenticating to Yacy. + + Args: + query_url (str): The base URL of the Yacy server. + username (str): Optional YaCy username. + password (str): Optional YaCy password. + query (str): The search term or question to find in the Yacy database. + count (int): The maximum number of results to retrieve from the search. + + Returns: + list[SearchResult]: A list of SearchResults sorted by relevance score in descending order. + + Raise: + requests.exceptions.RequestException: If a request error occurs during the search process. + """ + + # Use authentication if either username or password is set + yacy_auth = None + if username or password: + yacy_auth = HTTPDigestAuth(username, password) + + params = { + "query": query, + "contentdom": "text", + "resource": "global", + "maximumRecords": count, + "nav": "none", + } + + # Check if provided a json API URL + if not query_url.endswith("yacysearch.json"): + # Strip all query parameters from the URL + query_url = query_url.rstrip("/") + "/yacysearch.json" + + log.debug(f"searching {query_url}") + + response = requests.get( + query_url, + auth=yacy_auth, + headers={ + "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", + "Accept": "text/html", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.5", + "Connection": "keep-alive", + }, + params=params, + ) + + response.raise_for_status() # Raise an exception for HTTP errors. + + json_response = response.json() + results = json_response.get("channels", [{}])[0].get("items", []) + sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True) + if filter_list: + sorted_results = get_filtered_results(sorted_results, filter_list) + return [ + SearchResult( + link=result["link"], + title=result.get("title"), + snippet=result.get("description"), + ) + for result in sorted_results[:count] + ] diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index e2d05ba908..eac5839d96 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -7,6 +7,9 @@ from functools import lru_cache from pathlib import Path from pydub import AudioSegment from pydub.silence import split_on_silence +from concurrent.futures import ThreadPoolExecutor +from typing import Optional + import aiohttp import aiofiles @@ -17,6 +20,7 @@ from fastapi import ( Depends, FastAPI, File, + Form, HTTPException, Request, UploadFile, @@ -33,10 +37,13 @@ from open_webui.config import ( WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_DIR, CACHE_DIR, + WHISPER_LANGUAGE, ) from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_TIMEOUT, ENV, SRC_LOG_LEVELS, DEVICE_TYPE, @@ -47,13 +54,15 @@ from open_webui.env import ( router = APIRouter() # Constants -MAX_FILE_SIZE_MB = 25 +MAX_FILE_SIZE_MB = 20 MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes +AZURE_MAX_FILE_SIZE_MB = 200 +AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["AUDIO"]) -SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") +SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -67,27 +76,47 @@ from pydub import AudioSegment from pydub.utils import mediainfo -def is_mp4_audio(file_path): - """Check if the given file is an MP4 audio file.""" +def is_audio_conversion_required(file_path): + """ + Check if the given audio file needs conversion to mp3. + """ + SUPPORTED_FORMATS = {"flac", "m4a", "mp3", "mp4", "mpeg", "wav", "webm"} + if not os.path.isfile(file_path): - print(f"File not found: {file_path}") + log.error(f"File not found: {file_path}") return False - info = mediainfo(file_path) - if ( - info.get("codec_name") == "aac" - and info.get("codec_type") == "audio" - and info.get("codec_tag_string") == "mp4a" - ): + try: + info = mediainfo(file_path) + codec_name = info.get("codec_name", "").lower() + codec_type = info.get("codec_type", "").lower() + codec_tag_string = info.get("codec_tag_string", "").lower() + + if codec_name == "aac" and codec_type == "audio" and codec_tag_string == "mp4a": + # File is AAC/mp4a audio, recommend mp3 conversion + return True + + # If the codec name is in the supported formats + if codec_name in SUPPORTED_FORMATS: + return False + return True - return False + except Exception as e: + log.error(f"Error getting audio format: {e}") + return False -def convert_mp4_to_wav(file_path, output_path): - """Convert MP4 audio file to WAV format.""" - audio = AudioSegment.from_file(file_path, format="mp4") - audio.export(output_path, format="wav") - print(f"Converted {file_path} to {output_path}") +def convert_audio_to_mp3(file_path): + """Convert audio file to mp3 format.""" + try: + output_path = os.path.splitext(file_path)[0] + ".mp3" + audio = AudioSegment.from_file(file_path) + audio.export(output_path, format="mp3") + log.info(f"Converted {file_path} to {output_path}") + return output_path + except Exception as e: + log.error(f"Error converting audio file: {e}") + return None def set_faster_whisper_model(model: str, auto_update: bool = False): @@ -130,6 +159,7 @@ class TTSConfigForm(BaseModel): VOICE: str SPLIT_ON: str AZURE_SPEECH_REGION: str + AZURE_SPEECH_BASE_URL: str AZURE_SPEECH_OUTPUT_FORMAT: str @@ -140,6 +170,11 @@ class STTConfigForm(BaseModel): MODEL: str WHISPER_MODEL: str DEEPGRAM_API_KEY: str + AZURE_API_KEY: str + AZURE_REGION: str + AZURE_LOCALES: str + AZURE_BASE_URL: str + AZURE_MAX_SPEAKERS: str class AudioConfigUpdateForm(BaseModel): @@ -159,6 +194,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): "VOICE": request.app.state.config.TTS_VOICE, "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, + "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, "stt": { @@ -168,6 +204,11 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): "MODEL": request.app.state.config.STT_MODEL, "WHISPER_MODEL": request.app.state.config.WHISPER_MODEL, "DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY, + "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, + "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, + "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, + "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, }, } @@ -184,6 +225,9 @@ async def update_audio_config( request.app.state.config.TTS_VOICE = form_data.tts.VOICE request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION + request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = ( + form_data.tts.AZURE_SPEECH_BASE_URL + ) request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = ( form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT ) @@ -194,6 +238,13 @@ async def update_audio_config( request.app.state.config.STT_MODEL = form_data.stt.MODEL request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY + request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY + request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION + request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES + request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL + request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = ( + form_data.stt.AZURE_MAX_SPEAKERS + ) if request.app.state.config.STT_ENGINE == "": request.app.state.faster_whisper_model = set_faster_whisper_model( @@ -210,6 +261,7 @@ async def update_audio_config( "VOICE": request.app.state.config.TTS_VOICE, "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, + "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, "stt": { @@ -219,6 +271,11 @@ async def update_audio_config( "MODEL": request.app.state.config.STT_MODEL, "WHISPER_MODEL": request.app.state.config.WHISPER_MODEL, "DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY, + "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, + "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, + "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, + "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, }, } @@ -265,8 +322,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): payload["model"] = request.app.state.config.TTS_MODEL try: - # print(payload) - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession( + timeout=timeout, trust_env=True + ) as session: async with session.post( url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", json=payload, @@ -284,6 +343,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): else {} ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() @@ -309,7 +369,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = f"External: {e}" raise HTTPException( - status_code=getattr(r, "status", 500), + status_code=getattr(r, "status", 500) if r else 500, detail=detail if detail else "Open WebUI: Server Connection Error", ) @@ -323,7 +383,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): ) try: - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession( + timeout=timeout, trust_env=True + ) as session: async with session.post( f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", json={ @@ -336,6 +399,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): "Content-Type": "application/json", "xi-api-key": request.app.state.config.TTS_API_KEY, }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() @@ -360,7 +424,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = f"External: {e}" raise HTTPException( - status_code=getattr(r, "status", 500), + status_code=getattr(r, "status", 500) if r else 500, detail=detail if detail else "Open WebUI: Server Connection Error", ) @@ -371,7 +435,8 @@ async def speech(request: Request, user=Depends(get_verified_user)): log.exception(e) raise HTTPException(status_code=400, detail="Invalid JSON payload") - region = request.app.state.config.TTS_AZURE_SPEECH_REGION + region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus" + base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL language = request.app.state.config.TTS_VOICE locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:1]) output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT @@ -380,15 +445,20 @@ async def speech(request: Request, user=Depends(get_verified_user)): data = f""" {payload["input"]} """ - async with aiohttp.ClientSession() as session: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + async with aiohttp.ClientSession( + timeout=timeout, trust_env=True + ) as session: async with session.post( - f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1", + (base_url or f"https://{region}.tts.speech.microsoft.com") + + "/cognitiveservices/v1", headers={ "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": output_format, }, data=data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() @@ -413,7 +483,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = f"External: {e}" raise HTTPException( - status_code=getattr(r, "status", 500), + status_code=getattr(r, "status", 500) if r else 500, detail=detail if detail else "Open WebUI: Server Connection Error", ) @@ -457,12 +527,13 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) -def transcribe(request: Request, file_path): - print("transcribe", file_path) +def transcription_handler(request, file_path, metadata): filename = os.path.basename(file_path) file_dir = os.path.dirname(file_path) id = filename.split(".")[0] + metadata = metadata or {} + if request.app.state.config.STT_ENGINE == "": if request.app.state.faster_whisper_model is None: request.app.state.faster_whisper_model = set_faster_whisper_model( @@ -470,7 +541,12 @@ def transcribe(request: Request, file_path): ) model = request.app.state.faster_whisper_model - segments, info = model.transcribe(file_path, beam_size=5) + segments, info = model.transcribe( + file_path, + beam_size=5, + vad_filter=request.app.state.config.WHISPER_VAD_FILTER, + language=metadata.get("language") or WHISPER_LANGUAGE, + ) log.info( "Detected language '%s' with probability %f" % (info.language, info.language_probability) @@ -487,11 +563,6 @@ def transcribe(request: Request, file_path): log.debug(data) return data elif request.app.state.config.STT_ENGINE == "openai": - if is_mp4_audio(file_path): - os.rename(file_path, file_path.replace(".wav", ".mp4")) - # Convert MP4 audio file to WAV format - convert_mp4_to_wav(file_path.replace(".wav", ".mp4"), file_path) - r = None try: r = requests.post( @@ -500,7 +571,14 @@ def transcribe(request: Request, file_path): "Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}" }, files={"file": (filename, open(file_path, "rb"))}, - data={"model": request.app.state.config.STT_MODEL}, + data={ + "model": request.app.state.config.STT_MODEL, + **( + {"language": metadata.get("language")} + if metadata.get("language") + else {} + ), + }, ) r.raise_for_status() @@ -589,34 +667,254 @@ def transcribe(request: Request, file_path): detail = f"External: {e}" raise Exception(detail if detail else "Open WebUI: Server Connection Error") + elif request.app.state.config.STT_ENGINE == "azure": + # Check file exists and size + if not os.path.exists(file_path): + raise HTTPException(status_code=400, detail="Audio file not found") + + # Check file size (Azure has a larger limit of 200MB) + file_size = os.path.getsize(file_path) + if file_size > AZURE_MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File size exceeds Azure's limit of {AZURE_MAX_FILE_SIZE_MB}MB", + ) + + api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY + region = request.app.state.config.AUDIO_STT_AZURE_REGION or "eastus" + locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES + base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL + max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3 + + # IF NO LOCALES, USE DEFAULTS + if len(locales) < 2: + locales = [ + "en-US", + "es-ES", + "es-MX", + "fr-FR", + "hi-IN", + "it-IT", + "de-DE", + "en-GB", + "en-IN", + "ja-JP", + "ko-KR", + "pt-BR", + "zh-CN", + ] + locales = ",".join(locales) + + if not api_key or not region: + raise HTTPException( + status_code=400, + detail="Azure API key is required for Azure STT", + ) + + r = None + try: + # Prepare the request + data = { + "definition": json.dumps( + { + "locales": locales.split(","), + "diarization": {"maxSpeakers": max_speakers, "enabled": True}, + } + if locales + else {} + ) + } + + url = ( + base_url or f"https://{region}.api.cognitive.microsoft.com" + ) + "/speechtotext/transcriptions:transcribe?api-version=2024-11-15" + + # Use context manager to ensure file is properly closed + with open(file_path, "rb") as audio_file: + r = requests.post( + url=url, + files={"audio": audio_file}, + data=data, + headers={ + "Ocp-Apim-Subscription-Key": api_key, + }, + ) + + r.raise_for_status() + response = r.json() + + # Extract transcript from response + if not response.get("combinedPhrases"): + raise ValueError("No transcription found in response") + + # Get the full transcript from combinedPhrases + transcript = response["combinedPhrases"][0].get("text", "").strip() + if not transcript: + raise ValueError("Empty transcript in response") + + data = {"text": transcript} + + # Save transcript to json file (consistent with other providers) + transcript_file = f"{file_dir}/{id}.json" + with open(transcript_file, "w") as f: + json.dump(data, f) + + log.debug(data) + return data + + except (KeyError, IndexError, ValueError) as e: + log.exception("Error parsing Azure response") + raise HTTPException( + status_code=500, + detail=f"Failed to parse Azure response: {str(e)}", + ) + except requests.exceptions.RequestException as e: + log.exception(e) + detail = None + + try: + if r is not None and r.status_code != 200: + res = r.json() + if "error" in res: + detail = f"External: {res['error'].get('message', '')}" + except Exception: + detail = f"External: {e}" + + raise HTTPException( + status_code=getattr(r, "status_code", 500) if r else 500, + detail=detail if detail else "Open WebUI: Server Connection Error", + ) + + +def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None): + log.info(f"transcribe: {file_path} {metadata}") + + if is_audio_conversion_required(file_path): + file_path = convert_audio_to_mp3(file_path) + + try: + file_path = compress_audio(file_path) + except Exception as e: + log.exception(e) + + # Always produce a list of chunk paths (could be one entry if small) + try: + chunk_paths = split_audio(file_path, MAX_FILE_SIZE) + print(f"Chunk paths: {chunk_paths}") + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + results = [] + try: + with ThreadPoolExecutor() as executor: + # Submit tasks for each chunk_path + futures = [ + executor.submit(transcription_handler, request, chunk_path, metadata) + for chunk_path in chunk_paths + ] + # Gather results as they complete + for future in futures: + try: + results.append(future.result()) + except Exception as transcribe_exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error transcribing chunk: {transcribe_exc}", + ) + finally: + # Clean up only the temporary chunks, never the original file + for chunk_path in chunk_paths: + if chunk_path != file_path and os.path.isfile(chunk_path): + try: + os.remove(chunk_path) + except Exception: + pass + + return { + "text": " ".join([result["text"] for result in results]), + } + def compress_audio(file_path): if os.path.getsize(file_path) > MAX_FILE_SIZE: + id = os.path.splitext(os.path.basename(file_path))[ + 0 + ] # Handles names with multiple dots file_dir = os.path.dirname(file_path) + audio = AudioSegment.from_file(file_path) audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio - compressed_path = f"{file_dir}/{id}_compressed.opus" - audio.export(compressed_path, format="opus", bitrate="32k") - log.debug(f"Compressed audio to {compressed_path}") - if ( - os.path.getsize(compressed_path) > MAX_FILE_SIZE - ): # Still larger than MAX_FILE_SIZE after compression - raise Exception(ERROR_MESSAGES.FILE_TOO_LARGE(size=f"{MAX_FILE_SIZE_MB}MB")) + compressed_path = os.path.join(file_dir, f"{id}_compressed.mp3") + audio.export(compressed_path, format="mp3", bitrate="32k") + # log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined + return compressed_path else: return file_path +def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"): + """ + Splits audio into chunks not exceeding max_bytes. + Returns a list of chunk file paths. If audio fits, returns list with original path. + """ + file_size = os.path.getsize(file_path) + if file_size <= max_bytes: + return [file_path] # Nothing to split + + audio = AudioSegment.from_file(file_path) + duration_ms = len(audio) + orig_size = file_size + + approx_chunk_ms = max(int(duration_ms * (max_bytes / orig_size)) - 1000, 1000) + chunks = [] + start = 0 + i = 0 + + base, _ = os.path.splitext(file_path) + + while start < duration_ms: + end = min(start + approx_chunk_ms, duration_ms) + chunk = audio[start:end] + chunk_path = f"{base}_chunk_{i}.{format}" + chunk.export(chunk_path, format=format, bitrate=bitrate) + + # Reduce chunk duration if still too large + while os.path.getsize(chunk_path) > max_bytes and (end - start) > 5000: + end = start + ((end - start) // 2) + chunk = audio[start:end] + chunk.export(chunk_path, format=format, bitrate=bitrate) + + if os.path.getsize(chunk_path) > max_bytes: + os.remove(chunk_path) + raise Exception("Audio chunk cannot be reduced below max file size.") + + chunks.append(chunk_path) + start = end + i += 1 + + return chunks + + @router.post("/transcriptions") def transcription( request: Request, file: UploadFile = File(...), + language: Optional[str] = Form(None), user=Depends(get_verified_user), ): log.info(f"file.content_type: {file.content_type}") - if file.content_type not in ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-m4a"]: + SUPPORTED_CONTENT_TYPES = {"video/webm"} # Extend if you add more video types! + if not ( + file.content_type.startswith("audio/") + or file.content_type in SUPPORTED_CONTENT_TYPES + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, @@ -637,19 +935,18 @@ def transcription( f.write(contents) try: - try: - file_path = compress_audio(file_path) - except Exception as e: - log.exception(e) + metadata = None - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), - ) + if language: + metadata = {"language": language} + + result = transcribe(request, file_path, metadata) + + return { + **result, + "filename": os.path.basename(file_path), + } - data = transcribe(request, file_path) - file_path = file_path.split("/")[-1] - return {**data, "filename": file_path} except Exception as e: log.exception(e) @@ -670,7 +967,22 @@ def transcription( def get_available_models(request: Request) -> list[dict]: available_models = [] if request.app.state.config.TTS_ENGINE == "openai": - available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] + # Use custom endpoint if not using the official OpenAI API URL + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( + "https://api.openai.com" + ): + try: + response = requests.get( + f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models" + ) + response.raise_for_status() + data = response.json() + available_models = data.get("models", []) + except Exception as e: + log.error(f"Error fetching models from custom endpoint: {str(e)}") + available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] + else: + available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] elif request.app.state.config.TTS_ENGINE == "elevenlabs": try: response = requests.get( @@ -701,14 +1013,37 @@ def get_available_voices(request) -> dict: """Returns {voice_id: voice_name} dict""" available_voices = {} if request.app.state.config.TTS_ENGINE == "openai": - available_voices = { - "alloy": "alloy", - "echo": "echo", - "fable": "fable", - "onyx": "onyx", - "nova": "nova", - "shimmer": "shimmer", - } + # Use custom endpoint if not using the official OpenAI API URL + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( + "https://api.openai.com" + ): + try: + response = requests.get( + f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices" + ) + response.raise_for_status() + data = response.json() + voices_list = data.get("voices", []) + available_voices = {voice["id"]: voice["name"] for voice in voices_list} + except Exception as e: + log.error(f"Error fetching voices from custom endpoint: {str(e)}") + available_voices = { + "alloy": "alloy", + "echo": "echo", + "fable": "fable", + "onyx": "onyx", + "nova": "nova", + "shimmer": "shimmer", + } + else: + available_voices = { + "alloy": "alloy", + "echo": "echo", + "fable": "fable", + "onyx": "onyx", + "nova": "nova", + "shimmer": "shimmer", + } elif request.app.state.config.TTS_ENGINE == "elevenlabs": try: available_voices = get_elevenlabs_voices( @@ -720,7 +1055,10 @@ def get_available_voices(request) -> dict: elif request.app.state.config.TTS_ENGINE == "azure": try: region = request.app.state.config.TTS_AZURE_SPEECH_REGION - url = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/voices/list" + base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL + url = ( + base_url or f"https://{region}.tts.speech.microsoft.com" + ) + "/cognitiveservices/voices/list" headers = { "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY } diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index b6a2c75628..06e506228a 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -19,40 +19,45 @@ from open_webui.models.auths import ( UserResponse, ) from open_webui.models.users import Users +from open_webui.models.groups import Groups from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES from open_webui.env import ( WEBUI_AUTH, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, + WEBUI_AUTH_TRUSTED_GROUPS_HEADER, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, + WEBUI_AUTH_SIGNOUT_REDIRECT_URL, SRC_LOG_LEVELS, ) from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import RedirectResponse, Response -from open_webui.config import ( - OPENID_PROVIDER_URL, - ENABLE_OAUTH_SIGNUP, -) +from fastapi.responses import RedirectResponse, Response, JSONResponse +from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP from pydantic import BaseModel + from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.auth import ( + decode_token, create_api_key, create_token, get_admin_user, get_verified_user, get_current_user, get_password_hash, + get_http_authorization_cred, ) from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions from typing import Optional, List -from ssl import CERT_REQUIRED, PROTOCOL_TLS -from ldap3 import Server, Connection, NONE, Tls -from ldap3.utils.conv import escape_filter_chars +from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS + +if ENABLE_LDAP.value: + from ldap3 import Server, Connection, NONE, Tls + from ldap3.utils.conv import escape_filter_chars router = APIRouter() @@ -73,31 +78,36 @@ class SessionUserResponse(Token, UserResponse): async def get_session_user( request: Request, response: Response, user=Depends(get_current_user) ): - expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + + auth_header = request.headers.get("Authorization") + auth_token = get_http_authorization_cred(auth_header) + token = auth_token.credentials + data = decode_token(token) + expires_at = None - if expires_delta: - expires_at = int(time.time()) + int(expires_delta.total_seconds()) - token = create_token( - data={"id": user.id}, - expires_delta=expires_delta, - ) + if data: + expires_at = data.get("exp") - datetime_expires_at = ( - datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) - if expires_at - else None - ) + if (expires_at is not None) and int(time.time()) > expires_at: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.INVALID_TOKEN, + ) - # Set the cookie token - response.set_cookie( - key="token", - value=token, - expires=datetime_expires_at, - httponly=True, # Ensures the cookie is not accessible via JavaScript - samesite=WEBUI_AUTH_COOKIE_SAME_SITE, - secure=WEBUI_AUTH_COOKIE_SECURE, - ) + # Set the cookie token + response.set_cookie( + key="token", + value=token, + expires=( + datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) + if expires_at + else None + ), + httponly=True, # Ensures the cookie is not accessible via JavaScript + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, + ) user_permissions = get_permissions( user.id, request.app.state.config.USER_PERMISSIONS @@ -178,6 +188,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE + LDAP_VALIDATE_CERT = ( + CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE + ) LDAP_CIPHERS = ( request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS @@ -189,14 +202,14 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): try: tls = Tls( - validate=CERT_REQUIRED, + validate=LDAP_VALIDATE_CERT, version=PROTOCOL_TLS, ca_certs_file=LDAP_CA_CERT_FILE, ciphers=LDAP_CIPHERS, ) except Exception as e: - log.error(f"An error occurred on TLS: {str(e)}") - raise HTTPException(400, detail=str(e)) + log.error(f"TLS configuration error: {str(e)}") + raise HTTPException(400, detail="Failed to configure TLS for LDAP connection.") try: server = Server( @@ -211,7 +224,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): LDAP_APP_DN, LDAP_APP_PASSWORD, auto_bind="NONE", - authentication="SIMPLE", + authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS", ) if not connection_app.bind(): raise HTTPException(400, detail="Application account bind failed") @@ -226,14 +239,23 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ], ) - if not search_success: + if not search_success or not connection_app.entries: raise HTTPException(400, detail="User not found in the LDAP server") entry = connection_app.entries[0] username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() - mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]) - if not mail or mail == "" or mail == "[]": - raise HTTPException(400, f"User {form_data.user} does not have mail.") + email = entry[ + f"{LDAP_ATTRIBUTE_FOR_MAIL}" + ].value # retrieve the Attribute value + if not email: + raise HTTPException(400, "User does not have a valid email address.") + elif isinstance(email, str): + email = email.lower() + elif isinstance(email, list): + email = email[0].lower() + else: + email = str(email).lower() + cn = str(entry["cn"]) user_dn = entry.entry_dn @@ -246,19 +268,24 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): authentication="SIMPLE", ) if not connection_user.bind(): - raise HTTPException(400, f"Authentication failed for {form_data.user}") + raise HTTPException(400, "Authentication failed.") - user = Users.get_user_by_email(mail) + user = Users.get_user_by_email(email) if not user: try: + user_count = Users.get_num_users() + role = ( "admin" - if Users.get_num_users() == 0 + if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE ) user = Auths.insert_new_auth( - email=mail, password=str(uuid.uuid4()), name=cn, role=role + email=email, + password=str(uuid.uuid4()), + name=cn, + role=role, ) if not user: @@ -269,23 +296,38 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): except HTTPException: raise except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"LDAP user creation error: {str(err)}") + raise HTTPException( + 500, detail="Internal error occurred during LDAP user creation." + ) - user = Auths.authenticate_user_by_trusted_header(mail) + user = Auths.authenticate_user_by_email(email) if user: + expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) + expires_at = None + if expires_delta: + expires_at = int(time.time()) + int(expires_delta.total_seconds()) + token = create_token( data={"id": user.id}, - expires_delta=parse_duration( - request.app.state.config.JWT_EXPIRES_IN - ), + expires_delta=expires_delta, ) # Set the cookie token response.set_cookie( key="token", value=token, + expires=( + datetime.datetime.fromtimestamp( + expires_at, datetime.timezone.utc + ) + if expires_at + else None + ), httponly=True, # Ensures the cookie is not accessible via JavaScript + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, ) user_permissions = get_permissions( @@ -295,6 +337,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): return { "token": token, "token_type": "Bearer", + "expires_at": expires_at, "id": user.id, "email": user.email, "name": user.name, @@ -305,12 +348,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: - raise HTTPException( - 400, - f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}", - ) + raise HTTPException(400, "User record mismatch.") except Exception as e: - raise HTTPException(400, detail=str(e)) + log.error(f"LDAP authentication error: {str(e)}") + raise HTTPException(400, detail="LDAP authentication failed.") ############################ @@ -324,21 +365,29 @@ async def signin(request: Request, response: Response, form_data: SigninForm): if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER) - trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() - trusted_name = trusted_email + email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() + name = email + if WEBUI_AUTH_TRUSTED_NAME_HEADER: - trusted_name = request.headers.get( - WEBUI_AUTH_TRUSTED_NAME_HEADER, trusted_email - ) - if not Users.get_user_by_email(trusted_email.lower()): + name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email) + + if not Users.get_user_by_email(email.lower()): await signup( request, response, - SignupForm( - email=trusted_email, password=str(uuid.uuid4()), name=trusted_name - ), + SignupForm(email=email, password=str(uuid.uuid4()), name=name), ) - user = Auths.authenticate_user_by_trusted_header(trusted_email) + + user = Auths.authenticate_user_by_email(email) + if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin": + group_names = request.headers.get( + WEBUI_AUTH_TRUSTED_GROUPS_HEADER, "" + ).split(",") + group_names = [name.strip() for name in group_names if name.strip()] + + if group_names: + Groups.sync_user_groups_by_group_names(user.id, group_names) + elif WEBUI_AUTH == False: admin_email = "admin@localhost" admin_password = "admin" @@ -413,6 +462,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): @router.post("/signup", response_model=SessionUserResponse) async def signup(request: Request, response: Response, form_data: SignupForm): + if WEBUI_AUTH: if ( not request.app.state.config.ENABLE_SIGNUP @@ -427,6 +477,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED ) + user_count = Users.get_num_users() if not validate_email_format(form_data.email.lower()): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT @@ -437,14 +488,15 @@ async def signup(request: Request, response: Response, form_data: SignupForm): try: role = ( - "admin" - if Users.get_num_users() == 0 - else request.app.state.config.DEFAULT_USER_ROLE + "admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE ) - if Users.get_num_users() == 0: - # Disable signup after the first user is created - request.app.state.config.ENABLE_SIGNUP = False + # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing. + if len(form_data.password.encode("utf-8")) > 72: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.PASSWORD_TOO_LONG, + ) hashed = get_password_hash(form_data.password) user = Auths.insert_new_auth( @@ -484,6 +536,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): if request.app.state.config.WEBHOOK_URL: post_webhook( + request.app.state.WEBUI_NAME, request.app.state.config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { @@ -497,6 +550,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm): user.id, request.app.state.config.USER_PERMISSIONS ) + if user_count == 0: + # Disable signup after the first user is created + request.app.state.config.ENABLE_SIGNUP = False + return { "token": token, "token_type": "Bearer", @@ -511,7 +568,8 @@ async def signup(request: Request, response: Response, form_data: SignupForm): else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"Signup error: {str(err)}") + raise HTTPException(500, detail="An internal error occurred during signup.") @router.get("/signout") @@ -529,8 +587,14 @@ async def signout(request: Request, response: Response): logout_url = openid_data.get("end_session_endpoint") if logout_url: response.delete_cookie("oauth_id_token") - return RedirectResponse( - url=f"{logout_url}?id_token_hint={oauth_id_token}" + + return JSONResponse( + status_code=200, + content={ + "status": True, + "redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}", + }, + headers=response.headers, ) else: raise HTTPException( @@ -538,9 +602,25 @@ async def signout(request: Request, response: Response): detail="Failed to fetch OpenID configuration", ) except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + log.error(f"OpenID signout error: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to sign out from the OpenID provider.", + ) - return {"status": True} + if WEBUI_AUTH_SIGNOUT_REDIRECT_URL: + return JSONResponse( + status_code=200, + content={ + "status": True, + "redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL, + }, + headers=response.headers, + ) + + return JSONResponse( + status_code=200, content={"status": True}, headers=response.headers + ) ############################ @@ -582,7 +662,10 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) except Exception as err: - raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + log.error(f"Add user error: {str(err)}") + raise HTTPException( + 500, detail="An internal error occurred while adding the user." + ) ############################ @@ -596,7 +679,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)): admin_email = request.app.state.config.ADMIN_EMAIL admin_name = None - print(admin_email, admin_name) + log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}") if admin_email: admin = Users.get_user_by_email(admin_email) @@ -630,11 +713,16 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, + "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, + "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, + "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, + "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, } @@ -645,11 +733,16 @@ class AdminConfig(BaseModel): ENABLE_API_KEY: bool ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool API_KEY_ALLOWED_ENDPOINTS: str - ENABLE_CHANNELS: bool DEFAULT_USER_ROLE: str JWT_EXPIRES_IN: str ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool + ENABLE_CHANNELS: bool + ENABLE_NOTES: bool + ENABLE_USER_WEBHOOKS: bool + PENDING_USER_OVERLAY_TITLE: Optional[str] = None + PENDING_USER_OVERLAY_CONTENT: Optional[str] = None + RESPONSE_WATERMARK: Optional[str] = None @router.post("/admin/config") @@ -669,6 +762,7 @@ async def update_admin_config( ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS + request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE @@ -684,6 +778,17 @@ async def update_admin_config( ) request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING + request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS + + request.app.state.config.PENDING_USER_OVERLAY_TITLE = ( + form_data.PENDING_USER_OVERLAY_TITLE + ) + request.app.state.config.PENDING_USER_OVERLAY_CONTENT = ( + form_data.PENDING_USER_OVERLAY_CONTENT + ) + + request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK + return { "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "WEBUI_URL": request.app.state.config.WEBUI_URL, @@ -691,11 +796,16 @@ async def update_admin_config( "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, + "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, + "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, + "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, + "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, + "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, } @@ -711,6 +821,7 @@ class LdapServerConfig(BaseModel): search_filters: str = "" use_tls: bool = True certificate_path: Optional[str] = None + validate_cert: bool = True ciphers: Optional[str] = "ALL" @@ -728,6 +839,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)): "search_filters": request.app.state.config.LDAP_SEARCH_FILTERS, "use_tls": request.app.state.config.LDAP_USE_TLS, "certificate_path": request.app.state.config.LDAP_CA_CERT_FILE, + "validate_cert": request.app.state.config.LDAP_VALIDATE_CERT, "ciphers": request.app.state.config.LDAP_CIPHERS, } @@ -750,11 +862,6 @@ async def update_ldap_server( if not value: raise HTTPException(400, detail=f"Required field {key} is empty") - if form_data.use_tls and not form_data.certificate_path: - raise HTTPException( - 400, detail="TLS is enabled but certificate file path is missing" - ) - request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_HOST = form_data.host request.app.state.config.LDAP_SERVER_PORT = form_data.port @@ -768,6 +875,7 @@ async def update_ldap_server( request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters request.app.state.config.LDAP_USE_TLS = form_data.use_tls request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path + request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert request.app.state.config.LDAP_CIPHERS = form_data.ciphers return { @@ -782,6 +890,7 @@ async def update_ldap_server( "search_filters": request.app.state.config.LDAP_SEARCH_FILTERS, "use_tls": request.app.state.config.LDAP_USE_TLS, "certificate_path": request.app.state.config.LDAP_CA_CERT_FILE, + "validate_cert": request.app.state.config.LDAP_VALIDATE_CERT, "ciphers": request.app.state.config.LDAP_CIPHERS, } diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index da6a8d01f5..6da3f04cee 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -192,7 +192,7 @@ async def get_channel_messages( ############################ -async def send_notification(webui_url, channel, message, active_user_ids): +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: @@ -206,6 +206,7 @@ async def send_notification(webui_url, channel, message, active_user_ids): if webhook_url: post_webhook( + name, webhook_url, f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}", { @@ -302,6 +303,7 @@ async def post_new_message( background_tasks.add_task( send_notification, + request.app.state.WEBUI_NAME, request.app.state.config.WEBUI_URL, channel, message, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2efd043efe..29b12ed676 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -2,6 +2,8 @@ import json import logging from typing import Optional + +from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, ChatImportForm, @@ -74,17 +76,34 @@ async def delete_all_user_chats(request: Request, user=Depends(get_verified_user @router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_user_id( user_id: str, + page: Optional[int] = None, + query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, user=Depends(get_admin_user), - skip: int = 0, - limit: int = 50, ): if not ENABLE_ADMIN_CHAT_ACCESS: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + + if page is None: + page = 1 + + limit = 60 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + return Chats.get_chat_list_by_user_id( - user_id, include_archived=True, skip=skip, limit=limit + user_id, include_archived=True, filter=filter, skip=skip, limit=limit ) @@ -192,10 +211,10 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user) ############################ -@router.get("/pinned", response_model=list[ChatResponse]) +@router.get("/pinned", response_model=list[ChatTitleIdResponse]) async def get_user_pinned_chats(user=Depends(get_verified_user)): return [ - ChatResponse(**chat.model_dump()) + ChatTitleIdResponse(**chat.model_dump()) for chat in Chats.get_pinned_chats_by_user_id(user.id) ] @@ -265,9 +284,37 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user)): @router.get("/archived", response_model=list[ChatTitleIdResponse]) async def get_archived_session_user_chat_list( - user=Depends(get_verified_user), skip: int = 0, limit: int = 50 + page: Optional[int] = None, + query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + user=Depends(get_verified_user), ): - return Chats.get_archived_chat_list_by_user_id(user.id, skip, limit) + if page is None: + page = 1 + + limit = 60 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + chat_list = [ + ChatTitleIdResponse(**chat.model_dump()) + for chat in Chats.get_archived_chat_list_by_user_id( + user.id, + filter=filter, + skip=skip, + limit=limit, + ) + ] + + return chat_list ############################ @@ -372,6 +419,107 @@ async def update_chat_by_id( ) +############################ +# UpdateChatMessageById +############################ +class MessageForm(BaseModel): + content: str + + +@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse]) +async def update_chat_message_by_id( + id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user) +): + chat = Chats.get_chat_by_id(id) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chat = Chats.upsert_message_to_chat_by_id_and_message_id( + id, + message_id, + { + "content": form_data.content, + }, + ) + + event_emitter = get_event_emitter( + { + "user_id": user.id, + "chat_id": id, + "message_id": message_id, + }, + False, + ) + + if event_emitter: + await event_emitter( + { + "type": "chat:message", + "data": { + "chat_id": id, + "message_id": message_id, + "content": form_data.content, + }, + } + ) + + return ChatResponse(**chat.model_dump()) + + +############################ +# SendChatMessageEventById +############################ +class EventForm(BaseModel): + type: str + data: dict + + +@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool]) +async def send_chat_message_event_by_id( + id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user) +): + chat = Chats.get_chat_by_id(id) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + event_emitter = get_event_emitter( + { + "user_id": user.id, + "chat_id": id, + "message_id": message_id, + } + ) + + try: + if event_emitter: + await event_emitter(form_data.model_dump()) + else: + return False + return True + except: + return False + + ############################ # DeleteChatById ############################ @@ -476,7 +624,12 @@ async def clone_chat_by_id( @router.post("/{id}/clone/shared", response_model=Optional[ChatResponse]) async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_share_id(id) + + if user.role == "admin": + chat = Chats.get_chat_by_id(id) + else: + chat = Chats.get_chat_by_share_id(id) + if chat: updated_chat = { **chat.chat, @@ -530,8 +683,17 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/share", response_model=Optional[ChatResponse]) -async def share_chat_by_id(id: str, user=Depends(get_verified_user)): +async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if not has_permission( + user.id, "chat.share", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: if chat.share_id: shared_chat = Chats.update_shared_chat_by_chat_id(chat.id) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 016075234a..44b2ef40cf 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Request, HTTPException +from pydantic import BaseModel, ConfigDict from typing import Optional @@ -7,6 +7,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.config import get_config, save_config from open_webui.config import BannerModel +from open_webui.utils.tools import get_tool_server_data, get_tool_servers_data + router = APIRouter() @@ -66,10 +68,86 @@ async def set_direct_connections_config( } +############################ +# ToolServers Config +############################ + + +class ToolServerConnection(BaseModel): + url: str + path: str + auth_type: Optional[str] + key: Optional[str] + config: Optional[dict] + + model_config = ConfigDict(extra="allow") + + +class ToolServersConfigForm(BaseModel): + TOOL_SERVER_CONNECTIONS: list[ToolServerConnection] + + +@router.get("/tool_servers", response_model=ToolServersConfigForm) +async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)): + return { + "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + } + + +@router.post("/tool_servers", response_model=ToolServersConfigForm) +async def set_tool_servers_config( + request: Request, + form_data: ToolServersConfigForm, + user=Depends(get_admin_user), +): + request.app.state.config.TOOL_SERVER_CONNECTIONS = [ + connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS + ] + + request.app.state.TOOL_SERVERS = await get_tool_servers_data( + request.app.state.config.TOOL_SERVER_CONNECTIONS + ) + + return { + "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + } + + +@router.post("/tool_servers/verify") +async def verify_tool_servers_config( + request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user) +): + """ + Verify the connection to the tool server. + """ + try: + + token = None + if form_data.auth_type == "bearer": + token = form_data.key + elif form_data.auth_type == "session": + token = request.state.token.credentials + + url = f"{form_data.url}/{form_data.path}" + return await get_tool_server_data(token, url) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Failed to connect to the tool server: {str(e)}", + ) + + ############################ # CodeInterpreterConfig ############################ class CodeInterpreterConfigForm(BaseModel): + ENABLE_CODE_EXECUTION: bool + CODE_EXECUTION_ENGINE: str + CODE_EXECUTION_JUPYTER_URL: Optional[str] + CODE_EXECUTION_JUPYTER_AUTH: Optional[str] + CODE_EXECUTION_JUPYTER_AUTH_TOKEN: Optional[str] + CODE_EXECUTION_JUPYTER_AUTH_PASSWORD: Optional[str] + CODE_EXECUTION_JUPYTER_TIMEOUT: Optional[int] ENABLE_CODE_INTERPRETER: bool CODE_INTERPRETER_ENGINE: str CODE_INTERPRETER_PROMPT_TEMPLATE: Optional[str] @@ -77,11 +155,19 @@ class CodeInterpreterConfigForm(BaseModel): CODE_INTERPRETER_JUPYTER_AUTH: Optional[str] CODE_INTERPRETER_JUPYTER_AUTH_TOKEN: Optional[str] CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD: Optional[str] + CODE_INTERPRETER_JUPYTER_TIMEOUT: Optional[int] -@router.get("/code_interpreter", response_model=CodeInterpreterConfigForm) -async def get_code_interpreter_config(request: Request, user=Depends(get_admin_user)): +@router.get("/code_execution", response_model=CodeInterpreterConfigForm) +async def get_code_execution_config(request: Request, user=Depends(get_admin_user)): return { + "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, + "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, + "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, + "CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + "CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, @@ -89,13 +175,34 @@ async def get_code_interpreter_config(request: Request, user=Depends(get_admin_u "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + "CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, } -@router.post("/code_interpreter", response_model=CodeInterpreterConfigForm) -async def set_code_interpreter_config( +@router.post("/code_execution", response_model=CodeInterpreterConfigForm) +async def set_code_execution_config( request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user) ): + + request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION + + request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE + request.app.state.config.CODE_EXECUTION_JUPYTER_URL = ( + form_data.CODE_EXECUTION_JUPYTER_URL + ) + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = ( + form_data.CODE_EXECUTION_JUPYTER_AUTH + ) + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = ( + form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN + ) + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = ( + form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD + ) + request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = ( + form_data.CODE_EXECUTION_JUPYTER_TIMEOUT + ) + request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = ( @@ -116,8 +223,18 @@ async def set_code_interpreter_config( request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD ) + request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = ( + form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT + ) return { + "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, + "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, + "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, + "CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + "CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, @@ -125,6 +242,7 @@ async def set_code_interpreter_config( "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + "CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, } diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index f0c4a6b065..164f3c40b4 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -56,19 +56,35 @@ async def update_config( } +class UserResponse(BaseModel): + id: str + name: str + email: str + role: str = "pending" + + last_active_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + class FeedbackUserResponse(FeedbackResponse): - user: Optional[UserModel] = None + user: Optional[UserResponse] = None @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse]) async def get_all_feedbacks(user=Depends(get_admin_user)): feedbacks = Feedbacks.get_all_feedbacks() - return [ - FeedbackUserResponse( - **feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id) + + feedback_list = [] + for feedback in feedbacks: + user = Users.get_user_by_id(feedback.user_id) + feedback_list.append( + FeedbackUserResponse( + **feedback.model_dump(), + user=UserResponse(**user.model_dump()) if user else None, + ) ) - for feedback in feedbacks - ] + return feedback_list @router.delete("/feedbacks/all") @@ -80,12 +96,7 @@ async def delete_all_feedbacks(user=Depends(get_admin_user)): @router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) async def get_all_feedbacks(user=Depends(get_admin_user)): feedbacks = Feedbacks.get_all_feedbacks() - return [ - FeedbackModel( - **feedback.model_dump(), user=Users.get_user_by_id(feedback.user_id) - ) - for feedback in feedbacks - ] + return feedbacks @router.get("/feedbacks/user", response_model=list[FeedbackUserResponse]) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 0513212571..ba6758671e 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -1,21 +1,39 @@ import logging import os import uuid +import json +from fnmatch import fnmatch from pathlib import Path from typing import Optional from urllib.parse import quote -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Request, + UploadFile, + status, + Query, +) from fastapi.responses import FileResponse, StreamingResponse from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS + +from open_webui.models.users import Users from open_webui.models.files import ( FileForm, FileModel, FileModelResponse, Files, ) +from open_webui.models.knowledge import Knowledges + +from open_webui.routers.knowledge import get_knowledge, get_knowledge_list from open_webui.routers.retrieval import ProcessFileForm, process_file +from open_webui.routers.audio import transcribe from open_webui.storage.provider import Storage from open_webui.utils.auth import get_admin_user, get_verified_user from pydantic import BaseModel @@ -26,6 +44,39 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() + +############################ +# Check if the current user has access to a file through any knowledge bases the user may be in. +############################ + + +def has_access_to_file( + file_id: Optional[str], access_type: str, user=Depends(get_verified_user) +) -> bool: + file = Files.get_file_by_id(file_id) + log.debug(f"Checking if user has {access_type} access to file") + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + has_access = False + knowledge_base_id = file.meta.get("collection_name") if file.meta else None + + if knowledge_base_id: + knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( + user.id, access_type + ) + for knowledge_base in knowledge_bases: + if knowledge_base.id == knowledge_base_id: + has_access = True + break + + return has_access + + ############################ # Upload File ############################ @@ -35,19 +86,55 @@ router = APIRouter() def upload_file( request: Request, file: UploadFile = File(...), + metadata: Optional[dict | str] = Form(None), + process: bool = Query(True), + internal: bool = False, user=Depends(get_verified_user), - file_metadata: dict = {}, ): log.info(f"file.content_type: {file.content_type}") + + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Invalid metadata format"), + ) + file_metadata = metadata if metadata else {} + try: unsanitized_filename = file.filename filename = os.path.basename(unsanitized_filename) + file_extension = os.path.splitext(filename)[1] + # Remove the leading dot from the file extension + file_extension = file_extension[1:] if file_extension else "" + + if (not internal) and request.app.state.config.ALLOWED_FILE_EXTENSIONS: + request.app.state.config.ALLOWED_FILE_EXTENSIONS = [ + ext for ext in request.app.state.config.ALLOWED_FILE_EXTENSIONS if ext + ] + + if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT( + f"File type {file_extension} is not allowed" + ), + ) + # replace filename with uuid id = str(uuid.uuid4()) name = filename filename = f"{id}_{filename}" - contents, file_path = Storage.upload_file(file.file, filename) + tags = { + "OpenWebUI-User-Email": user.email, + "OpenWebUI-User-Id": user.id, + "OpenWebUI-User-Name": user.name, + "OpenWebUI-File-Id": id, + } + contents, file_path = Storage.upload_file(file.file, filename, tags) file_item = Files.insert_new_file( user.id, @@ -65,19 +152,40 @@ def upload_file( } ), ) + if process: + try: + if file.content_type: + if file.content_type.startswith("audio/") or file.content_type in { + "video/webm" + }: + file_path = Storage.get_file(file_path) + result = transcribe(request, file_path, file_metadata) - try: - process_file(request, ProcessFileForm(file_id=id), user=user) - file_item = Files.get_file_by_id(id=id) - except Exception as e: - log.exception(e) - log.error(f"Error processing file: {file_item.id}") - file_item = FileModelResponse( - **{ - **file_item.model_dump(), - "error": str(e.detail) if hasattr(e, "detail") else str(e), - } - ) + process_file( + request, + ProcessFileForm(file_id=id, content=result.get("text", "")), + user=user, + ) + elif (not file.content_type.startswith(("image/", "video/"))) or ( + request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" + ): + process_file(request, ProcessFileForm(file_id=id), user=user) + else: + log.info( + f"File type {file.content_type} is not provided, but trying to process anyway" + ) + process_file(request, ProcessFileForm(file_id=id), user=user) + + file_item = Files.get_file_by_id(id=id) + except Exception as e: + log.exception(e) + log.error(f"Error processing file: {file_item.id}") + file_item = FileModelResponse( + **{ + **file_item.model_dump(), + "error": str(e.detail) if hasattr(e, "detail") else str(e), + } + ) if file_item: return file_item @@ -91,7 +199,7 @@ def upload_file( log.exception(e) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), + detail=ERROR_MESSAGES.DEFAULT("Error uploading file"), ) @@ -101,14 +209,62 @@ def upload_file( @router.get("/", response_model=list[FileModelResponse]) -async def list_files(user=Depends(get_verified_user)): +async def list_files(user=Depends(get_verified_user), content: bool = Query(True)): if user.role == "admin": files = Files.get_files() else: files = Files.get_files_by_user_id(user.id) + + if not content: + for file in files: + if "content" in file.data: + del file.data["content"] + return files +############################ +# Search Files +############################ + + +@router.get("/search", response_model=list[FileModelResponse]) +async def search_files( + filename: str = Query( + ..., + description="Filename pattern to search for. Supports wildcards such as '*.txt'", + ), + content: bool = Query(True), + user=Depends(get_verified_user), +): + """ + Search for files by filename with support for wildcard patterns. + """ + # Get files according to user role + if user.role == "admin": + files = Files.get_files() + else: + files = Files.get_files_by_user_id(user.id) + + # Get matching files + matching_files = [ + file for file in files if fnmatch(file.filename.lower(), filename.lower()) + ] + + if not matching_files: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No files found matching the pattern.", + ) + + if not content: + for file in matching_files: + if "content" in file.data: + del file.data["content"] + + return matching_files + + ############################ # Delete All Files ############################ @@ -144,7 +300,17 @@ async def delete_all_files(user=Depends(get_admin_user)): async def get_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): return file else: raise HTTPException( @@ -162,7 +328,17 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)): async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): return {"content": file.data.get("content", "")} else: raise HTTPException( @@ -186,7 +362,17 @@ async def update_file_data_content_by_id( ): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "write", user) + ): try: process_file( request, @@ -212,9 +398,22 @@ async def update_file_data_content_by_id( @router.get("/{id}/content") -async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): +async def get_file_content_by_id( + id: str, user=Depends(get_verified_user), attachment: bool = Query(False) +): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) @@ -225,17 +424,29 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): filename = file.meta.get("name", file.filename) encoded_filename = quote(filename) # RFC5987 encoding + content_type = file.meta.get("content_type") + filename = file.meta.get("name", file.filename) + encoded_filename = quote(filename) headers = {} - if file.meta.get("content_type") not in [ - "application/pdf", - "text/plain", - ]: - headers = { - **headers, - "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", - } - return FileResponse(file_path, headers=headers) + if attachment: + headers["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{encoded_filename}" + ) + else: + if content_type == "application/pdf" or filename.lower().endswith( + ".pdf" + ): + headers["Content-Disposition"] = ( + f"inline; filename*=UTF-8''{encoded_filename}" + ) + content_type = "application/pdf" + elif content_type != "text/plain": + headers["Content-Disposition"] = ( + f"attachment; filename*=UTF-8''{encoded_filename}" + ) + + return FileResponse(file_path, headers=headers, media_type=content_type) else: raise HTTPException( @@ -259,14 +470,32 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): @router.get("/{id}/content/html") async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + file_user = Users.get_user_by_id(file.user_id) + if not file_user.role == "admin": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) # Check if the file already exists in the cache if file_path.is_file(): - print(f"file_path: {file_path}") + log.info(f"file_path: {file_path}") return FileResponse(file_path) else: raise HTTPException( @@ -291,7 +520,17 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "read", user) + ): file_path = file.path # Handle Unicode filenames @@ -342,7 +581,18 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): @router.delete("/{id}") async def delete_file_by_id(id: str, user=Depends(get_verified_user)): file = Files.get_file_by_id(id) - if file and (file.user_id == user.id or user.role == "admin"): + + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + file.user_id == user.id + or user.role == "admin" + or has_access_to_file(id, "write", user) + ): # We should add Chroma cleanup here result = Files.delete_file_by_id(id) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index ca2fbd2132..2c41c92854 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -20,11 +20,13 @@ from open_webui.env import SRC_LOG_LEVELS from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request 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_permission + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -228,7 +230,19 @@ async def update_folder_is_expanded_by_id( @router.delete("/{id}") -async def delete_folder_by_id(id: str, user=Depends(get_verified_user)): +async def delete_folder_by_id( + request: Request, id: str, user=Depends(get_verified_user) +): + chat_delete_permission = has_permission( + user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS + ) + + if user.role != "admin" and not chat_delete_permission: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + folder = Folders.get_folder_by_id_and_user_id(id, user.id) if folder: try: diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 7f3305f25a..355093335a 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -1,4 +1,8 @@ import os +import re + +import logging +import aiohttp from pathlib import Path from typing import Optional @@ -8,11 +12,22 @@ from open_webui.models.functions import ( FunctionResponse, Functions, ) -from open_webui.utils.plugin import load_function_module_by_id, replace_imports +from open_webui.utils.plugin import ( + load_function_module_by_id, + replace_imports, + get_function_module_from_cache, +) from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.env import SRC_LOG_LEVELS +from pydantic import BaseModel, HttpUrl + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + router = APIRouter() @@ -36,6 +51,97 @@ async def get_functions(user=Depends(get_admin_user)): return Functions.get_functions() +############################ +# LoadFunctionFromLink +############################ + + +class LoadUrlForm(BaseModel): + url: HttpUrl + + +def github_url_to_raw_url(url: str) -> str: + # Handle 'tree' (folder) URLs (add main.py at the end) + m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url) + if m1: + org, repo, branch, path = m1.groups() + return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py" + + # Handle 'blob' (file) URLs + m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url) + if m2: + org, repo, branch, path = m2.groups() + return ( + f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}" + ) + + # No match; return as-is + return url + + +@router.post("/load/url", response_model=Optional[dict]) +async def load_function_from_url( + request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user) +): + # NOTE: This is NOT a SSRF vulnerability: + # This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use, + # and does NOT accept untrusted user input. Access is enforced by authentication. + + url = str(form_data.url) + if not url: + raise HTTPException(status_code=400, detail="Please enter a valid URL") + + url = github_url_to_raw_url(url) + url_parts = url.rstrip("/").split("/") + + file_name = url_parts[-1] + function_name = ( + file_name[:-3] + if ( + file_name.endswith(".py") + and (not file_name.startswith(("main.py", "index.py", "__init__.py"))) + ) + else url_parts[-2] if len(url_parts) > 1 else "function" + ) + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, headers={"Content-Type": "application/json"} + ) as resp: + if resp.status != 200: + raise HTTPException( + status_code=resp.status, detail="Failed to fetch the function" + ) + data = await resp.text() + if not data: + raise HTTPException( + status_code=400, detail="No data received from the URL" + ) + return { + "name": function_name, + "content": data, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error importing function: {e}") + + +############################ +# SyncFunctions +############################ + + +class SyncFunctionsForm(FunctionForm): + functions: list[FunctionModel] = [] + + +@router.post("/sync", response_model=Optional[FunctionModel]) +async def sync_functions( + request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) +): + return Functions.sync_functions(user.id, form_data.functions) + + ############################ # CreateNewFunction ############################ @@ -68,7 +174,7 @@ async def create_new_function( function = Functions.insert_new_function(user.id, function_type, form_data) - function_cache_dir = Path(CACHE_DIR) / "functions" / form_data.id + function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) if function: @@ -79,7 +185,7 @@ async def create_new_function( detail=ERROR_MESSAGES.DEFAULT("Error creating function"), ) except Exception as e: - print(e) + log.exception(f"Failed to create a new function: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -183,7 +289,7 @@ async def update_function_by_id( FUNCTIONS[id] = function_module updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} - print(updated) + log.debug(updated) function = Functions.update_function_by_id(id, updated) @@ -256,11 +362,9 @@ async def get_function_valves_spec_by_id( ): function = Functions.get_function_by_id(id) if function: - if id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[id] - else: - function_module, function_type, frontmatter = load_function_module_by_id(id) - request.app.state.FUNCTIONS[id] = function_module + function_module, function_type, frontmatter = get_function_module_from_cache( + request, id + ) if hasattr(function_module, "Valves"): Valves = function_module.Valves @@ -284,11 +388,9 @@ async def update_function_valves_by_id( ): function = Functions.get_function_by_id(id) if function: - if id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[id] - else: - function_module, function_type, frontmatter = load_function_module_by_id(id) - request.app.state.FUNCTIONS[id] = function_module + function_module, function_type, frontmatter = get_function_module_from_cache( + request, id + ) if hasattr(function_module, "Valves"): Valves = function_module.Valves @@ -299,7 +401,7 @@ async def update_function_valves_by_id( Functions.update_function_valves_by_id(id, valves.model_dump()) return valves.model_dump() except Exception as e: - print(e) + log.exception(f"Error updating function values by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -347,11 +449,9 @@ async def get_function_user_valves_spec_by_id( ): function = Functions.get_function_by_id(id) if function: - if id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[id] - else: - function_module, function_type, frontmatter = load_function_module_by_id(id) - request.app.state.FUNCTIONS[id] = function_module + function_module, function_type, frontmatter = get_function_module_from_cache( + request, id + ) if hasattr(function_module, "UserValves"): UserValves = function_module.UserValves @@ -371,11 +471,9 @@ async def update_function_user_valves_by_id( function = Functions.get_function_by_id(id) if function: - if id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[id] - else: - function_module, function_type, frontmatter = load_function_module_by_id(id) - request.app.state.FUNCTIONS[id] = function_module + function_module, function_type, frontmatter = get_function_module_from_cache( + request, id + ) if hasattr(function_module, "UserValves"): UserValves = function_module.UserValves @@ -388,7 +486,7 @@ async def update_function_user_valves_by_id( ) return user_valves.model_dump() except Exception as e: - print(e) + log.exception(f"Error updating function user valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py old mode 100644 new mode 100755 index 5b5130f71d..ae822c0d00 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -1,7 +1,7 @@ import os from pathlib import Path from typing import Optional - +import logging from open_webui.models.users import Users from open_webui.models.groups import ( @@ -14,7 +14,13 @@ from open_webui.models.groups import ( from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status + from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.env import SRC_LOG_LEVELS + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -37,7 +43,7 @@ async def get_groups(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[GroupResponse]) -async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user)): +async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)): try: group = Groups.insert_new_group(user.id, form_data) if group: @@ -48,7 +54,7 @@ async def create_new_function(form_data: GroupForm, user=Depends(get_admin_user) detail=ERROR_MESSAGES.DEFAULT("Error creating group"), ) except Exception as e: - print(e) + log.exception(f"Error creating a new group: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -94,7 +100,7 @@ async def update_group_by_id( detail=ERROR_MESSAGES.DEFAULT("Error updating group"), ) except Exception as e: - print(e) + log.exception(f"Error updating group {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -118,7 +124,7 @@ async def delete_group_by_id(id: str, user=Depends(get_admin_user)): detail=ERROR_MESSAGES.DEFAULT("Error deleting group"), ) except Exception as e: - print(e) + log.exception(f"Error deleting group {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 4046773dea..c6d8e41864 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -25,7 +25,7 @@ from pydantic import BaseModel log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["IMAGES"]) -IMAGE_CACHE_DIR = Path(CACHE_DIR).joinpath("./image/generations/") +IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -55,6 +55,10 @@ async def get_config(request: Request, user=Depends(get_admin_user)): "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, }, + "gemini": { + "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, + }, } @@ -78,6 +82,11 @@ class ComfyUIConfigForm(BaseModel): COMFYUI_WORKFLOW_NODES: list[dict] +class GeminiConfigForm(BaseModel): + GEMINI_API_BASE_URL: str + GEMINI_API_KEY: str + + class ConfigForm(BaseModel): enabled: bool engine: str @@ -85,6 +94,7 @@ class ConfigForm(BaseModel): openai: OpenAIConfigForm automatic1111: Automatic1111ConfigForm comfyui: ComfyUIConfigForm + gemini: GeminiConfigForm @router.post("/config/update") @@ -103,6 +113,11 @@ async def update_config( ) request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY + request.app.state.config.IMAGES_GEMINI_API_BASE_URL = ( + form_data.gemini.GEMINI_API_BASE_URL + ) + request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.gemini.GEMINI_API_KEY + request.app.state.config.AUTOMATIC1111_BASE_URL = ( form_data.automatic1111.AUTOMATIC1111_BASE_URL ) @@ -129,6 +144,8 @@ async def update_config( request.app.state.config.COMFYUI_BASE_URL = ( form_data.comfyui.COMFYUI_BASE_URL.strip("/") ) + request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY + request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW request.app.state.config.COMFYUI_WORKFLOW_NODES = ( form_data.comfyui.COMFYUI_WORKFLOW_NODES @@ -155,6 +172,10 @@ async def update_config( "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, }, + "gemini": { + "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, + }, } @@ -184,9 +205,17 @@ async def verify_url(request: Request, user=Depends(get_admin_user)): request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + + headers = None + if request.app.state.config.COMFYUI_API_KEY: + headers = { + "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" + } + try: r = requests.get( - url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info" + url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", + headers=headers, ) r.raise_for_status() return True @@ -224,6 +253,12 @@ def get_image_model(request): if request.app.state.config.IMAGE_GENERATION_MODEL else "dall-e-2" ) + elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + return ( + request.app.state.config.IMAGE_GENERATION_MODEL + if request.app.state.config.IMAGE_GENERATION_MODEL + else "imagen-3.0-generate-002" + ) elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": return ( request.app.state.config.IMAGE_GENERATION_MODEL @@ -298,6 +333,11 @@ def get_models(request: Request, user=Depends(get_verified_user)): return [ {"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-3", "name": "DALL·E 3"}, + {"id": "gpt-image-1", "name": "GPT-IMAGE 1"}, + ] + elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + return [ + {"id": "imagen-3.0-generate-002", "name": "imagen-3.0 generate-002"}, ] elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": # TODO - get models from comfyui @@ -322,7 +362,7 @@ def get_models(request: Request, user=Depends(get_verified_user)): if model_node_id: model_list_key = None - print(workflow[model_node_id]["class_type"]) + log.info(workflow[model_node_id]["class_type"]) for key in info[workflow[model_node_id]["class_type"]]["input"][ "required" ]: @@ -411,7 +451,7 @@ def load_url_image_data(url, headers=None): return None -def upload_image(request, image_metadata, image_data, content_type, user): +def upload_image(request, image_data, content_type, metadata, user): image_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(image_data), @@ -420,7 +460,7 @@ def upload_image(request, image_metadata, image_data, content_type, user): "content-type": content_type, }, ) - file_item = upload_file(request, file, user, file_metadata=image_metadata) + file_item = upload_file(request, file, metadata=metadata, internal=True, user=user) url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) return url @@ -461,7 +501,11 @@ async def image_generations( if form_data.size else request.app.state.config.IMAGE_SIZE ), - "response_format": "b64_json", + **( + {} + if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL + else {"response_format": "b64_json"} + ), } # Use asyncio.to_thread for the requests.post call @@ -478,11 +522,50 @@ async def image_generations( images = [] for image in res["data"]: - image_data, content_type = load_b64_image_data(image["b64_json"]) - url = upload_image(request, data, image_data, content_type, user) + if image_url := image.get("url", None): + image_data, content_type = load_url_image_data(image_url, headers) + else: + image_data, content_type = load_b64_image_data(image["b64_json"]) + + url = upload_image(request, image_data, content_type, data, user) images.append({"url": url}) return images + elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + headers = {} + headers["Content-Type"] = "application/json" + headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY + + model = get_image_model(request) + data = { + "instances": {"prompt": form_data.prompt}, + "parameters": { + "sampleCount": form_data.n, + "outputOptions": {"mimeType": "image/png"}, + }, + } + + # Use asyncio.to_thread for the requests.post call + r = await asyncio.to_thread( + requests.post, + url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}:predict", + json=data, + headers=headers, + ) + + r.raise_for_status() + res = r.json() + + images = [] + for image in res["predictions"]: + image_data, content_type = load_b64_image_data( + image["bytesBase64Encoded"] + ) + url = upload_image(request, image_data, content_type, data, user) + images.append({"url": url}) + + return images + elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": data = { "prompt": form_data.prompt, @@ -529,9 +612,9 @@ async def image_generations( image_data, content_type = load_url_image_data(image["url"], headers) url = upload_image( request, - form_data.model_dump(exclude_none=True), image_data, content_type, + form_data.model_dump(exclude_none=True), user, ) images.append({"url": url}) @@ -541,7 +624,7 @@ async def image_generations( or request.app.state.config.IMAGE_GENERATION_ENGINE == "" ): if form_data.model: - set_image_model(form_data.model) + set_image_model(request, form_data.model) data = { "prompt": form_data.prompt, @@ -582,9 +665,9 @@ async def image_generations( image_data, content_type = load_b64_image_data(image) url = upload_image( request, - {**data, "info": res["info"]}, image_data, content_type, + {**data, "info": res["info"]}, user, ) images.append({"url": url}) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 0ba6191a2a..e6e55f4d38 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -9,8 +9,8 @@ from open_webui.models.knowledge import ( KnowledgeResponse, KnowledgeUserResponse, ) -from open_webui.models.files import Files, FileModel -from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT +from open_webui.models.files import Files, FileModel, FileMetadataResponse +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.routers.retrieval import ( process_file, ProcessFileForm, @@ -161,13 +161,94 @@ async def create_new_knowledge( ) +############################ +# ReindexKnowledgeFiles +############################ + + +@router.post("/reindex", response_model=bool) +async def reindex_knowledge_files(request: Request, user=Depends(get_verified_user)): + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + knowledge_bases = Knowledges.get_knowledge_bases() + + log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") + + deleted_knowledge_bases = [] + + for knowledge_base in knowledge_bases: + # -- Robust error handling for missing or invalid data + if not knowledge_base.data or not isinstance(knowledge_base.data, dict): + log.warning( + f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting." + ) + try: + Knowledges.delete_knowledge_by_id(id=knowledge_base.id) + deleted_knowledge_bases.append(knowledge_base.id) + except Exception as e: + log.error( + f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}" + ) + continue + + try: + file_ids = knowledge_base.data.get("file_ids", []) + files = Files.get_files_by_ids(file_ids) + try: + if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): + VECTOR_DB_CLIENT.delete_collection( + collection_name=knowledge_base.id + ) + except Exception as e: + log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}") + continue # Skip, don't raise + + failed_files = [] + for file in files: + try: + process_file( + request, + ProcessFileForm( + file_id=file.id, collection_name=knowledge_base.id + ), + user=user, + ) + except Exception as e: + log.error( + f"Error processing file {file.filename} (ID: {file.id}): {str(e)}" + ) + failed_files.append({"file_id": file.id, "error": str(e)}) + continue + + except Exception as e: + log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}") + # Don't raise, just continue + continue + + if failed_files: + log.warning( + f"Failed to process {len(failed_files)} files in knowledge base {knowledge_base.id}" + ) + for failed in failed_files: + log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}") + + log.info( + f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}" + ) + return True + + ############################ # GetKnowledgeById ############################ class KnowledgeFilesResponse(KnowledgeResponse): - files: list[FileModel] + files: list[FileMetadataResponse] @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) @@ -183,7 +264,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): ): file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -311,7 +392,7 @@ def add_file_to_knowledge_by_id( knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) if knowledge: - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -388,7 +469,7 @@ def update_file_from_knowledge_by_id( data = knowledge.data or {} file_ids = data.get("file_ids", []) - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -437,14 +518,24 @@ def remove_file_from_knowledge_by_id( ) # Remove content from the vector database - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": form_data.file_id} - ) + try: + VECTOR_DB_CLIENT.delete( + collection_name=knowledge.id, filter={"file_id": form_data.file_id} + ) + except Exception as e: + log.debug("This was most likely caused by bypassing embedding processing") + log.debug(e) + pass - # Remove the file's collection from vector database - file_collection = f"file-{form_data.file_id}" - if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): - VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + try: + # Remove the file's collection from vector database + file_collection = f"file-{form_data.file_id}" + if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): + VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) + except Exception as e: + log.debug("This was most likely caused by bypassing embedding processing") + log.debug(e) + pass # Delete file from database Files.delete_file_by_id(form_data.file_id) @@ -460,7 +551,7 @@ def remove_file_from_knowledge_by_id( knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) if knowledge: - files = Files.get_files_by_ids(file_ids) + files = Files.get_file_metadatas_by_ids(file_ids) return KnowledgeFilesResponse( **knowledge.model_dump(), @@ -614,7 +705,7 @@ def add_files_to_knowledge_batch( ) # Get files content - print(f"files/batch/add - {len(form_data)} files") + log.info(f"files/batch/add - {len(form_data)} files") files: List[FileModel] = [] for form in form_data: file = Files.get_file_by_id(form.file_id) @@ -656,7 +747,7 @@ def add_files_to_knowledge_batch( error_details = [f"{err.file_id}: {err.error}" for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Files.get_files_by_ids(existing_file_ids), + files=Files.get_file_metadatas_by_ids(existing_file_ids), warnings={ "message": "Some files failed to process", "errors": error_details, @@ -664,5 +755,6 @@ def add_files_to_knowledge_batch( ) return KnowledgeFilesResponse( - **knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids) + **knowledge.model_dump(), + files=Files.get_file_metadatas_by_ids(existing_file_ids), ) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index c55a6a9cc9..333e9ecc6a 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -4,7 +4,7 @@ import logging from typing import Optional from open_webui.models.memories import Memories, MemoryModel -from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.utils.auth import get_verified_user from open_webui.env import SRC_LOG_LEVELS @@ -57,7 +57,9 @@ async def add_memory( { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), + "vector": request.app.state.EMBEDDING_FUNCTION( + memory.content, user=user + ), "metadata": {"created_at": memory.created_at}, } ], @@ -82,7 +84,7 @@ async def query_memory( ): results = VECTOR_DB_CLIENT.search( collection_name=f"user-memory-{user.id}", - vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)], + vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)], limit=form_data.k, ) @@ -105,7 +107,9 @@ async def reset_memory_from_vector_db( { "id": memory.id, "text": memory.content, - "vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user), + "vector": request.app.state.EMBEDDING_FUNCTION( + memory.content, user=user + ), "metadata": { "created_at": memory.created_at, "updated_at": memory.updated_at, @@ -149,7 +153,9 @@ async def update_memory_by_id( form_data: MemoryUpdateModel, user=Depends(get_verified_user), ): - memory = Memories.update_memory_by_id(memory_id, form_data.content) + memory = Memories.update_memory_by_id_and_user_id( + memory_id, user.id, form_data.content + ) if memory is None: raise HTTPException(status_code=404, detail="Memory not found") @@ -161,7 +167,7 @@ async def update_memory_by_id( "id": memory.id, "text": memory.content, "vector": request.app.state.EMBEDDING_FUNCTION( - memory.content, user + memory.content, user=user ), "metadata": { "created_at": memory.created_at, diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py new file mode 100644 index 0000000000..5ad5ff051e --- /dev/null +++ b/backend/open_webui/routers/notes.py @@ -0,0 +1,218 @@ +import json +import logging +from typing import Optional + + +from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks +from pydantic import BaseModel + +from open_webui.models.users import Users, UserResponse +from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse + +from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS + + +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access, has_permission + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + +############################ +# GetNotes +############################ + + +@router.get("/", response_model=list[NoteUserResponse]) +async def get_notes(request: Request, user=Depends(get_verified_user)): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = [ + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } + ) + for note in Notes.get_notes_by_user_id(user.id, "write") + ] + + return notes + + +@router.get("/list", response_model=list[NoteUserResponse]) +async def get_note_list(request: Request, user=Depends(get_verified_user)): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + notes = [ + NoteUserResponse( + **{ + **note.model_dump(), + "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + } + ) + for note in Notes.get_notes_by_user_id(user.id, "read") + ] + + return notes + + +############################ +# CreateNewNote +############################ + + +@router.post("/create", response_model=Optional[NoteModel]) +async def create_new_note( + request: Request, form_data: NoteForm, user=Depends(get_verified_user) +): + + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + note = Notes.insert_new_note(form_data, user.id) + return note + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# GetNoteById +############################ + + +@router.get("/{id}", response_model=Optional[NoteModel]) +async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="read", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + return note + + +############################ +# UpdateNoteById +############################ + + +@router.post("/{id}/update", response_model=Optional[NoteModel]) +async def update_note_by_id( + request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user) +): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="write", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + note = Notes.update_note_by_id(id, form_data) + return note + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# DeleteNoteById +############################ + + +@router.delete("/{id}/delete", response_model=bool) +async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): + if user.role != "admin" and not has_permission( + user.id, "features.notes", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = Notes.get_note_by_id(id) + if not note: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if ( + user.role != "admin" + and user.id != note.user_id + and not has_access(user.id, type="write", access_control=note.access_control) + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + note = Notes.delete_note_by_id(id) + return True + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 64373c616c..95f48fb1c8 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -9,11 +9,18 @@ import os import random import re import time +from datetime import datetime + from typing import Optional, Union from urllib.parse import urlparse import aiohttp from aiocache import cached import requests +from open_webui.models.users import UserModel + +from open_webui.env import ( + ENABLE_FORWARD_USER_INFO_HEADERS, +) from fastapi import ( Depends, @@ -26,7 +33,7 @@ from fastapi import ( ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, validator from starlette.background import BackgroundTask @@ -49,8 +56,9 @@ from open_webui.config import ( from open_webui.env import ( ENV, SRC_LOG_LEVELS, + AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, BYPASS_MODEL_ACCESS_CONTROL, ) from open_webui.constants import ERROR_MESSAGES @@ -66,12 +74,27 @@ log.setLevel(SRC_LOG_LEVELS["OLLAMA"]) ########################################## -async def send_get_request(url, key=None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) +async def send_get_request(url, key=None, user: UserModel = None): + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( - url, headers={**({"Authorization": f"Bearer {key}"} if key else {})} + url, + headers={ + "Content-Type": "application/json", + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() except Exception as e: @@ -96,6 +119,7 @@ async def send_post_request( stream: bool = True, key: Optional[str] = None, content_type: Optional[str] = None, + user: UserModel = None, ): r = None @@ -110,7 +134,18 @@ async def send_post_request( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() @@ -186,12 +221,26 @@ async def verify_connection( key = form_data.key async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: async with session.get( f"{url}/api/version", - headers={**({"Authorization": f"Bearer {key}"} if key else {})}, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: detail = f"HTTP Error: {r.status}" @@ -253,8 +302,24 @@ async def update_config( } -@cached(ttl=3) -async def get_all_models(request: Request): +def merge_ollama_models_lists(model_lists): + merged_models = {} + + for idx, model_list in enumerate(model_lists): + if model_list is not None: + for model in model_list: + id = model["model"] + if id not in merged_models: + model["urls"] = [idx] + merged_models[id] = model + else: + merged_models[id]["urls"].append(idx) + + return list(merged_models.values()) + + +@cached(ttl=1) +async def get_all_models(request: Request, user: UserModel = None): log.info("get_all_models()") if request.app.state.config.ENABLE_OLLAMA_API: request_tasks = [] @@ -262,7 +327,7 @@ async def get_all_models(request: Request): if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support ): - request_tasks.append(send_get_request(f"{url}/api/tags")) + request_tasks.append(send_get_request(f"{url}/api/tags", user=user)) else: api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), @@ -275,7 +340,9 @@ async def get_all_models(request: Request): key = api_config.get("key", None) if enable: - request_tasks.append(send_get_request(f"{url}/api/tags", key)) + request_tasks.append( + send_get_request(f"{url}/api/tags", key, user=user) + ) else: request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -291,7 +358,10 @@ async def get_all_models(request: Request): ), # Legacy support ) + connection_type = api_config.get("connection_type", "local") + prefix_id = api_config.get("prefix_id", None) + tags = api_config.get("tags", []) model_ids = api_config.get("model_ids", []) if len(model_ids) != 0 and "models" in response: @@ -302,27 +372,18 @@ async def get_all_models(request: Request): ) ) - if prefix_id: - for model in response.get("models", []): + for model in response.get("models", []): + if prefix_id: model["model"] = f"{prefix_id}.{model['model']}" - def merge_models_lists(model_lists): - merged_models = {} + if tags: + model["tags"] = tags - for idx, model_list in enumerate(model_lists): - if model_list is not None: - for model in model_list: - id = model["model"] - if id not in merged_models: - model["urls"] = [idx] - merged_models[id] = model - else: - merged_models[id]["urls"].append(idx) - - return list(merged_models.values()) + if connection_type: + model["connection_type"] = connection_type models = { - "models": merge_models_lists( + "models": merge_ollama_models_lists( map( lambda response: response.get("models", []) if response else None, responses, @@ -330,6 +391,22 @@ async def get_all_models(request: Request): ) } + try: + loaded_models = await get_ollama_loaded_models(request, user=user) + expires_map = { + m["name"]: m["expires_at"] + for m in loaded_models["models"] + if "expires_at" in m + } + + for m in models["models"]: + if m["name"] in expires_map: + # Parse ISO8601 datetime with offset, get unix timestamp as int + dt = datetime.fromisoformat(expires_map[m["name"]]) + m["expires_at"] = int(dt.timestamp()) + except Exception as e: + log.debug(f"Failed to get loaded models: {e}") + else: models = {"models": []} @@ -360,7 +437,7 @@ async def get_ollama_tags( models = [] if url_idx is None: - models = await get_all_models(request) + models = await get_all_models(request, user=user) else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) @@ -370,7 +447,19 @@ async def get_ollama_tags( r = requests.request( method="GET", url=f"{url}/api/tags", - headers={**({"Authorization": f"Bearer {key}"} if key else {})}, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + }, ) r.raise_for_status() @@ -398,24 +487,95 @@ async def get_ollama_tags( return models +@router.get("/api/ps") +async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user)): + """ + List models that are currently loaded into Ollama memory, and which node they are loaded on. + """ + if request.app.state.config.ENABLE_OLLAMA_API: + request_tasks = [] + for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): + if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( + url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support + ): + request_tasks.append(send_get_request(f"{url}/api/ps", user=user)) + else: + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), + request.app.state.config.OLLAMA_API_CONFIGS.get( + url, {} + ), # Legacy support + ) + + enable = api_config.get("enable", True) + key = api_config.get("key", None) + + if enable: + request_tasks.append( + send_get_request(f"{url}/api/ps", key, user=user) + ) + else: + request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) + + responses = await asyncio.gather(*request_tasks) + + for idx, response in enumerate(responses): + if response: + url = request.app.state.config.OLLAMA_BASE_URLS[idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), + request.app.state.config.OLLAMA_API_CONFIGS.get( + url, {} + ), # Legacy support + ) + + prefix_id = api_config.get("prefix_id", None) + + for model in response.get("models", []): + if prefix_id: + model["model"] = f"{prefix_id}.{model['model']}" + + models = { + "models": merge_ollama_models_lists( + map( + lambda response: response.get("models", []) if response else None, + responses, + ) + ) + } + else: + models = {"models": []} + + return models + + @router.get("/api/version") @router.get("/api/version/{url_idx}") async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): if request.app.state.config.ENABLE_OLLAMA_API: if url_idx is None: # returns lowest version - request_tasks = [ - send_get_request( - f"{url}/api/version", + request_tasks = [] + + for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get( - str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support - ).get("key", None), + url, {} + ), # Legacy support ) - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS) - ] + + enable = api_config.get("enable", True) + key = api_config.get("key", None) + + if enable: + request_tasks.append( + send_get_request( + f"{url}/api/version", + key, + ) + ) + responses = await asyncio.gather(*request_tasks) responses = list(filter(lambda x: x is not None, responses)) @@ -462,35 +622,74 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): return {"version": False} -@router.get("/api/ps") -async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_user)): - """ - List models that are currently loaded into Ollama memory, and which node they are loaded on. - """ - if request.app.state.config.ENABLE_OLLAMA_API: - request_tasks = [ - send_get_request( - f"{url}/api/ps", - request.app.state.config.OLLAMA_API_CONFIGS.get( - str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support - ).get("key", None), - ) - for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS) - ] - responses = await asyncio.gather(*request_tasks) - - return dict(zip(request.app.state.config.OLLAMA_BASE_URLS, responses)) - else: - return {} - - class ModelNameForm(BaseModel): name: str +@router.post("/api/unload") +async def unload_model( + request: Request, + form_data: ModelNameForm, + user=Depends(get_admin_user), +): + model_name = form_data.name + if not model_name: + raise HTTPException( + status_code=400, detail="Missing 'name' of model to unload." + ) + + # Refresh/load models if needed, get mapping from name to URLs + await get_all_models(request, user=user) + models = request.app.state.OLLAMA_MODELS + + # Canonicalize model name (if not supplied with version) + if ":" not in model_name: + model_name = f"{model_name}:latest" + + if model_name not in models: + raise HTTPException( + status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name) + ) + url_indices = models[model_name]["urls"] + + # Send unload to ALL url_indices + results = [] + errors = [] + for idx in url_indices: + url = request.app.state.config.OLLAMA_BASE_URLS[idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}) + ) + key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + + prefix_id = api_config.get("prefix_id", None) + if prefix_id and model_name.startswith(f"{prefix_id}."): + model_name = model_name[len(f"{prefix_id}.") :] + + payload = {"model": model_name, "keep_alive": 0, "prompt": ""} + + try: + res = await send_post_request( + url=f"{url}/api/generate", + payload=json.dumps(payload), + stream=False, + key=key, + user=user, + ) + results.append({"url_idx": idx, "success": True, "response": res}) + except Exception as e: + log.exception(f"Failed to unload model on node {idx}: {e}") + errors.append({"url_idx": idx, "success": False, "error": str(e)}) + + if len(errors) > 0: + raise HTTPException( + status_code=500, + detail=f"Failed to unload model on {len(errors)} nodes: {errors}", + ) + + return {"status": True} + + @router.post("/api/pull") @router.post("/api/pull/{url_idx}") async def pull_model( @@ -509,6 +708,7 @@ async def pull_model( url=f"{url}/api/pull", payload=json.dumps(payload), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -527,7 +727,7 @@ async def push_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name in models: @@ -545,6 +745,7 @@ async def push_model( url=f"{url}/api/push", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -571,6 +772,7 @@ async def create_model( url=f"{url}/api/create", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -588,7 +790,7 @@ async def copy_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.source in models: @@ -609,6 +811,16 @@ async def copy_model( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -643,7 +855,7 @@ async def delete_model( user=Depends(get_admin_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name in models: @@ -665,6 +877,16 @@ async def delete_model( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, ) r.raise_for_status() @@ -693,7 +915,7 @@ async def delete_model( async def show_model_info( request: Request, form_data: ModelNameForm, user=Depends(get_verified_user) ): - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.name not in models: @@ -714,6 +936,16 @@ async def show_model_info( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -757,7 +989,7 @@ async def embed( log.info(f"generate_ollama_batch_embeddings {form_data}") if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -774,8 +1006,16 @@ async def embed( ) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + prefix_id = api_config.get("prefix_id", None) + if prefix_id: + form_data.model = form_data.model.replace(f"{prefix_id}.", "") + try: r = requests.request( method="POST", @@ -783,6 +1023,16 @@ async def embed( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -826,7 +1076,7 @@ async def embeddings( log.info(f"generate_ollama_embeddings {form_data}") if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -843,8 +1093,16 @@ async def embeddings( ) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support + ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + prefix_id = api_config.get("prefix_id", None) + if prefix_id: + form_data.model = form_data.model.replace(f"{prefix_id}.", "") + try: r = requests.request( method="POST", @@ -852,6 +1110,16 @@ async def embeddings( headers={ "Content-Type": "application/json", **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), }, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -882,7 +1150,7 @@ class GenerateCompletionForm(BaseModel): prompt: str suffix: Optional[str] = None images: Optional[list[str]] = None - format: Optional[str] = None + format: Optional[Union[dict, str]] = None options: Optional[dict] = None system: Optional[str] = None template: Optional[str] = None @@ -901,7 +1169,7 @@ async def generate_completion( user=Depends(get_verified_user), ): if url_idx is None: - await get_all_models(request) + await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS model = form_data.model @@ -931,20 +1199,34 @@ async def generate_completion( url=f"{url}/api/generate", payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) class ChatMessage(BaseModel): role: str - content: str + content: Optional[str] = None tool_calls: Optional[list[dict]] = None images: Optional[list[str]] = None + @validator("content", pre=True) + @classmethod + def check_at_least_one_field(cls, field_value, values, **kwargs): + # Raise an error if both 'content' and 'tool_calls' are None + if field_value is None and ( + "tool_calls" not in values or values["tool_calls"] is None + ): + raise ValueError( + "At least one of 'content' or 'tool_calls' must be provided" + ) + + return field_value + class GenerateChatCompletionForm(BaseModel): model: str messages: list[ChatMessage] - format: Optional[dict] = None + format: Optional[Union[dict, str]] = None options: Optional[dict] = None template: Optional[str] = None stream: Optional[bool] = True @@ -1001,13 +1283,14 @@ async def generate_chat_completion( params = model_info.params.model_dump() if params: - if payload.get("options") is None: - payload["options"] = {} + system = params.pop("system", None) + # Unlike OpenAI, Ollama does not support params directly in the body payload["options"] = apply_model_params_to_body_ollama( - params, payload["options"] + params, (payload.get("options", {}) or {}) ) - payload = apply_model_system_prompt_to_body(params, payload, metadata, user) + + payload = apply_model_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model if not bypass_filter and user.role == "user": @@ -1040,13 +1323,14 @@ async def generate_chat_completion( prefix_id = api_config.get("prefix_id", None) if prefix_id: payload["model"] = payload["model"].replace(f"{prefix_id}.", "") - + # payload["keep_alive"] = -1 # keep alive forever return await send_post_request( url=f"{url}/api/chat", payload=json.dumps(payload), stream=form_data.stream, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), content_type="application/x-ndjson", + user=user, ) @@ -1058,7 +1342,7 @@ class OpenAIChatMessageContent(BaseModel): class OpenAIChatMessage(BaseModel): role: str - content: Union[str, list[OpenAIChatMessageContent]] + content: Union[Optional[str], list[OpenAIChatMessageContent]] model_config = ConfigDict(extra="allow") @@ -1149,6 +1433,7 @@ async def generate_openai_completion( payload=json.dumps(payload), stream=payload.get("stream", False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -1187,8 +1472,10 @@ async def generate_openai_chat_completion( params = model_info.params.model_dump() if params: + system = params.pop("system", None) + payload = apply_model_params_to_body_openai(params, payload) - payload = apply_model_system_prompt_to_body(params, payload, metadata, user) + payload = apply_model_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model if user.role == "user": @@ -1227,6 +1514,7 @@ async def generate_openai_chat_completion( payload=json.dumps(payload), stream=payload.get("stream", False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, ) @@ -1240,7 +1528,7 @@ async def get_openai_models( models = [] if url_idx is None: - model_list = await get_all_models(request) + model_list = await get_all_models(request, user=user) models = [ { "id": model["model"], @@ -1341,7 +1629,9 @@ async def download_file_stream( timeout = aiohttp.ClientTimeout(total=600) # Set the timeout async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - async with session.get(file_url, headers=headers) as response: + async with session.get( + file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as response: total_size = int(response.headers.get("content-length", 0)) + current_size with open(file_path, "ab+") as file: @@ -1356,7 +1646,8 @@ async def download_file_stream( if done: file.seek(0) - hashed = calculate_sha256(file) + chunk_size = 1024 * 1024 * 2 + hashed = calculate_sha256(file, chunk_size) file.seek(0) url = f"{ollama_url}/api/blobs/sha256:{hashed}" @@ -1420,7 +1711,9 @@ async def upload_model( if url_idx is None: url_idx = 0 ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - file_path = os.path.join(UPLOAD_DIR, file.filename) + + filename = os.path.basename(file.filename) + file_path = os.path.join(UPLOAD_DIR, filename) os.makedirs(UPLOAD_DIR, exist_ok=True) # --- P1: save file locally --- @@ -1465,13 +1758,13 @@ async def upload_model( os.remove(file_path) # Create model in ollama - model_name, ext = os.path.splitext(file.filename) + model_name, ext = os.path.splitext(filename) log.info(f"Created Model: {model_name}") # DEBUG create_payload = { "model": model_name, # Reference the file by its original name => the uploaded blob's digest - "files": {file.filename: f"sha256:{file_hash}"}, + "files": {filename: f"sha256:{file_hash}"}, } log.info(f"Model Payload: {create_payload}") # DEBUG @@ -1488,7 +1781,7 @@ async def upload_model( done_msg = { "done": True, "blob": f"sha256:{file_hash}", - "name": file.filename, + "name": filename, "model_created": model_name, } yield f"data: {json.dumps(done_msg)}\n\n" diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 1b707150ee..5343c3e7ad 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -21,11 +21,13 @@ from open_webui.config import ( CACHE_DIR, ) from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, - AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST, + AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ENABLE_FORWARD_USER_INFO_HEADERS, BYPASS_MODEL_ACCESS_CONTROL, ) +from open_webui.models.users import UserModel from open_webui.constants import ERROR_MESSAGES from open_webui.env import ENV, SRC_LOG_LEVELS @@ -35,6 +37,9 @@ from open_webui.utils.payload import ( apply_model_params_to_body_openai, apply_model_system_prompt_to_body, ) +from open_webui.utils.misc import ( + convert_logit_bias_input_to_json, +) from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access @@ -51,12 +56,26 @@ log.setLevel(SRC_LOG_LEVELS["OPENAI"]) ########################################## -async def send_get_request(url, key=None): - timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) +async def send_get_request(url, key=None, user: UserModel = None): + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( - url, headers={**({"Authorization": f"Bearer {key}"} if key else {})} + url, + headers={ + **({"Authorization": f"Bearer {key}"} if key else {}), + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + }, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() except Exception as e: @@ -75,18 +94,23 @@ async def cleanup_response( await session.close() -def openai_o1_o3_handler(payload): +def openai_o_series_handler(payload): """ - Handle o1, o3 specific parameters + Handle "o" series specific parameters """ if "max_tokens" in payload: - # Remove "max_tokens" from the payload + # Convert "max_tokens" to "max_completion_tokens" for all o-series models payload["max_completion_tokens"] = payload["max_tokens"] del payload["max_tokens"] - # Fix: O1 does not support the "system" parameter, Modify "system" to "user" + # Handle system role conversion based on model type if payload["messages"][0]["role"] == "system": - payload["messages"][0]["role"] = "user" + model_lower = payload["model"].lower() + # Legacy models use "user" role instead of "system" + if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"): + payload["messages"][0]["role"] = "user" + else: + payload["messages"][0]["role"] = "developer" return payload @@ -172,7 +196,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256(body).hexdigest() - SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") + SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") @@ -247,7 +271,7 @@ async def speech(request: Request, user=Depends(get_verified_user)): raise HTTPException(status_code=401, detail=ERROR_MESSAGES.OPENAI_NOT_FOUND) -async def get_all_models_responses(request: Request) -> list: +async def get_all_models_responses(request: Request, user: UserModel) -> list: if not request.app.state.config.ENABLE_OPENAI_API: return [] @@ -271,7 +295,9 @@ async def get_all_models_responses(request: Request) -> list: ): request_tasks.append( send_get_request( - f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx] + f"{url}/models", + request.app.state.config.OPENAI_API_KEYS[idx], + user=user, ) ) else: @@ -291,6 +317,7 @@ async def get_all_models_responses(request: Request) -> list: send_get_request( f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx], + user=user, ) ) else: @@ -326,14 +353,22 @@ async def get_all_models_responses(request: Request) -> list: ), # Legacy support ) + connection_type = api_config.get("connection_type", "external") prefix_id = api_config.get("prefix_id", None) + tags = api_config.get("tags", []) - if prefix_id: - for model in ( - response if isinstance(response, list) else response.get("data", []) - ): + for model in ( + response if isinstance(response, list) else response.get("data", []) + ): + if prefix_id: model["id"] = f"{prefix_id}.{model['id']}" + if tags: + model["tags"] = tags + + if connection_type: + model["connection_type"] = connection_type + log.debug(f"get_all_models:responses() {responses}") return responses @@ -351,14 +386,14 @@ async def get_filtered_models(models, user): return filtered_models -@cached(ttl=3) -async def get_all_models(request: Request) -> dict[str, list]: +@cached(ttl=1) +async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: log.info("get_all_models()") if not request.app.state.config.ENABLE_OPENAI_API: return {"data": []} - responses = await get_all_models_responses(request) + responses = await get_all_models_responses(request, user=user) def extract_data(response): if response and "data" in response: @@ -373,6 +408,7 @@ async def get_all_models(request: Request) -> dict[str, list]: for idx, models in enumerate(model_lists): if models is not None and "error" not in models: + merged_list.extend( [ { @@ -380,21 +416,25 @@ async def get_all_models(request: Request) -> dict[str, list]: "name": model.get("name", model["id"]), "owned_by": "openai", "openai": model, + "connection_type": model.get("connection_type", "external"), "urlIdx": idx, } for model in models - if "api.openai.com" - not in request.app.state.config.OPENAI_API_BASE_URLS[idx] - or not any( - name in model["id"] - for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", - ] + if (model.get("id") or model.get("name")) + and ( + "api.openai.com" + not in request.app.state.config.OPENAI_API_BASE_URLS[idx] + or not any( + name in model["id"] + for name in [ + "babbage", + "dall-e", + "davinci", + "embedding", + "tts", + "whisper", + ] + ) ) ] ) @@ -418,65 +458,79 @@ async def get_models( } if url_idx is None: - models = await get_all_models(request) + models = await get_all_models(request, user=user) else: url = request.app.state.config.OPENAI_API_BASE_URLS[url_idx] key = request.app.state.config.OPENAI_API_KEYS[url_idx] + api_config = request.app.state.config.OPENAI_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support + ) + r = None async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout( - total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST - ) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - async with session.get( - f"{url}/models", - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": user.name, - "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 {} - ), - }, - ) as r: - if r.status != 200: - # Extract response error details if available - error_detail = f"HTTP Error: {r.status}" - res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" - raise Exception(error_detail) + headers = { + "Content-Type": "application/json", + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + } - response_data = await r.json() + if api_config.get("azure", False): + models = { + "data": api_config.get("model_ids", []) or [], + "object": "list", + } + else: + headers["Authorization"] = f"Bearer {key}" - # Check if we're calling OpenAI API based on the URL - if "api.openai.com" in url: - # Filter models according to the specified conditions - response_data["data"] = [ - model - for model in response_data.get("data", []) - if not any( - name in model["id"] - for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", - ] - ) - ] + async with session.get( + f"{url}/models", + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status != 200: + # Extract response error details if available + error_detail = f"HTTP Error: {r.status}" + res = await r.json() + if "error" in res: + error_detail = f"External Error: {res['error']}" + raise Exception(error_detail) - models = response_data + response_data = await r.json() + + # Check if we're calling OpenAI API based on the URL + if "api.openai.com" in url: + # Filter models according to the specified conditions + response_data["data"] = [ + model + for model in response_data.get("data", []) + if not any( + name in model["id"] + for name in [ + "babbage", + "dall-e", + "davinci", + "embedding", + "tts", + "whisper", + ] + ) + ] + + models = response_data except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues log.exception(f"Client error: {str(e)}") @@ -498,6 +552,8 @@ class ConnectionVerificationForm(BaseModel): url: str key: str + config: Optional[dict] = None + @router.post("/verify") async def verify_connection( @@ -506,27 +562,64 @@ async def verify_connection( url = form_data.url key = form_data.key + api_config = form_data.config or {} + async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST) + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - async with session.get( - f"{url}/models", - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - }, - ) as r: - if r.status != 200: - # Extract response error details if available - error_detail = f"HTTP Error: {r.status}" - res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" - raise Exception(error_detail) + headers = { + "Content-Type": "application/json", + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + } - response_data = await r.json() - return response_data + if api_config.get("azure", False): + 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, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status != 200: + # Extract response error details if available + error_detail = f"HTTP Error: {r.status}" + res = await r.json() + if "error" in res: + error_detail = f"External Error: {res['error']}" + raise Exception(error_detail) + + response_data = await r.json() + return response_data + else: + headers["Authorization"] = f"Bearer {key}" + + async with session.get( + f"{url}/models", + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as r: + if r.status != 200: + # Extract response error details if available + error_detail = f"HTTP Error: {r.status}" + res = await r.json() + if "error" in res: + error_detail = f"External Error: {res['error']}" + raise Exception(error_detail) + + response_data = await r.json() + return response_data except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues @@ -540,6 +633,63 @@ async def verify_connection( raise HTTPException(status_code=500, detail=error_detail) +def convert_to_azure_payload( + url, + payload: dict, +): + model = payload.get("model", "") + + # Filter allowed parameters based on Azure OpenAI API + allowed_params = { + "messages", + "temperature", + "role", + "content", + "contentPart", + "contentPartImage", + "enhancements", + "dataSources", + "n", + "stream", + "stop", + "max_tokens", + "presence_penalty", + "frequency_penalty", + "logit_bias", + "user", + "function_call", + "functions", + "tools", + "tool_choice", + "top_p", + "log_probs", + "top_logprobs", + "response_format", + "seed", + "max_completion_tokens", + } + + # Special handling for o-series models + if model.startswith("o") and model.endswith("-mini"): + # Convert max_tokens to max_completion_tokens for o-series models + if "max_tokens" in payload: + payload["max_completion_tokens"] = payload["max_tokens"] + del payload["max_tokens"] + + # Remove temperature if not 1 for o-series models + if "temperature" in payload and payload["temperature"] != 1: + log.debug( + f"Removing temperature parameter for o-series model {model} as only default value (1) is supported" + ) + del payload["temperature"] + + # Filter out unsupported parameters + payload = {k: v for k, v in payload.items() if k in allowed_params} + + url = f"{url}/openai/deployments/{model}" + return url, payload + + @router.post("/chat/completions") async def generate_chat_completion( request: Request, @@ -565,8 +715,12 @@ async def generate_chat_completion( model_id = model_info.base_model_id params = model_info.params.model_dump() - payload = apply_model_params_to_body_openai(params, payload) - payload = apply_model_system_prompt_to_body(params, payload, metadata, user) + + if params: + system = params.pop("system", None) + + payload = apply_model_params_to_body_openai(params, payload) + payload = apply_model_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model if not bypass_filter and user.role == "user": @@ -587,7 +741,7 @@ async def generate_chat_completion( detail="Model not found", ) - await get_all_models(request) + await get_all_models(request, user=user) model = request.app.state.OPENAI_MODELS.get(model_id) if model: idx = model["urlIdx"] @@ -621,10 +775,10 @@ async def generate_chat_completion( url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] - # Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens" - is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-")) - if is_o1_o3: - payload = openai_o1_o3_handler(payload) + # Check if model is from "o" series + is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4")) + if is_o_series: + payload = openai_o_series_handler(payload) elif "api.openai.com" not in url: # Remove "max_completion_tokens" from the payload for backward compatibility if "max_completion_tokens" in payload: @@ -635,6 +789,43 @@ async def generate_chat_completion( del payload["max_tokens"] # Convert the modified body back to JSON + if "logit_bias" in payload: + payload["logit_bias"] = json.loads( + 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": user.name, + "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 {} + ), + } + + if api_config.get("azure", False): + request_url, payload = convert_to_azure_payload(url, payload) + api_version = api_config.get("api_version", "") or "2023-03-15-preview" + 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) r = None @@ -649,30 +840,10 @@ async def generate_chat_completion( r = await session.request( method="POST", - url=f"{url}/chat/completions", + url=request_url, data=payload, - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - **( - { - "HTTP-Referer": "https://openwebui.com/", - "X-Title": "Open WebUI", - } - if "openrouter.ai" in url - else {} - ), - **( - { - "X-OpenWebUI-User-Name": user.name, - "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, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) # Check if response is SSE @@ -801,31 +972,54 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): idx = 0 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( + request.app.state.config.OPENAI_API_BASE_URLS[idx], {} + ), # Legacy support + ) r = None session = None streaming = False try: + headers = { + "Content-Type": "application/json", + **( + { + "X-OpenWebUI-User-Name": user.name, + "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 {} + ), + } + + if api_config.get("azure", False): + headers["api-key"] = key + headers["api-version"] = ( + api_config.get("api_version", "") or "2023-03-15-preview" + ) + + payload = json.loads(body) + url, payload = convert_to_azure_payload(url, payload) + body = json.dumps(payload).encode() + + request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}" + else: + headers["Authorization"] = f"Bearer {key}" + request_url = f"{url}/{path}" + session = aiohttp.ClientSession(trust_env=True) r = await session.request( method=request.method, - url=f"{url}/{path}", + url=request_url, data=body, - headers={ - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - **( - { - "X-OpenWebUI-User-Name": user.name, - "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, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) r.raise_for_status() @@ -851,7 +1045,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): if r is not None: try: res = await r.json() - print(res) + log.error(res) if "error" in res: detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" except Exception: diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 062663671f..f80ea91f84 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -9,6 +9,7 @@ from fastapi import ( status, APIRouter, ) +import aiohttp import os import logging import shutil @@ -17,7 +18,7 @@ from pydantic import BaseModel from starlette.responses import FileResponse from typing import Optional -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES @@ -56,96 +57,111 @@ def get_sorted_filters(model_id, models): return sorted_filters -def process_pipeline_inlet_filter(request, payload, user, models): +async def process_pipeline_inlet_filter(request, payload, user, models): user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role} model_id = payload["model"] - sorted_filters = get_sorted_filters(model_id, models) model = models[model_id] if "pipeline" in model: sorted_filters.append(model) - for filter in sorted_filters: - r = None - try: - urlIdx = filter["urlIdx"] + async with aiohttp.ClientSession(trust_env=True) as session: + for filter in sorted_filters: + urlIdx = filter.get("urlIdx") + + try: + urlIdx = int(urlIdx) + except: + continue url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] key = request.app.state.config.OPENAI_API_KEYS[urlIdx] - if key == "": + if not key: continue headers = {"Authorization": f"Bearer {key}"} - r = requests.post( - f"{url}/{filter['id']}/filter/inlet", - headers=headers, - json={ - "user": user, - "body": payload, - }, - ) + request_data = { + "user": user, + "body": payload, + } - r.raise_for_status() - payload = r.json() - except Exception as e: - # Handle connection error here - print(f"Connection error: {e}") - - if r is not None: - res = r.json() + try: + async with session.post( + f"{url}/{filter['id']}/filter/inlet", + headers=headers, + json=request_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + payload = await response.json() + response.raise_for_status() + except aiohttp.ClientResponseError as e: + res = ( + await response.json() + if response.content_type == "application/json" + else {} + ) if "detail" in res: - raise Exception(r.status_code, res["detail"]) + raise Exception(response.status, res["detail"]) + except Exception as e: + log.exception(f"Connection error: {e}") return payload -def process_pipeline_outlet_filter(request, payload, user, models): +async def process_pipeline_outlet_filter(request, payload, user, models): user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role} model_id = payload["model"] - sorted_filters = get_sorted_filters(model_id, models) model = models[model_id] if "pipeline" in model: sorted_filters = [model] + sorted_filters - for filter in sorted_filters: - r = None - try: - urlIdx = filter["urlIdx"] + async with aiohttp.ClientSession(trust_env=True) as session: + for filter in sorted_filters: + urlIdx = filter.get("urlIdx") + + try: + urlIdx = int(urlIdx) + except: + continue url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] key = request.app.state.config.OPENAI_API_KEYS[urlIdx] - if key != "": - r = requests.post( + if not key: + continue + + headers = {"Authorization": f"Bearer {key}"} + request_data = { + "user": user, + "body": payload, + } + + try: + async with session.post( f"{url}/{filter['id']}/filter/outlet", - headers={"Authorization": f"Bearer {key}"}, - json={ - "user": user, - "body": payload, - }, - ) - - r.raise_for_status() - data = r.json() - payload = data - except Exception as e: - # Handle connection error here - print(f"Connection error: {e}") - - if r is not None: + headers=headers, + json=request_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + payload = await response.json() + response.raise_for_status() + except aiohttp.ClientResponseError as e: try: - res = r.json() + res = ( + await response.json() + if "application/json" in response.content_type + else {} + ) if "detail" in res: - return Exception(r.status_code, res) + raise Exception(response.status, res) except Exception: pass - - else: - pass + except Exception as e: + log.exception(f"Connection error: {e}") return payload @@ -161,7 +177,7 @@ router = APIRouter() @router.get("/list") async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): - responses = await get_all_models_responses(request) + responses = await get_all_models_responses(request, user) log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}") urlIdxs = [ @@ -188,9 +204,11 @@ async def upload_pipeline( file: UploadFile = File(...), user=Depends(get_admin_user), ): - print("upload_pipeline", urlIdx, file.filename) + log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}") + filename = os.path.basename(file.filename) + # Check if the uploaded file is a python file - if not (file.filename and file.filename.endswith(".py")): + if not (filename and filename.endswith(".py")): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Only Python (.py) files are allowed.", @@ -198,7 +216,7 @@ async def upload_pipeline( upload_folder = f"{CACHE_DIR}/pipelines" os.makedirs(upload_folder, exist_ok=True) - file_path = os.path.join(upload_folder, file.filename) + file_path = os.path.join(upload_folder, filename) r = None try: @@ -223,7 +241,7 @@ async def upload_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None status_code = status.HTTP_404_NOT_FOUND @@ -274,7 +292,7 @@ async def add_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -319,7 +337,7 @@ async def delete_pipeline( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -353,7 +371,7 @@ async def get_pipelines( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -392,7 +410,7 @@ async def get_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -432,7 +450,7 @@ async def get_pipeline_valves_spec( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None if r is not None: @@ -474,7 +492,7 @@ async def update_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - print(f"Connection error: {e}") + log.exception(f"Connection error: {e}") detail = None diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 415d3bbb55..343b0513c9 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -3,6 +3,8 @@ import logging import mimetypes import os import shutil +import asyncio + import uuid from datetime import datetime @@ -21,6 +23,7 @@ from fastapi import ( APIRouter, ) from fastapi.middleware.cors import CORSMiddleware +from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel import tiktoken @@ -33,7 +36,7 @@ from open_webui.models.knowledge import Knowledges from open_webui.storage.provider import Storage -from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT +from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT # Document loaders from open_webui.retrieval.loaders.main import Loader @@ -52,13 +55,17 @@ from open_webui.retrieval.web.jina_search import search_jina from open_webui.retrieval.web.searchapi import search_searchapi from open_webui.retrieval.web.serpapi import search_serpapi from open_webui.retrieval.web.searxng import search_searxng +from open_webui.retrieval.web.yacy import search_yacy from open_webui.retrieval.web.serper import search_serper from open_webui.retrieval.web.serply import search_serply from open_webui.retrieval.web.serpstack import search_serpstack from open_webui.retrieval.web.tavily import search_tavily from open_webui.retrieval.web.bing import search_bing from open_webui.retrieval.web.exa import search_exa - +from open_webui.retrieval.web.perplexity import search_perplexity +from open_webui.retrieval.web.sougou import search_sougou +from open_webui.retrieval.web.firecrawl import search_firecrawl +from open_webui.retrieval.web.external import search_external from open_webui.retrieval.utils import ( get_embedding_function, @@ -73,7 +80,6 @@ from open_webui.utils.misc import ( ) from open_webui.utils.auth import get_admin_user, get_verified_user - from open_webui.config import ( ENV, RAG_EMBEDDING_MODEL_AUTO_UPDATE, @@ -82,12 +88,19 @@ from open_webui.config import ( RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, UPLOAD_DIR, DEFAULT_LOCALE, + RAG_EMBEDDING_CONTENT_PREFIX, + RAG_EMBEDDING_QUERY_PREFIX, ) from open_webui.env import ( SRC_LOG_LEVELS, DEVICE_TYPE, DOCKER, + SENTENCE_TRANSFORMERS_BACKEND, + SENTENCE_TRANSFORMERS_MODEL_KWARGS, + SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, ) + from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) @@ -114,6 +127,8 @@ def get_ef( get_model_path(embedding_model, auto_update), device=DEVICE_TYPE, trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE, + backend=SENTENCE_TRANSFORMERS_BACKEND, + model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS, ) except Exception as e: log.debug(f"Error loading SentenceTransformer: {e}") @@ -122,7 +137,10 @@ def get_ef( def get_rf( - reranking_model: str, + engine: str = "", + reranking_model: Optional[str] = None, + external_reranker_url: str = "", + external_reranker_api_key: str = "", auto_update: bool = False, ): rf = None @@ -140,17 +158,33 @@ def get_rf( log.error(f"ColBERT: {e}") raise Exception(ERROR_MESSAGES.DEFAULT(e)) else: - import sentence_transformers + if engine == "external": + try: + from open_webui.retrieval.models.external import ExternalReranker + + rf = ExternalReranker( + url=external_reranker_url, + api_key=external_reranker_api_key, + model=reranking_model, + ) + except Exception as e: + log.error(f"ExternalReranking: {e}") + raise Exception(ERROR_MESSAGES.DEFAULT(e)) + else: + import sentence_transformers + + try: + rf = sentence_transformers.CrossEncoder( + get_model_path(reranking_model, auto_update), + device=DEVICE_TYPE, + trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, + backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, + model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, + ) + except Exception as e: + log.error(f"CrossEncoder: {e}") + raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) - try: - rf = sentence_transformers.CrossEncoder( - get_model_path(reranking_model, auto_update), - device=DEVICE_TYPE, - trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, - ) - except: - log.error("CrossEncoder error") - raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) return rf @@ -172,8 +206,8 @@ class ProcessUrlForm(CollectionNameForm): url: str -class SearchForm(CollectionNameForm): - query: str +class SearchForm(BaseModel): + queries: List[str] @router.get("/") @@ -205,14 +239,11 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)): "url": request.app.state.config.RAG_OLLAMA_BASE_URL, "key": request.app.state.config.RAG_OLLAMA_API_KEY, }, - } - - -@router.get("/reranking") -async def get_reraanking_config(request: Request, user=Depends(get_admin_user)): - return { - "status": True, - "reranking_model": request.app.state.config.RAG_RERANKING_MODEL, + "azure_openai_config": { + "url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, + "key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY, + "version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + }, } @@ -226,9 +257,16 @@ class OllamaConfigForm(BaseModel): key: str +class AzureOpenAIConfigForm(BaseModel): + url: str + key: str + version: str + + class EmbeddingModelUpdateForm(BaseModel): openai_config: Optional[OpenAIConfigForm] = None ollama_config: Optional[OllamaConfigForm] = None + azure_openai_config: Optional[AzureOpenAIConfigForm] = None embedding_engine: str embedding_model: str embedding_batch_size: Optional[int] = 1 @@ -245,7 +283,11 @@ async def update_embedding_config( request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model - if request.app.state.config.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]: + if request.app.state.config.RAG_EMBEDDING_ENGINE in [ + "ollama", + "openai", + "azure_openai", + ]: if form_data.openai_config is not None: request.app.state.config.RAG_OPENAI_API_BASE_URL = ( form_data.openai_config.url @@ -262,6 +304,17 @@ async def update_embedding_config( form_data.ollama_config.key ) + if form_data.azure_openai_config is not None: + request.app.state.config.RAG_AZURE_OPENAI_BASE_URL = ( + form_data.azure_openai_config.url + ) + request.app.state.config.RAG_AZURE_OPENAI_API_KEY = ( + form_data.azure_openai_config.key + ) + request.app.state.config.RAG_AZURE_OPENAI_API_VERSION = ( + form_data.azure_openai_config.version + ) + request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = ( form_data.embedding_batch_size ) @@ -278,14 +331,27 @@ async def update_embedding_config( ( request.app.state.config.RAG_OPENAI_API_BASE_URL if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else request.app.state.config.RAG_OLLAMA_BASE_URL + else ( + request.app.state.config.RAG_OLLAMA_BASE_URL + if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL + ) ), ( request.app.state.config.RAG_OPENAI_API_KEY if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else request.app.state.config.RAG_OLLAMA_API_KEY + else ( + request.app.state.config.RAG_OLLAMA_API_KEY + if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else request.app.state.config.RAG_AZURE_OPENAI_API_KEY + ) ), request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + azure_api_version=( + request.app.state.config.RAG_AZURE_OPENAI_API_VERSION + if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + else None + ), ) return { @@ -301,6 +367,11 @@ async def update_embedding_config( "url": request.app.state.config.RAG_OLLAMA_BASE_URL, "key": request.app.state.config.RAG_OLLAMA_API_KEY, }, + "azure_openai_config": { + "url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, + "key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY, + "version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + }, } except Exception as e: log.exception(f"Problem updating embedding model: {e}") @@ -310,367 +381,672 @@ async def update_embedding_config( ) -class RerankingModelUpdateForm(BaseModel): - reranking_model: str - - -@router.post("/reranking/update") -async def update_reranking_config( - request: Request, form_data: RerankingModelUpdateForm, user=Depends(get_admin_user) -): - log.info( - f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.reranking_model}" - ) - try: - request.app.state.config.RAG_RERANKING_MODEL = form_data.reranking_model - - try: - request.app.state.rf = get_rf( - request.app.state.config.RAG_RERANKING_MODEL, - True, - ) - except Exception as e: - log.error(f"Error loading reranking model: {e}") - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False - - return { - "status": True, - "reranking_model": request.app.state.config.RAG_RERANKING_MODEL, - } - except Exception as e: - log.exception(f"Problem updating reranking model: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT(e), - ) - - @router.get("/config") async def get_rag_config(request: Request, user=Depends(get_admin_user)): return { "status": True, - "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, - "enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - "content_extraction": { - "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, - "tika_server_url": request.app.state.config.TIKA_SERVER_URL, - }, - "chunk": { - "text_splitter": request.app.state.config.TEXT_SPLITTER, - "chunk_size": request.app.state.config.CHUNK_SIZE, - "chunk_overlap": request.app.state.config.CHUNK_OVERLAP, - }, - "file": { - "max_size": request.app.state.config.FILE_MAX_SIZE, - "max_count": request.app.state.config.FILE_MAX_COUNT, - }, - "youtube": { - "language": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - "translation": request.app.state.YOUTUBE_LOADER_TRANSLATION, - "proxy_url": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, - }, + # RAG settings + "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, + "TOP_K": request.app.state.config.TOP_K, + "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, + "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + # Hybrid search settings + "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, + "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, + "HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT, + # Content extraction settings + "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, + "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, + "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, + "DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS, + "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, + "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, + "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, + "DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, + "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + "DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM, + "DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, + "EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, + "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_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, + "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, + "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, + "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + # Reranking settings + "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, + "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, + "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + # Chunking settings + "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, + "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, + "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, + # File upload settings + "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, + "FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT, + "ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS, + # Integration settings + "ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + "ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + # Web search settings "web": { - "web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - "search": { - "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, - "drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, - "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL, - "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY, - "google_pse_engine_id": request.app.state.config.GOOGLE_PSE_ENGINE_ID, - "brave_search_api_key": request.app.state.config.BRAVE_SEARCH_API_KEY, - "kagi_search_api_key": request.app.state.config.KAGI_SEARCH_API_KEY, - "mojeek_search_api_key": request.app.state.config.MOJEEK_SEARCH_API_KEY, - "bocha_search_api_key": request.app.state.config.BOCHA_SEARCH_API_KEY, - "serpstack_api_key": request.app.state.config.SERPSTACK_API_KEY, - "serpstack_https": request.app.state.config.SERPSTACK_HTTPS, - "serper_api_key": request.app.state.config.SERPER_API_KEY, - "serply_api_key": request.app.state.config.SERPLY_API_KEY, - "tavily_api_key": request.app.state.config.TAVILY_API_KEY, - "searchapi_api_key": request.app.state.config.SEARCHAPI_API_KEY, - "searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE, - "serpapi_api_key": request.app.state.config.SERPAPI_API_KEY, - "serpapi_engine": request.app.state.config.SERPAPI_ENGINE, - "jina_api_key": request.app.state.config.JINA_API_KEY, - "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, - "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, - "exa_api_key": request.app.state.config.EXA_API_KEY, - "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, - "domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, - }, + "ENABLE_WEB_SEARCH": request.app.state.config.ENABLE_WEB_SEARCH, + "WEB_SEARCH_ENGINE": request.app.state.config.WEB_SEARCH_ENGINE, + "WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV, + "WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT, + "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, + "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, + "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, + "YACY_USERNAME": request.app.state.config.YACY_USERNAME, + "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, + "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, + "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, + "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, + "KAGI_SEARCH_API_KEY": request.app.state.config.KAGI_SEARCH_API_KEY, + "MOJEEK_SEARCH_API_KEY": request.app.state.config.MOJEEK_SEARCH_API_KEY, + "BOCHA_SEARCH_API_KEY": request.app.state.config.BOCHA_SEARCH_API_KEY, + "SERPSTACK_API_KEY": request.app.state.config.SERPSTACK_API_KEY, + "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, + "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, + "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, + "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, + "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, + "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, + "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, + "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, + "JINA_API_KEY": request.app.state.config.JINA_API_KEY, + "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, + "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + "EXA_API_KEY": request.app.state.config.EXA_API_KEY, + "PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY, + "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, + "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, + "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, + "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, + "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, + "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, + "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, + "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, + "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, + "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, + "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, }, } -class FileConfig(BaseModel): - max_size: Optional[int] = None - max_count: Optional[int] = None - - -class ContentExtractionConfig(BaseModel): - engine: str = "" - tika_server_url: Optional[str] = None - - -class ChunkParamUpdateForm(BaseModel): - text_splitter: Optional[str] = None - chunk_size: int - chunk_overlap: int - - -class YoutubeLoaderConfig(BaseModel): - language: list[str] - translation: Optional[str] = None - proxy_url: str = "" - - -class WebSearchConfig(BaseModel): - enabled: bool - engine: Optional[str] = None - searxng_query_url: Optional[str] = None - google_pse_api_key: Optional[str] = None - google_pse_engine_id: Optional[str] = None - brave_search_api_key: Optional[str] = None - kagi_search_api_key: Optional[str] = None - mojeek_search_api_key: Optional[str] = None - bocha_search_api_key: Optional[str] = None - serpstack_api_key: Optional[str] = None - serpstack_https: Optional[bool] = None - serper_api_key: Optional[str] = None - serply_api_key: Optional[str] = None - tavily_api_key: Optional[str] = None - searchapi_api_key: Optional[str] = None - searchapi_engine: Optional[str] = None - serpapi_api_key: Optional[str] = None - serpapi_engine: Optional[str] = None - jina_api_key: Optional[str] = None - bing_search_v7_endpoint: Optional[str] = None - bing_search_v7_subscription_key: Optional[str] = None - exa_api_key: Optional[str] = None - result_count: Optional[int] = None - concurrent_requests: Optional[int] = None - domain_filter_list: Optional[List[str]] = [] - - class WebConfig(BaseModel): - search: WebSearchConfig - web_loader_ssl_verification: Optional[bool] = None + ENABLE_WEB_SEARCH: Optional[bool] = None + WEB_SEARCH_ENGINE: Optional[str] = None + WEB_SEARCH_TRUST_ENV: Optional[bool] = None + WEB_SEARCH_RESULT_COUNT: Optional[int] = None + WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None + WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] + BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None + BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None + SEARXNG_QUERY_URL: Optional[str] = None + YACY_QUERY_URL: Optional[str] = None + YACY_USERNAME: Optional[str] = None + YACY_PASSWORD: Optional[str] = None + GOOGLE_PSE_API_KEY: Optional[str] = None + GOOGLE_PSE_ENGINE_ID: Optional[str] = None + BRAVE_SEARCH_API_KEY: Optional[str] = None + KAGI_SEARCH_API_KEY: Optional[str] = None + MOJEEK_SEARCH_API_KEY: Optional[str] = None + BOCHA_SEARCH_API_KEY: Optional[str] = None + SERPSTACK_API_KEY: Optional[str] = None + SERPSTACK_HTTPS: Optional[bool] = None + SERPER_API_KEY: Optional[str] = None + SERPLY_API_KEY: Optional[str] = None + TAVILY_API_KEY: Optional[str] = None + SEARCHAPI_API_KEY: Optional[str] = None + SEARCHAPI_ENGINE: Optional[str] = None + SERPAPI_API_KEY: Optional[str] = None + SERPAPI_ENGINE: Optional[str] = None + JINA_API_KEY: Optional[str] = None + BING_SEARCH_V7_ENDPOINT: Optional[str] = None + BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None + EXA_API_KEY: Optional[str] = None + PERPLEXITY_API_KEY: Optional[str] = None + SOUGOU_API_SID: Optional[str] = None + SOUGOU_API_SK: Optional[str] = None + WEB_LOADER_ENGINE: Optional[str] = None + ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None + PLAYWRIGHT_WS_URL: Optional[str] = None + PLAYWRIGHT_TIMEOUT: Optional[int] = None + FIRECRAWL_API_KEY: Optional[str] = None + FIRECRAWL_API_BASE_URL: Optional[str] = None + TAVILY_EXTRACT_DEPTH: Optional[str] = None + EXTERNAL_WEB_SEARCH_URL: Optional[str] = None + EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None + EXTERNAL_WEB_LOADER_URL: Optional[str] = None + EXTERNAL_WEB_LOADER_API_KEY: Optional[str] = None + YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None + YOUTUBE_LOADER_PROXY_URL: Optional[str] = None + YOUTUBE_LOADER_TRANSLATION: Optional[str] = None -class ConfigUpdateForm(BaseModel): - pdf_extract_images: Optional[bool] = None - enable_google_drive_integration: Optional[bool] = None - file: Optional[FileConfig] = None - content_extraction: Optional[ContentExtractionConfig] = None - chunk: Optional[ChunkParamUpdateForm] = None - youtube: Optional[YoutubeLoaderConfig] = None +class ConfigForm(BaseModel): + # RAG settings + RAG_TEMPLATE: Optional[str] = None + TOP_K: Optional[int] = None + BYPASS_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None + RAG_FULL_CONTEXT: Optional[bool] = None + + # Hybrid search settings + ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None + TOP_K_RERANKER: Optional[int] = None + RELEVANCE_THRESHOLD: Optional[float] = None + HYBRID_BM25_WEIGHT: Optional[float] = None + + # Content extraction settings + CONTENT_EXTRACTION_ENGINE: Optional[str] = None + PDF_EXTRACT_IMAGES: Optional[bool] = None + DATALAB_MARKER_API_KEY: Optional[str] = None + DATALAB_MARKER_LANGS: Optional[str] = None + DATALAB_MARKER_SKIP_CACHE: Optional[bool] = None + DATALAB_MARKER_FORCE_OCR: Optional[bool] = None + DATALAB_MARKER_PAGINATE: Optional[bool] = None + DATALAB_MARKER_STRIP_EXISTING_OCR: Optional[bool] = None + DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION: Optional[bool] = None + DATALAB_MARKER_USE_LLM: Optional[bool] = None + DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None + EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None + EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None + + TIKA_SERVER_URL: Optional[str] = None + DOCLING_SERVER_URL: Optional[str] = None + DOCLING_OCR_ENGINE: Optional[str] = None + DOCLING_OCR_LANG: Optional[str] = None + DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None + DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None + DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None + MISTRAL_OCR_API_KEY: Optional[str] = None + + # Reranking settings + RAG_RERANKING_MODEL: Optional[str] = None + RAG_RERANKING_ENGINE: Optional[str] = None + RAG_EXTERNAL_RERANKER_URL: Optional[str] = None + RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None + + # Chunking settings + TEXT_SPLITTER: Optional[str] = None + CHUNK_SIZE: Optional[int] = None + CHUNK_OVERLAP: Optional[int] = None + + # File upload settings + FILE_MAX_SIZE: Optional[int] = None + FILE_MAX_COUNT: Optional[int] = None + ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None + + # Integration settings + ENABLE_GOOGLE_DRIVE_INTEGRATION: Optional[bool] = None + ENABLE_ONEDRIVE_INTEGRATION: Optional[bool] = None + + # Web search settings web: Optional[WebConfig] = None @router.post("/config/update") async def update_rag_config( - request: Request, form_data: ConfigUpdateForm, user=Depends(get_admin_user) + request: Request, form_data: ConfigForm, user=Depends(get_admin_user) ): + # RAG settings + request.app.state.config.RAG_TEMPLATE = ( + form_data.RAG_TEMPLATE + if form_data.RAG_TEMPLATE is not None + else request.app.state.config.RAG_TEMPLATE + ) + request.app.state.config.TOP_K = ( + form_data.TOP_K + if form_data.TOP_K is not None + else request.app.state.config.TOP_K + ) + request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = ( + form_data.BYPASS_EMBEDDING_AND_RETRIEVAL + if form_data.BYPASS_EMBEDDING_AND_RETRIEVAL is not None + else request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL + ) + request.app.state.config.RAG_FULL_CONTEXT = ( + form_data.RAG_FULL_CONTEXT + if form_data.RAG_FULL_CONTEXT is not None + else request.app.state.config.RAG_FULL_CONTEXT + ) + + # Hybrid search settings + request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( + form_data.ENABLE_RAG_HYBRID_SEARCH + if form_data.ENABLE_RAG_HYBRID_SEARCH is not None + else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH + ) + # Free up memory if hybrid search is disabled + if not request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + request.app.state.rf = None + + request.app.state.config.TOP_K_RERANKER = ( + form_data.TOP_K_RERANKER + if form_data.TOP_K_RERANKER is not None + else request.app.state.config.TOP_K_RERANKER + ) + request.app.state.config.RELEVANCE_THRESHOLD = ( + form_data.RELEVANCE_THRESHOLD + if form_data.RELEVANCE_THRESHOLD is not None + else request.app.state.config.RELEVANCE_THRESHOLD + ) + request.app.state.config.HYBRID_BM25_WEIGHT = ( + form_data.HYBRID_BM25_WEIGHT + if form_data.HYBRID_BM25_WEIGHT is not None + else request.app.state.config.HYBRID_BM25_WEIGHT + ) + + # Content extraction settings + request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( + form_data.CONTENT_EXTRACTION_ENGINE + if form_data.CONTENT_EXTRACTION_ENGINE is not None + else request.app.state.config.CONTENT_EXTRACTION_ENGINE + ) request.app.state.config.PDF_EXTRACT_IMAGES = ( - form_data.pdf_extract_images - if form_data.pdf_extract_images is not None + form_data.PDF_EXTRACT_IMAGES + if form_data.PDF_EXTRACT_IMAGES is not None else request.app.state.config.PDF_EXTRACT_IMAGES ) + request.app.state.config.DATALAB_MARKER_API_KEY = ( + form_data.DATALAB_MARKER_API_KEY + if form_data.DATALAB_MARKER_API_KEY is not None + else request.app.state.config.DATALAB_MARKER_API_KEY + ) + request.app.state.config.DATALAB_MARKER_LANGS = ( + form_data.DATALAB_MARKER_LANGS + if form_data.DATALAB_MARKER_LANGS is not None + else request.app.state.config.DATALAB_MARKER_LANGS + ) + request.app.state.config.DATALAB_MARKER_SKIP_CACHE = ( + form_data.DATALAB_MARKER_SKIP_CACHE + if form_data.DATALAB_MARKER_SKIP_CACHE is not None + else request.app.state.config.DATALAB_MARKER_SKIP_CACHE + ) + request.app.state.config.DATALAB_MARKER_FORCE_OCR = ( + form_data.DATALAB_MARKER_FORCE_OCR + if form_data.DATALAB_MARKER_FORCE_OCR is not None + else request.app.state.config.DATALAB_MARKER_FORCE_OCR + ) + request.app.state.config.DATALAB_MARKER_PAGINATE = ( + form_data.DATALAB_MARKER_PAGINATE + if form_data.DATALAB_MARKER_PAGINATE is not None + else request.app.state.config.DATALAB_MARKER_PAGINATE + ) + request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = ( + form_data.DATALAB_MARKER_STRIP_EXISTING_OCR + if form_data.DATALAB_MARKER_STRIP_EXISTING_OCR is not None + else request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR + ) + request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( + form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION + if form_data.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION is not None + else request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION + ) + request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = ( + form_data.DATALAB_MARKER_OUTPUT_FORMAT + if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None + else request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT + ) + request.app.state.config.DATALAB_MARKER_USE_LLM = ( + form_data.DATALAB_MARKER_USE_LLM + if form_data.DATALAB_MARKER_USE_LLM is not None + else request.app.state.config.DATALAB_MARKER_USE_LLM + ) + request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = ( + form_data.EXTERNAL_DOCUMENT_LOADER_URL + if form_data.EXTERNAL_DOCUMENT_LOADER_URL is not None + else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL + ) + request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = ( + form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY + if form_data.EXTERNAL_DOCUMENT_LOADER_API_KEY is not None + else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY + ) + request.app.state.config.TIKA_SERVER_URL = ( + form_data.TIKA_SERVER_URL + if form_data.TIKA_SERVER_URL is not None + else request.app.state.config.TIKA_SERVER_URL + ) + request.app.state.config.DOCLING_SERVER_URL = ( + form_data.DOCLING_SERVER_URL + if form_data.DOCLING_SERVER_URL is not None + else request.app.state.config.DOCLING_SERVER_URL + ) + request.app.state.config.DOCLING_OCR_ENGINE = ( + form_data.DOCLING_OCR_ENGINE + if form_data.DOCLING_OCR_ENGINE is not None + else request.app.state.config.DOCLING_OCR_ENGINE + ) + request.app.state.config.DOCLING_OCR_LANG = ( + form_data.DOCLING_OCR_LANG + if form_data.DOCLING_OCR_LANG is not None + else request.app.state.config.DOCLING_OCR_LANG + ) + request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = ( + form_data.DOCLING_DO_PICTURE_DESCRIPTION + if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None + else request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION + ) + + request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( + form_data.DOCUMENT_INTELLIGENCE_ENDPOINT + if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None + else request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT + ) + request.app.state.config.DOCUMENT_INTELLIGENCE_KEY = ( + form_data.DOCUMENT_INTELLIGENCE_KEY + if form_data.DOCUMENT_INTELLIGENCE_KEY is not None + else request.app.state.config.DOCUMENT_INTELLIGENCE_KEY + ) + request.app.state.config.MISTRAL_OCR_API_KEY = ( + form_data.MISTRAL_OCR_API_KEY + if form_data.MISTRAL_OCR_API_KEY is not None + else request.app.state.config.MISTRAL_OCR_API_KEY + ) + + # Reranking settings + request.app.state.config.RAG_RERANKING_ENGINE = ( + form_data.RAG_RERANKING_ENGINE + if form_data.RAG_RERANKING_ENGINE is not None + else request.app.state.config.RAG_RERANKING_ENGINE + ) + + request.app.state.config.RAG_EXTERNAL_RERANKER_URL = ( + form_data.RAG_EXTERNAL_RERANKER_URL + if form_data.RAG_EXTERNAL_RERANKER_URL is not None + else request.app.state.config.RAG_EXTERNAL_RERANKER_URL + ) + + request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = ( + form_data.RAG_EXTERNAL_RERANKER_API_KEY + if form_data.RAG_EXTERNAL_RERANKER_API_KEY is not None + else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY + ) + + log.info( + f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}" + ) + try: + request.app.state.config.RAG_RERANKING_MODEL = form_data.RAG_RERANKING_MODEL + + try: + request.app.state.rf = get_rf( + request.app.state.config.RAG_RERANKING_ENGINE, + request.app.state.config.RAG_RERANKING_MODEL, + request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + True, + ) + except Exception as e: + log.error(f"Error loading reranking model: {e}") + request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False + except Exception as e: + log.exception(f"Problem updating reranking model: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + # Chunking settings + request.app.state.config.TEXT_SPLITTER = ( + form_data.TEXT_SPLITTER + if form_data.TEXT_SPLITTER is not None + else request.app.state.config.TEXT_SPLITTER + ) + request.app.state.config.CHUNK_SIZE = ( + form_data.CHUNK_SIZE + if form_data.CHUNK_SIZE is not None + else request.app.state.config.CHUNK_SIZE + ) + request.app.state.config.CHUNK_OVERLAP = ( + form_data.CHUNK_OVERLAP + if form_data.CHUNK_OVERLAP is not None + else request.app.state.config.CHUNK_OVERLAP + ) + + # File upload settings + request.app.state.config.FILE_MAX_SIZE = ( + form_data.FILE_MAX_SIZE + if form_data.FILE_MAX_SIZE is not None + else request.app.state.config.FILE_MAX_SIZE + ) + request.app.state.config.FILE_MAX_COUNT = ( + form_data.FILE_MAX_COUNT + if form_data.FILE_MAX_COUNT is not None + else request.app.state.config.FILE_MAX_COUNT + ) + request.app.state.config.ALLOWED_FILE_EXTENSIONS = ( + form_data.ALLOWED_FILE_EXTENSIONS + if form_data.ALLOWED_FILE_EXTENSIONS is not None + else request.app.state.config.ALLOWED_FILE_EXTENSIONS + ) + + # Integration settings request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ( - form_data.enable_google_drive_integration - if form_data.enable_google_drive_integration is not None + form_data.ENABLE_GOOGLE_DRIVE_INTEGRATION + if form_data.ENABLE_GOOGLE_DRIVE_INTEGRATION is not None else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION ) - - if form_data.file is not None: - request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size - request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count - - if form_data.content_extraction is not None: - log.info(f"Updating text settings: {form_data.content_extraction}") - request.app.state.config.CONTENT_EXTRACTION_ENGINE = ( - form_data.content_extraction.engine - ) - request.app.state.config.TIKA_SERVER_URL = ( - form_data.content_extraction.tika_server_url - ) - - if form_data.chunk is not None: - request.app.state.config.TEXT_SPLITTER = form_data.chunk.text_splitter - request.app.state.config.CHUNK_SIZE = form_data.chunk.chunk_size - request.app.state.config.CHUNK_OVERLAP = form_data.chunk.chunk_overlap - - if form_data.youtube is not None: - request.app.state.config.YOUTUBE_LOADER_LANGUAGE = form_data.youtube.language - request.app.state.config.YOUTUBE_LOADER_PROXY_URL = form_data.youtube.proxy_url - request.app.state.YOUTUBE_LOADER_TRANSLATION = form_data.youtube.translation - - if form_data.web is not None: - request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( - # Note: When UI "Bypass SSL verification for Websites"=True then ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION=False - form_data.web.web_loader_ssl_verification - ) - - request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled - request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine - request.app.state.config.SEARXNG_QUERY_URL = ( - form_data.web.search.searxng_query_url - ) - request.app.state.config.GOOGLE_PSE_API_KEY = ( - form_data.web.search.google_pse_api_key - ) - request.app.state.config.GOOGLE_PSE_ENGINE_ID = ( - form_data.web.search.google_pse_engine_id - ) - request.app.state.config.BRAVE_SEARCH_API_KEY = ( - form_data.web.search.brave_search_api_key - ) - request.app.state.config.KAGI_SEARCH_API_KEY = ( - form_data.web.search.kagi_search_api_key - ) - request.app.state.config.MOJEEK_SEARCH_API_KEY = ( - form_data.web.search.mojeek_search_api_key - ) - request.app.state.config.BOCHA_SEARCH_API_KEY = ( - form_data.web.search.bocha_search_api_key - ) - request.app.state.config.SERPSTACK_API_KEY = ( - form_data.web.search.serpstack_api_key - ) - request.app.state.config.SERPSTACK_HTTPS = form_data.web.search.serpstack_https - request.app.state.config.SERPER_API_KEY = form_data.web.search.serper_api_key - request.app.state.config.SERPLY_API_KEY = form_data.web.search.serply_api_key - request.app.state.config.TAVILY_API_KEY = form_data.web.search.tavily_api_key - request.app.state.config.SEARCHAPI_API_KEY = ( - form_data.web.search.searchapi_api_key - ) - request.app.state.config.SEARCHAPI_ENGINE = ( - form_data.web.search.searchapi_engine - ) - - request.app.state.config.SERPAPI_API_KEY = form_data.web.search.serpapi_api_key - request.app.state.config.SERPAPI_ENGINE = form_data.web.search.serpapi_engine - - request.app.state.config.JINA_API_KEY = form_data.web.search.jina_api_key - request.app.state.config.BING_SEARCH_V7_ENDPOINT = ( - form_data.web.search.bing_search_v7_endpoint - ) - request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = ( - form_data.web.search.bing_search_v7_subscription_key - ) - - request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key - - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = ( - form_data.web.search.result_count - ) - request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = ( - form_data.web.search.concurrent_requests - ) - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = ( - form_data.web.search.domain_filter_list - ) - - return { - "status": True, - "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES, - "file": { - "max_size": request.app.state.config.FILE_MAX_SIZE, - "max_count": request.app.state.config.FILE_MAX_COUNT, - }, - "content_extraction": { - "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE, - "tika_server_url": request.app.state.config.TIKA_SERVER_URL, - }, - "chunk": { - "text_splitter": request.app.state.config.TEXT_SPLITTER, - "chunk_size": request.app.state.config.CHUNK_SIZE, - "chunk_overlap": request.app.state.config.CHUNK_OVERLAP, - }, - "youtube": { - "language": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - "proxy_url": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, - "translation": request.app.state.YOUTUBE_LOADER_TRANSLATION, - }, - "web": { - "web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - "search": { - "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH, - "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE, - "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL, - "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY, - "google_pse_engine_id": request.app.state.config.GOOGLE_PSE_ENGINE_ID, - "brave_search_api_key": request.app.state.config.BRAVE_SEARCH_API_KEY, - "kagi_search_api_key": request.app.state.config.KAGI_SEARCH_API_KEY, - "mojeek_search_api_key": request.app.state.config.MOJEEK_SEARCH_API_KEY, - "bocha_search_api_key": request.app.state.config.BOCHA_SEARCH_API_KEY, - "serpstack_api_key": request.app.state.config.SERPSTACK_API_KEY, - "serpstack_https": request.app.state.config.SERPSTACK_HTTPS, - "serper_api_key": request.app.state.config.SERPER_API_KEY, - "serply_api_key": request.app.state.config.SERPLY_API_KEY, - "serachapi_api_key": request.app.state.config.SEARCHAPI_API_KEY, - "searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE, - "serpapi_api_key": request.app.state.config.SERPAPI_API_KEY, - "serpapi_engine": request.app.state.config.SERPAPI_ENGINE, - "tavily_api_key": request.app.state.config.TAVILY_API_KEY, - "jina_api_key": request.app.state.config.JINA_API_KEY, - "bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT, - "bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, - "exa_api_key": request.app.state.config.EXA_API_KEY, - "result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - "concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, - "domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, - }, - }, - } - - -@router.get("/template") -async def get_rag_template(request: Request, user=Depends(get_verified_user)): - return { - "status": True, - "template": request.app.state.config.RAG_TEMPLATE, - } - - -@router.get("/query/settings") -async def get_query_settings(request: Request, user=Depends(get_admin_user)): - return { - "status": True, - "template": request.app.state.config.RAG_TEMPLATE, - "k": request.app.state.config.TOP_K, - "r": request.app.state.config.RELEVANCE_THRESHOLD, - "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - } - - -class QuerySettingsForm(BaseModel): - k: Optional[int] = None - r: Optional[float] = None - template: Optional[str] = None - hybrid: Optional[bool] = None - - -@router.post("/query/settings/update") -async def update_query_settings( - request: Request, form_data: QuerySettingsForm, user=Depends(get_admin_user) -): - request.app.state.config.RAG_TEMPLATE = form_data.template - request.app.state.config.TOP_K = form_data.k if form_data.k else 4 - request.app.state.config.RELEVANCE_THRESHOLD = form_data.r if form_data.r else 0.0 - - request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = ( - form_data.hybrid if form_data.hybrid else False + request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ( + form_data.ENABLE_ONEDRIVE_INTEGRATION + if form_data.ENABLE_ONEDRIVE_INTEGRATION is not None + else request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION ) + if form_data.web is not None: + # Web search settings + request.app.state.config.ENABLE_WEB_SEARCH = form_data.web.ENABLE_WEB_SEARCH + request.app.state.config.WEB_SEARCH_ENGINE = form_data.web.WEB_SEARCH_ENGINE + request.app.state.config.WEB_SEARCH_TRUST_ENV = ( + form_data.web.WEB_SEARCH_TRUST_ENV + ) + request.app.state.config.WEB_SEARCH_RESULT_COUNT = ( + form_data.web.WEB_SEARCH_RESULT_COUNT + ) + request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = ( + form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS + ) + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = ( + form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST + ) + request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( + form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL + ) + request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = ( + form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER + ) + request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL + request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL + request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME + request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD + request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY + request.app.state.config.GOOGLE_PSE_ENGINE_ID = ( + form_data.web.GOOGLE_PSE_ENGINE_ID + ) + request.app.state.config.BRAVE_SEARCH_API_KEY = ( + form_data.web.BRAVE_SEARCH_API_KEY + ) + request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY + request.app.state.config.MOJEEK_SEARCH_API_KEY = ( + form_data.web.MOJEEK_SEARCH_API_KEY + ) + request.app.state.config.BOCHA_SEARCH_API_KEY = ( + form_data.web.BOCHA_SEARCH_API_KEY + ) + request.app.state.config.SERPSTACK_API_KEY = form_data.web.SERPSTACK_API_KEY + request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS + request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY + request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY + request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY + request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY + request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE + request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY + request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE + request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY + request.app.state.config.BING_SEARCH_V7_ENDPOINT = ( + form_data.web.BING_SEARCH_V7_ENDPOINT + ) + request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = ( + form_data.web.BING_SEARCH_V7_SUBSCRIPTION_KEY + ) + request.app.state.config.EXA_API_KEY = form_data.web.EXA_API_KEY + request.app.state.config.PERPLEXITY_API_KEY = form_data.web.PERPLEXITY_API_KEY + request.app.state.config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID + request.app.state.config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK + + # Web loader settings + request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE + request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ( + form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION + ) + request.app.state.config.PLAYWRIGHT_WS_URL = form_data.web.PLAYWRIGHT_WS_URL + request.app.state.config.PLAYWRIGHT_TIMEOUT = form_data.web.PLAYWRIGHT_TIMEOUT + request.app.state.config.FIRECRAWL_API_KEY = form_data.web.FIRECRAWL_API_KEY + request.app.state.config.FIRECRAWL_API_BASE_URL = ( + form_data.web.FIRECRAWL_API_BASE_URL + ) + request.app.state.config.EXTERNAL_WEB_SEARCH_URL = ( + form_data.web.EXTERNAL_WEB_SEARCH_URL + ) + request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = ( + form_data.web.EXTERNAL_WEB_SEARCH_API_KEY + ) + request.app.state.config.EXTERNAL_WEB_LOADER_URL = ( + form_data.web.EXTERNAL_WEB_LOADER_URL + ) + request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = ( + form_data.web.EXTERNAL_WEB_LOADER_API_KEY + ) + request.app.state.config.TAVILY_EXTRACT_DEPTH = ( + form_data.web.TAVILY_EXTRACT_DEPTH + ) + request.app.state.config.YOUTUBE_LOADER_LANGUAGE = ( + form_data.web.YOUTUBE_LOADER_LANGUAGE + ) + request.app.state.config.YOUTUBE_LOADER_PROXY_URL = ( + form_data.web.YOUTUBE_LOADER_PROXY_URL + ) + request.app.state.YOUTUBE_LOADER_TRANSLATION = ( + form_data.web.YOUTUBE_LOADER_TRANSLATION + ) + return { "status": True, - "template": request.app.state.config.RAG_TEMPLATE, - "k": request.app.state.config.TOP_K, - "r": request.app.state.config.RELEVANCE_THRESHOLD, - "hybrid": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + # RAG settings + "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, + "TOP_K": request.app.state.config.TOP_K, + "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, + "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + # Hybrid search settings + "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, + "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, + "HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT, + # Content extraction settings + "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, + "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, + "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, + "DATALAB_MARKER_LANGS": request.app.state.config.DATALAB_MARKER_LANGS, + "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, + "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, + "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, + "DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, + "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + "DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM, + "DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, + "EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, + "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_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, + "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, + "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, + "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + # Reranking settings + "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, + "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, + "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + # Chunking settings + "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, + "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, + "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, + # File upload settings + "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, + "FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT, + "ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS, + # Integration settings + "ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + "ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + # Web search settings + "web": { + "ENABLE_WEB_SEARCH": request.app.state.config.ENABLE_WEB_SEARCH, + "WEB_SEARCH_ENGINE": request.app.state.config.WEB_SEARCH_ENGINE, + "WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV, + "WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT, + "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, + "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, + "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, + "YACY_USERNAME": request.app.state.config.YACY_USERNAME, + "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, + "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, + "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, + "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, + "KAGI_SEARCH_API_KEY": request.app.state.config.KAGI_SEARCH_API_KEY, + "MOJEEK_SEARCH_API_KEY": request.app.state.config.MOJEEK_SEARCH_API_KEY, + "BOCHA_SEARCH_API_KEY": request.app.state.config.BOCHA_SEARCH_API_KEY, + "SERPSTACK_API_KEY": request.app.state.config.SERPSTACK_API_KEY, + "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, + "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, + "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, + "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, + "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, + "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, + "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, + "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, + "JINA_API_KEY": request.app.state.config.JINA_API_KEY, + "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, + "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + "EXA_API_KEY": request.app.state.config.EXA_API_KEY, + "PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY, + "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, + "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, + "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, + "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, + "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, + "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, + "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, + "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, + "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, + "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, + "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, + }, } @@ -798,18 +1174,33 @@ def save_docs_to_vector_db( ( request.app.state.config.RAG_OPENAI_API_BASE_URL if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else request.app.state.config.RAG_OLLAMA_BASE_URL + else ( + request.app.state.config.RAG_OLLAMA_BASE_URL + if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL + ) ), ( request.app.state.config.RAG_OPENAI_API_KEY if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" - else request.app.state.config.RAG_OLLAMA_API_KEY + else ( + request.app.state.config.RAG_OLLAMA_API_KEY + if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + else request.app.state.config.RAG_AZURE_OPENAI_API_KEY + ) ), request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + azure_api_version=( + request.app.state.config.RAG_AZURE_OPENAI_API_VERSION + if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + else None + ), ) embeddings = embedding_function( - list(map(lambda x: x.replace("\n", " "), texts)), user=user + list(map(lambda x: x.replace("\n", " "), texts)), + prefix=RAG_EMBEDDING_CONTENT_PREFIX, + user=user, ) items = [ @@ -855,9 +1246,14 @@ def process_file( if form_data.content: # Update the content in the file - # Usage: /files/{file_id}/data/content/update + # Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline) - VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") + try: + # /files/{file_id}/data/content/update + VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") + except: + # Audio file upload pipeline + pass docs = [ Document( @@ -912,8 +1308,26 @@ def process_file( file_path = Storage.get_file(file_path) loader = Loader( engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE, + DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY, + DATALAB_MARKER_LANGS=request.app.state.config.DATALAB_MARKER_LANGS, + DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE, + DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR, + DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE, + DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, + DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM, + DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, + EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, + 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_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE, + DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG, + DOCLING_DO_PICTURE_DESCRIPTION=request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, + DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY, ) docs = loader.load( file.filename, file.meta.get("content_type"), file_path @@ -956,36 +1370,45 @@ def process_file( hash = calculate_sha256_string(text_content) Files.update_file_hash_by_id(file.id, hash) - try: - result = save_docs_to_vector_db( - request, - docs=docs, - collection_name=collection_name, - metadata={ - "file_id": file.id, - "name": file.filename, - "hash": hash, - }, - add=(True if form_data.collection_name else False), - user=user, - ) - - if result: - Files.update_file_metadata_by_id( - file.id, - { - "collection_name": collection_name, + if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + try: + result = save_docs_to_vector_db( + request, + docs=docs, + collection_name=collection_name, + metadata={ + "file_id": file.id, + "name": file.filename, + "hash": hash, }, + add=(True if form_data.collection_name else False), + user=user, ) - return { - "status": True, - "collection_name": collection_name, - "filename": file.filename, - "content": text_content, - } - except Exception as e: - raise e + if result: + Files.update_file_metadata_by_id( + file.id, + { + "collection_name": collection_name, + }, + ) + + return { + "status": True, + "collection_name": collection_name, + "filename": file.filename, + "content": text_content, + } + 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) if "No pandoc was found" in str(e): @@ -1094,16 +1517,20 @@ def process_web( loader = get_web_loader( form_data.url, - verify_ssl=request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - requests_per_second=request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, + verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, ) docs = loader.load() content = " ".join([doc.page_content for doc in docs]) log.debug(f"text_content: {content}") - save_docs_to_vector_db( - request, docs, collection_name, overwrite=True, user=user - ) + + if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + save_docs_to_vector_db( + request, docs, collection_name, overwrite=True, user=user + ) + else: + collection_name = None return { "status": True, @@ -1115,6 +1542,7 @@ def process_web( }, "meta": { "name": form_data.url, + "source": form_data.url, }, }, } @@ -1130,6 +1558,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: """Search the web using a search engine and return the results as a list of SearchResult objects. Will look for a search engine API key in environment variables in the following order: - SEARXNG_QUERY_URL + - YACY_QUERY_URL + YACY_USERNAME + YACY_PASSWORD - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID - BRAVE_SEARCH_API_KEY - KAGI_SEARCH_API_KEY @@ -1140,6 +1569,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: - SERPLY_API_KEY - TAVILY_API_KEY - EXA_API_KEY + - PERPLEXITY_API_KEY + - SOUGOU_API_SID + SOUGOU_API_SK - SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`) - SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`) Args: @@ -1152,11 +1583,23 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_searxng( request.app.state.config.SEARXNG_QUERY_URL, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SEARXNG_QUERY_URL found in environment variables") + elif engine == "yacy": + if request.app.state.config.YACY_QUERY_URL: + return search_yacy( + request.app.state.config.YACY_QUERY_URL, + request.app.state.config.YACY_USERNAME, + request.app.state.config.YACY_PASSWORD, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception("No YACY_QUERY_URL found in environment variables") elif engine == "google_pse": if ( request.app.state.config.GOOGLE_PSE_API_KEY @@ -1166,8 +1609,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.GOOGLE_PSE_API_KEY, request.app.state.config.GOOGLE_PSE_ENGINE_ID, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception( @@ -1178,8 +1621,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_brave( request.app.state.config.BRAVE_SEARCH_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No BRAVE_SEARCH_API_KEY found in environment variables") @@ -1188,8 +1631,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_kagi( request.app.state.config.KAGI_SEARCH_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No KAGI_SEARCH_API_KEY found in environment variables") @@ -1198,8 +1641,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_mojeek( request.app.state.config.MOJEEK_SEARCH_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No MOJEEK_SEARCH_API_KEY found in environment variables") @@ -1208,8 +1651,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_bocha( request.app.state.config.BOCHA_SEARCH_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No BOCHA_SEARCH_API_KEY found in environment variables") @@ -1218,8 +1661,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_serpstack( request.app.state.config.SERPSTACK_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, https_enabled=request.app.state.config.SERPSTACK_HTTPS, ) else: @@ -1229,8 +1672,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_serper( request.app.state.config.SERPER_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SERPER_API_KEY found in environment variables") @@ -1239,23 +1682,24 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_serply( request.app.state.config.SERPLY_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SERPLY_API_KEY found in environment variables") elif engine == "duckduckgo": return search_duckduckgo( query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == "tavily": if request.app.state.config.TAVILY_API_KEY: return search_tavily( request.app.state.config.TAVILY_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No TAVILY_API_KEY found in environment variables") @@ -1265,8 +1709,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.SEARCHAPI_API_KEY, request.app.state.config.SEARCHAPI_ENGINE, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SEARCHAPI_API_KEY found in environment variables") @@ -1276,8 +1720,8 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.SERPAPI_API_KEY, request.app.state.config.SERPAPI_ENGINE, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No SERPAPI_API_KEY found in environment variables") @@ -1285,7 +1729,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: return search_jina( request.app.state.config.JINA_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, ) elif engine == "bing": return search_bing( @@ -1293,31 +1737,91 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]: request.app.state.config.BING_SEARCH_V7_ENDPOINT, str(DEFAULT_LOCALE), query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) elif engine == "exa": return search_exa( request.app.state.config.EXA_API_KEY, query, - request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, - request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + elif engine == "perplexity": + return search_perplexity( + request.app.state.config.PERPLEXITY_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + elif engine == "sougou": + if ( + request.app.state.config.SOUGOU_API_SID + and request.app.state.config.SOUGOU_API_SK + ): + return search_sougou( + request.app.state.config.SOUGOU_API_SID, + request.app.state.config.SOUGOU_API_SK, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + else: + raise Exception( + "No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables" + ) + elif engine == "firecrawl": + return search_firecrawl( + request.app.state.config.FIRECRAWL_API_BASE_URL, + request.app.state.config.FIRECRAWL_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + ) + elif engine == "external": + return search_external( + request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + query, + request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: raise Exception("No search engine API key found in environment variables") @router.post("/process/web/search") -def process_web_search( +async def process_web_search( request: Request, form_data: SearchForm, user=Depends(get_verified_user) ): + + urls = [] try: logging.info( - f"trying to web search with {request.app.state.config.RAG_WEB_SEARCH_ENGINE, form_data.query}" - ) - web_results = search_web( - request, request.app.state.config.RAG_WEB_SEARCH_ENGINE, form_data.query + f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" ) + + search_tasks = [ + run_in_threadpool( + search_web, + request, + request.app.state.config.WEB_SEARCH_ENGINE, + query, + ) + for query in form_data.queries + ] + + search_results = await asyncio.gather(*search_tasks) + + for result in search_results: + if result: + for item in result: + if item and item.link: + urls.append(item.link) + + urls = list(dict.fromkeys(urls)) + log.debug(f"urls: {urls}") + except Exception as e: log.exception(e) @@ -1326,31 +1830,74 @@ def process_web_search( detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e), ) - log.debug(f"web_results: {web_results}") - try: - collection_name = form_data.collection_name - if collection_name == "" or collection_name is None: - collection_name = f"web-search-{calculate_sha256_string(form_data.query)}"[ - :63 + if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER: + docs = [ + Document( + page_content=result.snippet, + metadata={ + "source": result.link, + "title": result.title, + "snippet": result.snippet, + "link": result.link, + }, + ) + for result in search_results + if hasattr(result, "snippet") ] + else: + loader = get_web_loader( + urls, + verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV, + ) + docs = await loader.aload() - urls = [result.link for result in web_results] - loader = get_web_loader( - urls, - verify_ssl=request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, - requests_per_second=request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, - ) - docs = loader.load() - save_docs_to_vector_db( - request, docs, collection_name, overwrite=True, user=user - ) + urls = [ + doc.metadata.get("source") for doc in docs if doc.metadata.get("source") + ] # only keep the urls returned by the loader - return { - "status": True, - "collection_name": collection_name, - "filenames": urls, - } + if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + return { + "status": True, + "collection_name": None, + "filenames": urls, + "docs": [ + { + "content": doc.page_content, + "metadata": doc.metadata, + } + for doc in docs + ], + "loaded_count": len(docs), + } + else: + # Create a single collection for all documents + collection_name = ( + f"web-search-{calculate_sha256_string('-'.join(form_data.queries))}"[ + :63 + ] + ) + + try: + await run_in_threadpool( + save_docs_to_vector_db, + request, + docs, + collection_name, + overwrite=True, + user=user, + ) + except Exception as e: + log.debug(f"error saving docs: {e}") + + return { + "status": True, + "collection_names": [collection_name], + "filenames": urls, + "loaded_count": len(docs), + } except Exception as e: log.exception(e) raise HTTPException( @@ -1363,6 +1910,7 @@ class QueryDocForm(BaseModel): collection_name: str query: str k: Optional[int] = None + k_reranker: Optional[int] = None r: Optional[float] = None hybrid: Optional[bool] = None @@ -1375,26 +1923,38 @@ def query_doc_handler( ): try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + collection_results = {} + collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( + collection_name=form_data.collection_name + ) return query_doc_with_hybrid_search( collection_name=form_data.collection_name, + collection_result=collection_results[form_data.collection_name], query=form_data.query, - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=form_data.k_reranker + or request.app.state.config.TOP_K_RERANKER, r=( form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD ), + hybrid_bm25_weight=( + form_data.hybrid_bm25_weight + if form_data.hybrid_bm25_weight + else request.app.state.config.HYBRID_BM25_WEIGHT + ), user=user, ) else: return query_doc( collection_name=form_data.collection_name, query_embedding=request.app.state.EMBEDDING_FUNCTION( - form_data.query, user=user + form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, user=user, @@ -1411,8 +1971,10 @@ class QueryCollectionsForm(BaseModel): collection_names: list[str] query: str k: Optional[int] = None + k_reranker: Optional[int] = None r: Optional[float] = None hybrid: Optional[bool] = None + hybrid_bm25_weight: Optional[float] = None @router.post("/query/collection") @@ -1426,23 +1988,30 @@ def query_collection_handler( return query_collection_with_hybrid_search( collection_names=form_data.collection_names, queries=[form_data.query], - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=form_data.k_reranker + or request.app.state.config.TOP_K_RERANKER, r=( form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD ), + hybrid_bm25_weight=( + form_data.hybrid_bm25_weight + if form_data.hybrid_bm25_weight + else request.app.state.config.HYBRID_BM25_WEIGHT + ), ) else: return query_collection( collection_names=form_data.collection_names, queries=[form_data.query], - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, ) @@ -1507,11 +2076,11 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - print(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f"Failed to delete {file_path}. Reason: {e}") else: - print(f"The directory {folder} does not exist") + log.warning(f"The directory {folder} does not exist") except Exception as e: - print(f"Failed to process the directory {folder}. Reason: {e}") + log.exception(f"Failed to process the directory {folder}. Reason: {e}") return True @@ -1519,7 +2088,11 @@ if ENV == "dev": @router.get("/ef/{text}") async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"): - return {"result": request.app.state.EMBEDDING_FUNCTION(text)} + return { + "result": request.app.state.EMBEDDING_FUNCTION( + text, prefix=RAG_EMBEDDING_QUERY_PREFIX + ) + } class BatchProcessFilesForm(BaseModel): diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 8b17c6c4b3..f94346099e 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -20,6 +20,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.constants import TASKS from open_webui.routers.pipelines import process_pipeline_inlet_filter + from open_webui.utils.task import get_task_model_id from open_webui.config import ( @@ -182,35 +183,28 @@ async def generate_title( else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE - messages = form_data["messages"] - - # Remove reasoning details from the messages - for message in messages: - message["content"] = re.sub( - r"]*>.*?<\/details>", - "", - message["content"], - flags=re.S, - ).strip() - content = title_generation_template( template, - messages, + form_data["messages"], { "name": user.name, "location": user.info.get("location") if user.info else None, }, ) + max_tokens = ( + models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000) + ) + payload = { "model": task_model_id, "messages": [{"role": "user", "content": content}], "stream": False, **( - {"max_tokens": 1000} - if models[task_model_id]["owned_by"] == "ollama" + {"max_tokens": max_tokens} + if models[task_model_id].get("owned_by") == "ollama" else { - "max_completion_tokens": 1000, + "max_completion_tokens": max_tokens, } ), "metadata": { @@ -221,6 +215,12 @@ async def generate_title( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -290,6 +290,12 @@ async def generate_chat_tags( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -356,6 +362,12 @@ async def generate_image_prompt( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -433,6 +445,12 @@ async def generate_queries( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -514,6 +532,12 @@ async def generate_autocompletion( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -571,7 +595,7 @@ async def generate_emoji( "stream": False, **( {"max_tokens": 4} - if models[task_model_id]["owned_by"] == "ollama" + if models[task_model_id].get("owned_by") == "ollama" else { "max_completion_tokens": 4, } @@ -584,6 +608,12 @@ async def generate_emoji( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: @@ -613,17 +643,6 @@ async def generate_moa_response( detail="Model not found", ) - # Check if the user has a custom task model - # If the user has a custom task model, use that model - task_model_id = get_task_model_id( - model_id, - request.app.state.config.TASK_MODEL, - request.app.state.config.TASK_MODEL_EXTERNAL, - models, - ) - - log.debug(f"generating MOA model {task_model_id} for user {user.email} ") - template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE content = moa_response_generation_template( @@ -633,7 +652,7 @@ async def generate_moa_response( ) payload = { - "model": task_model_id, + "model": model_id, "messages": [{"role": "user", "content": content}], "stream": form_data.get("stream", False), "metadata": { @@ -644,6 +663,12 @@ async def generate_moa_response( }, } + # Process the payload through the pipeline + try: + payload = await process_pipeline_inlet_filter(request, payload, user, models) + except Exception as e: + raise e + try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index d6a5c5532f..f726368eba 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -1,5 +1,10 @@ +import logging from pathlib import Path from typing import Optional +import time +import re +import aiohttp +from pydantic import BaseModel, HttpUrl from open_webui.models.tools import ( ToolForm, @@ -8,13 +13,20 @@ from open_webui.models.tools import ( ToolUserResponse, Tools, ) -from open_webui.utils.plugin import load_tools_module_by_id, replace_imports +from open_webui.utils.plugin import load_tool_module_by_id, replace_imports from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status -from open_webui.utils.tools import get_tools_specs +from open_webui.utils.tools import get_tool_specs 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.env import SRC_LOG_LEVELS + +from open_webui.utils.tools import get_tool_servers_data + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -25,11 +37,51 @@ router = APIRouter() @router.get("/", response_model=list[ToolUserResponse]) -async def get_tools(user=Depends(get_verified_user)): - if user.role == "admin": - tools = Tools.get_tools() - else: - tools = Tools.get_tools_by_user_id(user.id, "read") +async def get_tools(request: Request, user=Depends(get_verified_user)): + + if not request.app.state.TOOL_SERVERS: + # If the tool servers are not set, we need to set them + # This is done only once when the server starts + # This is done to avoid loading the tool servers every time + + request.app.state.TOOL_SERVERS = await get_tool_servers_data( + request.app.state.config.TOOL_SERVER_CONNECTIONS + ) + + tools = Tools.get_tools() + for server in request.app.state.TOOL_SERVERS: + tools.append( + ToolUserResponse( + **{ + "id": f"server:{server['idx']}", + "user_id": f"server:{server['idx']}", + "name": server.get("openapi", {}) + .get("info", {}) + .get("title", "Tool Server"), + "meta": { + "description": server.get("openapi", {}) + .get("info", {}) + .get("description", ""), + }, + "access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[ + server["idx"] + ] + .get("config", {}) + .get("access_control", None), + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + ) + + if user.role != "admin": + tools = [ + tool + for tool in tools + if tool.user_id == user.id + or has_access(user.id, "read", tool.access_control) + ] + return tools @@ -47,6 +99,81 @@ async def get_tool_list(user=Depends(get_verified_user)): return tools +############################ +# LoadFunctionFromLink +############################ + + +class LoadUrlForm(BaseModel): + url: HttpUrl + + +def github_url_to_raw_url(url: str) -> str: + # Handle 'tree' (folder) URLs (add main.py at the end) + m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url) + if m1: + org, repo, branch, path = m1.groups() + return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py" + + # Handle 'blob' (file) URLs + m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url) + if m2: + org, repo, branch, path = m2.groups() + return ( + f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}" + ) + + # No match; return as-is + return url + + +@router.post("/load/url", response_model=Optional[dict]) +async def load_tool_from_url( + request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user) +): + # NOTE: This is NOT a SSRF vulnerability: + # This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use, + # and does NOT accept untrusted user input. Access is enforced by authentication. + + url = str(form_data.url) + if not url: + raise HTTPException(status_code=400, detail="Please enter a valid URL") + + url = github_url_to_raw_url(url) + url_parts = url.rstrip("/").split("/") + + file_name = url_parts[-1] + tool_name = ( + file_name[:-3] + if ( + file_name.endswith(".py") + and (not file_name.startswith(("main.py", "index.py", "__init__.py"))) + ) + else url_parts[-2] if len(url_parts) > 1 else "function" + ) + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, headers={"Content-Type": "application/json"} + ) as resp: + if resp.status != 200: + raise HTTPException( + status_code=resp.status, detail="Failed to fetch the tool" + ) + data = await resp.text() + if not data: + raise HTTPException( + status_code=400, detail="No data received from the URL" + ) + return { + "name": tool_name, + "content": data, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error importing tool: {e}") + + ############################ # ExportTools ############################ @@ -89,18 +216,18 @@ async def create_new_tools( if tools is None: try: form_data.content = replace_imports(form_data.content) - tools_module, frontmatter = load_tools_module_by_id( + tool_module, frontmatter = load_tool_module_by_id( form_data.id, content=form_data.content ) form_data.meta.manifest = frontmatter TOOLS = request.app.state.TOOLS - TOOLS[form_data.id] = tools_module + TOOLS[form_data.id] = tool_module - specs = get_tools_specs(TOOLS[form_data.id]) + specs = get_tool_specs(TOOLS[form_data.id]) tools = Tools.insert_new_tool(user.id, form_data, specs) - tool_cache_dir = Path(CACHE_DIR) / "tools" / form_data.id + tool_cache_dir = CACHE_DIR / "tools" / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) if tools: @@ -111,7 +238,7 @@ async def create_new_tools( detail=ERROR_MESSAGES.DEFAULT("Error creating tools"), ) except Exception as e: - print(e) + log.exception(f"Failed to load the tool by id {form_data.id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -178,22 +305,20 @@ async def update_tools_by_id( try: form_data.content = replace_imports(form_data.content) - tools_module, frontmatter = load_tools_module_by_id( - id, content=form_data.content - ) + tool_module, frontmatter = load_tool_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter TOOLS = request.app.state.TOOLS - TOOLS[id] = tools_module + TOOLS[id] = tool_module - specs = get_tools_specs(TOOLS[id]) + specs = get_tool_specs(TOOLS[id]) updated = { **form_data.model_dump(exclude={"id"}), "specs": specs, } - print(updated) + log.debug(updated) tools = Tools.update_tool_by_id(id, updated) if tools: @@ -284,7 +409,7 @@ async def get_tools_valves_spec_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tools_module_by_id(id) + tools_module, _ = load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if hasattr(tools_module, "Valves"): @@ -327,7 +452,7 @@ async def update_tools_valves_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tools_module_by_id(id) + tools_module, _ = load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if not hasattr(tools_module, "Valves"): @@ -343,7 +468,7 @@ async def update_tools_valves_by_id( Tools.update_tool_valves_by_id(id, valves.model_dump()) return valves.model_dump() except Exception as e: - print(e) + log.exception(f"Failed to update tool valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -383,7 +508,7 @@ async def get_tools_user_valves_spec_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tools_module_by_id(id) + tools_module, _ = load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if hasattr(tools_module, "UserValves"): @@ -407,7 +532,7 @@ async def update_tools_user_valves_by_id( if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] else: - tools_module, _ = load_tools_module_by_id(id) + tools_module, _ = load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module if hasattr(tools_module, "UserValves"): @@ -421,7 +546,7 @@ async def update_tools_user_valves_by_id( ) return user_valves.model_dump() except Exception as e: - print(e) + log.exception(f"Failed to update user valves by id {id}: {e}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 872212d3ce..8702ae50ba 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -2,9 +2,11 @@ import logging from typing import Optional from open_webui.models.auths import Auths +from open_webui.models.groups import Groups from open_webui.models.chats import Chats from open_webui.models.users import ( UserModel, + UserListResponse, UserRoleUpdateForm, Users, UserSettings, @@ -17,7 +19,10 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel + from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user +from open_webui.utils.access_control import get_permissions, has_permission + log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -29,13 +34,38 @@ router = APIRouter() ############################ -@router.get("/", response_model=list[UserModel]) +PAGE_ITEM_COUNT = 30 + + +@router.get("/", response_model=UserListResponse) async def get_users( - skip: Optional[int] = None, - limit: Optional[int] = None, + query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, user=Depends(get_admin_user), ): - return Users.get_users(skip, limit) + limit = PAGE_ITEM_COUNT + + page = max(1, page) + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + return Users.get_users(filter=filter, skip=skip, limit=limit) + + +@router.get("/all", response_model=UserListResponse) +async def get_all_users( + user=Depends(get_admin_user), +): + return Users.get_users() ############################ @@ -45,7 +75,7 @@ async def get_users( @router.get("/groups") async def get_user_groups(user=Depends(get_verified_user)): - return Users.get_user_groups(user.id) + return Groups.get_groups_by_member_id(user.id) ############################ @@ -54,8 +84,12 @@ async def get_user_groups(user=Depends(get_verified_user)): @router.get("/permissions") -async def get_user_permissisions(user=Depends(get_verified_user)): - return Users.get_user_groups(user.id) +async def get_user_permissisions(request: Request, user=Depends(get_verified_user)): + user_permissions = get_permissions( + user.id, request.app.state.config.USER_PERMISSIONS + ) + + return user_permissions ############################ @@ -68,32 +102,52 @@ class WorkspacePermissions(BaseModel): tools: bool = False +class SharingPermissions(BaseModel): + public_models: bool = True + public_knowledge: bool = True + public_prompts: bool = True + public_tools: bool = True + + class ChatPermissions(BaseModel): controls: bool = True file_upload: bool = True delete: bool = True edit: bool = True + share: bool = True + export: bool = True + stt: bool = True + tts: bool = True + call: bool = True + multiple_models: bool = True temporary: bool = True + temporary_enforced: bool = False class FeaturesPermissions(BaseModel): + direct_tool_servers: bool = False web_search: bool = True image_generation: bool = True code_interpreter: bool = True + notes: bool = True class UserPermissions(BaseModel): workspace: WorkspacePermissions + sharing: SharingPermissions chat: ChatPermissions features: FeaturesPermissions @router.get("/default/permissions", response_model=UserPermissions) -async def get_user_permissions(request: Request, user=Depends(get_admin_user)): +async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): return { "workspace": WorkspacePermissions( **request.app.state.config.USER_PERMISSIONS.get("workspace", {}) ), + "sharing": SharingPermissions( + **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) + ), "chat": ChatPermissions( **request.app.state.config.USER_PERMISSIONS.get("chat", {}) ), @@ -104,7 +158,7 @@ async def get_user_permissions(request: Request, user=Depends(get_admin_user)): @router.post("/default/permissions") -async def update_user_permissions( +async def update_default_user_permissions( request: Request, form_data: UserPermissions, user=Depends(get_admin_user) ): request.app.state.config.USER_PERMISSIONS = form_data.model_dump() @@ -151,9 +205,22 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)): @router.post("/user/settings/update", response_model=UserSettings) async def update_user_settings_by_session_user( - form_data: UserSettings, user=Depends(get_verified_user) + request: Request, form_data: UserSettings, user=Depends(get_verified_user) ): - user = Users.update_user_settings_by_id(user.id, form_data.model_dump()) + updated_user_settings = form_data.model_dump() + if ( + user.role != "admin" + and "toolServers" in updated_user_settings.get("ui").keys() + and not has_permission( + user.id, + "features.direct_tool_servers", + request.app.state.config.USER_PERMISSIONS, + ) + ): + # If the user is not an admin and does not have permission to use tool servers, remove the key + updated_user_settings["ui"].pop("toolServers", None) + + user = Users.update_user_settings_by_id(user.id, updated_user_settings) if user: return user.settings else: @@ -263,6 +330,21 @@ async def update_user_by_id( form_data: UserUpdateForm, session_user=Depends(get_admin_user), ): + # Prevent modification of the primary admin user by other admins + try: + first_user = Users.get_first_user() + if first_user and user_id == first_user.id and session_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + except Exception as e: + log.error(f"Error checking primary admin status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not verify primary admin status.", + ) + user = Users.get_user_by_id(user_id) if user: @@ -310,6 +392,21 @@ async def update_user_by_id( @router.delete("/{user_id}", response_model=bool) async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): + # Prevent deletion of the primary admin user + try: + first_user = Users.get_first_user() + if first_user and user_id == first_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + except Exception as e: + log.error(f"Error checking primary admin status: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not verify primary admin status.", + ) + if user.id != user_id: result = Auths.delete_auth_by_id(user_id) @@ -321,6 +418,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): detail=ERROR_MESSAGES.DELETE_USER_ERROR, ) + # Prevent self-deletion raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED, diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index ea73e97596..b64adafb44 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -1,48 +1,84 @@ import black +import logging import markdown from open_webui.models.chats import ChatTitleMessagesForm from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from starlette.responses import FileResponse + + from open_webui.utils.misc import get_gravatar_url from open_webui.utils.pdf_generator import PDFGenerator -from open_webui.utils.auth import get_admin_user +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.code_interpreter import execute_code_jupyter +from open_webui.env import SRC_LOG_LEVELS + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @router.get("/gravatar") -async def get_gravatar( - email: str, -): +async def get_gravatar(email: str, user=Depends(get_verified_user)): return get_gravatar_url(email) -class CodeFormatRequest(BaseModel): +class CodeForm(BaseModel): code: str @router.post("/code/format") -async def format_code(request: CodeFormatRequest): +async def format_code(form_data: CodeForm, user=Depends(get_verified_user)): try: - formatted_code = black.format_str(request.code, mode=black.Mode()) + formatted_code = black.format_str(form_data.code, mode=black.Mode()) return {"code": formatted_code} except black.NothingChanged: - return {"code": request.code} + return {"code": form_data.code} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) +@router.post("/code/execute") +async def execute_code( + request: Request, form_data: CodeForm, user=Depends(get_verified_user) +): + if request.app.state.config.CODE_EXECUTION_ENGINE == "jupyter": + output = await execute_code_jupyter( + request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + form_data.code, + ( + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN + if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "token" + else None + ), + ( + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD + if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "password" + else None + ), + request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, + ) + + return output + else: + raise HTTPException( + status_code=400, + detail="Code execution engine not supported", + ) + + class MarkdownForm(BaseModel): md: str @router.post("/markdown") async def get_html_from_markdown( - form_data: MarkdownForm, + form_data: MarkdownForm, user=Depends(get_verified_user) ): return {"html": markdown.markdown(form_data.md)} @@ -54,7 +90,7 @@ class ChatForm(BaseModel): @router.post("/pdf") async def download_chat_as_pdf( - form_data: ChatTitleMessagesForm, + form_data: ChatTitleMessagesForm, user=Depends(get_verified_user) ): try: pdf_bytes = PDFGenerator(form_data).generate_chat_pdf() @@ -65,7 +101,7 @@ async def download_chat_as_pdf( headers={"Content-Disposition": "attachment;filename=chat.pdf"}, ) except Exception as e: - print(e) + log.exception(f"Error generating PDF: {e}") raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 6f59151227..09eccd8267 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -3,15 +3,23 @@ import socketio import logging import sys import time +from redis import asyncio as aioredis from open_webui.models.users import Users, UserNameResponse from open_webui.models.channels import Channels from open_webui.models.chats import Chats +from open_webui.utils.redis import ( + get_sentinels_from_env, + get_sentinel_url_from_env, +) from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, WEBSOCKET_MANAGER, WEBSOCKET_REDIS_URL, + WEBSOCKET_REDIS_LOCK_TIMEOUT, + WEBSOCKET_SENTINEL_PORT, + WEBSOCKET_SENTINEL_HOSTS, ) from open_webui.utils.auth import decode_token from open_webui.socket.utils import RedisDict, RedisLock @@ -28,7 +36,14 @@ log.setLevel(SRC_LOG_LEVELS["SOCKET"]) if WEBSOCKET_MANAGER == "redis": - mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) + if WEBSOCKET_SENTINEL_HOSTS: + mgr = socketio.AsyncRedisManager( + get_sentinel_url_from_env( + WEBSOCKET_REDIS_URL, WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT + ) + ) + else: + mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL) sio = socketio.AsyncServer( cors_allowed_origins=[], async_mode="asgi", @@ -54,14 +69,30 @@ TIMEOUT_DURATION = 3 if WEBSOCKET_MANAGER == "redis": log.debug("Using Redis to manage websockets.") - SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL) - USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL) - USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL) + redis_sentinels = get_sentinels_from_env( + WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT + ) + SESSION_POOL = RedisDict( + "open-webui:session_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) + USER_POOL = RedisDict( + "open-webui:user_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) + USAGE_POOL = RedisDict( + "open-webui:usage_pool", + redis_url=WEBSOCKET_REDIS_URL, + redis_sentinels=redis_sentinels, + ) clean_up_lock = RedisLock( redis_url=WEBSOCKET_REDIS_URL, lock_name="usage_cleanup_lock", - timeout_secs=TIMEOUT_DURATION * 2, + timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, + redis_sentinels=redis_sentinels, ) aquire_func = clean_up_lock.aquire_lock renew_func = clean_up_lock.renew_lock @@ -128,18 +159,19 @@ def get_models_in_use(): @sio.on("usage") async def usage(sid, data): - model_id = data["model"] - # Record the timestamp for the last update - current_time = int(time.time()) + if sid in SESSION_POOL: + model_id = data["model"] + # Record the timestamp for the last update + current_time = int(time.time()) - # Store the new usage data and task - USAGE_POOL[model_id] = { - **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), - sid: {"updated_at": current_time}, - } + # Store the new usage data and task + USAGE_POOL[model_id] = { + **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), + sid: {"updated_at": current_time}, + } - # Broadcast the usage data to all clients - await sio.emit("usage", {"models": get_models_in_use()}) + # Broadcast the usage data to all clients + await sio.emit("usage", {"models": get_models_in_use()}) @sio.event @@ -247,7 +279,8 @@ async def channel_events(sid, data): @sio.on("user-list") async def user_list(sid): - await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())}) + if sid in SESSION_POOL: + await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())}) @sio.event @@ -268,15 +301,23 @@ async def disconnect(sid): # print(f"Unknown session ID {sid} disconnected") -def get_event_emitter(request_info): +def get_event_emitter(request_info, update_db=True): async def __event_emitter__(event_data): user_id = request_info["user_id"] + session_ids = list( - set(USER_POOL.get(user_id, []) + [request_info["session_id"]]) + set( + USER_POOL.get(user_id, []) + + ( + [request_info.get("session_id")] + if request_info.get("session_id") + else [] + ) + ) ) - for session_id in session_ids: - await sio.emit( + emit_tasks = [ + sio.emit( "chat-events", { "chat_id": request_info.get("chat_id", None), @@ -285,41 +326,47 @@ def get_event_emitter(request_info): }, to=session_id, ) + for session_id in session_ids + ] - if "type" in event_data and event_data["type"] == "status": - Chats.add_message_status_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - event_data.get("data", {}), - ) + await asyncio.gather(*emit_tasks) - if "type" in event_data and event_data["type"] == "message": - message = Chats.get_message_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - ) + if update_db: + if "type" in event_data and event_data["type"] == "status": + Chats.add_message_status_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + event_data.get("data", {}), + ) - content = message.get("content", "") - content += event_data.get("data", {}).get("content", "") + if "type" in event_data and event_data["type"] == "message": + message = Chats.get_message_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + ) - Chats.upsert_message_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - { - "content": content, - }, - ) + if message: + content = message.get("content", "") + content += event_data.get("data", {}).get("content", "") - if "type" in event_data and event_data["type"] == "replace": - content = event_data.get("data", {}).get("content", "") + Chats.upsert_message_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + { + "content": content, + }, + ) - Chats.upsert_message_to_chat_by_id_and_message_id( - request_info["chat_id"], - request_info["message_id"], - { - "content": content, - }, - ) + if "type" in event_data and event_data["type"] == "replace": + content = event_data.get("data", {}).get("content", "") + + Chats.upsert_message_to_chat_by_id_and_message_id( + request_info["chat_id"], + request_info["message_id"], + { + "content": content, + }, + ) return __event_emitter__ diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 46fafbb9e7..85a8bb7909 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -1,15 +1,17 @@ import json -import redis import uuid +from open_webui.utils.redis import get_redis_connection class RedisLock: - def __init__(self, redis_url, lock_name, timeout_secs): + def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]): self.lock_name = lock_name self.lock_id = str(uuid.uuid4()) self.timeout_secs = timeout_secs self.lock_obtained = False - self.redis = redis.Redis.from_url(redis_url, decode_responses=True) + self.redis = get_redis_connection( + redis_url, redis_sentinels, decode_responses=True + ) def aquire_lock(self): # nx=True will only set this key if it _hasn't_ already been set @@ -31,9 +33,11 @@ class RedisLock: class RedisDict: - def __init__(self, name, redis_url): + def __init__(self, name, redis_url, redis_sentinels=[]): self.name = name - self.redis = redis.Redis.from_url(redis_url, decode_responses=True) + self.redis = get_redis_connection( + redis_url, redis_sentinels, decode_responses=True + ) def __setitem__(self, key, value): serialized_value = json.dumps(value) diff --git a/backend/open_webui/static/apple-touch-icon.png b/backend/open_webui/static/apple-touch-icon.png new file mode 100644 index 0000000000..ece4b85dbc Binary files /dev/null and b/backend/open_webui/static/apple-touch-icon.png differ diff --git a/backend/open_webui/static/assets/pdf-style.css b/backend/open_webui/static/assets/pdf-style.css index 7cb5b0cd24..8b4e8d2370 100644 --- a/backend/open_webui/static/assets/pdf-style.css +++ b/backend/open_webui/static/assets/pdf-style.css @@ -269,11 +269,6 @@ tbody + tbody { margin-bottom: 0; } -/* Add a rule to reset margin-bottom for

not followed by

    */ -.markdown-section p + ul { - margin-top: 0; -} - /* List item styles */ .markdown-section li { padding: 2px; diff --git a/backend/open_webui/static/favicon-96x96.png b/backend/open_webui/static/favicon-96x96.png new file mode 100644 index 0000000000..2ebdffebe5 Binary files /dev/null and b/backend/open_webui/static/favicon-96x96.png differ diff --git a/backend/open_webui/static/favicon-dark.png b/backend/open_webui/static/favicon-dark.png new file mode 100644 index 0000000000..08627a23f7 Binary files /dev/null and b/backend/open_webui/static/favicon-dark.png differ diff --git a/backend/open_webui/static/favicon.ico b/backend/open_webui/static/favicon.ico new file mode 100644 index 0000000000..14c5f9c6d4 Binary files /dev/null and b/backend/open_webui/static/favicon.ico differ diff --git a/backend/open_webui/static/favicon.svg b/backend/open_webui/static/favicon.svg new file mode 100644 index 0000000000..0aa909745a --- /dev/null +++ b/backend/open_webui/static/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/backend/open_webui/static/loader.js b/backend/open_webui/static/loader.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/static/favicon/site.webmanifest b/backend/open_webui/static/site.webmanifest similarity index 76% rename from static/favicon/site.webmanifest rename to backend/open_webui/static/site.webmanifest index 0e59bbb282..95915ae2bc 100644 --- a/static/favicon/site.webmanifest +++ b/backend/open_webui/static/site.webmanifest @@ -3,13 +3,13 @@ "short_name": "WebUI", "icons": [ { - "src": "/favicon/web-app-manifest-192x192.png", + "src": "/static/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/favicon/web-app-manifest-512x512.png", + "src": "/static/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/backend/open_webui/static/splash-dark.png b/backend/open_webui/static/splash-dark.png new file mode 100644 index 0000000000..202c03f8e4 Binary files /dev/null and b/backend/open_webui/static/splash-dark.png differ diff --git a/backend/open_webui/static/swagger-ui/swagger-ui.css b/backend/open_webui/static/swagger-ui/swagger-ui.css index 749dba8c90..4efa5efeec 100644 --- a/backend/open_webui/static/swagger-ui/swagger-ui.css +++ b/backend/open_webui/static/swagger-ui/swagger-ui.css @@ -9308,5 +9308,3 @@ .json-schema-2020-12__title:first-of-type { font-size: 16px; } - -/*# sourceMappingURL=swagger-ui.css.map*/ diff --git a/backend/open_webui/static/web-app-manifest-192x192.png b/backend/open_webui/static/web-app-manifest-192x192.png new file mode 100644 index 0000000000..fbd2eab6e2 Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-192x192.png differ diff --git a/backend/open_webui/static/web-app-manifest-512x512.png b/backend/open_webui/static/web-app-manifest-512x512.png new file mode 100644 index 0000000000..afebe2cd08 Binary files /dev/null and b/backend/open_webui/static/web-app-manifest-512x512.png differ diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index b03cf0a7ec..41a92fafe9 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -1,10 +1,13 @@ import os import shutil import json +import logging +import re from abc import ABC, abstractmethod -from typing import BinaryIO, Tuple +from typing import BinaryIO, Tuple, Dict import boto3 +from botocore.config import Config from botocore.exceptions import ClientError from open_webui.config import ( S3_ACCESS_KEY_ID, @@ -13,14 +16,28 @@ from open_webui.config import ( S3_KEY_PREFIX, S3_REGION_NAME, S3_SECRET_ACCESS_KEY, + S3_USE_ACCELERATE_ENDPOINT, + S3_ADDRESSING_STYLE, + S3_ENABLE_TAGGING, GCS_BUCKET_NAME, GOOGLE_APPLICATION_CREDENTIALS_JSON, + AZURE_STORAGE_ENDPOINT, + AZURE_STORAGE_CONTAINER_NAME, + AZURE_STORAGE_KEY, STORAGE_PROVIDER, UPLOAD_DIR, ) from google.cloud import storage from google.cloud.exceptions import GoogleCloudError, NotFound from open_webui.constants import ERROR_MESSAGES +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobServiceClient +from azure.core.exceptions import ResourceNotFoundError +from open_webui.env import SRC_LOG_LEVELS + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) class StorageProvider(ABC): @@ -29,7 +46,9 @@ class StorageProvider(ABC): pass @abstractmethod - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: pass @abstractmethod @@ -43,7 +62,9 @@ class StorageProvider(ABC): class LocalStorageProvider(StorageProvider): @staticmethod - def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: contents = file.read() if not contents: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) @@ -65,7 +86,7 @@ class LocalStorageProvider(StorageProvider): if os.path.isfile(file_path): os.remove(file_path) else: - print(f"File {file_path} not found in local storage.") + log.warning(f"File {file_path} not found in local storage.") @staticmethod def delete_all_files() -> None: @@ -79,32 +100,74 @@ class LocalStorageProvider(StorageProvider): elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - print(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f"Failed to delete {file_path}. Reason: {e}") else: - print(f"Directory {UPLOAD_DIR} not found in local storage.") + log.warning(f"Directory {UPLOAD_DIR} not found in local storage.") class S3StorageProvider(StorageProvider): def __init__(self): - self.s3_client = boto3.client( - "s3", - region_name=S3_REGION_NAME, - endpoint_url=S3_ENDPOINT_URL, - aws_access_key_id=S3_ACCESS_KEY_ID, - aws_secret_access_key=S3_SECRET_ACCESS_KEY, + config = Config( + s3={ + "use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT, + "addressing_style": S3_ADDRESSING_STYLE, + }, ) + + # If access key and secret are provided, use them for authentication + if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY: + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + aws_access_key_id=S3_ACCESS_KEY_ID, + aws_secret_access_key=S3_SECRET_ACCESS_KEY, + config=config, + ) + else: + # If no explicit credentials are provided, fall back to default AWS credentials + # This supports workload identity (IAM roles for EC2, EKS, etc.) + self.s3_client = boto3.client( + "s3", + region_name=S3_REGION_NAME, + endpoint_url=S3_ENDPOINT_URL, + config=config, + ) + self.bucket_name = S3_BUCKET_NAME self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + @staticmethod + def sanitize_tag_value(s: str) -> str: + """Only include S3 allowed characters.""" + return re.sub(r"[^a-zA-Z0-9 äöüÄÖÜß\+\-=\._:/@]", "", s) + + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: """Handles uploading of the file to S3 storage.""" - _, file_path = LocalStorageProvider.upload_file(file, filename) + _, file_path = LocalStorageProvider.upload_file(file, filename, tags) + s3_key = os.path.join(self.key_prefix, filename) try: - s3_key = os.path.join(self.key_prefix, filename) self.s3_client.upload_file(file_path, self.bucket_name, s3_key) + if S3_ENABLE_TAGGING and tags: + sanitized_tags = { + self.sanitize_tag_value(k): self.sanitize_tag_value(v) + for k, v in tags.items() + } + tagging = { + "TagSet": [ + {"Key": k, "Value": v} for k, v in sanitized_tags.items() + ] + } + self.s3_client.put_object_tagging( + Bucket=self.bucket_name, + Key=s3_key, + Tagging=tagging, + ) return ( open(file_path, "rb").read(), - "s3://" + self.bucket_name + "/" + s3_key, + f"s3://{self.bucket_name}/{s3_key}", ) except ClientError as e: raise RuntimeError(f"Error uploading file to S3: {e}") @@ -172,9 +235,11 @@ class GCSStorageProvider(StorageProvider): self.gcs_client = storage.Client() self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME) - def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]: + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: """Handles uploading of the file to GCS storage.""" - contents, file_path = LocalStorageProvider.upload_file(file, filename) + contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) try: blob = self.bucket.blob(filename) blob.upload_from_filename(file_path) @@ -221,6 +286,76 @@ class GCSStorageProvider(StorageProvider): LocalStorageProvider.delete_all_files() +class AzureStorageProvider(StorageProvider): + def __init__(self): + self.endpoint = AZURE_STORAGE_ENDPOINT + self.container_name = AZURE_STORAGE_CONTAINER_NAME + storage_key = AZURE_STORAGE_KEY + + if storage_key: + # Configure using the Azure Storage Account Endpoint and Key + self.blob_service_client = BlobServiceClient( + account_url=self.endpoint, credential=storage_key + ) + else: + # Configure using the Azure Storage Account Endpoint and DefaultAzureCredential + # If the key is not configured, then the DefaultAzureCredential will be used to support Managed Identity authentication + self.blob_service_client = BlobServiceClient( + account_url=self.endpoint, credential=DefaultAzureCredential() + ) + self.container_client = self.blob_service_client.get_container_client( + self.container_name + ) + + def upload_file( + self, file: BinaryIO, filename: str, tags: Dict[str, str] + ) -> Tuple[bytes, str]: + """Handles uploading of the file to Azure Blob Storage.""" + contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) + try: + blob_client = self.container_client.get_blob_client(filename) + blob_client.upload_blob(contents, overwrite=True) + return contents, f"{self.endpoint}/{self.container_name}/{filename}" + except Exception as e: + raise RuntimeError(f"Error uploading file to Azure Blob Storage: {e}") + + def get_file(self, file_path: str) -> str: + """Handles downloading of the file from Azure Blob Storage.""" + try: + filename = file_path.split("/")[-1] + local_file_path = f"{UPLOAD_DIR}/{filename}" + blob_client = self.container_client.get_blob_client(filename) + with open(local_file_path, "wb") as download_file: + download_file.write(blob_client.download_blob().readall()) + return local_file_path + except ResourceNotFoundError as e: + raise RuntimeError(f"Error downloading file from Azure Blob Storage: {e}") + + def delete_file(self, file_path: str) -> None: + """Handles deletion of the file from Azure Blob Storage.""" + try: + filename = file_path.split("/")[-1] + blob_client = self.container_client.get_blob_client(filename) + blob_client.delete_blob() + except ResourceNotFoundError as e: + raise RuntimeError(f"Error deleting file from Azure Blob Storage: {e}") + + # Always delete from local storage + LocalStorageProvider.delete_file(file_path) + + def delete_all_files(self) -> None: + """Handles deletion of all files from Azure Blob Storage.""" + try: + blobs = self.container_client.list_blobs() + for blob in blobs: + self.container_client.delete_blob(blob.name) + except Exception as e: + raise RuntimeError(f"Error deleting all files from Azure Blob Storage: {e}") + + # Always delete from local storage + LocalStorageProvider.delete_all_files() + + def get_storage_provider(storage_provider: str): if storage_provider == "local": Storage = LocalStorageProvider() @@ -228,6 +363,8 @@ def get_storage_provider(storage_provider: str): Storage = S3StorageProvider() elif storage_provider == "gcs": Storage = GCSStorageProvider() + elif storage_provider == "azure": + Storage = AzureStorageProvider() else: raise RuntimeError(f"Unsupported storage provider: {storage_provider}") return Storage diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 2740ecb5aa..e575e6885c 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -5,16 +5,23 @@ from uuid import uuid4 # A dictionary to keep track of active tasks tasks: Dict[str, asyncio.Task] = {} +chat_tasks = {} -def cleanup_task(task_id: str): +def cleanup_task(task_id: str, id=None): """ Remove a completed or canceled task from the global `tasks` dictionary. """ tasks.pop(task_id, None) # Remove the task if it exists + # If an ID is provided, remove the task from the chat_tasks dictionary + if id and task_id in chat_tasks.get(id, []): + chat_tasks[id].remove(task_id) + if not chat_tasks[id]: # If no tasks left for this ID, remove the entry + chat_tasks.pop(id, None) -def create_task(coroutine): + +def create_task(coroutine, id=None): """ Create a new asyncio task and add it to the global task dictionary. """ @@ -22,9 +29,15 @@ def create_task(coroutine): task = asyncio.create_task(coroutine) # Create the task # Add a done callback for cleanup - task.add_done_callback(lambda t: cleanup_task(task_id)) - + task.add_done_callback(lambda t: cleanup_task(task_id, id)) tasks[task_id] = task + + # If an ID is provided, associate the task with that ID + if chat_tasks.get(id): + chat_tasks[id].append(task_id) + else: + chat_tasks[id] = [task_id] + return task_id, task @@ -42,6 +55,13 @@ def list_tasks(): return list(tasks.keys()) +def list_task_ids_by_chat_id(id): + """ + List all tasks associated with a specific ID. + """ + return chat_tasks.get(id, []) + + async def stop_task(task_id: str): """ Cancel a running task and remove it from the global task list. diff --git a/backend/open_webui/test/apps/webui/storage/test_provider.py b/backend/open_webui/test/apps/webui/storage/test_provider.py index 863106e75a..3c874592fe 100644 --- a/backend/open_webui/test/apps/webui/storage/test_provider.py +++ b/backend/open_webui/test/apps/webui/storage/test_provider.py @@ -7,6 +7,8 @@ from moto import mock_aws from open_webui.storage import provider from gcp_storage_emulator.server import create_server from google.cloud import storage +from azure.storage.blob import BlobServiceClient, ContainerClient, BlobClient +from unittest.mock import MagicMock def mock_upload_dir(monkeypatch, tmp_path): @@ -22,6 +24,7 @@ def test_imports(): provider.LocalStorageProvider provider.S3StorageProvider provider.GCSStorageProvider + provider.AzureStorageProvider provider.Storage @@ -32,6 +35,8 @@ def test_get_storage_provider(): assert isinstance(Storage, provider.S3StorageProvider) Storage = provider.get_storage_provider("gcs") assert isinstance(Storage, provider.GCSStorageProvider) + Storage = provider.get_storage_provider("azure") + assert isinstance(Storage, provider.AzureStorageProvider) with pytest.raises(RuntimeError): provider.get_storage_provider("invalid") @@ -48,6 +53,7 @@ def test_class_instantiation(): provider.LocalStorageProvider() provider.S3StorageProvider() provider.GCSStorageProvider() + provider.AzureStorageProvider() class TestLocalStorageProvider: @@ -181,6 +187,17 @@ class TestS3StorageProvider: assert not (upload_dir / self.filename).exists() assert not (upload_dir / self.filename_extra).exists() + def test_init_without_credentials(self, monkeypatch): + """Test that S3StorageProvider can initialize without explicit credentials.""" + # Temporarily unset the environment variables + monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None) + monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None) + + # Should not raise an exception + storage = provider.S3StorageProvider() + assert storage.s3_client is not None + assert storage.bucket_name == provider.S3_BUCKET_NAME + class TestGCSStorageProvider: Storage = provider.GCSStorageProvider() @@ -272,3 +289,147 @@ class TestGCSStorageProvider: assert not (upload_dir / self.filename_extra).exists() assert self.Storage.bucket.get_blob(self.filename) == None assert self.Storage.bucket.get_blob(self.filename_extra) == None + + +class TestAzureStorageProvider: + def __init__(self): + super().__init__() + + @pytest.fixture(scope="class") + def setup_storage(self, monkeypatch): + # Create mock Blob Service Client and related clients + mock_blob_service_client = MagicMock() + mock_container_client = MagicMock() + mock_blob_client = MagicMock() + + # Set up return values for the mock + mock_blob_service_client.get_container_client.return_value = ( + mock_container_client + ) + mock_container_client.get_blob_client.return_value = mock_blob_client + + # Monkeypatch the Azure classes to return our mocks + monkeypatch.setattr( + azure.storage.blob, + "BlobServiceClient", + lambda *args, **kwargs: mock_blob_service_client, + ) + monkeypatch.setattr( + azure.storage.blob, + "ContainerClient", + lambda *args, **kwargs: mock_container_client, + ) + monkeypatch.setattr( + azure.storage.blob, "BlobClient", lambda *args, **kwargs: mock_blob_client + ) + + self.Storage = provider.AzureStorageProvider() + self.Storage.endpoint = "https://myaccount.blob.core.windows.net" + self.Storage.container_name = "my-container" + self.file_content = b"test content" + self.filename = "test.txt" + self.filename_extra = "test_extra.txt" + self.file_bytesio_empty = io.BytesIO() + + # Apply mocks to the Storage instance + self.Storage.blob_service_client = mock_blob_service_client + self.Storage.container_client = mock_container_client + + def test_upload_file(self, monkeypatch, tmp_path): + upload_dir = mock_upload_dir(monkeypatch, tmp_path) + + # Simulate an error when container does not exist + self.Storage.container_client.get_blob_client.side_effect = Exception( + "Container does not exist" + ) + with pytest.raises(Exception): + self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) + + # Reset side effect and create container + self.Storage.container_client.get_blob_client.side_effect = None + self.Storage.create_container() + contents, azure_file_path = self.Storage.upload_file( + io.BytesIO(self.file_content), self.filename + ) + + # Assertions + self.Storage.container_client.get_blob_client.assert_called_with(self.filename) + self.Storage.container_client.get_blob_client().upload_blob.assert_called_once_with( + self.file_content, overwrite=True + ) + assert contents == self.file_content + assert ( + azure_file_path + == f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + ) + assert (upload_dir / self.filename).exists() + assert (upload_dir / self.filename).read_bytes() == self.file_content + + with pytest.raises(ValueError): + self.Storage.upload_file(self.file_bytesio_empty, self.filename) + + def test_get_file(self, monkeypatch, tmp_path): + upload_dir = mock_upload_dir(monkeypatch, tmp_path) + self.Storage.create_container() + + # Mock upload behavior + self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) + # Mock blob download behavior + self.Storage.container_client.get_blob_client().download_blob().readall.return_value = ( + self.file_content + ) + + file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + file_path = self.Storage.get_file(file_url) + + assert file_path == str(upload_dir / self.filename) + assert (upload_dir / self.filename).exists() + assert (upload_dir / self.filename).read_bytes() == self.file_content + + def test_delete_file(self, monkeypatch, tmp_path): + upload_dir = mock_upload_dir(monkeypatch, tmp_path) + self.Storage.create_container() + + # Mock file upload + self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) + # Mock deletion + self.Storage.container_client.get_blob_client().delete_blob.return_value = None + + file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + self.Storage.delete_file(file_url) + + self.Storage.container_client.get_blob_client().delete_blob.assert_called_once() + assert not (upload_dir / self.filename).exists() + + def test_delete_all_files(self, monkeypatch, tmp_path): + upload_dir = mock_upload_dir(monkeypatch, tmp_path) + self.Storage.create_container() + + # Mock file uploads + self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) + self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra) + + # Mock listing and deletion behavior + self.Storage.container_client.list_blobs.return_value = [ + {"name": self.filename}, + {"name": self.filename_extra}, + ] + self.Storage.container_client.get_blob_client().delete_blob.return_value = None + + self.Storage.delete_all_files() + + self.Storage.container_client.list_blobs.assert_called_once() + self.Storage.container_client.get_blob_client().delete_blob.assert_any_call() + assert not (upload_dir / self.filename).exists() + assert not (upload_dir / self.filename_extra).exists() + + def test_get_file_not_found(self, monkeypatch): + self.Storage.create_container() + + file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + # Mock behavior to raise an error for missing blobs + self.Storage.container_client.get_blob_client().download_blob.side_effect = ( + Exception("Blob not found") + ) + with pytest.raises(Exception, match="Blob not found"): + self.Storage.get_file(file_url) diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py new file mode 100644 index 0000000000..8193907d27 --- /dev/null +++ b/backend/open_webui/utils/audit.py @@ -0,0 +1,283 @@ +from contextlib import asynccontextmanager +from dataclasses import asdict, dataclass +from enum import Enum +import re +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Dict, + MutableMapping, + Optional, + cast, +) +import uuid + +from asgiref.typing import ( + ASGI3Application, + ASGIReceiveCallable, + ASGIReceiveEvent, + ASGISendCallable, + ASGISendEvent, + Scope as ASGIScope, +) +from loguru import logger +from starlette.requests import Request + +from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE +from open_webui.utils.auth import get_current_user, get_http_authorization_cred +from open_webui.models.users import UserModel + + +if TYPE_CHECKING: + from loguru import Logger + + +@dataclass(frozen=True) +class AuditLogEntry: + # `Metadata` audit level properties + id: str + user: Optional[dict[str, Any]] + audit_level: str + verb: str + request_uri: str + user_agent: Optional[str] = None + source_ip: Optional[str] = None + # `Request` audit level properties + request_object: Any = None + # `Request Response` level + response_object: Any = None + response_status_code: Optional[int] = None + + +class AuditLevel(str, Enum): + NONE = "NONE" + METADATA = "METADATA" + REQUEST = "REQUEST" + REQUEST_RESPONSE = "REQUEST_RESPONSE" + + +class AuditLogger: + """ + A helper class that encapsulates audit logging functionality. It uses Loguru’s logger with an auditable binding to ensure that audit log entries are filtered correctly. + + Parameters: + logger (Logger): An instance of Loguru’s logger. + """ + + def __init__(self, logger: "Logger"): + self.logger = logger.bind(auditable=True) + + def write( + self, + audit_entry: AuditLogEntry, + *, + log_level: str = "INFO", + extra: Optional[dict] = None, + ): + + entry = asdict(audit_entry) + + if extra: + entry["extra"] = extra + + self.logger.log( + log_level, + "", + **entry, + ) + + +class AuditContext: + """ + Captures and aggregates the HTTP request and response bodies during the processing of a request. It ensures that only a configurable maximum amount of data is stored to prevent excessive memory usage. + + Attributes: + request_body (bytearray): Accumulated request payload. + response_body (bytearray): Accumulated response payload. + max_body_size (int): Maximum number of bytes to capture. + metadata (Dict[str, Any]): A dictionary to store additional audit metadata (user, http verb, user agent, etc.). + """ + + def __init__(self, max_body_size: int = MAX_BODY_LOG_SIZE): + self.request_body = bytearray() + self.response_body = bytearray() + self.max_body_size = max_body_size + self.metadata: Dict[str, Any] = {} + + def add_request_chunk(self, chunk: bytes): + if len(self.request_body) < self.max_body_size: + self.request_body.extend( + chunk[: self.max_body_size - len(self.request_body)] + ) + + def add_response_chunk(self, chunk: bytes): + if len(self.response_body) < self.max_body_size: + self.response_body.extend( + chunk[: self.max_body_size - len(self.response_body)] + ) + + +class AuditLoggingMiddleware: + """ + ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle. + """ + + AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"} + + def __init__( + self, + app: ASGI3Application, + *, + excluded_paths: Optional[list[str]] = None, + max_body_size: int = MAX_BODY_LOG_SIZE, + audit_level: AuditLevel = AuditLevel.NONE, + ) -> None: + self.app = app + self.audit_logger = AuditLogger(logger) + self.excluded_paths = excluded_paths or [] + self.max_body_size = max_body_size + self.audit_level = audit_level + + async def __call__( + self, + scope: ASGIScope, + receive: ASGIReceiveCallable, + send: ASGISendCallable, + ) -> None: + if scope["type"] != "http": + return await self.app(scope, receive, send) + + request = Request(scope=cast(MutableMapping, scope)) + + if self._should_skip_auditing(request): + return await self.app(scope, receive, send) + + async with self._audit_context(request) as context: + + async def send_wrapper(message: ASGISendEvent) -> None: + if self.audit_level == AuditLevel.REQUEST_RESPONSE: + await self._capture_response(message, context) + + await send(message) + + original_receive = receive + + async def receive_wrapper() -> ASGIReceiveEvent: + nonlocal original_receive + message = await original_receive() + + if self.audit_level in ( + AuditLevel.REQUEST, + AuditLevel.REQUEST_RESPONSE, + ): + await self._capture_request(message, context) + + return message + + await self.app(scope, receive_wrapper, send_wrapper) + + @asynccontextmanager + async def _audit_context( + self, request: Request + ) -> AsyncGenerator[AuditContext, None]: + """ + async context manager that ensures that an audit log entry is recorded after the request is processed. + """ + context = AuditContext() + try: + yield context + finally: + await self._log_audit_entry(request, context) + + async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]: + auth_header = request.headers.get("Authorization") + + try: + user = get_current_user( + request, None, get_http_authorization_cred(auth_header) + ) + return user + except Exception as e: + logger.debug(f"Failed to get authenticated user: {str(e)}") + + return None + + def _should_skip_auditing(self, request: Request) -> bool: + if ( + request.method not in {"POST", "PUT", "PATCH", "DELETE"} + or AUDIT_LOG_LEVEL == "NONE" + ): + return True + + ALWAYS_LOG_ENDPOINTS = { + "/api/v1/auths/signin", + "/api/v1/auths/signout", + "/api/v1/auths/signup", + } + path = request.url.path.lower() + for endpoint in ALWAYS_LOG_ENDPOINTS: + if path.startswith(endpoint): + return False # Do NOT skip logging for auth endpoints + + # Skip logging if the request is not authenticated + if not request.headers.get("authorization"): + return True + + # match either /api//...(for the endpoint /api/chat case) or /api/v1//... + pattern = re.compile( + r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b" + ) + if pattern.match(request.url.path): + return True + + return False + + async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext): + if message["type"] == "http.request": + body = message.get("body", b"") + context.add_request_chunk(body) + + async def _capture_response(self, message: ASGISendEvent, context: AuditContext): + if message["type"] == "http.response.start": + context.metadata["response_status_code"] = message["status"] + + elif message["type"] == "http.response.body": + body = message.get("body", b"") + context.add_response_chunk(body) + + async def _log_audit_entry(self, request: Request, context: AuditContext): + try: + user = await self._get_authenticated_user(request) + + user = ( + user.model_dump(include={"id", "name", "email", "role"}) if user else {} + ) + + request_body = context.request_body.decode("utf-8", errors="replace") + response_body = context.response_body.decode("utf-8", errors="replace") + + # Redact sensitive information + if "password" in request_body: + request_body = re.sub( + r'"password":\s*"(.*?)"', + '"password": "********"', + request_body, + ) + + entry = AuditLogEntry( + id=str(uuid.uuid4()), + user=user, + audit_level=self.audit_level.value, + verb=request.method, + request_uri=str(request.url), + response_status_code=context.metadata.get("response_status_code", None), + source_ip=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + request_object=request_body, + response_object=response_body, + ) + + self.audit_logger.write(entry) + except Exception as e: + logger.error(f"Failed to log audit entry: {str(e)}") diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 3a04909606..2db0da7e5d 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -1,21 +1,39 @@ import logging import uuid import jwt +import base64 +import hmac +import hashlib +import requests +import os -from datetime import UTC, datetime, timedelta + +from datetime import datetime, timedelta +import pytz +from pytz import UTC from typing import Optional, Union, List, Dict +from opentelemetry import trace + from open_webui.models.users import Users from open_webui.constants import ERROR_MESSAGES -from open_webui.env import WEBUI_SECRET_KEY +from open_webui.env import ( + WEBUI_SECRET_KEY, + TRUSTED_SIGNATURE_KEY, + STATIC_DIR, + SRC_LOG_LEVELS, +) -from fastapi import Depends, HTTPException, Request, Response, status +from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from passlib.context import CryptContext + logging.getLogger("passlib").setLevel(logging.ERROR) +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["OAUTH"]) SESSION_SECRET = WEBUI_SECRET_KEY ALGORITHM = "HS256" @@ -24,6 +42,67 @@ ALGORITHM = "HS256" # Auth Utils ############## + +def verify_signature(payload: str, signature: str) -> bool: + """ + Verifies the HMAC signature of the received payload. + """ + try: + expected_signature = base64.b64encode( + hmac.new(TRUSTED_SIGNATURE_KEY, payload.encode(), hashlib.sha256).digest() + ).decode() + + # Compare securely to prevent timing attacks + return hmac.compare_digest(expected_signature, signature) + + except Exception: + return False + + +def override_static(path: str, content: str): + # Ensure path is safe + if "/" in path or ".." in path: + log.error(f"Invalid path: {path}") + return + + file_path = os.path.join(STATIC_DIR, path) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + with open(file_path, "wb") as f: + f.write(base64.b64decode(content)) # Convert Base64 back to raw binary + + +def get_license_data(app, key): + if key: + try: + res = requests.post( + "https://api.openwebui.com/api/v1/license/", + json={"key": key, "version": "1"}, + timeout=5, + ) + + if getattr(res, "ok", False): + payload = getattr(res, "json", lambda: {})() + for k, v in payload.items(): + if k == "resources": + for p, c in v.items(): + globals().get("override_static", lambda a, b: None)(p, c) + elif k == "count": + setattr(app.state, "USER_COUNT", v) + elif k == "name": + setattr(app.state, "WEBUI_NAME", v) + elif k == "metadata": + setattr(app.state, "LICENSE_METADATA", v) + return True + else: + log.error( + f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}" + ) + except Exception as ex: + log.exception(f"License: Uncaught Exception: {ex}") + return False + + bearer_security = HTTPBearer(auto_error=False) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -66,16 +145,19 @@ def create_api_key(): return f"sk-{key}" -def get_http_authorization_cred(auth_header: str): +def get_http_authorization_cred(auth_header: Optional[str]): + if not auth_header: + return None try: scheme, credentials = auth_header.split(" ") return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) except Exception: - raise ValueError(ERROR_MESSAGES.INVALID_TOKEN) + return None def get_current_user( request: Request, + background_tasks: BackgroundTasks, auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), ): token = None @@ -104,12 +186,27 @@ def get_current_user( ).split(",") ] - if request.url.path not in allowed_paths: + # Check if the request path matches any allowed endpoint. + if not any( + request.url.path == allowed + or request.url.path.startswith(allowed + "/") + for allowed in allowed_paths + ): raise HTTPException( status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED ) - return get_current_user_by_api_key(token) + user = get_current_user_by_api_key(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", "api_key") + + return user # auth by jwt token try: @@ -128,7 +225,18 @@ def get_current_user( detail=ERROR_MESSAGES.INVALID_TOKEN, ) else: - Users.update_user_last_active_by_id(user.id) + # 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( @@ -146,6 +254,14 @@ def get_current_user_by_api_key(api_key: str): detail=ERROR_MESSAGES.INVALID_TOKEN, ) else: + # 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", "api_key") + Users.update_user_last_active_by_id(user.id) return user diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 569bcad854..4bd744e3c3 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -40,7 +40,10 @@ from open_webui.models.functions import Functions from open_webui.models.models import Models -from open_webui.utils.plugin import load_function_module_by_id +from open_webui.utils.plugin import ( + load_function_module_by_id, + get_function_module_from_cache, +) from open_webui.utils.models import get_all_models, check_model_access from open_webui.utils.payload import convert_payload_openai_to_ollama from open_webui.utils.response import ( @@ -66,7 +69,7 @@ async def generate_direct_chat_completion( user: Any, models: dict, ): - print("generate_direct_chat_completion") + log.info("generate_direct_chat_completion") metadata = form_data.pop("metadata", {}) @@ -103,7 +106,7 @@ async def generate_direct_chat_completion( } ) - print("res", res) + log.info(f"res: {res}") if res.get("status", False): # Define a generator to stream responses @@ -149,7 +152,7 @@ async def generate_direct_chat_completion( } ) - if "error" in res: + if "error" in res and res["error"]: raise Exception(res["error"]) return res @@ -186,12 +189,6 @@ async def generate_chat_completion( if model_id not in models: raise Exception("Model not found") - # Process the form_data through the pipeline - try: - form_data = process_pipeline_inlet_filter(request, form_data, user, models) - except Exception as e: - raise e - model = models[model_id] if getattr(request.state, "direct", False): @@ -206,7 +203,7 @@ async def generate_chat_completion( except Exception as e: raise e - if model["owned_by"] == "arena": + if model.get("owned_by") == "arena": model_ids = model.get("info", {}).get("meta", {}).get("model_ids") filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode") if model_ids and filter_mode == "exclude": @@ -259,7 +256,7 @@ async def generate_chat_completion( return await generate_function_chat_completion( request, form_data, user=user, models=models ) - if model["owned_by"] == "ollama": + if model.get("owned_by") == "ollama": # Using /ollama/api/chat endpoint form_data = convert_payload_openai_to_ollama(form_data) response = await generate_ollama_chat_completion( @@ -291,7 +288,7 @@ chat_completion = generate_chat_completion async def chat_completed(request: Request, form_data: dict, user: Any): if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) if getattr(request.state, "direct", False) and hasattr(request.state, "model"): models = { @@ -308,13 +305,14 @@ async def chat_completed(request: Request, form_data: dict, user: Any): model = models[model_id] try: - data = process_pipeline_outlet_filter(request, data, user, models) + data = await process_pipeline_outlet_filter(request, data, user, models) except Exception as e: return Exception(f"Error: {e}") metadata = { "chat_id": data["chat_id"], "message_id": data["id"], + "filter_ids": data.get("filter_ids", []), "session_id": data["session_id"], "user_id": user.id, } @@ -334,9 +332,16 @@ async def chat_completed(request: Request, form_data: dict, user: Any): } try: + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids( + request, model, metadata.get("filter_ids", []) + ) + ] + result, _ = await process_filter_functions( request=request, - filter_ids=get_sorted_filter_ids(model), + filter_functions=filter_functions, filter_type="outlet", form_data=data, extra_params=extra_params, @@ -357,7 +362,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A raise Exception(f"Action not found: {action_id}") if not request.app.state.MODELS: - await get_all_models(request) + await get_all_models(request, user=user) if getattr(request.state, "direct", False) and hasattr(request.state, "model"): models = { @@ -390,11 +395,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A } ) - if action_id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[action_id] - else: - function_module, _, _ = load_function_module_by_id(action_id) - request.app.state.FUNCTIONS[action_id] = function_module + function_module, _, _ = get_function_module_from_cache(request, action_id) if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): valves = Functions.get_function_valves_by_id(action_id) @@ -438,7 +439,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A ) ) except Exception as e: - print(e) + log.exception(f"Failed to get user values: {e}") params = {**params, "__user__": __user__} diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index 0a74da9c77..f3dcbb81fb 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -1,148 +1,210 @@ import asyncio import json +import logging import uuid +from typing import Optional + +import aiohttp import websockets -import requests -from urllib.parse import urljoin +from pydantic import BaseModel + +from open_webui.env import SRC_LOG_LEVELS + +logger = logging.getLogger(__name__) +logger.setLevel(SRC_LOG_LEVELS["MAIN"]) -async def execute_code_jupyter( - jupyter_url, code, token=None, password=None, timeout=10 -): +class ResultModel(BaseModel): """ - Executes Python code in a Jupyter kernel. - Supports authentication with a token or password. - :param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888") - :param code: Code to execute - :param token: Jupyter authentication token (optional) - :param password: Jupyter password (optional) - :param timeout: WebSocket timeout in seconds (default: 10s) - :return: Dictionary with stdout, stderr, and result - - Images are prefixed with "base64:image/png," and separated by newlines if multiple. + Execute Code Result Model """ - session = requests.Session() # Maintain cookies - headers = {} # Headers for requests - # Authenticate using password - if password and not token: + stdout: Optional[str] = "" + stderr: Optional[str] = "" + result: Optional[str] = "" + + +class JupyterCodeExecuter: + """ + Execute code in jupyter notebook + """ + + def __init__( + self, + base_url: str, + code: str, + token: str = "", + password: str = "", + timeout: int = 60, + ): + """ + :param base_url: Jupyter server URL (e.g., "http://localhost:8888") + :param code: Code to execute + :param token: Jupyter authentication token (optional) + :param password: Jupyter password (optional) + :param timeout: WebSocket timeout in seconds (default: 60s) + """ + self.base_url = base_url + self.code = code + self.token = token + self.password = password + self.timeout = timeout + self.kernel_id = "" + if self.base_url[-1] != "/": + self.base_url += "/" + self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url) + self.params = {} + self.result = ResultModel() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.kernel_id: + try: + async with self.session.delete( + f"api/kernels/{self.kernel_id}", params=self.params + ) as response: + response.raise_for_status() + except Exception as err: + logger.exception("close kernel failed, %s", err) + await self.session.close() + + async def run(self) -> ResultModel: try: - login_url = urljoin(jupyter_url, "/login") - response = session.get(login_url) + await self.sign_in() + await self.init_kernel() + await self.execute_code() + except Exception as err: + logger.exception("execute code failed, %s", err) + self.result.stderr = f"Error: {err}" + return self.result + + async def sign_in(self) -> None: + # password authentication + if self.password and not self.token: + async with self.session.get("login") as response: + response.raise_for_status() + xsrf_token = response.cookies["_xsrf"].value + if not xsrf_token: + raise ValueError("_xsrf token not found") + self.session.cookie_jar.update_cookies(response.cookies) + self.session.headers.update({"X-XSRFToken": xsrf_token}) + async with self.session.post( + "login", + data={"_xsrf": xsrf_token, "password": self.password}, + allow_redirects=False, + ) as response: + response.raise_for_status() + self.session.cookie_jar.update_cookies(response.cookies) + + # token authentication + if self.token: + self.params.update({"token": self.token}) + + async def init_kernel(self) -> None: + async with self.session.post(url="api/kernels", params=self.params) as response: response.raise_for_status() - xsrf_token = session.cookies.get("_xsrf") - if not xsrf_token: - raise ValueError("Failed to fetch _xsrf token") - - login_data = {"_xsrf": xsrf_token, "password": password} - login_response = session.post( - login_url, data=login_data, cookies=session.cookies - ) - login_response.raise_for_status() - headers["X-XSRFToken"] = xsrf_token - except Exception as e: - return { - "stdout": "", - "stderr": f"Authentication Error: {str(e)}", - "result": "", - } - - # Construct API URLs with authentication token if provided - params = f"?token={token}" if token else "" - kernel_url = urljoin(jupyter_url, f"/api/kernels{params}") - - try: - response = session.post(kernel_url, headers=headers, cookies=session.cookies) - response.raise_for_status() - kernel_id = response.json()["id"] - - websocket_url = urljoin( - jupyter_url.replace("http", "ws"), - f"/api/kernels/{kernel_id}/channels{params}", - ) + kernel_data = await response.json() + self.kernel_id = kernel_data["id"] + def init_ws(self) -> (str, dict): + ws_base = self.base_url.replace("http", "ws", 1) + ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()]) + websocket_url = f"{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" ws_headers = {} - if password and not token: - ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf") - cookies = {name: value for name, value in session.cookies.items()} - ws_headers["Cookie"] = "; ".join( - [f"{name}={value}" for name, value in cookies.items()] - ) + if self.password and not self.token: + ws_headers = { + "Cookie": "; ".join( + [ + f"{cookie.key}={cookie.value}" + for cookie in self.session.cookie_jar + ] + ), + **self.session.headers, + } + return websocket_url, ws_headers + async def execute_code(self) -> None: + # initialize ws + websocket_url, ws_headers = self.init_ws() + # execute async with websockets.connect( websocket_url, additional_headers=ws_headers ) as ws: - msg_id = str(uuid.uuid4()) - execute_request = { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "user", - "session": str(uuid.uuid4()), - "date": "", - "version": "5.3", - }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, - }, - "channel": "shell", - } - await ws.send(json.dumps(execute_request)) + await self.execute_in_jupyter(ws) - stdout, stderr, result = "", "", [] - - while True: - try: - message = await asyncio.wait_for(ws.recv(), timeout) - message_data = json.loads(message) - if message_data.get("parent_header", {}).get("msg_id") == msg_id: - msg_type = message_data.get("msg_type") - - if msg_type == "stream": - if message_data["content"]["name"] == "stdout": - stdout += message_data["content"]["text"] - elif message_data["content"]["name"] == "stderr": - stderr += message_data["content"]["text"] - - elif msg_type in ("execute_result", "display_data"): - data = message_data["content"]["data"] - if "image/png" in data: - result.append( - f"data:image/png;base64,{data['image/png']}" - ) - elif "text/plain" in data: - result.append(data["text/plain"]) - - elif msg_type == "error": - stderr += "\n".join(message_data["content"]["traceback"]) - - elif ( - msg_type == "status" - and message_data["content"]["execution_state"] == "idle" - ): + async def execute_in_jupyter(self, ws) -> None: + # send message + msg_id = uuid.uuid4().hex + await ws.send( + json.dumps( + { + "header": { + "msg_id": msg_id, + "msg_type": "execute_request", + "username": "user", + "session": uuid.uuid4().hex, + "date": "", + "version": "5.3", + }, + "parent_header": {}, + "metadata": {}, + "content": { + "code": self.code, + "silent": False, + "store_history": True, + "user_expressions": {}, + "allow_stdin": False, + "stop_on_error": True, + }, + "channel": "shell", + } + ) + ) + # parse message + stdout, stderr, result = "", "", [] + while True: + try: + # wait for message + message = await asyncio.wait_for(ws.recv(), self.timeout) + message_data = json.loads(message) + # msg id not match, skip + if message_data.get("parent_header", {}).get("msg_id") != msg_id: + continue + # check message type + msg_type = message_data.get("msg_type") + match msg_type: + case "stream": + if message_data["content"]["name"] == "stdout": + stdout += message_data["content"]["text"] + elif message_data["content"]["name"] == "stderr": + stderr += message_data["content"]["text"] + case "execute_result" | "display_data": + data = message_data["content"]["data"] + if "image/png" in data: + result.append(f"data:image/png;base64,{data['image/png']}") + elif "text/plain" in data: + result.append(data["text/plain"]) + case "error": + stderr += "\n".join(message_data["content"]["traceback"]) + case "status": + if message_data["content"]["execution_state"] == "idle": break - except asyncio.TimeoutError: - stderr += "\nExecution timed out." - break + except asyncio.TimeoutError: + stderr += "\nExecution timed out." + break + self.result.stdout = stdout.strip() + self.result.stderr = stderr.strip() + self.result.result = "\n".join(result).strip() if result else "" - except Exception as e: - return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""} - finally: - if kernel_id: - requests.delete( - f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies - ) - - return { - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "result": "\n".join(result).strip() if result else "", - } +async def execute_code_jupyter( + base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60 +) -> dict: + async with JupyterCodeExecuter( + base_url, code, token, password, timeout + ) as executor: + result = await executor.run() + return result.model_dump() diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index de51bd46e5..1986e55b64 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -1,46 +1,80 @@ import inspect -from open_webui.utils.plugin import load_function_module_by_id +import logging + +from open_webui.utils.plugin import ( + load_function_module_by_id, + get_function_module_from_cache, +) from open_webui.models.functions import Functions +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) -def get_sorted_filter_ids(model): +def get_function_module(request, function_id, load_from_db=True): + """ + Get the function module by its ID. + """ + function_module, _, _ = get_function_module_from_cache( + request, function_id, load_from_db + ) + return function_module + + +def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None): def get_priority(function_id): function = Functions.get_function_by_id(function_id) - if function is not None and hasattr(function, "valves"): - # TODO: Fix FunctionModel to include vavles - return (function.valves if function.valves else {}).get("priority", 0) + if function is not None: + valves = Functions.get_function_valves_by_id(function_id) + return valves.get("priority", 0) if valves else 0 return 0 filter_ids = [function.id for function in Functions.get_global_filter_functions()] if "info" in model and "meta" in model["info"]: filter_ids.extend(model["info"]["meta"].get("filterIds", [])) filter_ids = list(set(filter_ids)) - - enabled_filter_ids = [ + active_filter_ids = [ function.id for function in Functions.get_functions_by_type("filter", active_only=True) ] - filter_ids = [fid for fid in filter_ids if fid in enabled_filter_ids] + def get_active_status(filter_id): + function_module = get_function_module(request, filter_id) + + if getattr(function_module, "toggle", None): + return filter_id in (enabled_filter_ids or []) + + return True + + active_filter_ids = [ + filter_id for filter_id in active_filter_ids if get_active_status(filter_id) + ] + + filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] filter_ids.sort(key=get_priority) + return filter_ids async def process_filter_functions( - request, filter_ids, filter_type, form_data, extra_params + request, filter_functions, filter_type, form_data, extra_params ): skip_files = None - for filter_id in filter_ids: - filter = Functions.get_function_by_id(filter_id) + for function in filter_functions: + filter = function + filter_id = function.id if not filter: continue - if filter_id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[filter_id] - else: - function_module, _, _ = load_function_module_by_id(filter_id) - request.app.state.FUNCTIONS[filter_id] = function_module + function_module = get_function_module( + request, filter_id, load_from_db=(filter_type != "stream") + ) + # Prepare handler function + handler = getattr(function_module, filter_type, None) + if not handler: + continue # Check if the function has a file_handler variable if filter_type == "inlet" and hasattr(function_module, "file_handler"): @@ -53,15 +87,15 @@ async def process_filter_functions( **(valves if valves else {}) ) - # Prepare handler function - handler = getattr(function_module, filter_type, None) - if not handler: - continue - try: # Prepare parameters sig = inspect.signature(handler) - params = {"body": form_data} | { + + params = {"body": form_data} + if filter_type == "stream": + params = {"event": form_data} + + params = params | { k: v for k, v in { **extra_params, @@ -80,7 +114,7 @@ async def process_filter_functions( ) ) except Exception as e: - print(e) + log.exception(f"Failed to get user values: {e}") # Execute handler if inspect.iscoroutinefunction(handler): @@ -89,11 +123,12 @@ async def process_filter_functions( form_data = handler(**params) except Exception as e: - print(f"Error in {filter_type} handler {filter_id}: {e}") + log.debug(f"Error in {filter_type} handler {filter_id}: {e}") 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"] return form_data, {} diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py new file mode 100644 index 0000000000..2557610060 --- /dev/null +++ b/backend/open_webui/utils/logger.py @@ -0,0 +1,140 @@ +import json +import logging +import sys +from typing import TYPE_CHECKING + +from loguru import logger + +from open_webui.env import ( + AUDIT_LOG_FILE_ROTATION_SIZE, + AUDIT_LOG_LEVEL, + AUDIT_LOGS_FILE_PATH, + GLOBAL_LOG_LEVEL, +) + + +if TYPE_CHECKING: + from loguru import Record + + +def stdout_format(record: "Record") -> str: + """ + Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON). + + Parameters: + record (Record): A Loguru record that contains logging details including time, level, name, function, line, message, and any extra context. + Returns: + str: A formatted log string intended for stdout. + """ + record["extra"]["extra_json"] = json.dumps(record["extra"]) + return ( + "{time:YYYY-MM-DD HH:mm:ss.SSS} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message} - {extra[extra_json]}" + "\n{exception}" + ) + + +class InterceptHandler(logging.Handler): + """ + Intercepts log records from Python's standard logging module + and redirects them to Loguru's logger. + """ + + def emit(self, record): + """ + Called by the standard logging module for each log event. + It transforms the standard `LogRecord` into a format compatible with Loguru + and passes it to Loguru's logger. + """ + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = sys._getframe(6), 6 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + + +def file_format(record: "Record"): + """ + Formats audit log records into a structured JSON string for file output. + + Parameters: + record (Record): A Loguru record containing extra audit data. + Returns: + str: A JSON-formatted string representing the audit data. + """ + + audit_data = { + "id": record["extra"].get("id", ""), + "timestamp": int(record["time"].timestamp()), + "user": record["extra"].get("user", dict()), + "audit_level": record["extra"].get("audit_level", ""), + "verb": record["extra"].get("verb", ""), + "request_uri": record["extra"].get("request_uri", ""), + "response_status_code": record["extra"].get("response_status_code", 0), + "source_ip": record["extra"].get("source_ip", ""), + "user_agent": record["extra"].get("user_agent", ""), + "request_object": record["extra"].get("request_object", b""), + "response_object": record["extra"].get("response_object", b""), + "extra": record["extra"].get("extra", {}), + } + + record["extra"]["file_extra"] = json.dumps(audit_data, default=str) + return "{extra[file_extra]}\n" + + +def start_logger(): + """ + Initializes and configures Loguru's logger with distinct handlers: + + A console (stdout) handler for general log messages (excluding those marked as auditable). + An optional file handler for audit logs if audit logging is enabled. + Additionally, this function reconfigures Python’s standard logging to route through Loguru and adjusts logging levels for Uvicorn. + + Parameters: + enable_audit_logging (bool): Determines whether audit-specific log entries should be recorded to file. + """ + logger.remove() + + logger.add( + sys.stdout, + level=GLOBAL_LOG_LEVEL, + format=stdout_format, + filter=lambda record: "auditable" not in record["extra"], + ) + + if AUDIT_LOG_LEVEL != "NONE": + try: + logger.add( + AUDIT_LOGS_FILE_PATH, + level="INFO", + rotation=AUDIT_LOG_FILE_ROTATION_SIZE, + compression="zip", + format=file_format, + filter=lambda record: record["extra"].get("auditable") is True, + ) + except Exception as e: + logger.error(f"Failed to initialize audit log file handler: {str(e)}") + + logging.basicConfig( + handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True + ) + for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]: + uvicorn_logger = logging.getLogger(uvicorn_logger_name) + uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) + uvicorn_logger.handlers = [] + for uvicorn_logger_name in ["uvicorn.access"]: + uvicorn_logger = logging.getLogger(uvicorn_logger_name) + uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) + uvicorn_logger.handlers = [InterceptHandler()] + + logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 4e4ba8d307..7b5659d514 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -18,9 +18,7 @@ from uuid import uuid4 from concurrent.futures import ThreadPoolExecutor -from fastapi import Request -from fastapi import BackgroundTasks - +from fastapi import Request, HTTPException from starlette.responses import Response, StreamingResponse @@ -39,7 +37,11 @@ from open_webui.routers.tasks import ( ) from open_webui.routers.retrieval import process_web_search, SearchForm from open_webui.routers.images import image_generations, GenerateImageForm - +from open_webui.routers.pipelines import ( + process_pipeline_inlet_filter, + process_pipeline_outlet_filter, +) +from open_webui.routers.memories import query_memory, QueryMemoryForm from open_webui.utils.webhook import post_webhook @@ -65,6 +67,7 @@ from open_webui.utils.misc import ( get_last_user_message, get_last_assistant_message, prepend_to_first_user_message_content, + convert_logit_bias_input_to_json, ) from open_webui.utils.tools import get_tools from open_webui.utils.plugin import load_function_module_by_id @@ -96,7 +99,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"]) async def chat_completion_tools_handler( - request: Request, body: dict, user: UserModel, models, tools + request: Request, body: dict, extra_params: dict, user: UserModel, models, tools ) -> tuple[dict, dict]: async def get_content_from_response(response) -> Optional[str]: content = None @@ -131,6 +134,9 @@ async def chat_completion_tools_handler( "metadata": {"task": str(TASKS.FUNCTION_CALLING)}, } + event_caller = extra_params["__event_call__"] + metadata = extra_params["__metadata__"] + task_model_id = get_task_model_id( body["model"], request.app.state.config.TASK_MODEL, @@ -152,7 +158,6 @@ async def chat_completion_tools_handler( tools_function_calling_prompt = tools_function_calling_generation_template( template, tools_specs ) - log.info(f"{tools_function_calling_prompt=}") payload = get_tools_function_calling_payload( body["messages"], task_model_id, tools_function_calling_prompt ) @@ -185,52 +190,88 @@ async def chat_completion_tools_handler( tool_function_params = tool_call.get("parameters", {}) try: - required_params = ( - tools[tool_function_name] - .get("spec", {}) - .get("parameters", {}) - .get("required", []) + tool = tools[tool_function_name] + + spec = tool.get("spec", {}) + allowed_params = ( + spec.get("parameters", {}).get("properties", {}).keys() ) - tool_function = tools[tool_function_name]["callable"] tool_function_params = { k: v for k, v in tool_function_params.items() - if k in required_params + if k in allowed_params } - tool_output = await tool_function(**tool_function_params) + + if tool.get("direct", False): + tool_result = await event_caller( + { + "type": "execute:tool", + "data": { + "id": str(uuid4()), + "name": tool_function_name, + "params": tool_function_params, + "server": tool.get("server", {}), + "session_id": metadata.get("session_id", None), + }, + } + ) + else: + tool_function = tool["callable"] + tool_result = await tool_function(**tool_function_params) except Exception as e: - tool_output = str(e) + tool_result = str(e) - if isinstance(tool_output, str): - if tools[tool_function_name]["citation"]: + 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.remove(item) + + if isinstance(tool_result, dict) or isinstance(tool_result, list): + tool_result = json.dumps(tool_result, indent=2) + + if isinstance(tool_result, str): + tool = tools[tool_function_name] + tool_id = tool.get("tool_id", "") + + tool_name = ( + f"{tool_id}/{tool_function_name}" + if tool_id + else f"{tool_function_name}" + ) + if tool.get("metadata", {}).get("citation", False) or tool.get( + "direct", False + ): + # Citation is enabled for this tool sources.append( { "source": { - "name": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + "name": (f"TOOL:{tool_name}"), }, - "document": [tool_output], + "document": [tool_result], "metadata": [ { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" + "source": (f"TOOL:{tool_name}"), + "parameters": tool_function_params, } ], } ) else: - sources.append( - { - "source": {}, - "document": [tool_output], - "metadata": [ - { - "source": f"TOOL:{tools[tool_function_name]['toolkit_id']}/{tool_function_name}" - } - ], - } + # Citation is not enabled for this tool + body["messages"] = add_or_update_user_message( + f"\nTool `{tool_name}` Output: {tool_result}", + body["messages"], ) - if tools[tool_function_name]["file_handler"]: + if ( + tools[tool_function_name] + .get("metadata", {}) + .get("file_handler", False) + ): skip_files = True # check if "tool_calls" in result @@ -241,10 +282,10 @@ async def chat_completion_tools_handler( await tool_call_handler(result) except Exception as e: - log.exception(f"Error: {e}") + log.debug(f"Error: {e}") content = None except Exception as e: - log.exception(f"Error: {e}") + log.debug(f"Error: {e}") content = None log.debug(f"tool_contexts: {sources}") @@ -255,6 +296,45 @@ async def chat_completion_tools_handler( return body, {"sources": sources} +async def chat_memory_handler( + request: Request, form_data: dict, extra_params: dict, user +): + try: + results = await query_memory( + request, + QueryMemoryForm( + **{ + "content": get_last_user_message(form_data["messages"]) or "", + "k": 3, + } + ), + user, + ) + except Exception as e: + log.debug(e) + results = None + + user_context = "" + if results and hasattr(results, "documents"): + if results.documents and len(results.documents) > 0: + for doc_idx, doc in enumerate(results.documents[0]): + created_at_date = "Unknown Date" + + if results.metadatas[0][doc_idx].get("created_at"): + created_at_timestamp = results.metadatas[0][doc_idx]["created_at"] + created_at_date = time.strftime( + "%Y-%m-%d", time.localtime(created_at_timestamp) + ) + + user_context += f"{doc_idx + 1}. [{created_at_date}] {doc}\n" + + form_data["messages"] = add_or_update_system_message( + f"User Context:\n{user_context}\n", form_data["messages"], append=True + ) + + return form_data + + async def chat_web_search_handler( request: Request, form_data: dict, extra_params: dict, user ): @@ -305,6 +385,11 @@ async def chat_web_search_handler( log.exception(e) queries = [user_message] + # Check if generated queries are empty + if len(queries) == 1 and queries[0].strip() == "": + queries = [user_message] + + # Check if queries are not found if len(queries) == 0: await event_emitter( { @@ -318,62 +403,66 @@ async def chat_web_search_handler( ) return form_data - searchQuery = queries[0] - await event_emitter( { "type": "status", "data": { "action": "web_search", - "description": 'Searching "{{searchQuery}}"', - "query": searchQuery, + "description": "Searching the web", "done": False, }, } ) try: - - # Offload process_web_search to a separate thread - loop = asyncio.get_running_loop() - with ThreadPoolExecutor() as executor: - results = await loop.run_in_executor( - executor, - lambda: process_web_search( - request, - SearchForm( - **{ - "query": searchQuery, - } - ), - user, - ), - ) + results = await process_web_search( + request, + SearchForm(queries=queries), + user=user, + ) if results: + files = form_data.get("files", []) + + if results.get("collection_names"): + for col_idx, collection_name in enumerate( + results.get("collection_names") + ): + files.append( + { + "collection_name": collection_name, + "name": ", ".join(queries), + "type": "web_search", + "urls": results["filenames"], + "queries": queries, + } + ) + elif results.get("docs"): + # Invoked when bypass embedding and retrieval is set to True + docs = results["docs"] + files.append( + { + "docs": docs, + "name": ", ".join(queries), + "type": "web_search", + "urls": results["filenames"], + "queries": queries, + } + ) + + form_data["files"] = files + await event_emitter( { "type": "status", "data": { "action": "web_search", "description": "Searched {{count}} sites", - "query": searchQuery, "urls": results["filenames"], "done": True, }, } ) - - files = form_data.get("files", []) - files.append( - { - "collection_name": results["collection_name"], - "name": searchQuery, - "type": "web_search_results", - "urls": results["filenames"], - } - ) - form_data["files"] = files else: await event_emitter( { @@ -381,12 +470,12 @@ async def chat_web_search_handler( "data": { "action": "web_search", "description": "No search results found", - "query": searchQuery, "done": True, "error": True, }, } ) + except Exception as e: log.exception(e) await event_emitter( @@ -394,8 +483,8 @@ async def chat_web_search_handler( "type": "status", "data": { "action": "web_search", - "description": 'Error searching "{{searchQuery}}"', - "query": searchQuery, + "description": "An error occurred while searching the web", + "queries": queries, "done": True, "error": True, }, @@ -468,13 +557,20 @@ async def chat_image_generation_handler( } ) - for image in images: - await __event_emitter__( - { - "type": "message", - "data": {"content": f"![Generated Image]({image['url']})\n"}, - } - ) + await __event_emitter__( + { + "type": "files", + "data": { + "files": [ + { + "type": "image", + "url": image["url"], + } + for image in images + ] + }, + } + ) system_message_content = "User is shown the generated image, tell the user that the image has been generated" except Exception as e: @@ -505,6 +601,7 @@ async def chat_completion_files_handler( sources = [] if files := body.get("metadata", {}).get("files", None): + queries = [] try: queries_response = await generate_queries( request, @@ -530,8 +627,8 @@ async def chat_completion_files_handler( queries_response = {"queries": [queries_response]} queries = queries_response.get("queries", []) - except Exception as e: - queries = [] + except: + pass if len(queries) == 0: queries = [get_last_user_message(body["messages"])] @@ -543,18 +640,21 @@ async def chat_completion_files_handler( sources = await loop.run_in_executor( executor, lambda: get_sources_from_files( + request=request, files=files, queries=queries, - embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION( - query, user=user + embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( + query, prefix=prefix, user=user ), k=request.app.state.config.TOP_K, reranking_function=request.app.state.rf, + k_reranker=request.app.state.config.TOP_K_RERANKER, r=request.app.state.config.RELEVANCE_THRESHOLD, + hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + full_context=request.app.state.config.RAG_FULL_CONTEXT, ), ) - except Exception as e: log.exception(e) @@ -565,6 +665,32 @@ async def chat_completion_files_handler( def apply_params_to_form_data(form_data, model): params = form_data.pop("params", {}) + custom_params = params.pop("custom_params", {}) + + open_webui_params = { + "stream_response": bool, + "function_calling": str, + "system": str, + } + + for key in list(params.keys()): + if key in open_webui_params: + del params[key] + + if custom_params: + # Attempt to parse custom_params if they are strings + for key, value in custom_params.items(): + if isinstance(value, str): + try: + # Attempt to parse the string as JSON + custom_params[key] = json.loads(value) + except json.JSONDecodeError: + # If it fails, keep the original string + pass + + # If custom_params are provided, merge them into params + params = deep_update(params, custom_params) + if model.get("ollama"): form_data["options"] = params @@ -574,32 +700,23 @@ def apply_params_to_form_data(form_data, model): if "keep_alive" in params: form_data["keep_alive"] = params["keep_alive"] else: - if "seed" in params: - form_data["seed"] = params["seed"] + if isinstance(params, dict): + for key, value in params.items(): + if value is not None: + form_data[key] = value - if "stop" in params: - form_data["stop"] = params["stop"] - - if "temperature" in params: - form_data["temperature"] = params["temperature"] - - if "max_tokens" in params: - form_data["max_tokens"] = params["max_tokens"] - - if "top_p" in params: - form_data["top_p"] = params["top_p"] - - if "frequency_penalty" in params: - form_data["frequency_penalty"] = params["frequency_penalty"] - - if "reasoning_effort" in params: - form_data["reasoning_effort"] = params["reasoning_effort"] + if "logit_bias" in params and params["logit_bias"] is not None: + try: + form_data["logit_bias"] = json.loads( + convert_logit_bias_input_to_json(params["logit_bias"]) + ) + except Exception as e: + log.exception(f"Error parsing logit_bias: {e}") return form_data -async def process_chat_payload(request, form_data, metadata, user, model): - +async def process_chat_payload(request, form_data, user, metadata, model): form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") @@ -682,8 +799,40 @@ async def process_chat_payload(request, form_data, metadata, user, model): variables = form_data.pop("variables", None) + # Process the form_data through the pipeline + try: + form_data = await process_pipeline_inlet_filter( + request, form_data, user, models + ) + except Exception as e: + raise e + + try: + + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids( + request, model, metadata.get("filter_ids", []) + ) + ] + + form_data, flags = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="inlet", + form_data=form_data, + extra_params=extra_params, + ) + except Exception as e: + raise Exception(f"Error: {e}") + features = form_data.pop("features", None) if features: + if "memory" in features and features["memory"]: + form_data = await chat_memory_handler( + request, form_data, extra_params, user + ) + if "web_search" in features and features["web_search"]: form_data = await chat_web_search_handler( request, form_data, extra_params, user @@ -704,19 +853,9 @@ async def process_chat_payload(request, form_data, metadata, user, model): form_data["messages"], ) - try: - form_data, flags = await process_filter_functions( - request=request, - filter_ids=get_sorted_filter_ids(model), - filter_type="inlet", - form_data=form_data, - extra_params=extra_params, - ) - except Exception as e: - raise Exception(f"Error: {e}") - tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) + # Remove files duplicates if files: files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) @@ -728,12 +867,18 @@ async def process_chat_payload(request, form_data, metadata, user, model): } form_data["metadata"] = metadata + # Server side tools tool_ids = metadata.get("tool_ids", None) + # Client side tools + tool_servers = metadata.get("tool_servers", None) + log.debug(f"{tool_ids=}") + log.debug(f"{tool_servers=}") + + tools_dict = {} if tool_ids: - # If tool_ids field is present, then get the tools - tools = get_tools( + tools_dict = get_tools( request, tool_ids, user, @@ -744,20 +889,31 @@ async def process_chat_payload(request, form_data, metadata, user, model): "__files__": metadata.get("files", []), }, ) - log.info(f"{tools=}") + if tool_servers: + for tool_server in tool_servers: + tool_specs = tool_server.pop("specs", []) + + for tool in tool_specs: + tools_dict[tool["name"]] = { + "spec": tool, + "direct": True, + "server": tool_server, + } + + if tools_dict: if metadata.get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler - metadata["tools"] = tools + metadata["tools"] = tools_dict form_data["tools"] = [ {"type": "function", "function": tool.get("spec", {})} - for tool in tools.values() + for tool in tools_dict.values() ] else: # If the function calling is not native, then call the tools function calling handler try: form_data, flags = await chat_completion_tools_handler( - request, form_data, user, models, tools + request, form_data, extra_params, user, models, tools_dict ) sources.extend(flags.get("sources", [])) @@ -773,12 +929,25 @@ async def process_chat_payload(request, form_data, metadata, user, model): # If context is not empty, insert it into the messages if len(sources) > 0: context_string = "" - for source_idx, source in enumerate(sources): - source_id = source.get("source", {}).get("name", "") - + citation_idx = {} + for source in sources: if "document" in source: - for doc_idx, doc_context in enumerate(source["document"]): - context_string += f"{doc_idx}{doc_context}\n" + for doc_context, doc_meta in zip( + source["document"], source["metadata"] + ): + source_name = source.get("source", {}).get("name", None) + citation_id = ( + doc_meta.get("source", None) + or source.get("source", {}).get("id", None) + or "N/A" + ) + if citation_id not in citation_idx: + citation_idx[citation_id] = len(citation_idx) + 1 + context_string += ( + f'{doc_context}\n" + ) context_string = context_string.strip() prompt = get_last_user_message(form_data["messages"]) @@ -795,7 +964,7 @@ async def process_chat_payload(request, form_data, metadata, user, model): # Workaround for Ollama 2.0+ system prompt issue # TODO: replace with add_or_update_system_message - if model["owned_by"] == "ollama": + if model.get("owned_by") == "ollama": form_data["messages"] = prepend_to_first_user_message_content( rag_template( request.app.state.config.RAG_TEMPLATE, context_string, prompt @@ -811,7 +980,12 @@ async def process_chat_payload(request, form_data, metadata, user, model): ) # If there are citations, add them to the data_items - sources = [source for source in sources if source.get("source", {}).get("name", "")] + sources = [ + source + for source in sources + if source.get("source", {}).get("name", "") + or source.get("source", {}).get("id", "") + ] if len(sources) > 0: events.append({"sources": sources}) @@ -833,14 +1007,45 @@ async def process_chat_payload(request, form_data, metadata, user, model): async def process_chat_response( - request, response, form_data, user, events, metadata, tasks + 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 if message: - messages = get_message_list(message_map, message.get("id")) + message_list = get_message_list(message_map, metadata["message_id"]) + + # Remove details tags and files from the messages. + # as get_message_list creates a new list, it does not affect + # the original messages outside of this handler + + messages = [] + for message in message_list: + content = message.get("content", "") + if isinstance(content, list): + for item in content: + if item.get("type") == "text": + content = item["text"] + break + + if isinstance(content, str): + content = re.sub( + r"]*>.*?<\/details>|!\[.*?\]\(.*?\)", + "", + content, + flags=re.S | re.I, + ).strip() + + messages.append( + { + **message, + "role": message.get( + "role", "assistant" + ), # Safe fallback for missing role + "content": content, + } + ) if tasks and messages: if TASKS.TITLE_GENERATION in tasks: @@ -955,6 +1160,16 @@ async def process_chat_response( # Non-streaming response if not isinstance(response, StreamingResponse): if event_emitter: + if "error" in response: + error = response["error"].get("detail", response["error"]) + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "error": {"content": error}, + }, + ) + if "selected_model_id" in response: Chats.upsert_message_to_chat_by_id_and_message_id( metadata["chat_id"], @@ -964,7 +1179,8 @@ async def process_chat_response( }, ) - if response.get("choices", [])[0].get("message", {}).get("content"): + choices = response.get("choices", []) + if choices and choices[0].get("message", {}).get("content"): content = response["choices"][0]["message"]["content"] if content: @@ -994,15 +1210,17 @@ async def process_chat_response( metadata["chat_id"], metadata["message_id"], { + "role": "assistant", "content": content, }, ) # Send a webhook notification if the user is not active - if get_active_status_by_user_id(user.id) is None: + if not get_active_status_by_user_id(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: post_webhook( + request.app.state.WEBUI_NAME, webhook_url, f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", { @@ -1015,8 +1233,34 @@ async def process_chat_response( await background_tasks_handler() + if events and isinstance(events, list) and isinstance(response, dict): + extra_response = {} + for event in events: + if isinstance(event, dict): + extra_response.update(event) + else: + extra_response[event] = True + + response = { + **extra_response, + **response, + } + return response else: + if events and isinstance(events, list) and isinstance(response, dict): + extra_response = {} + for event in events: + if isinstance(event, dict): + extra_response.update(event) + else: + extra_response[event] = True + + response = { + **extra_response, + **response, + } + return response # Non standard response @@ -1026,6 +1270,26 @@ async def process_chat_response( ): return response + extra_params = { + "__event_emitter__": event_emitter, + "__event_call__": event_caller, + "__user__": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + }, + "__metadata__": metadata, + "__request__": request, + "__model__": model, + } + filter_functions = [ + Functions.get_function_by_id(filter_id) + for filter_id in get_sorted_filter_ids( + request, model, metadata.get("filter_ids", []) + ) + ] + # Streaming response if event_emitter and event_caller: task_id = str(uuid4()) # Create a unique task ID. @@ -1064,36 +1328,53 @@ async def process_chat_response( elif block["type"] == "tool_calls": attributes = block.get("attributes", {}) - block_content = block.get("content", []) + tool_calls = block.get("content", []) results = block.get("results", []) if results: - result_display_content = "" + tool_calls_display_content = "" + for tool_call in tool_calls: - for result in results: - tool_call_id = result.get("tool_call_id", "") - tool_name = "" + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("function", {}).get( + "name", "" + ) + tool_arguments = tool_call.get("function", {}).get( + "arguments", "" + ) - for tool_call in block_content: - if tool_call.get("id", "") == tool_call_id: - tool_name = tool_call.get("function", {}).get( - "name", "" - ) + tool_result = None + tool_result_files = None + for result in results: + if tool_call_id == result.get("tool_call_id", ""): + tool_result = result.get("content", None) + tool_result_files = result.get("files", None) break - result_display_content = f"{result_display_content}\n> {tool_name}: {result.get('content', '')}" + if tool_result: + tool_calls_display_content = f'{tool_calls_display_content}\n
    \nTool Executed\n
    \n' + else: + tool_calls_display_content = f'{tool_calls_display_content}\n
    \nExecuting...\n
    ' if not raw: - content = f'{content}\n
    \nTool Executed\n{result_display_content}\n
    \n' + content = f"{content}\n{tool_calls_display_content}\n\n" else: tool_calls_display_content = "" - for tool_call in block_content: - tool_calls_display_content = f"{tool_calls_display_content}\n> Executing {tool_call.get('function', {}).get('name', '')}" + for tool_call in tool_calls: + tool_call_id = tool_call.get("id", "") + tool_name = tool_call.get("function", {}).get( + "name", "" + ) + tool_arguments = tool_call.get("function", {}).get( + "arguments", "" + ) + + tool_calls_display_content = f'{tool_calls_display_content}\n
    \nExecuting...\n
    ' if not raw: - content = f'{content}\n
    \nTool Executing...\n{tool_calls_display_content}\n
    \n' + content = f"{content}\n{tool_calls_display_content}\n\n" elif block["type"] == "reasoning": reasoning_display_content = "\n".join( @@ -1105,12 +1386,12 @@ async def process_chat_response( if reasoning_duration is not None: if raw: - content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n' else: content = f'{content}\n
    \nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n
    \n' else: if raw: - content = f'{content}\n<{block["tag"]}>{block["content"]}\n' + content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n' else: content = f'{content}\n
    \nThinking…\n{reasoning_display_content}\n
    \n' @@ -1206,9 +1487,9 @@ async def process_chat_response( return attributes if content_blocks[-1]["type"] == "text": - for tag in tags: + for start_tag, end_tag in tags: # Match start tag e.g., or - start_tag_pattern = rf"<{tag}(\s.*?)?>" + start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>" match = re.search(start_tag_pattern, content) if match: attr_content = ( @@ -1241,7 +1522,8 @@ async def process_chat_response( content_blocks.append( { "type": content_type, - "tag": tag, + "start_tag": start_tag, + "end_tag": end_tag, "attributes": attributes, "content": "", "started_at": time.time(), @@ -1250,12 +1532,16 @@ async def process_chat_response( if after_tag: content_blocks[-1]["content"] = after_tag + tag_content_handler( + content_type, tags, after_tag, content_blocks + ) break elif content_blocks[-1]["type"] == content_type: - tag = content_blocks[-1]["tag"] + start_tag = content_blocks[-1]["start_tag"] + end_tag = content_blocks[-1]["end_tag"] # Match end tag e.g., - end_tag_pattern = rf"" + end_tag_pattern = rf"<{re.escape(end_tag)}>" # Check if the content has the end tag if re.search(end_tag_pattern, content): @@ -1263,7 +1549,7 @@ async def process_chat_response( block_content = content_blocks[-1]["content"] # Strip start and end tags from the content - start_tag_pattern = rf"<{tag}(.*?)>" + start_tag_pattern = rf"<{re.escape(start_tag)}(.*?)>" block_content = re.sub( start_tag_pattern, "", block_content ).strip() @@ -1328,7 +1614,7 @@ async def process_chat_response( # Clean processed content content = re.sub( - rf"<{tag}(.*?)>(.|\n)*?", + rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>", "", content, flags=re.DOTALL, @@ -1341,7 +1627,22 @@ async def process_chat_response( ) tool_calls = [] - content = message.get("content", "") if message else "" + + last_assistant_message = None + try: + if form_data["messages"][-1]["role"] == "assistant": + last_assistant_message = get_last_assistant_message( + form_data["messages"] + ) + except Exception as e: + pass + + content = ( + message.get("content", "") + if message + else last_assistant_message if last_assistant_message else "" + ) + content_blocks = [ { "type": "text", @@ -1351,19 +1652,24 @@ async def process_chat_response( # We might want to disable this by default DETECT_REASONING = True + DETECT_SOLUTION = True DETECT_CODE_INTERPRETER = metadata.get("features", {}).get( "code_interpreter", False ) reasoning_tags = [ - "think", - "thinking", - "reason", - "reasoning", - "thought", - "Thought", + ("think", "/think"), + ("thinking", "/thinking"), + ("reason", "/reason"), + ("reasoning", "/reasoning"), + ("thought", "/thought"), + ("Thought", "/Thought"), + ("|begin_of_thought|", "|end_of_thought|"), ] - code_interpreter_tags = ["code_interpreter"] + + code_interpreter_tags = [("code_interpreter", "/code_interpreter")] + + solution_tags = [("|begin_of_solution|", "|end_of_solution|")] try: for event in events: @@ -1407,119 +1713,239 @@ async def process_chat_response( try: data = json.loads(data) - if "selected_model_id" in data: - model_id = data["selected_model_id"] - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "selectedModelId": model_id, - }, - ) - else: - choices = data.get("choices", []) - if not choices: - continue + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=data, + extra_params=extra_params, + ) - delta = choices[0].get("delta", {}) - delta_tool_calls = delta.get("tool_calls", None) + if data: + if "event" in data: + await event_emitter(data.get("event", {})) - if delta_tool_calls: - for delta_tool_call in delta_tool_calls: - tool_call_index = delta_tool_call.get("index") - - if tool_call_index is not None: - if ( - len(response_tool_calls) - <= tool_call_index - ): - response_tool_calls.append( - delta_tool_call - ) - else: - delta_name = delta_tool_call.get( - "function", {} - ).get("name") - delta_arguments = delta_tool_call.get( - "function", {} - ).get("arguments") - - if delta_name: - response_tool_calls[ - tool_call_index - ]["function"]["name"] += delta_name - - if delta_arguments: - response_tool_calls[ - tool_call_index - ]["function"][ - "arguments" - ] += delta_arguments - - value = delta.get("content") - - if value: - content = f"{content}{value}" - - if not content_blocks: - content_blocks.append( - { - "type": "text", - "content": "", - } - ) - - content_blocks[-1]["content"] = ( - content_blocks[-1]["content"] + value + if "selected_model_id" in data: + model_id = data["selected_model_id"] + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "selectedModelId": model_id, + }, ) - - if DETECT_REASONING: - content, content_blocks, _ = ( - tag_content_handler( - "reasoning", - reasoning_tags, - content, - content_blocks, + else: + choices = data.get("choices", []) + if not choices: + error = data.get("error", {}) + if error: + await event_emitter( + { + "type": "chat:completion", + "data": { + "error": error, + }, + } ) - ) - - if DETECT_CODE_INTERPRETER: - content, content_blocks, end = ( - tag_content_handler( - "code_interpreter", - code_interpreter_tags, - content, - content_blocks, + usage = data.get("usage", {}) + if usage: + await event_emitter( + { + "type": "chat:completion", + "data": { + "usage": usage, + }, + } ) - ) + continue - if end: - break + delta = choices[0].get("delta", {}) + delta_tool_calls = delta.get("tool_calls", None) + + if delta_tool_calls: + for delta_tool_call in delta_tool_calls: + tool_call_index = delta_tool_call.get( + "index" + ) + + if tool_call_index is not None: + # Check if the tool call already exists + current_response_tool_call = None + for ( + response_tool_call + ) in response_tool_calls: + if ( + response_tool_call.get("index") + == tool_call_index + ): + current_response_tool_call = ( + response_tool_call + ) + break + + if current_response_tool_call is None: + # Add the new tool call + delta_tool_call.setdefault( + "function", {} + ) + delta_tool_call[ + "function" + ].setdefault("name", "") + delta_tool_call[ + "function" + ].setdefault("arguments", "") + response_tool_calls.append( + delta_tool_call + ) + else: + # Update the existing tool call + delta_name = delta_tool_call.get( + "function", {} + ).get("name") + delta_arguments = ( + delta_tool_call.get( + "function", {} + ).get("arguments") + ) + + if delta_name: + current_response_tool_call[ + "function" + ]["name"] += delta_name + + if delta_arguments: + current_response_tool_call[ + "function" + ][ + "arguments" + ] += delta_arguments + + value = delta.get("content") + + reasoning_content = delta.get( + "reasoning_content" + ) or delta.get("reasoning") + if reasoning_content: + if ( + not content_blocks + or content_blocks[-1]["type"] != "reasoning" + ): + reasoning_block = { + "type": "reasoning", + "start_tag": "think", + "end_tag": "/think", + "attributes": { + "type": "reasoning_content" + }, + "content": "", + "started_at": time.time(), + } + content_blocks.append(reasoning_block) + else: + reasoning_block = content_blocks[-1] + + reasoning_block["content"] += reasoning_content - if ENABLE_REALTIME_CHAT_SAVE: - # Save message in the database - Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - { - "content": serialize_content_blocks( - content_blocks - ), - }, - ) - else: data = { "content": serialize_content_blocks( content_blocks - ), + ) } - await event_emitter( - { - "type": "chat:completion", - "data": data, - } - ) + if value: + if ( + content_blocks + and content_blocks[-1]["type"] + == "reasoning" + and content_blocks[-1] + .get("attributes", {}) + .get("type") + == "reasoning_content" + ): + reasoning_block = content_blocks[-1] + reasoning_block["ended_at"] = time.time() + reasoning_block["duration"] = int( + reasoning_block["ended_at"] + - reasoning_block["started_at"] + ) + + content_blocks.append( + { + "type": "text", + "content": "", + } + ) + + content = f"{content}{value}" + if not content_blocks: + content_blocks.append( + { + "type": "text", + "content": "", + } + ) + + content_blocks[-1]["content"] = ( + content_blocks[-1]["content"] + value + ) + + if DETECT_REASONING: + content, content_blocks, _ = ( + tag_content_handler( + "reasoning", + reasoning_tags, + content, + content_blocks, + ) + ) + + if DETECT_CODE_INTERPRETER: + content, content_blocks, end = ( + tag_content_handler( + "code_interpreter", + code_interpreter_tags, + content, + content_blocks, + ) + ) + + if end: + break + + if DETECT_SOLUTION: + content, content_blocks, _ = ( + tag_content_handler( + "solution", + solution_tags, + content, + content_blocks, + ) + ) + + if ENABLE_REALTIME_CHAT_SAVE: + # Save message in the database + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "content": serialize_content_blocks( + content_blocks + ), + }, + ) + else: + data = { + "content": serialize_content_blocks( + content_blocks + ), + } + + await event_emitter( + { + "type": "chat:completion", + "data": data, + } + ) except Exception as e: done = "data: [DONE]" in line if done: @@ -1554,7 +1980,7 @@ async def process_chat_response( await stream_body_handler(response) - MAX_TOOL_CALL_RETRIES = 5 + MAX_TOOL_CALL_RETRIES = 10 tool_call_retries = 0 while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES: @@ -1593,6 +2019,15 @@ async def process_chat_response( ) except Exception as e: log.debug(e) + # Fallback to JSON parsing + try: + tool_function_params = json.loads( + tool_call.get("function", {}).get("arguments", "{}") + ) + except Exception as e: + log.debug( + f"Error parsing tool call arguments: {tool_call.get('function', {}).get('arguments', '{}')}" + ) tool_result = None @@ -1601,25 +2036,65 @@ async def process_chat_response( spec = tool.get("spec", {}) try: - required_params = spec.get("parameters", {}).get( - "required", [] + allowed_params = ( + spec.get("parameters", {}) + .get("properties", {}) + .keys() ) - tool_function = tool["callable"] + tool_function_params = { k: v for k, v in tool_function_params.items() - if k in required_params + if k in allowed_params } - tool_result = await tool_function( - **tool_function_params - ) + + if tool.get("direct", False): + tool_result = await event_caller( + { + "type": "execute:tool", + "data": { + "id": str(uuid4()), + "name": tool_name, + "params": tool_function_params, + "server": tool.get("server", {}), + "session_id": metadata.get( + "session_id", None + ), + }, + } + ) + + else: + tool_function = tool["callable"] + tool_result = await tool_function( + **tool_function_params + ) + except Exception as e: tool_result = str(e) + 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.remove(item) + + if isinstance(tool_result, dict) or isinstance( + tool_result, list + ): + tool_result = json.dumps(tool_result, indent=2) + results.append( { "tool_call_id": tool_call_id, "content": tool_result, + **( + {"files": tool_result_files} + if tool_result_files + else {} + ), } ) @@ -1724,6 +2199,7 @@ async def process_chat_response( == "password" else None ), + request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, ) else: output = { @@ -1817,8 +2293,6 @@ async def process_chat_response( } ) - print(content_blocks, serialize_content_blocks(content_blocks)) - try: res = await generate_chat_completion( request, @@ -1864,10 +2338,11 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if get_active_status_by_user_id(user.id) is None: + if not get_active_status_by_user_id(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: post_webhook( + request.app.state.WEBUI_NAME, webhook_url, f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", { @@ -1887,7 +2362,7 @@ async def process_chat_response( await background_tasks_handler() except asyncio.CancelledError: - print("Task was cancelled!") + log.warning("Task was cancelled!") await event_emitter({"type": "task-cancelled"}) if not ENABLE_REALTIME_CHAT_SAVE: @@ -1904,21 +2379,40 @@ async def process_chat_response( await response.background() # background_tasks.add_task(post_response_handler, response, events) - task_id, _ = create_task(post_response_handler(response, events)) + task_id, _ = create_task( + post_response_handler(response, events), id=metadata["chat_id"] + ) return {"status": True, "task_id": task_id} else: - # Fallback to the original response async def stream_wrapper(original_generator, events): def wrap_item(item): return f"data: {item}\n\n" for event in events: - yield wrap_item(json.dumps(event)) + event, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=event, + extra_params=extra_params, + ) + + if event: + yield wrap_item(json.dumps(event)) async for data in original_generator: - yield data + data, _ = await process_filter_functions( + request=request, + filter_functions=filter_functions, + filter_type="stream", + form_data=data, + extra_params=extra_params, + ) + + if data: + yield data return StreamingResponse( stream_wrapper(response.body_iterator, events), diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index f79b626843..ffc8c93ca4 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -2,12 +2,18 @@ import hashlib import re import time import uuid +import logging from datetime import timedelta from pathlib import Path from typing import Callable, Optional +import json import collections.abc +from open_webui.env import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) def deep_update(d, u): @@ -28,11 +34,15 @@ def get_message_list(messages, message_id): :return: List of ordered messages starting from the root to the given message """ + # Handle case where messages is None + if not messages: + return [] # Return empty list instead of None to prevent iteration errors + # Find the message by its id current_message = messages.get(message_id) if not current_message: - return None + return [] # Return empty list instead of None to prevent iteration errors # Reconstruct the chain by following the parentId links message_list = [] @@ -41,7 +51,7 @@ def get_message_list(messages, message_id): message_list.insert( 0, current_message ) # Insert the message at the beginning of the list - parent_id = current_message["parentId"] + parent_id = current_message.get("parentId") # Use .get() for safety current_message = messages.get(parent_id) if parent_id else None return message_list @@ -64,12 +74,12 @@ def get_last_user_message_item(messages: list[dict]) -> Optional[dict]: def get_content_from_message(message: dict) -> Optional[str]: - if isinstance(message["content"], list): + if isinstance(message.get("content"), list): for item in message["content"]: if item["type"] == "text": return item["text"] else: - return message["content"] + return message.get("content") return None @@ -124,7 +134,9 @@ def prepend_to_first_user_message_content( return messages -def add_or_update_system_message(content: str, messages: list[dict]): +def add_or_update_system_message( + content: str, messages: list[dict], append: bool = False +): """ Adds a new system message at the beginning of the messages list or updates the existing system message at the beginning. @@ -135,7 +147,10 @@ def add_or_update_system_message(content: str, messages: list[dict]): """ if messages and messages[0].get("role") == "system": - messages[0]["content"] = f"{content}\n{messages[0]['content']}" + if append: + messages[0]["content"] = f"{messages[0]['content']}\n{content}" + else: + messages[0]["content"] = f"{content}\n{messages[0]['content']}" else: # Insert at the beginning messages.insert(0, {"role": "system", "content": content}) @@ -412,7 +427,7 @@ def parse_ollama_modelfile(model_text): elif param_type is bool: value = value.lower() == "true" except Exception as e: - print(e) + log.exception(f"Failed to parse parameter {param}: {e}") continue data["params"][param] = value @@ -445,3 +460,15 @@ def parse_ollama_modelfile(model_text): data["params"]["messages"] = messages return data + + +def convert_logit_bias_input_to_json(user_input): + logit_bias_pairs = user_input.split(",") + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(":") + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 975f8cb095..f637449ba9 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -1,5 +1,6 @@ import time import logging +import asyncio import sys from aiocache import cached @@ -13,7 +14,10 @@ from open_webui.models.functions import Functions from open_webui.models.models import Models -from open_webui.utils.plugin import load_function_module_by_id +from open_webui.utils.plugin import ( + load_function_module_by_id, + get_function_module_from_cache, +) from open_webui.utils.access_control import has_access @@ -22,6 +26,7 @@ from open_webui.config import ( ) from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL +from open_webui.models.users import UserModel logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -29,37 +34,50 @@ log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MAIN"]) -async def get_all_base_models(request: Request): - function_models = [] - openai_models = [] - ollama_models = [] - - if request.app.state.config.ENABLE_OPENAI_API: - openai_models = await openai.get_all_models(request) - openai_models = openai_models["data"] - - if request.app.state.config.ENABLE_OLLAMA_API: - ollama_models = await ollama.get_all_models(request) - ollama_models = [ - { - "id": model["model"], - "name": model["name"], - "object": "model", - "created": int(time.time()), - "owned_by": "ollama", - "ollama": model, - } - for model in ollama_models["models"] - ] - - function_models = await get_function_models(request) - models = function_models + openai_models + ollama_models - - return models +async def fetch_ollama_models(request: Request, user: UserModel = None): + raw_ollama_models = await ollama.get_all_models(request, user=user) + return [ + { + "id": model["model"], + "name": model["name"], + "object": "model", + "created": int(time.time()), + "owned_by": "ollama", + "ollama": model, + "connection_type": model.get("connection_type", "local"), + "tags": model.get("tags", []), + } + for model in raw_ollama_models["models"] + ] -async def get_all_models(request): - models = await get_all_base_models(request) +async def fetch_openai_models(request: Request, user: UserModel = None): + openai_response = await openai.get_all_models(request, user=user) + return openai_response["data"] + + +async def get_all_base_models(request: Request, user: UserModel = None): + openai_task = ( + fetch_openai_models(request, user) + if request.app.state.config.ENABLE_OPENAI_API + else asyncio.sleep(0, result=[]) + ) + ollama_task = ( + fetch_ollama_models(request, user) + if request.app.state.config.ENABLE_OLLAMA_API + else asyncio.sleep(0, result=[]) + ) + function_task = get_function_models(request) + + openai_models, ollama_models, function_models = await asyncio.gather( + openai_task, ollama_task, function_task + ) + + return function_models + openai_models + ollama_models + + +async def get_all_models(request, user: UserModel = None): + models = await get_all_base_models(request, user=user) # If there are no models, return an empty list if len(models) == 0: @@ -108,25 +126,43 @@ async def get_all_models(request): for function in Functions.get_functions_by_type("action", active_only=True) ] + global_filter_ids = [ + function.id for function in Functions.get_global_filter_functions() + ] + enabled_filter_ids = [ + function.id + for function in Functions.get_functions_by_type("filter", active_only=True) + ] + custom_models = Models.get_all_models() for custom_model in custom_models: if custom_model.base_model_id is None: for model in models: - if ( - custom_model.id == model["id"] - or custom_model.id == model["id"].split(":")[0] + if custom_model.id == model["id"] or ( + model.get("owned_by") == "ollama" + and custom_model.id + == model["id"].split(":")[ + 0 + ] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b') ): if custom_model.is_active: model["name"] = custom_model.name model["info"] = custom_model.model_dump() + # Set action_ids and filter_ids action_ids = [] + filter_ids = [] + if "info" in model and "meta" in model["info"]: action_ids.extend( model["info"]["meta"].get("actionIds", []) ) + filter_ids.extend( + model["info"]["meta"].get("filterIds", []) + ) model["action_ids"] = action_ids + model["filter_ids"] = filter_ids else: models.remove(model) @@ -135,23 +171,29 @@ async def get_all_models(request): ): owned_by = "openai" pipe = None + action_ids = [] + filter_ids = [] for model in models: if ( custom_model.base_model_id == model["id"] or custom_model.base_model_id == model["id"].split(":")[0] ): - owned_by = model["owned_by"] + owned_by = model.get("owned_by", "unknown owner") if "pipe" in model: pipe = model["pipe"] break if custom_model.meta: meta = custom_model.meta.model_dump() + if "actionIds" in meta: action_ids.extend(meta["actionIds"]) + if "filterIds" in meta: + filter_ids.extend(meta["filterIds"]) + models.append( { "id": f"{custom_model.id}", @@ -163,6 +205,7 @@ async def get_all_models(request): "preset": True, **({"pipe": pipe} if pipe is not None else {}), "action_ids": action_ids, + "filter_ids": filter_ids, } ) @@ -176,8 +219,11 @@ async def get_all_models(request): "id": f"{function.id}.{action['id']}", "name": action.get("name", f"{function.name} ({action['id']})"), "description": function.meta.description, - "icon_url": action.get( - "icon_url", function.meta.manifest.get("icon_url", None) + "icon": action.get( + "icon_url", + function.meta.manifest.get("icon_url", None) + or getattr(module, "icon_url", None) + or getattr(module, "icon", None), ), } for action in actions @@ -188,16 +234,28 @@ async def get_all_models(request): "id": function.id, "name": function.name, "description": function.meta.description, - "icon_url": function.meta.manifest.get("icon_url", None), + "icon": function.meta.manifest.get("icon_url", None) + or getattr(module, "icon_url", None) + or getattr(module, "icon", None), } ] + # Process filter_ids to get the filters + def get_filter_items_from_module(function, module): + return [ + { + "id": function.id, + "name": function.name, + "description": function.meta.description, + "icon": function.meta.manifest.get("icon_url", None) + or getattr(module, "icon_url", None) + or getattr(module, "icon", None), + } + ] + def get_function_module_by_id(function_id): - if function_id in request.app.state.FUNCTIONS: - function_module = request.app.state.FUNCTIONS[function_id] - else: - function_module, _, _ = load_function_module_by_id(function_id) - request.app.state.FUNCTIONS[function_id] = function_module + function_module, _, _ = get_function_module_from_cache(request, function_id) + return function_module for model in models: action_ids = [ @@ -205,6 +263,11 @@ async def get_all_models(request): for action_id in list(set(model.pop("action_ids", []) + global_action_ids)) if action_id in enabled_action_ids ] + filter_ids = [ + filter_id + for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids)) + if filter_id in enabled_filter_ids + ] model["actions"] = [] for action_id in action_ids: @@ -216,6 +279,20 @@ async def get_all_models(request): model["actions"].extend( get_action_items_from_module(action_function, function_module) ) + + model["filters"] = [] + for filter_id in filter_ids: + filter_function = Functions.get_function_by_id(filter_id) + if filter_function is None: + raise Exception(f"Filter not found: {filter_id}") + + function_module = get_function_module_by_id(filter_id) + + if getattr(function_module, "toggle", None): + model["filters"].extend( + get_filter_items_from_module(filter_function, function_module) + ) + log.debug(f"get_all_models() returned {len(models)} models") request.app.state.MODELS = {model["id"]: model for model in models} diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 463f67adcc..de33558596 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -3,6 +3,7 @@ import logging import mimetypes import sys import uuid +import json import aiohttp from authlib.integrations.starlette_client import OAuth @@ -15,7 +16,7 @@ from starlette.responses import RedirectResponse from open_webui.models.auths import Auths from open_webui.models.users import Users -from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm +from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_SIGNUP, @@ -23,6 +24,8 @@ from open_webui.config import ( OAUTH_PROVIDERS, ENABLE_OAUTH_ROLE_MANAGEMENT, ENABLE_OAUTH_GROUP_MANAGEMENT, + ENABLE_OAUTH_GROUP_CREATION, + OAUTH_BLOCKED_GROUPS, OAUTH_ROLES_CLAIM, OAUTH_GROUPS_CLAIM, OAUTH_EMAIL_CLAIM, @@ -31,12 +34,18 @@ from open_webui.config import ( OAUTH_ALLOWED_ROLES, OAUTH_ADMIN_ROLES, OAUTH_ALLOWED_DOMAINS, + OAUTH_UPDATE_PICTURE_ON_LOGIN, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, ) from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES -from open_webui.env import WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE +from open_webui.env import ( + AIOHTTP_CLIENT_SESSION_SSL, + WEBUI_NAME, + WEBUI_AUTH_COOKIE_SAME_SITE, + WEBUI_AUTH_COOKIE_SECURE, +) from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.webhook import post_webhook @@ -53,6 +62,8 @@ auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT +auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION +auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM @@ -63,11 +74,13 @@ auth_manager_config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS auth_manager_config.WEBHOOK_URL = WEBHOOK_URL auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN +auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN class OAuthManager: - def __init__(self): + def __init__(self, app): self.oauth = OAuth() + self.app = app for _, provider_config in OAUTH_PROVIDERS.items(): provider_config["register"](self.oauth) @@ -89,7 +102,7 @@ class OAuthManager: oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES - oauth_roles = None + oauth_roles = [] # Default/fallback role if no matching roles are found role = auth_manager_config.DEFAULT_USER_ROLE @@ -99,7 +112,7 @@ class OAuthManager: nested_claims = oauth_claim.split(".") for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) - oauth_roles = claim_data if isinstance(claim_data, list) else None + oauth_roles = claim_data if isinstance(claim_data, list) else [] log.debug(f"Oauth Roles claim: {oauth_claim}") log.debug(f"User roles from oauth: {oauth_roles}") @@ -135,10 +148,75 @@ class OAuthManager: log.debug("Running OAUTH Group management") oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM - user_oauth_groups: list[str] = user_data.get(oauth_claim, list()) + try: + blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS) + except Exception as e: + log.exception(f"Error loading OAUTH_BLOCKED_GROUPS: {e}") + blocked_groups = [] + + user_oauth_groups = [] + # Nested claim search for groups claim + if oauth_claim: + claim_data = user_data + nested_claims = oauth_claim.split(".") + for nested_claim in nested_claims: + claim_data = claim_data.get(nested_claim, {}) + + if isinstance(claim_data, list): + user_oauth_groups = claim_data + elif isinstance(claim_data, str): + user_oauth_groups = [claim_data] + else: + user_oauth_groups = [] + user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) all_available_groups: list[GroupModel] = Groups.get_groups() + # Create groups if they don't exist and creation is enabled + if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: + log.debug("Checking for missing groups to create...") + all_group_names = {g.name for g in all_available_groups} + groups_created = False + # Determine creator ID: Prefer admin, fallback to current user if no admin exists + admin_user = Users.get_super_admin_user() + creator_id = admin_user.id if admin_user else user.id + log.debug(f"Using creator ID {creator_id} for potential group creation.") + + for group_name in user_oauth_groups: + if group_name not in all_group_names: + log.info( + f"Group '{group_name}' not found via OAuth claim. Creating group..." + ) + try: + new_group_form = GroupForm( + name=group_name, + description=f"Group '{group_name}' created automatically via OAuth.", + permissions=default_permissions, # Use default permissions from function args + user_ids=[], # Start with no users, user will be added later by subsequent logic + ) + # Use determined creator ID (admin or fallback to current user) + created_group = Groups.insert_new_group( + creator_id, new_group_form + ) + if created_group: + log.info( + f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}" + ) + groups_created = True + # Add to local set to prevent duplicate creation attempts in this run + all_group_names.add(group_name) + else: + log.error( + f"Failed to create group '{group_name}' via OAuth." + ) + except Exception as e: + log.error(f"Error creating group '{group_name}' via OAuth: {e}") + + # Refresh the list of all available groups if any were created + if groups_created: + all_available_groups = Groups.get_groups() + log.debug("Refreshed list of all available groups after creation.") + log.debug(f"Oauth Groups claim: {oauth_claim}") log.debug(f"User oauth groups: {user_oauth_groups}") log.debug(f"User's current groups: {[g.name for g in user_current_groups]}") @@ -148,7 +226,11 @@ class OAuthManager: # Remove groups that user is no longer a part of for group_model in user_current_groups: - if group_model.name not in user_oauth_groups: + if ( + user_oauth_groups + and group_model.name not in user_oauth_groups + and group_model.name not in blocked_groups + ): # Remove group from user log.debug( f"Removing user from group {group_model.name} as it is no longer in their oauth groups" @@ -174,8 +256,11 @@ class OAuthManager: # Add user to new groups for group_model in all_available_groups: - if group_model.name in user_oauth_groups and not any( - gm.name == group_model.name for gm in user_current_groups + if ( + user_oauth_groups + and group_model.name in user_oauth_groups + and not any(gm.name == group_model.name for gm in user_current_groups) + and group_model.name not in blocked_groups ): # Add user to group log.debug( @@ -200,7 +285,52 @@ class OAuthManager: id=group_model.id, form_data=update_form, overwrite=False ) - async def handle_login(self, provider, request): + async def _process_picture_url( + self, picture_url: str, access_token: str = None + ) -> str: + """Process a picture URL and return a base64 encoded data URL. + + Args: + picture_url: The URL of the picture to process + access_token: Optional OAuth access token for authenticated requests + + Returns: + A data URL containing the base64 encoded picture, or "/user.png" if processing fails + """ + if not picture_url: + return "/user.png" + + try: + get_kwargs = {} + if access_token: + get_kwargs["headers"] = { + "Authorization": f"Bearer {access_token}", + } + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get( + picture_url, **get_kwargs, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resp: + if resp.ok: + picture = await resp.read() + base64_encoded_picture = base64.b64encode(picture).decode( + "utf-8" + ) + guessed_mime_type = mimetypes.guess_type(picture_url)[0] + if guessed_mime_type is None: + guessed_mime_type = "image/jpeg" + return ( + f"data:{guessed_mime_type};base64,{base64_encoded_picture}" + ) + else: + log.warning( + f"Failed to fetch profile picture from {picture_url}" + ) + return "/user.png" + except Exception as e: + log.error(f"Error processing profile picture '{picture_url}': {e}") + return "/user.png" + + async def handle_login(self, request, provider): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one @@ -212,7 +342,7 @@ class OAuthManager: raise HTTPException(404) return await client.authorize_redirect(request, redirect_uri) - async def handle_callback(self, provider, request, response): + async def handle_callback(self, request, provider, response): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) client = self.get_client(provider) @@ -222,7 +352,7 @@ class OAuthManager: 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 "email" not in user_data: + if not user_data or auth_manager_config.OAUTH_EMAIL_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}") @@ -234,11 +364,48 @@ class OAuthManager: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) provider_sub = f"{provider}@{sub}" email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM - email = user_data.get(email_claim, "").lower() + email = user_data.get(email_claim, "") # We currently mandate that email addresses are provided if not email: - log.warning(f"OAuth callback failed, email is missing: {user_data}") - raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + # 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 + ) + 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 @@ -265,51 +432,41 @@ class OAuthManager: if user.role != determined_role: Users.update_user_role_by_id(user.id, determined_role) + # Update profile picture if enabled and different from current + if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM + if picture_claim: + new_picture_url = user_data.get( + picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") + ) + processed_picture_url = await self._process_picture_url( + new_picture_url, token.get("access_token") + ) + if processed_picture_url != user.profile_image_url: + Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url + ) + log.debug(f"Updated profile picture for user {user.email}") + if not user: + user_count = Users.get_num_users() + # 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( - user_data.get("email", "").lower() - ) + 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 - picture_url = user_data.get( - picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "") - ) - if picture_url: - # Download the profile image into a base64 string - try: - access_token = token.get("access_token") - get_kwargs = {} - if access_token: - get_kwargs["headers"] = { - "Authorization": f"Bearer {access_token}", - } - async with aiohttp.ClientSession() as session: - async with session.get(picture_url, **get_kwargs) as resp: - if resp.ok: - picture = await resp.read() - base64_encoded_picture = base64.b64encode( - picture - ).decode("utf-8") - guessed_mime_type = mimetypes.guess_type( - picture_url - )[0] - if guessed_mime_type is None: - # assume JPG, browsers are tolerant enough of image formats - guessed_mime_type = "image/jpeg" - picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}" - else: - picture_url = "/user.png" - except Exception as e: - log.error( - f"Error downloading profile image '{picture_url}': {e}" - ) - picture_url = "/user.png" - if not picture_url: + 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 @@ -334,6 +491,7 @@ class OAuthManager: if auth_manager_config.WEBHOOK_URL: post_webhook( + WEBUI_NAME, auth_manager_config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { @@ -378,8 +536,10 @@ class OAuthManager: secure=WEBUI_AUTH_COOKIE_SECURE, ) # Redirect back to the frontend with the JWT token - redirect_url = f"{request.base_url}auth#token={jwt_token}" + + redirect_base_url = request.app.state.config.WEBUI_URL or request.base_url + if redirect_base_url.endswith("/"): + redirect_base_url = redirect_base_url[:-1] + redirect_url = f"{redirect_base_url}/auth#token={jwt_token}" + return RedirectResponse(url=redirect_url, headers=response.headers) - - -oauth_manager = OAuthManager() diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 5eb040434b..02eb0da22b 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -1,16 +1,17 @@ from open_webui.utils.task import prompt_template, prompt_variables_template from open_webui.utils.misc import ( + deep_update, add_or_update_system_message, ) from typing import Callable, Optional +import json # inplace function: form_data is modified def apply_model_system_prompt_to_body( - params: dict, form_data: dict, metadata: Optional[dict] = None, user=None + system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None ) -> dict: - system = params.get("system", None) if not system: return form_data @@ -44,60 +45,146 @@ def apply_model_params_to_body( if not params: return form_data - for key, cast_func in mappings.items(): - if (value := params.get(key)) is not None: - form_data[key] = cast_func(value) + for key, value in params.items(): + if value is not None: + if key in mappings: + cast_func = mappings[key] + if isinstance(cast_func, Callable): + form_data[key] = cast_func(value) + else: + form_data[key] = value return form_data +def remove_open_webui_params(params: dict) -> dict: + """ + Removes OpenWebUI specific parameters from the provided dictionary. + + Args: + params (dict): The dictionary containing parameters. + + Returns: + dict: The modified dictionary with OpenWebUI parameters removed. + """ + open_webui_params = { + "stream_response": bool, + "function_calling": str, + "system": str, + } + + for key in list(params.keys()): + if key in open_webui_params: + del params[key] + + return params + + # inplace function: form_data is modified def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: + params = remove_open_webui_params(params) + + custom_params = params.pop("custom_params", {}) + if custom_params: + # Attempt to parse custom_params if they are strings + for key, value in custom_params.items(): + if isinstance(value, str): + try: + # Attempt to parse the string as JSON + custom_params[key] = json.loads(value) + except json.JSONDecodeError: + # If it fails, keep the original string + pass + + # If there are custom parameters, we need to apply them first + params = deep_update(params, custom_params) + mappings = { "temperature": float, "top_p": float, + "min_p": float, "max_tokens": int, "frequency_penalty": float, + "presence_penalty": float, "reasoning_effort": str, "seed": lambda x: x, "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], + "logit_bias": lambda x: x, + "response_format": dict, } return apply_model_params_to_body(params, form_data, mappings) def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: - opts = [ - "temperature", - "top_p", - "seed", - "mirostat", - "mirostat_eta", - "mirostat_tau", - "num_ctx", - "num_batch", - "num_keep", - "repeat_last_n", - "tfs_z", - "top_k", - "min_p", - "use_mmap", - "use_mlock", - "num_thread", - "num_gpu", - ] - mappings = {i: lambda x: x for i in opts} - form_data = apply_model_params_to_body(params, form_data, mappings) + params = remove_open_webui_params(params) + custom_params = params.pop("custom_params", {}) + if custom_params: + # Attempt to parse custom_params if they are strings + for key, value in custom_params.items(): + if isinstance(value, str): + try: + # Attempt to parse the string as JSON + custom_params[key] = json.loads(value) + except json.JSONDecodeError: + # If it fails, keep the original string + pass + + # If there are custom parameters, we need to apply them first + params = deep_update(params, custom_params) + + # Convert OpenAI parameter names to Ollama parameter names if needed. name_differences = { "max_tokens": "num_predict", - "frequency_penalty": "repeat_penalty", } for key, value in name_differences.items(): if (param := params.get(key, None)) is not None: - form_data[value] = param + # Copy the parameter to new name then delete it, to prevent Ollama warning of invalid option provided + params[value] = params[key] + del params[key] - return form_data + # See https://github.com/ollama/ollama/blob/main/docs/api.md#request-8 + mappings = { + "temperature": float, + "top_p": float, + "seed": lambda x: x, + "mirostat": int, + "mirostat_eta": float, + "mirostat_tau": float, + "num_ctx": int, + "num_batch": int, + "num_keep": int, + "num_predict": int, + "repeat_last_n": int, + "top_k": int, + "min_p": float, + "typical_p": float, + "repeat_penalty": float, + "presence_penalty": float, + "frequency_penalty": float, + "penalize_newline": bool, + "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], + "numa": bool, + "num_gpu": int, + "main_gpu": int, + "low_vram": bool, + "vocab_only": bool, + "use_mmap": bool, + "use_mlock": bool, + "num_thread": int, + } + + # Extract keep_alive from options if it exists + if "options" in form_data and "keep_alive" in form_data["options"]: + form_data["keep_alive"] = form_data["options"]["keep_alive"] + del form_data["options"]["keep_alive"] + + if "options" in form_data and "format" in form_data["options"]: + form_data["format"] = form_data["options"]["format"] + del form_data["options"]["format"] + + return apply_model_params_to_body(params, form_data, mappings) def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: @@ -108,11 +195,38 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: new_message = {"role": message["role"]} content = message.get("content", []) + tool_calls = message.get("tool_calls", None) + tool_call_id = message.get("tool_call_id", None) # Check if the content is a string (just a simple message) - if isinstance(content, str): + if isinstance(content, str) and not tool_calls: # If the content is a string, it's pure text new_message["content"] = content + + # If message is a tool call, add the tool call id to the message + if tool_call_id: + new_message["tool_call_id"] = tool_call_id + + elif tool_calls: + # If tool calls are present, add them to the message + ollama_tool_calls = [] + for tool_call in tool_calls: + ollama_tool_call = { + "index": tool_call.get("index", 0), + "id": tool_call.get("id", None), + "function": { + "name": tool_call.get("function", {}).get("name", ""), + "arguments": json.loads( + tool_call.get("function", {}).get("arguments", {}) + ), + }, + } + ollama_tool_calls.append(ollama_tool_call) + new_message["tool_calls"] = ollama_tool_calls + + # Put the content to empty string (Ollama requires an empty string for tool calls) + new_message["content"] = "" + else: # Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL content_text = "" @@ -173,36 +287,45 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: ollama_payload["format"] = openai_payload["format"] # If there are advanced parameters in the payload, format them in Ollama's options field - ollama_options = {} - if openai_payload.get("options"): ollama_payload["options"] = openai_payload["options"] ollama_options = openai_payload["options"] - # Handle parameters which map directly - for param in ["temperature", "top_p", "seed"]: - if param in openai_payload: - ollama_options[param] = openai_payload[param] + # Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict` + if "max_tokens" in ollama_options: + ollama_options["num_predict"] = ollama_options["max_tokens"] + del ollama_options[ + "max_tokens" + ] # To prevent Ollama warning of invalid option provided - # Mapping OpenAI's `max_tokens` -> Ollama's `num_predict` - if "max_completion_tokens" in openai_payload: - ollama_options["num_predict"] = openai_payload["max_completion_tokens"] - elif "max_tokens" in openai_payload: - ollama_options["num_predict"] = openai_payload["max_tokens"] + # Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down. + if "system" in ollama_options: + ollama_payload["system"] = ollama_options["system"] + del ollama_options[ + "system" + ] # To prevent Ollama warning of invalid option provided - # Handle frequency / presence_penalty, which needs renaming and checking - if "frequency_penalty" in openai_payload: - ollama_options["repeat_penalty"] = openai_payload["frequency_penalty"] + # Extract keep_alive from options if it exists + if "keep_alive" in ollama_options: + ollama_payload["keep_alive"] = ollama_options["keep_alive"] + del ollama_options["keep_alive"] - if "presence_penalty" in openai_payload and "penalty" not in ollama_options: - # We are assuming presence penalty uses a similar concept in Ollama, which needs custom handling if exists. - ollama_options["new_topic_penalty"] = openai_payload["presence_penalty"] - - # Add options to payload if any have been set - if ollama_options: + # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options + if "stop" in openai_payload: + ollama_options = ollama_payload.get("options", {}) + ollama_options["stop"] = openai_payload.get("stop") ollama_payload["options"] = ollama_options if "metadata" in openai_payload: ollama_payload["metadata"] = openai_payload["metadata"] + if "response_format" in openai_payload: + response_format = openai_payload["response_format"] + format_type = response_format.get("type", None) + + schema = response_format.get(format_type, None) + if schema: + format = schema.get("schema", None) + ollama_payload["format"] = format + return ollama_payload diff --git a/backend/open_webui/utils/pdf_generator.py b/backend/open_webui/utils/pdf_generator.py index 8b04dd81bc..c137b49da0 100644 --- a/backend/open_webui/utils/pdf_generator.py +++ b/backend/open_webui/utils/pdf_generator.py @@ -110,7 +110,7 @@ class PDFGenerator: # When running using `pip install -e .` the static directory is in the site packages. # This path only works if `open-webui serve` is run from the root of this project. if not FONTS_DIR.exists(): - FONTS_DIR = Path("./backend/static/fonts") + FONTS_DIR = Path(".") / "backend" / "static" / "fonts" pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf") pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf") diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index d6e24d6b93..9d539f4840 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -7,7 +7,7 @@ import types import tempfile import logging -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS from open_webui.models.functions import Functions from open_webui.models.tools import Tools @@ -45,7 +45,7 @@ def extract_frontmatter(content): frontmatter[key.strip()] = value.strip() except Exception as e: - print(f"An error occurred: {e}") + log.exception(f"Failed to extract frontmatter: {e}") return {} return frontmatter @@ -68,23 +68,23 @@ def replace_imports(content): return content -def load_tools_module_by_id(toolkit_id, content=None): +def load_tool_module_by_id(tool_id, content=None): if content is None: - tool = Tools.get_tool_by_id(toolkit_id) + tool = Tools.get_tool_by_id(tool_id) if not tool: - raise Exception(f"Toolkit not found: {toolkit_id}") + raise Exception(f"Toolkit not found: {tool_id}") content = tool.content content = replace_imports(content) - Tools.update_tool_by_id(toolkit_id, {"content": content}) + Tools.update_tool_by_id(tool_id, {"content": content}) else: frontmatter = extract_frontmatter(content) # Install required packages found within the frontmatter install_frontmatter_requirements(frontmatter.get("requirements", "")) - module_name = f"tool_{toolkit_id}" + module_name = f"tool_{tool_id}" module = types.ModuleType(module_name) sys.modules[module_name] = module @@ -108,14 +108,14 @@ def load_tools_module_by_id(toolkit_id, content=None): else: raise Exception("No Tools class found in the module") except Exception as e: - log.error(f"Error loading module: {toolkit_id}: {e}") + log.error(f"Error loading module: {tool_id}: {e}") del sys.modules[module_name] # Clean up raise e finally: os.unlink(temp_file.name) -def load_function_module_by_id(function_id, content=None): +def load_function_module_by_id(function_id: str, content: str | None = None): if content is None: function = Functions.get_function_by_id(function_id) if not function: @@ -157,7 +157,8 @@ def load_function_module_by_id(function_id, content=None): raise Exception("No Function class found in the module") except Exception as e: log.error(f"Error loading module: {function_id}: {e}") - del sys.modules[module_name] # Cleanup by removing the module in case of error + # Cleanup by removing the module in case of error + del sys.modules[module_name] Functions.update_function_by_id(function_id, {"is_active": False}) raise e @@ -165,16 +166,105 @@ def load_function_module_by_id(function_id, content=None): os.unlink(temp_file.name) -def install_frontmatter_requirements(requirements): +def get_function_module_from_cache(request, function_id, load_from_db=True): + if load_from_db: + # Always load from the database by default + # This is useful for hooks like "inlet" or "outlet" where the content might change + # and we want to ensure the latest content is used. + + function = Functions.get_function_by_id(function_id) + if not function: + raise Exception(f"Function not found: {function_id}") + content = function.content + + new_content = replace_imports(content) + if new_content != content: + content = new_content + # Update the function content in the database + Functions.update_function_by_id(function_id, {"content": content}) + + if ( + hasattr(request.app.state, "FUNCTION_CONTENTS") + and function_id in request.app.state.FUNCTION_CONTENTS + ) and ( + hasattr(request.app.state, "FUNCTIONS") + and function_id in request.app.state.FUNCTIONS + ): + if request.app.state.FUNCTION_CONTENTS[function_id] == content: + return request.app.state.FUNCTIONS[function_id], None, None + + function_module, function_type, frontmatter = load_function_module_by_id( + function_id, content + ) + else: + # Load from cache (e.g. "stream" hook) + # This is useful for performance reasons + + if ( + hasattr(request.app.state, "FUNCTIONS") + and function_id in request.app.state.FUNCTIONS + ): + return request.app.state.FUNCTIONS[function_id], None, None + + function_module, function_type, frontmatter = load_function_module_by_id( + function_id + ) + + if not hasattr(request.app.state, "FUNCTIONS"): + request.app.state.FUNCTIONS = {} + + if not hasattr(request.app.state, "FUNCTION_CONTENTS"): + request.app.state.FUNCTION_CONTENTS = {} + + request.app.state.FUNCTIONS[function_id] = function_module + request.app.state.FUNCTION_CONTENTS[function_id] = content + + return function_module, function_type, frontmatter + + +def install_frontmatter_requirements(requirements: str): if requirements: try: req_list = [req.strip() for req in requirements.split(",")] - for req in req_list: - log.info(f"Installing requirement: {req}") - subprocess.check_call([sys.executable, "-m", "pip", "install", req]) + log.info(f"Installing requirements: {' '.join(req_list)}") + subprocess.check_call( + [sys.executable, "-m", "pip", "install"] + + PIP_OPTIONS + + req_list + + PIP_PACKAGE_INDEX_OPTIONS + ) except Exception as e: - log.error(f"Error installing package: {req}") + log.error(f"Error installing packages: {' '.join(req_list)}") raise e else: log.info("No requirements found in frontmatter.") + + +def install_tool_and_function_dependencies(): + """ + Install all dependencies for all admin tools and active functions. + + By first collecting all dependencies from the frontmatter of each tool and function, + and then installing them using pip. Duplicates or similar version specifications are + handled by pip as much as possible. + """ + function_list = Functions.get_functions(active_only=True) + tool_list = Tools.get_tools() + + all_dependencies = "" + try: + for function in function_list: + frontmatter = extract_frontmatter(replace_imports(function.content)) + if dependencies := frontmatter.get("requirements"): + all_dependencies += f"{dependencies}, " + for tool in tool_list: + # Only install requirements for admin tools + if tool.user.role == "admin": + frontmatter = extract_frontmatter(replace_imports(tool.content)) + if dependencies := frontmatter.get("requirements"): + all_dependencies += f"{dependencies}, " + + install_frontmatter_requirements(all_dependencies.strip(", ")) + except Exception as e: + log.error(f"Error installing requirements: {e}") diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py new file mode 100644 index 0000000000..e0a53e73d1 --- /dev/null +++ b/backend/open_webui/utils/redis.py @@ -0,0 +1,58 @@ +import socketio +import redis +from redis import asyncio as aioredis +from urllib.parse import urlparse + + +def parse_redis_service_url(redis_url): + parsed_url = urlparse(redis_url) + if parsed_url.scheme != "redis": + raise ValueError("Invalid Redis URL scheme. Must be 'redis'.") + + return { + "username": parsed_url.username or None, + "password": parsed_url.password or None, + "service": parsed_url.hostname or "mymaster", + "port": parsed_url.port or 6379, + "db": int(parsed_url.path.lstrip("/") or 0), + } + + +def get_redis_connection(redis_url, redis_sentinels, decode_responses=True): + if redis_sentinels: + redis_config = parse_redis_service_url(redis_url) + sentinel = redis.sentinel.Sentinel( + redis_sentinels, + port=redis_config["port"], + db=redis_config["db"], + username=redis_config["username"], + password=redis_config["password"], + decode_responses=decode_responses, + ) + + # Get a master connection from Sentinel + return sentinel.master_for(redis_config["service"]) + else: + # Standard Redis connection + return redis.Redis.from_url(redis_url, decode_responses=decode_responses) + + +def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env): + if sentinel_hosts_env: + sentinel_hosts = sentinel_hosts_env.split(",") + sentinel_port = int(sentinel_port_env) + return [(host, sentinel_port) for host in sentinel_hosts] + return [] + + +def get_sentinel_url_from_env(redis_url, sentinel_hosts_env, sentinel_port_env): + redis_config = parse_redis_service_url(redis_url) + username = redis_config["username"] or "" + password = redis_config["password"] or "" + auth_part = "" + if username or password: + auth_part = f"{username}:{password}@" + hosts_part = ",".join( + f"{host}:{sentinel_port_env}" for host in sentinel_hosts_env.split(",") + ) + return f"redis+sentinel://{auth_part}{hosts_part}/{redis_config['db']}/{redis_config['service']}" diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index f9979b4a27..8c3f1a58eb 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -24,17 +24,8 @@ def convert_ollama_tool_call_to_openai(tool_calls: dict) -> dict: return openai_tool_calls -def convert_response_ollama_to_openai(ollama_response: dict) -> dict: - model = ollama_response.get("model", "ollama") - message_content = ollama_response.get("message", {}).get("content", "") - tool_calls = ollama_response.get("message", {}).get("tool_calls", None) - openai_tool_calls = None - - if tool_calls: - openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls) - - data = ollama_response - usage = { +def convert_ollama_usage_to_openai(data: dict) -> dict: + return { "response_token/s": ( round( ( @@ -66,14 +57,42 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict: "total_duration": data.get("total_duration", 0), "load_duration": data.get("load_duration", 0), "prompt_eval_count": data.get("prompt_eval_count", 0), + "prompt_tokens": int( + data.get("prompt_eval_count", 0) + ), # This is the OpenAI compatible key "prompt_eval_duration": data.get("prompt_eval_duration", 0), "eval_count": data.get("eval_count", 0), + "completion_tokens": int( + data.get("eval_count", 0) + ), # This is the OpenAI compatible key "eval_duration": data.get("eval_duration", 0), "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( (data.get("total_duration", 0) or 0) // 1_000_000_000 ), + "total_tokens": int( # This is the OpenAI compatible key + data.get("prompt_eval_count", 0) + data.get("eval_count", 0) + ), + "completion_tokens_details": { # This is the OpenAI compatible key + "reasoning_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0, + }, } + +def convert_response_ollama_to_openai(ollama_response: dict) -> dict: + model = ollama_response.get("model", "ollama") + message_content = ollama_response.get("message", {}).get("content", "") + tool_calls = ollama_response.get("message", {}).get("tool_calls", None) + openai_tool_calls = None + + if tool_calls: + openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls) + + data = ollama_response + + usage = convert_ollama_usage_to_openai(data) + response = openai_chat_completion_message_template( model, message_content, openai_tool_calls, usage ) @@ -85,7 +104,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) data = json.loads(data) model = data.get("model", "ollama") - message_content = data.get("message", {}).get("content", "") + message_content = data.get("message", {}).get("content", None) tool_calls = data.get("message", {}).get("tool_calls", None) openai_tool_calls = None @@ -96,48 +115,10 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) usage = None if done: - usage = { - "response_token/s": ( - round( - ( - ( - data.get("eval_count", 0) - / ((data.get("eval_duration", 0) / 10_000_000)) - ) - * 100 - ), - 2, - ) - if data.get("eval_duration", 0) > 0 - else "N/A" - ), - "prompt_token/s": ( - round( - ( - ( - data.get("prompt_eval_count", 0) - / ((data.get("prompt_eval_duration", 0) / 10_000_000)) - ) - * 100 - ), - 2, - ) - if data.get("prompt_eval_duration", 0) > 0 - else "N/A" - ), - "total_duration": data.get("total_duration", 0), - "load_duration": data.get("load_duration", 0), - "prompt_eval_count": data.get("prompt_eval_count", 0), - "prompt_eval_duration": data.get("prompt_eval_duration", 0), - "eval_count": data.get("eval_count", 0), - "eval_duration": data.get("eval_duration", 0), - "approximate_total": ( - lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s" - )((data.get("total_duration", 0) or 0) // 1_000_000_000), - } + usage = convert_ollama_usage_to_openai(data) data = openai_chat_chunk_message_template( - model, message_content if not done else None, openai_tool_calls, usage + model, message_content, openai_tool_calls, usage ) line = f"data: {json.dumps(data)}\n\n" diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 3d8c05d455..95018eef18 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -22,7 +22,7 @@ def get_task_model_id( # Set the task model task_model_id = default_model_id # Check if the user has a custom task model and use that model - if models[task_model_id]["owned_by"] == "ollama": + if models[task_model_id].get("connection_type") == "local": if task_model and task_model in models: task_model_id = task_model else: @@ -104,7 +104,7 @@ def replace_prompt_variable(template: str, prompt: str) -> str: def replace_messages_variable( - template: str, messages: Optional[list[str]] = None + template: str, messages: Optional[list[dict]] = None ) -> str: def replacement_function(match): full_match = match.group(0) @@ -152,6 +152,8 @@ def rag_template(template: str, context: str, query: str): if template.strip() == "": template = DEFAULT_RAG_TEMPLATE + template = prompt_template(template) + if "[context]" not in template and "{{CONTEXT}}" not in template: log.debug( "WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder." diff --git a/backend/open_webui/utils/telemetry/__init__.py b/backend/open_webui/utils/telemetry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/open_webui/utils/telemetry/constants.py b/backend/open_webui/utils/telemetry/constants.py new file mode 100644 index 0000000000..6ef511f934 --- /dev/null +++ b/backend/open_webui/utils/telemetry/constants.py @@ -0,0 +1,26 @@ +from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes + +# Span Tags +SPAN_DB_TYPE = "mysql" +SPAN_REDIS_TYPE = "redis" +SPAN_DURATION = "duration" +SPAN_SQL_STR = "sql" +SPAN_SQL_EXPLAIN = "explain" +SPAN_ERROR_TYPE = "error" + + +class SpanAttributes(_SpanAttributes): + """ + Span Attributes + """ + + DB_INSTANCE = "db.instance" + DB_TYPE = "db.type" + DB_IP = "db.ip" + DB_PORT = "db.port" + ERROR_KIND = "error.kind" + ERROR_OBJECT = "error.object" + ERROR_MESSAGE = "error.message" + RESULT_CODE = "result.code" + RESULT_MESSAGE = "result.message" + RESULT_ERRORS = "result.errors" diff --git a/backend/open_webui/utils/telemetry/exporters.py b/backend/open_webui/utils/telemetry/exporters.py new file mode 100644 index 0000000000..4bf166e655 --- /dev/null +++ b/backend/open_webui/utils/telemetry/exporters.py @@ -0,0 +1,31 @@ +import threading + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +class LazyBatchSpanProcessor(BatchSpanProcessor): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.done = True + with self.condition: + self.condition.notify_all() + self.worker_thread.join() + self.done = False + self.worker_thread = None + + def on_end(self, span: ReadableSpan) -> None: + if self.worker_thread is None: + self.worker_thread = threading.Thread( + name=self.__class__.__name__, target=self.worker, daemon=True + ) + self.worker_thread.start() + super().on_end(span) + + def shutdown(self) -> None: + self.done = True + with self.condition: + self.condition.notify_all() + if self.worker_thread: + self.worker_thread.join() + self.span_exporter.shutdown() diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py new file mode 100644 index 0000000000..0ba42efd4b --- /dev/null +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -0,0 +1,202 @@ +import logging +import traceback +from typing import Collection, Union + +from aiohttp import ( + TraceRequestStartParams, + TraceRequestEndParams, + TraceRequestExceptionParams, +) +from chromadb.telemetry.opentelemetry.fastapi import instrument_fastapi +from fastapi import FastAPI +from opentelemetry.instrumentation.httpx import ( + HTTPXClientInstrumentor, + RequestInfo, + ResponseInfo, +) +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.logging import LoggingInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.trace import Span, StatusCode +from redis import Redis +from requests import PreparedRequest, Response +from sqlalchemy import Engine +from fastapi import status + +from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes + +from open_webui.env import SRC_LOG_LEVELS + +logger = logging.getLogger(__name__) +logger.setLevel(SRC_LOG_LEVELS["MAIN"]) + + +def requests_hook(span: Span, request: PreparedRequest): + """ + Http Request Hook + """ + + span.update_name(f"{request.method} {request.url}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: request.url, + SpanAttributes.HTTP_METHOD: request.method, + } + ) + + +def response_hook(span: Span, request: PreparedRequest, response: Response): + """ + HTTP Response Hook + """ + + span.set_attributes( + attributes={ + SpanAttributes.HTTP_STATUS_CODE: response.status_code, + } + ) + span.set_status(StatusCode.ERROR if response.status_code >= 400 else StatusCode.OK) + + +def redis_request_hook(span: Span, instance: Redis, args, kwargs): + """ + Redis Request Hook + """ + + try: + connection_kwargs: dict = instance.connection_pool.connection_kwargs + host = connection_kwargs.get("host") + port = connection_kwargs.get("port") + db = connection_kwargs.get("db") + span.set_attributes( + { + SpanAttributes.DB_INSTANCE: f"{host}/{db}", + SpanAttributes.DB_NAME: f"{host}/{db}", + SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE, + SpanAttributes.DB_PORT: port, + SpanAttributes.DB_IP: host, + SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]), + SpanAttributes.DB_OPERATION: str(args[0]), + } + ) + except Exception: # pylint: disable=W0718 + logger.error(traceback.format_exc()) + + +def httpx_request_hook(span: Span, request: RequestInfo): + """ + HTTPX Request Hook + """ + + span.update_name(f"{request.method.decode()} {str(request.url)}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: str(request.url), + SpanAttributes.HTTP_METHOD: request.method.decode(), + } + ) + + +def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo): + """ + HTTPX Response Hook + """ + + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code) + span.set_status( + StatusCode.ERROR + if response.status_code >= status.HTTP_400_BAD_REQUEST + else StatusCode.OK + ) + + +async def httpx_async_request_hook(span: Span, request: RequestInfo): + """ + Async Request Hook + """ + + httpx_request_hook(span, request) + + +async def httpx_async_response_hook( + span: Span, request: RequestInfo, response: ResponseInfo +): + """ + Async Response Hook + """ + + httpx_response_hook(span, request, response) + + +def aiohttp_request_hook(span: Span, request: TraceRequestStartParams): + """ + Aiohttp Request Hook + """ + + span.update_name(f"{request.method} {str(request.url)}") + span.set_attributes( + attributes={ + SpanAttributes.HTTP_URL: str(request.url), + SpanAttributes.HTTP_METHOD: request.method, + } + ) + + +def aiohttp_response_hook( + span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams] +): + """ + Aiohttp Response Hook + """ + + if isinstance(response, TraceRequestEndParams): + span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status) + span.set_status( + StatusCode.ERROR + if response.response.status >= status.HTTP_400_BAD_REQUEST + else StatusCode.OK + ) + elif isinstance(response, TraceRequestExceptionParams): + span.set_status(StatusCode.ERROR) + span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception)) + + +class Instrumentor(BaseInstrumentor): + """ + Instrument OT + """ + + def __init__(self, app: FastAPI, db_engine: Engine): + self.app = app + self.db_engine = db_engine + + def instrumentation_dependencies(self) -> Collection[str]: + return [] + + def _instrument(self, **kwargs): + instrument_fastapi(app=self.app) + SQLAlchemyInstrumentor().instrument(engine=self.db_engine) + RedisInstrumentor().instrument(request_hook=redis_request_hook) + RequestsInstrumentor().instrument( + request_hook=requests_hook, response_hook=response_hook + ) + LoggingInstrumentor().instrument() + HTTPXClientInstrumentor().instrument( + request_hook=httpx_request_hook, + response_hook=httpx_response_hook, + async_request_hook=httpx_async_request_hook, + async_response_hook=httpx_async_response_hook, + ) + AioHttpClientInstrumentor().instrument( + request_hook=aiohttp_request_hook, + response_hook=aiohttp_response_hook, + ) + + def _uninstrument(self, **kwargs): + if getattr(self, "instrumentors", None) is None: + return + for instrumentor in self.instrumentors: + instrumentor.uninstrument() diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py new file mode 100644 index 0000000000..eb6a238c8d --- /dev/null +++ b/backend/open_webui/utils/telemetry/setup.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from sqlalchemy import Engine + +from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor +from open_webui.utils.telemetry.instrumentors import Instrumentor +from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT + + +def setup(app: FastAPI, db_engine: Engine): + # set up trace + trace.set_tracer_provider( + TracerProvider( + resource=Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) + ) + ) + # otlp export + exporter = OTLPSpanExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT) + trace.get_tracer_provider().add_span_processor(LazyBatchSpanProcessor(exporter)) + Instrumentor(app=app, db_engine=db_engine).instrument() diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index c44c30402d..0774522dbd 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,108 +1,209 @@ import inspect import logging import re -from typing import Any, Awaitable, Callable, get_type_hints +import inspect +import aiohttp +import asyncio +import yaml + +from pydantic import BaseModel +from pydantic.fields import FieldInfo +from typing import ( + Any, + Awaitable, + Callable, + get_type_hints, + get_args, + get_origin, + Dict, + List, + Tuple, + Union, + Optional, + Type, +) from functools import update_wrapper, partial from fastapi import Request from pydantic import BaseModel, Field, create_model -from langchain_core.utils.function_calling import convert_to_openai_function + +from langchain_core.utils.function_calling import ( + convert_to_openai_function as convert_pydantic_model_to_openai_function_spec, +) from open_webui.models.tools import Tools from open_webui.models.users import UserModel -from open_webui.utils.plugin import load_tools_module_by_id +from open_webui.utils.plugin import load_tool_module_by_id +from open_webui.env import ( + SRC_LOG_LEVELS, + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, + AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, +) + +import copy log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) -def apply_extra_params_to_tool_function( +def get_async_tool_function_and_apply_extra_params( function: Callable, extra_params: dict ) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} partial_func = partial(function, **extra_params) + if inspect.iscoroutinefunction(function): update_wrapper(partial_func, function) return partial_func + else: + # Make it a coroutine function + async def new_function(*args, **kwargs): + return partial_func(*args, **kwargs) - async def new_function(*args, **kwargs): - return partial_func(*args, **kwargs) - - update_wrapper(new_function, function) - return new_function + update_wrapper(new_function, function) + return new_function -# Mutation on extra_params def get_tools( request: Request, tool_ids: list[str], user: UserModel, extra_params: dict ) -> dict[str, dict]: tools_dict = {} for tool_id in tool_ids: - tools = Tools.get_tool_by_id(tool_id) - if tools is None: - continue + tool = Tools.get_tool_by_id(tool_id) + if tool is None: + if tool_id.startswith("server:"): + server_idx = int(tool_id.split(":")[1]) + tool_server_connection = ( + request.app.state.config.TOOL_SERVER_CONNECTIONS[server_idx] + ) + tool_server_data = None + for server in request.app.state.TOOL_SERVERS: + if server["idx"] == server_idx: + tool_server_data = server + break + assert tool_server_data is not None + specs = tool_server_data.get("specs", []) - module = request.app.state.TOOLS.get(tool_id, None) - if module is None: - module, _ = load_tools_module_by_id(tool_id) - request.app.state.TOOLS[tool_id] = module + for spec in specs: + function_name = spec["name"] - extra_params["__id__"] = tool_id - if hasattr(module, "valves") and hasattr(module, "Valves"): - valves = Tools.get_tool_valves_by_id(tool_id) or {} - module.valves = module.Valves(**valves) + auth_type = tool_server_connection.get("auth_type", "bearer") + token = None - if hasattr(module, "UserValves"): - extra_params["__user__"]["valves"] = module.UserValves( # type: ignore - **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) - ) + if auth_type == "bearer": + token = tool_server_connection.get("key", "") + elif auth_type == "session": + token = request.state.token.credentials - for spec in tools.specs: - # TODO: Fix hack for OpenAI API - # Some times breaks OpenAI but others don't. Leaving the comment - for val in spec.get("parameters", {}).get("properties", {}).values(): - if val["type"] == "str": - val["type"] = "string" + def make_tool_function(function_name, token, tool_server_data): + async def tool_function(**kwargs): + print( + f"Executing tool function {function_name} with params: {kwargs}" + ) + return await execute_tool_server( + token=token, + url=tool_server_data["url"], + name=function_name, + params=kwargs, + server_data=tool_server_data, + ) - # Remove internal parameters - spec["parameters"]["properties"] = { - key: val - for key, val in spec["parameters"]["properties"].items() - if not key.startswith("__") - } + return tool_function - function_name = spec["name"] + tool_function = make_tool_function( + function_name, token, tool_server_data + ) - # convert to function that takes only model params and inserts custom params - original_func = getattr(module, function_name) - callable = apply_extra_params_to_tool_function(original_func, extra_params) + callable = get_async_tool_function_and_apply_extra_params( + tool_function, + {}, + ) - if callable.__doc__ and callable.__doc__.strip() != "": - s = re.split(":(param|return)", callable.__doc__, 1) - spec["description"] = s[0] + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + } + + # TODO: if collision, prepend toolkit name + if function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + log.warning(f"Discarding {tool_id}.{function_name}") + else: + tools_dict[function_name] = tool_dict else: - spec["description"] = function_name + continue + else: + module = request.app.state.TOOLS.get(tool_id, None) + if module is None: + module, _ = load_tool_module_by_id(tool_id) + request.app.state.TOOLS[tool_id] = module - # TODO: This needs to be a pydantic model - tool_dict = { - "toolkit_id": tool_id, - "callable": callable, - "spec": spec, - "pydantic_model": function_to_pydantic_model(callable), - "file_handler": hasattr(module, "file_handler") and module.file_handler, - "citation": hasattr(module, "citation") and module.citation, - } + extra_params["__id__"] = tool_id - # TODO: if collision, prepend toolkit name - if function_name in tools_dict: - log.warning(f"Tool {function_name} already exists in another tools!") - log.warning(f"Collision between {tools} and {tool_id}.") - log.warning(f"Discarding {tools}.{function_name}") - else: - tools_dict[function_name] = tool_dict + # Set valves for the tool + if hasattr(module, "valves") and hasattr(module, "Valves"): + valves = Tools.get_tool_valves_by_id(tool_id) or {} + module.valves = module.Valves(**valves) + if hasattr(module, "UserValves"): + extra_params["__user__"]["valves"] = module.UserValves( # type: ignore + **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) + ) + + for spec in tool.specs: + # TODO: Fix hack for OpenAI API + # Some times breaks OpenAI but others don't. Leaving the comment + for val in spec.get("parameters", {}).get("properties", {}).values(): + if val.get("type") == "str": + val["type"] = "string" + + # Remove internal reserved parameters (e.g. __id__, __user__) + spec["parameters"]["properties"] = { + key: val + for key, val in spec["parameters"]["properties"].items() + if not key.startswith("__") + } + + # convert to function that takes only model params and inserts custom params + function_name = spec["name"] + tool_function = getattr(module, function_name) + callable = get_async_tool_function_and_apply_extra_params( + tool_function, extra_params + ) + + # TODO: Support Pydantic models as parameters + if callable.__doc__ and callable.__doc__.strip() != "": + s = re.split(":(param|return)", callable.__doc__, 1) + spec["description"] = s[0] + else: + spec["description"] = function_name + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + # Misc info + "metadata": { + "file_handler": hasattr(module, "file_handler") + and module.file_handler, + "citation": hasattr(module, "citation") and module.citation, + }, + } + + # TODO: if collision, prepend toolkit name + if function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + log.warning(f"Discarding {tool_id}.{function_name}") + else: + tools_dict[function_name] = tool_dict return tools_dict @@ -162,7 +263,7 @@ def parse_docstring(docstring): return param_descriptions -def function_to_pydantic_model(func: Callable) -> type[BaseModel]: +def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]: """ Converts a Python function's type hints and docstring to a Pydantic model, including support for nested types, default values, and descriptions. @@ -179,37 +280,369 @@ def function_to_pydantic_model(func: Callable) -> type[BaseModel]: parameters = signature.parameters docstring = func.__doc__ - descriptions = parse_docstring(docstring) - tool_description = parse_description(docstring) + function_description = parse_description(docstring) + function_param_descriptions = parse_docstring(docstring) field_defs = {} for name, param in parameters.items(): + type_hint = type_hints.get(name, Any) default_value = param.default if param.default is not param.empty else ... - description = descriptions.get(name, None) - if not description: + + param_description = function_param_descriptions.get(name, None) + + if param_description: + field_defs[name] = type_hint, Field( + default_value, description=param_description + ) + else: field_defs[name] = type_hint, default_value - continue - field_defs[name] = type_hint, Field(default_value, description=description) model = create_model(func.__name__, **field_defs) - model.__doc__ = tool_description + model.__doc__ = function_description return model -def get_callable_attributes(tool: object) -> list[Callable]: +def get_functions_from_tool(tool: object) -> list[Callable]: return [ getattr(tool, func) for func in dir(tool) - if callable(getattr(tool, func)) - and not func.startswith("__") - and not inspect.isclass(getattr(tool, func)) + if callable( + getattr(tool, func) + ) # checks if the attribute is callable (a method or function). + and not func.startswith( + "__" + ) # filters out special (dunder) methods like init, str, etc. — these are usually built-in functions of an object that you might not need to use directly. + and not inspect.isclass( + getattr(tool, func) + ) # ensures that the callable is not a class itself, just a method or function. ] -def get_tools_specs(tool_class: object) -> list[dict]: - function_list = get_callable_attributes(tool_class) - models = map(function_to_pydantic_model, function_list) - return [convert_to_openai_function(tool) for tool in models] +def get_tool_specs(tool_module: object) -> list[dict]: + function_models = map( + convert_function_to_pydantic_model, get_functions_from_tool(tool_module) + ) + + specs = [ + convert_pydantic_model_to_openai_function_spec(function_model) + for function_model in function_models + ] + + return specs + + +def resolve_schema(schema, components): + """ + Recursively resolves a JSON schema using OpenAPI components. + """ + if not schema: + return {} + + if "$ref" in schema: + ref_path = schema["$ref"] + ref_parts = ref_path.strip("#/").split("/") + resolved = components + for part in ref_parts[1:]: # Skip the initial 'components' + resolved = resolved.get(part, {}) + return resolve_schema(resolved, components) + + resolved_schema = copy.deepcopy(schema) + + # Recursively resolve inner schemas + if "properties" in resolved_schema: + for prop, prop_schema in resolved_schema["properties"].items(): + resolved_schema["properties"][prop] = resolve_schema( + prop_schema, components + ) + + if "items" in resolved_schema: + resolved_schema["items"] = resolve_schema(resolved_schema["items"], components) + + return resolved_schema + + +def convert_openapi_to_tool_payload(openapi_spec): + """ + Converts an OpenAPI specification into a custom tool payload structure. + + Args: + openapi_spec (dict): The OpenAPI specification as a Python dict. + + Returns: + list: A list of tool payloads. + """ + tool_payload = [] + + for path, methods in openapi_spec.get("paths", {}).items(): + for method, operation in methods.items(): + if operation.get("operationId"): + tool = { + "type": "function", + "name": operation.get("operationId"), + "description": operation.get( + "description", + operation.get("summary", "No description available."), + ), + "parameters": {"type": "object", "properties": {}, "required": []}, + } + + # Extract path and query parameters + for param in operation.get("parameters", []): + param_name = param["name"] + param_schema = param.get("schema", {}) + description = param_schema.get("description", "") + if not description: + description = param.get("description") or "" + if param_schema.get("enum") and isinstance( + param_schema.get("enum"), list + ): + description += ( + f". Possible values: {', '.join(param_schema.get('enum'))}" + ) + tool["parameters"]["properties"][param_name] = { + "type": param_schema.get("type"), + "description": description, + } + if param.get("required"): + tool["parameters"]["required"].append(param_name) + + # Extract and resolve requestBody if available + request_body = operation.get("requestBody") + if request_body: + content = request_body.get("content", {}) + json_schema = content.get("application/json", {}).get("schema") + if json_schema: + resolved_schema = resolve_schema( + json_schema, openapi_spec.get("components", {}) + ) + + if resolved_schema.get("properties"): + tool["parameters"]["properties"].update( + resolved_schema["properties"] + ) + if "required" in resolved_schema: + tool["parameters"]["required"] = list( + set( + tool["parameters"]["required"] + + resolved_schema["required"] + ) + ) + elif resolved_schema.get("type") == "array": + tool["parameters"] = ( + resolved_schema # special case for array + ) + + tool_payload.append(tool) + + return tool_payload + + +async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if token: + headers["Authorization"] = f"Bearer {token}" + + error = None + try: + timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + async with session.get( + url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL + ) as response: + if response.status != 200: + error_body = await response.json() + raise Exception(error_body) + + # 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() + except Exception as err: + log.exception(f"Could not fetch tool server spec from {url}") + if isinstance(err, dict) and "detail" in err: + error = err["detail"] + else: + error = str(err) + raise Exception(error) + + data = { + "openapi": res, + "info": res.get("info", {}), + "specs": convert_openapi_to_tool_payload(res), + } + + log.info("Fetched data:", data) + return data + + +async def get_tool_servers_data( + servers: List[Dict[str, Any]], session_token: Optional[str] = None +) -> 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"): + # 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") + if "://" in openapi_path: + # If it contains "://", it's a full URL + full_url = openapi_path + else: + if not openapi_path.startswith("/"): + # Ensure the path starts with a slash + openapi_path = f"/{openapi_path}" + + full_url = f"{server.get('url')}{openapi_path}" + + info = server.get("info", {}) + + auth_type = server.get("auth_type", "bearer") + token = None + + if auth_type == "bearer": + token = server.get("key", "") + elif auth_type == "session": + token = session_token + server_entries.append((idx, server, full_url, info, token)) + + # Create async tasks to fetch data + tasks = [ + get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries + ] + + # Execute tasks concurrently + responses = await asyncio.gather(*tasks, return_exceptions=True) + + # Build final results with index and server metadata + results = [] + for (idx, server, url, info, _), response in zip(server_entries, responses): + if isinstance(response, Exception): + log.error(f"Failed to connect to {url} OpenAPI tool server") + continue + + openapi_data = response.get("openapi", {}) + + if info and isinstance(openapi_data, dict): + if "name" in info: + openapi_data["info"]["title"] = info.get("name", "Tool Server") + + if "description" in info: + openapi_data["info"]["description"] = info.get("description", "") + + results.append( + { + "idx": idx, + "url": server.get("url"), + "openapi": openapi_data, + "info": response.get("info"), + "specs": response.get("specs"), + } + ) + + return results + + +async def execute_tool_server( + token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any] +) -> Any: + error = None + try: + openapi = server_data.get("openapi", {}) + paths = openapi.get("paths", {}) + + matching_route = None + for route_path, methods in paths.items(): + for http_method, operation in methods.items(): + if isinstance(operation, dict) and operation.get("operationId") == name: + matching_route = (route_path, methods) + break + if matching_route: + break + + if not matching_route: + raise Exception(f"No matching route found for operationId: {name}") + + route_path, methods = matching_route + + method_entry = None + for http_method, operation in methods.items(): + if operation.get("operationId") == name: + method_entry = (http_method.lower(), operation) + break + + if not method_entry: + raise Exception(f"No matching method found for operationId: {name}") + + http_method, operation = method_entry + + path_params = {} + query_params = {} + body_params = {} + + for param in operation.get("parameters", []): + param_name = param["name"] + param_in = param["in"] + if param_name in params: + if param_in == "path": + path_params[param_name] = params[param_name] + elif param_in == "query": + query_params[param_name] = params[param_name] + + final_url = f"{url}{route_path}" + for key, value in path_params.items(): + final_url = final_url.replace(f"{{{key}}}", str(value)) + + if query_params: + query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) + final_url = f"{final_url}?{query_string}" + + if operation.get("requestBody", {}).get("content"): + if params: + body_params = params + else: + raise Exception( + f"Request body expected for operation '{name}' but none found." + ) + + headers = {"Content-Type": "application/json"} + + if token: + headers["Authorization"] = f"Bearer {token}" + + async with aiohttp.ClientSession(trust_env=True) as session: + request_method = getattr(session, http_method.lower()) + + if http_method in ["post", "put", "patch"]: + async with request_method( + final_url, + json=body_params, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + ) as response: + if response.status >= 400: + text = await response.text() + raise Exception(f"HTTP error {response.status}: {text}") + return await response.json() + else: + async with request_method( + final_url, + headers=headers, + ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, + ) as response: + if response.status >= 400: + text = await response.text() + raise Exception(f"HTTP error {response.status}: {text}") + return await response.json() + + except Exception as err: + error = str(err) + log.exception("API Request Error:", error) + return {"error": error} diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index d59244dd32..bf0b334d82 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -2,14 +2,14 @@ import json import logging import requests -from open_webui.config import WEBUI_FAVICON_URL, WEBUI_NAME +from open_webui.config import WEBUI_FAVICON_URL from open_webui.env import SRC_LOG_LEVELS, VERSION log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["WEBHOOK"]) -def post_webhook(url: str, message: str, event_data: dict) -> bool: +def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool: try: log.debug(f"post_webhook: {url}, {message}, {event_data}") payload = {} @@ -39,7 +39,7 @@ def post_webhook(url: str, message: str, event_data: dict) -> bool: "sections": [ { "activityTitle": message, - "activitySubtitle": f"{WEBUI_NAME} ({VERSION}) - {action}", + "activitySubtitle": f"{name} ({VERSION}) - {action}", "activityImage": WEBUI_FAVICON_URL, "facts": facts, "markdown": True, diff --git a/backend/requirements.txt b/backend/requirements.txt index 9b859b84a0..9930cd3b68 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,10 +1,10 @@ fastapi==0.115.7 -uvicorn[standard]==0.30.6 -pydantic==2.9.2 -python-multipart==0.0.18 +uvicorn[standard]==0.34.0 +pydantic==2.10.6 +python-multipart==0.0.20 -python-socketio==5.11.3 -python-jose==3.3.0 +python-socketio==5.13.0 +python-jose==3.4.0 passlib[bcrypt]==1.7.4 requests==2.32.3 @@ -12,15 +12,17 @@ aiohttp==3.11.11 async-timeout aiocache aiofiles +starlette-compress==1.6.0 -sqlalchemy==2.0.32 + +sqlalchemy==2.0.38 alembic==1.14.0 -peewee==3.17.8 +peewee==3.18.1 peewee-migrate==1.12.2 psycopg2-binary==2.9.9 -pgvector==0.3.5 +pgvector==0.4.0 PyMySQL==1.1.1 -bcrypt==4.2.0 +bcrypt==4.3.0 pymongo redis @@ -31,26 +33,33 @@ APScheduler==3.10.4 RestrictedPython==8.0 +loguru==0.7.3 +asgiref==3.8.1 + # AI libraries openai anthropic -google-generativeai==0.7.2 +google-genai==1.15.0 +google-generativeai==0.8.5 tiktoken -langchain==0.3.7 -langchain-community==0.3.7 +langchain==0.3.24 +langchain-community==0.3.23 -fake-useragent==1.5.1 -chromadb==0.6.2 +fake-useragent==2.1.0 +chromadb==0.6.3 pymilvus==2.5.0 qdrant-client~=1.12.0 opensearch-py==2.8.0 - +playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml +elasticsearch==9.0.1 +pinecone==6.0.2 transformers -sentence-transformers==3.3.1 +sentence-transformers==4.1.0 +accelerate colbert-ai==0.2.21 -einops==0.8.0 +einops==0.8.1 ftfy==6.2.3 @@ -59,10 +68,10 @@ fpdf2==2.8.2 pymdown-extensions==10.14.2 docx2txt==0.8 python-pptx==1.0.0 -unstructured==0.16.11 +unstructured==0.16.17 nltk==3.9.1 Markdown==3.7 -pypandoc==1.13 +pypandoc==1.15 pandas==2.2.3 openpyxl==3.1.5 pyxlsb==1.0.10 @@ -71,24 +80,28 @@ validators==0.34.0 psutil sentencepiece soundfile==0.13.1 +azure-ai-documentintelligence==1.0.0 +pillow==11.1.0 opencv-python-headless==4.11.0.86 -rapidocr-onnxruntime==1.3.24 +rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 +onnxruntime==1.20.1 + faster-whisper==1.1.1 PyJWT[crypto]==2.10.1 authlib==1.4.1 -black==24.8.0 +black==25.1.0 langfuse==2.44.0 -youtube-transcript-api==0.6.3 +youtube-transcript-api==1.0.3 pytube==15.0.0 extract_msg pydub -duckduckgo-search~=7.3.2 +duckduckgo-search==8.0.2 ## Google Drive google-api-python-client @@ -97,11 +110,34 @@ google-auth-oauthlib ## Tests docker~=7.1.0 -pytest~=8.3.2 +pytest~=8.3.5 pytest-docker~=3.1.1 googleapis-common-protos==1.63.2 google-cloud-storage==2.19.0 +azure-identity==1.21.0 +azure-storage-blob==12.24.1 + + ## LDAP ldap3==2.9.1 + +## Firecrawl +firecrawl-py==1.12.0 + +# Sougou API SDK(Tencentcloud SDK) +tencentcloud-sdk-python==3.0.1336 + +## Trace +opentelemetry-api==1.32.1 +opentelemetry-sdk==1.32.1 +opentelemetry-exporter-otlp==1.32.1 +opentelemetry-instrumentation==0.53b1 +opentelemetry-instrumentation-fastapi==0.53b1 +opentelemetry-instrumentation-sqlalchemy==0.53b1 +opentelemetry-instrumentation-redis==0.53b1 +opentelemetry-instrumentation-requests==0.53b1 +opentelemetry-instrumentation-logging==0.53b1 +opentelemetry-instrumentation-httpx==0.53b1 +opentelemetry-instrumentation-aiohttp-client==0.53b1 diff --git a/backend/start.sh b/backend/start.sh index a945acb62e..84d5ec8958 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -3,6 +3,17 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) cd "$SCRIPT_DIR" || exit +# Add conditional Playwright browser installation +if [[ "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then + if [[ -z "${PLAYWRIGHT_WS_URL}" ]]; then + echo "Installing Playwright browsers..." + playwright install chromium + playwright install-deps chromium + fi + + python -c "import nltk; nltk.download('punkt_tab')" +fi + KEY_FILE=.webui_secret_key PORT="${PORT:-8080}" @@ -54,4 +65,6 @@ if [ -n "$SPACE_ID" ]; then export WEBUI_URL=${SPACE_HOST} fi -WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' +PYTHON_CMD=$(command -v python3 || command -v python) + +WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}" diff --git a/backend/start_windows.bat b/backend/start_windows.bat index 3e8c6b97c4..8d9aae3ac6 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -6,6 +6,17 @@ SETLOCAL ENABLEDELAYEDEXPANSION SET "SCRIPT_DIR=%~dp0" cd /d "%SCRIPT_DIR%" || exit /b +:: Add conditional Playwright browser installation +IF /I "%WEB_LOADER_ENGINE%" == "playwright" ( + IF "%PLAYWRIGHT_WS_URL%" == "" ( + echo Installing Playwright browsers... + playwright install chromium + playwright install-deps chromium + ) + + python -c "import nltk; nltk.download('punkt_tab')" +) + SET "KEY_FILE=.webui_secret_key" IF "%PORT%"=="" SET PORT=8080 IF "%HOST%"=="" SET HOST=0.0.0.0 @@ -30,4 +41,6 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " ( :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" -uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' +IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1 +uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --workers %UVICORN_WORKERS% --ws auto +:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto diff --git a/contribution_stats.py b/contribution_stats.py new file mode 100644 index 0000000000..3caa4738ec --- /dev/null +++ b/contribution_stats.py @@ -0,0 +1,74 @@ +import os +import subprocess +from collections import Counter + +CONFIG_FILE_EXTENSIONS = (".json", ".yml", ".yaml", ".ini", ".conf", ".toml") + + +def is_text_file(filepath): + # Check for binary file by scanning for null bytes. + try: + with open(filepath, "rb") as f: + chunk = f.read(4096) + if b"\0" in chunk: + return False + return True + except Exception: + return False + + +def should_skip_file(path): + base = os.path.basename(path) + # Skip dotfiles and dotdirs + if base.startswith("."): + return True + # Skip config files by extension + if base.lower().endswith(CONFIG_FILE_EXTENSIONS): + return True + return False + + +def get_tracked_files(): + try: + output = subprocess.check_output(["git", "ls-files"], text=True) + files = output.strip().split("\n") + files = [f for f in files if f and os.path.isfile(f)] + return files + except subprocess.CalledProcessError: + print("Error: Are you in a git repository?") + return [] + + +def main(): + files = get_tracked_files() + email_counter = Counter() + total_lines = 0 + + for file in files: + if should_skip_file(file): + continue + if not is_text_file(file): + continue + try: + blame = subprocess.check_output( + ["git", "blame", "-e", file], text=True, errors="replace" + ) + for line in blame.splitlines(): + # The email always inside <> + if "<" in line and ">" in line: + try: + email = line.split("<")[1].split(">")[0].strip() + except Exception: + continue + email_counter[email] += 1 + total_lines += 1 + except subprocess.CalledProcessError: + continue + + for email, lines in email_counter.most_common(): + percent = (lines / total_lines * 100) if total_lines else 0 + print(f"{email}: {lines}/{total_lines} {percent:.2f}%") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.playwright.yaml b/docker-compose.playwright.yaml new file mode 100644 index 0000000000..fa2b49ff9a --- /dev/null +++ b/docker-compose.playwright.yaml @@ -0,0 +1,10 @@ +services: + playwright: + image: mcr.microsoft.com/playwright:v1.49.1-noble # Version must match requirements.txt + container_name: playwright + command: npx -y playwright@1.49.1 run-server --port 3000 --host 0.0.0.0 + + open-webui: + environment: + - 'WEB_LOADER_ENGINE=playwright' + - 'PLAYWRIGHT_WS_URL=ws://playwright:3000' diff --git a/docs/apache.md b/docs/apache.md index 1bd9205937..bdf119b5b6 100644 --- a/docs/apache.md +++ b/docs/apache.md @@ -1,6 +1,6 @@ # Hosting UI and Models separately -Sometimes, its beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: +Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users: # Open WebUI Configuration diff --git a/package-lock.json b/package-lock.json index 56a76c09c8..ae602efa0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "open-webui", - "version": "0.5.12", + "version": "0.6.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.5.12", + "version": "0.6.13", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -17,34 +18,43 @@ "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", - "@tiptap/core": "^2.10.0", - "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/core": "^2.11.9", + "@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/extension-table": "^2.12.0", + "@tiptap/extension-table-cell": "^2.12.0", + "@tiptap/extension-table-header": "^2.12.0", + "@tiptap/extension-table-row": "^2.12.0", "@tiptap/extension-typography": "^2.10.0", - "@tiptap/pm": "^2.10.0", + "@tiptap/pm": "^2.11.7", "@tiptap/starter-kit": "^2.10.0", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.19.7", "codemirror": "^6.0.1", - "codemirror-lang-hcl": "^0.0.0-beta.2", + "codemirror-lang-elixir": "^4.0.0", + "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", - "dompurify": "^3.1.6", + "dompurify": "^3.2.5", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", + "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html-entities": "^2.5.3", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", + "jspdf": "^3.0.0", "katex": "^0.16.21", "kokoro-js": "^1.1.1", "marked": "^9.1.0", - "mermaid": "^10.9.3", + "mermaid": "^11.6.0", "paneforge": "^0.0.6", "panzoom": "^9.4.3", "prosemirror-commands": "^1.6.0", @@ -54,17 +64,21 @@ "prosemirror-markdown": "^1.13.1", "prosemirror-model": "^1.23.0", "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", + "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.2", + "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", + "turndown-plugin-gfm": "^1.0.2", + "undici": "^7.3.0", "uuid": "^9.0.1", - "vite-plugin-static-copy": "^2.2.0" + "vite-plugin-static-copy": "^2.2.0", + "yaml": "^2.7.1" }, "devDependencies": { "@sveltejs/adapter-auto": "3.2.2", @@ -72,10 +86,10 @@ "@sveltejs/kit": "^2.5.20", "@sveltejs/vite-plugin-svelte": "^3.1.1", "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", - "@typescript-eslint/eslint-plugin": "^6.17.0", - "@typescript-eslint/parser": "^6.17.0", - "autoprefixer": "^10.4.16", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", "cypress": "^13.15.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -89,7 +103,7 @@ "svelte": "^4.2.18", "svelte-check": "^3.8.5", "svelte-confetti": "^1.3.2", - "tailwindcss": "^3.3.3", + "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", "vite": "^5.4.14", @@ -133,10 +147,54 @@ "node": ">=6.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz", + "integrity": "sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^0.2.8", + "tinyexec": "^0.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", + "integrity": "sha512-H7mWmu8yI0n0XxhJobrgncXI6IU5h8DKMiWDHL5y+Dc58cdg26GbmaMUehbUkdKAQV2OTiFa4FUa6Fdu/wIxBg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.2.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", + "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/runtime": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", - "integrity": "sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -145,9 +203,10 @@ } }, "node_modules/@braintree/sanitize-url": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", - "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", + "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", + "license": "MIT" }, "node_modules/@bufbuild/protobuf": { "version": "2.2.2", @@ -156,6 +215,45 @@ "devOptional": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, "node_modules/@codemirror/autocomplete": { "version": "6.16.2", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz", @@ -600,371 +698,428 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", "cpu": [ - "x64" + "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1155,6 +1310,103 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.0.0", + "@antfu/utils": "^8.1.0", + "@iconify/types": "^2.0.0", + "debug": "^4.4.0", + "globals": "^15.14.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "mlly": "^1.7.4" + } + }, + "node_modules/@iconify/utils/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "license": "MIT" + }, + "node_modules/@iconify/utils/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@iconify/utils/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@iconify/utils/node_modules/local-pkg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", + "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.0.1", + "quansync": "^0.2.8" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@iconify/utils/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/@iconify/utils/node_modules/pkg-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", + "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.1", + "exsolve": "^1.0.1", + "pathe": "^2.0.3" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1795,6 +2047,15 @@ "svelte": ">=3 <5" } }, + "node_modules/@mermaid-js/parser": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz", + "integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -2266,9 +2527,9 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.6.tgz", - "integrity": "sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", + "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2276,24 +2537,22 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.12.1.tgz", - "integrity": "sha512-M3rPijGImeOkI0DBJSwjqz+YFX2DyOf6NzWgHVk3mqpT06dlYCpcv5xh1q4rYEqB58yQlk4QA1Y35PUqnUiFKw==", - "hasInstallScript": true, + "version": "2.20.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.2.tgz", + "integrity": "sha512-Dv8TOAZC9vyfcAB9TMsvUEJsRbklRTeNfcYBPaeH6KnABJ99i3CvCB2eNx8fiiliIqe+9GIchBg4RodRH5p1BQ==", "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", - "esm-env": "^1.2.1", + "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^3.0.0", - "tiny-glob": "^0.2.9" + "sirv": "^3.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -2368,6 +2627,260 @@ "tailwindcss": ">=3.2.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.6.tgz", + "integrity": "sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.0", + "jiti": "^2.4.2", + "tailwindcss": "4.0.6" + } + }, + "node_modules/@tailwindcss/node/node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.6.tgz", + "integrity": "sha512-mysewHYJKaXgNOW6pp5xon/emCsfAMnO8WMaGKZZ35fomnR/T5gYnRg2/yRTTrtXiEl1tiVkeRt0eMO6HxEZqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.6.tgz", + "integrity": "sha512-lVyKV2y58UE9CeKVcYykULe9QaE1dtKdxDEdrTPIdbzRgBk6bdxHNAoDqvcqXbIGXubn3VOl1O/CFF77v/EqSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.0.6", + "@tailwindcss/oxide-darwin-arm64": "4.0.6", + "@tailwindcss/oxide-darwin-x64": "4.0.6", + "@tailwindcss/oxide-freebsd-x64": "4.0.6", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.6", + "@tailwindcss/oxide-linux-arm64-gnu": "4.0.6", + "@tailwindcss/oxide-linux-arm64-musl": "4.0.6", + "@tailwindcss/oxide-linux-x64-gnu": "4.0.6", + "@tailwindcss/oxide-linux-x64-musl": "4.0.6", + "@tailwindcss/oxide-win32-arm64-msvc": "4.0.6", + "@tailwindcss/oxide-win32-x64-msvc": "4.0.6" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.6.tgz", + "integrity": "sha512-xDbym6bDPW3D2XqQqX3PjqW3CKGe1KXH7Fdkc60sX5ZLVUbzPkFeunQaoP+BuYlLc2cC1FoClrIRYnRzof9Sow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.6.tgz", + "integrity": "sha512-1f71/ju/tvyGl5c2bDkchZHy8p8EK/tDHCxlpYJ1hGNvsYihZNurxVpZ0DefpN7cNc9RTT8DjrRoV8xXZKKRjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.6.tgz", + "integrity": "sha512-s/hg/ZPgxFIrGMb0kqyeaqZt505P891buUkSezmrDY6lxv2ixIELAlOcUVTkVh245SeaeEiUVUPiUN37cwoL2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.6.tgz", + "integrity": "sha512-Z3Wo8FWZnmio8+xlcbb7JUo/hqRMSmhQw8IGIRoRJ7GmLR0C+25Wq+bEX/135xe/yEle2lFkhu9JBHd4wZYiig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.6.tgz", + "integrity": "sha512-SNSwkkim1myAgmnbHs4EjXsPL7rQbVGtjcok5EaIzkHkCAVK9QBQsWeP2Jm2/JJhq4wdx8tZB9Y7psMzHYWCkA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.6.tgz", + "integrity": "sha512-tJ+mevtSDMQhKlwCCuhsFEFg058kBiSy4TkoeBG921EfrHKmexOaCyFKYhVXy4JtkaeeOcjJnCLasEeqml4i+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.6.tgz", + "integrity": "sha512-IoArz1vfuTR4rALXMUXI/GWWfx2EaO4gFNtBNkDNOYhlTD4NVEwE45nbBoojYiTulajI4c2XH8UmVEVJTOJKxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.6.tgz", + "integrity": "sha512-QtsUfLkEAeWAC3Owx9Kg+7JdzE+k9drPhwTAXbXugYB9RZUnEWWx5x3q/au6TvUYcL+n0RBqDEO2gucZRvRFgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.6.tgz", + "integrity": "sha512-QthvJqIji2KlGNwLcK/PPYo7w1Wsi/8NK0wAtRGbv4eOPdZHkQ9KUk+oCoP20oPO7i2a6X1aBAFQEL7i08nNMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.6.tgz", + "integrity": "sha512-+oka+dYX8jy9iP00DJ9Y100XsqvbqR5s0yfMZJuPR1H/lDVtDfsZiSix1UFBQ3X1HWxoEEl6iXNJHWd56TocVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.6.tgz", + "integrity": "sha512-+o+juAkik4p8Ue/0LiflQXPmVatl6Av3LEZXpBTfg4qkMIbZdhCGWFzHdt2NjoMiLOJCFDddoV6GYaimvK1Olw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.0.tgz", + "integrity": "sha512-lI2bPk4TvwavHdehjr5WiC6HnZ59hacM6ySEo4RM/H7tsjWd8JpqiNW9ThH7rO/yKtrn4mGBoXshpvn8clXjPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "^4.0.0", + "@tailwindcss/oxide": "^4.0.0", + "lightningcss": "^1.29.1", + "postcss": "^8.4.41", + "tailwindcss": "4.0.0" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz", @@ -2384,9 +2897,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.0.tgz", - "integrity": "sha512-58nAjPxLRFcXepdDqQRC1mhrw6E8Sanqr6bbO4Tz0+FWgDJMZvHG+dOK5wHaDVNSgK2iJDz08ETvQayfOOgDvg==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.9.tgz", + "integrity": "sha512-UZSxQLLyJst47xep3jlyKM6y1ebZnmvbGsB7njBVjfxf5H+4yFpRJwwNqrBHM/vyU55LCtPChojqaYC1wXLf6g==", "license": "MIT", "funding": { "type": "github", @@ -2463,9 +2976,9 @@ } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.10.0.tgz", - "integrity": "sha512-dAv03XIHT5h+sdFmJzvx2FfpfFOOK9SBKHflRUdqTa8eA+0VZNAcPRjvJWVEWqts1fKZDJj774mO28NlhFzk9Q==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.9.tgz", + "integrity": "sha512-bB8N59A2aU18/ieyKRZAI0J0xyimmUckYePqBkUX8HFnq8yf9HsM0NPFpqZdK0eqjnZYCXcNwAI3YluLsHuutw==", "license": "MIT", "funding": { "type": "github", @@ -2666,6 +3179,59 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-table": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.12.0.tgz", + "integrity": "sha512-tT3IbbBal0vPQ1Bc/3Xl+tmqqZQCYWxnycBPl/WZBqhd57DWzfJqRPESwCGUIJgjOtTnipy/ulvj0FxHi1j9JA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.12.0.tgz", + "integrity": "sha512-8i35uCkmkSiQxMiZ+DLgT/wj24P5U/Zo3jr1e0tMAAMG7sRO1MljjLmkpV8WCdBo0xoRqzkz4J7Nkq+DtzZv9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.12.0.tgz", + "integrity": "sha512-gRKEsy13KKLpg9RxyPeUGqh4BRFSJ2Bc2KQP1ldhef6CPRYHCbGycxXCVQ5aAb7Mhpo54L+AAkmAv1iMHUTflw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.12.0.tgz", + "integrity": "sha512-AEW/Zl9V0IoaYDBLMhF5lVl0xgoIJs3IuKCsIYxGDlxBfTVFC6PfQzvuy296CMjO5ZcZ0xalVipPV9ggsMRD+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz", @@ -2706,9 +3272,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.10.0.tgz", - "integrity": "sha512-ohshlWf4MlW6D3rQkNQnhmiQ2w4pwRoQcJmTPt8UJoIDGkeKmZh494fQp4Aeh80XuGd81SsCv//1HJeyaeHJYQ==", + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz", + "integrity": "sha512-7gEEfz2Q6bYKXM07vzLUD0vqXFhC5geWRA6LCozTiLdVFDdHWiBrvb2rtkL5T7mfLq03zc1QhH7rI3F6VntOEA==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.2.1", @@ -2725,10 +3291,10 @@ "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.1", + "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.36.0" + "prosemirror-view": "^1.37.0" }, "funding": { "type": "github", @@ -2773,11 +3339,101 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "license": "MIT" + }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -2786,6 +3442,54 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2794,28 +3498,76 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-scale-chromatic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", - "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" }, "node_modules/@types/d3-selection": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" }, "node_modules/@types/d3-transition": { "version": "3.0.8", @@ -2834,19 +3586,17 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -2857,12 +3607,6 @@ "@types/unist": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2877,14 +3621,6 @@ "@types/mdurl": "^2" } }, - "node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", @@ -2896,11 +3632,6 @@ "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, "node_modules/@types/node": { "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", @@ -2915,17 +3646,18 @@ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true - }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -2944,10 +3676,18 @@ "integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==", + "peer": true }, "node_modules/@types/yauzl": { "version": "2.10.3", @@ -2960,79 +3700,72 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", + "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/type-utils": "8.31.1", + "@typescript-eslint/utils": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", + "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/typescript-estree": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", + "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -3040,39 +3773,37 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", + "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "8.31.1", + "@typescript-eslint/utils": "8.31.1", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", + "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", "dev": true, + "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -3080,73 +3811,85 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", + "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/visitor-keys": "8.31.1", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", + "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.31.1", + "@typescript-eslint/types": "8.31.1", + "@typescript-eslint/typescript-estree": "8.31.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.31.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", + "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.31.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@ungap/structured-clone": { @@ -3305,9 +4048,10 @@ } }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3428,12 +4172,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3466,12 +4204,6 @@ } ] }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3485,15 +4217,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -3551,41 +4274,16 @@ "node": ">= 4.0.0" } }, - "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", "bin": { - "autoprefixer": "bin/autoprefixer" + "atob": "bin/atob.js" }, "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 4.5.0" } }, "node_modules/aws-sign2": { @@ -3623,6 +4321,15 @@ "dev": true, "optional": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3727,7 +4434,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/brace-expansion": { "version": "2.0.1", @@ -3827,36 +4535,16 @@ "node": "10.* || >= 12.*" } }, - "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", "bin": { - "browserslist": "cli.js" + "btoa": "bin/btoa.js" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">= 0.4.0" } }, "node_modules/buffer": { @@ -3957,34 +4645,32 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, "engines": { - "node": ">= 6" + "node": ">=10.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001600", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001600.tgz", - "integrity": "sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] + "node_modules/canvg/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true }, "node_modules/caseless": { "version": "0.12.0", @@ -4027,15 +4713,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -4059,21 +4736,26 @@ } }, "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "dev": true, + "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=18.17" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -4084,6 +4766,7 @@ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", @@ -4096,6 +4779,42 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio/node_modules/undici": { + "version": "6.21.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", + "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4308,10 +5027,19 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/codemirror-lang-elixir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.0.tgz", + "integrity": "sha512-mzFesxo/t6KOxwnkqVd34R/q7yk+sMtHh6vUKGAvjwHmpL7bERHB+vQAsmU/nqrndkwVeJEHWGw/z/ybfdiudA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "lezer-elixir": "^1.0.0" + } + }, "node_modules/codemirror-lang-hcl": { - "version": "0.0.0-beta.2", - "resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.0.0-beta.2.tgz", - "integrity": "sha512-R3ew7Z2EYTdHTMXsWKBW9zxnLoLPYO+CrAa3dPZjXLrIR96Q3GR4cwJKF7zkSsujsnWgwRQZonyWpXYXfhQYuQ==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.1.0.tgz", + "integrity": "sha512-duwKEaQDhkJWad4YQ9pv4282BS6hCdR+gS/qTAj3f9bypXNNZ42bIN43h9WK3DjyZRENtVlUQdrQM1sA44wHmA==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -4434,10 +5162,10 @@ "dev": true }, "node_modules/confbox": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", - "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", - "dev": true + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -4454,6 +5182,18 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", + "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4498,11 +5238,21 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -4531,6 +5281,7 @@ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">= 6" }, @@ -4671,9 +5422,10 @@ } }, "node_modules/cytoscape": { - "version": "3.29.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.29.2.tgz", - "integrity": "sha512-2G1ycU28Nh7OHT9rkXRLpCDP30MKH1dXJORZuBhtEhEW7pKwgPi77ImqlCWinouyE1PNepIOGZBOrE84DG7LyQ==", + "version": "3.31.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.31.2.tgz", + "integrity": "sha512-/eOXg2uGdMdpGlEes5Sf6zE+jUG+05f3htFNQIxLxduOH/SsaUZiPBfAwP1btVIVzsnhiNOdi+hvDRLYfMZjGw==", + "license": "MIT", "engines": { "node": ">=0.10" } @@ -4689,10 +5441,38 @@ "cytoscape": "^3.2.0" } }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -4733,6 +5513,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { "internmap": "1 - 2" }, @@ -4744,6 +5525,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4752,6 +5534,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -4767,6 +5550,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", "dependencies": { "d3-path": "1 - 3" }, @@ -4786,6 +5570,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", "dependencies": { "d3-array": "^3.2.0" }, @@ -4797,6 +5582,7 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", "dependencies": { "delaunator": "5" }, @@ -4828,6 +5614,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", "dependencies": { "commander": "7", "iconv-lite": "0.6", @@ -4852,6 +5639,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", "engines": { "node": ">= 10" } @@ -4868,6 +5656,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" }, @@ -4879,6 +5668,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", @@ -4892,6 +5682,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4900,6 +5691,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" }, @@ -4911,6 +5703,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4930,6 +5723,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4938,6 +5732,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4946,6 +5741,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4954,6 +5750,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", "engines": { "node": ">=12" } @@ -4997,6 +5794,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -5012,6 +5810,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" @@ -5032,6 +5831,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { "d3-path": "^3.1.0" }, @@ -5043,6 +5843,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { "d3-array": "2 - 3" }, @@ -5054,6 +5855,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { "d3-time": "1 - 3" }, @@ -5103,11 +5905,12 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz", - "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", + "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", + "license": "MIT", "dependencies": { - "d3": "^7.8.2", + "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, @@ -5124,9 +5927,10 @@ } }, "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" }, "node_modules/debug": { "version": "4.3.4", @@ -5144,18 +5948,6 @@ } } }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -5204,6 +5996,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" } @@ -5261,20 +6054,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5285,24 +6064,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5320,6 +6081,7 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -5339,13 +6101,15 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -5357,15 +6121,20 @@ } }, "node_modules/dompurify": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", - "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", + "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -5390,22 +6159,25 @@ "safer-buffer": "^2.1.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.4.715", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.715.tgz", - "integrity": "sha512-XzWNH4ZSa9BwVUQSDorPWAUQ5WGuYz7zJUNpNif40zFCiCl20t8zgylmreNmn26h5kiyw2lg7RfTmeMBsDklqg==", - "dev": true - }, - "node_modules/elkjs": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", - "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -5435,6 +6207,20 @@ "node": ">=10.0.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -5499,50 +6285,44 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/escape-string-regexp": { @@ -5748,9 +6528,9 @@ } }, "node_modules/esm-env": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", - "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "license": "MIT" }, "node_modules/espree": { @@ -5872,6 +6652,12 @@ "node": ">=4" } }, + "node_modules/exsolve": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", + "integrity": "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5974,6 +6760,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6069,9 +6861,10 @@ "dev": true }, "node_modules/focus-trap": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", - "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", + "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", + "license": "MIT", "dependencies": { "tabbable": "^6.2.0" } @@ -6114,19 +6907,6 @@ "node": ">= 6" } }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -6405,36 +7185,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" - }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -6473,6 +7223,12 @@ "through2": "^2.0.1" } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6577,15 +7333,58 @@ "node": ">=12.0.0" } }, + "node_modules/html-entities": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", + "integrity": "sha512-D3AfvN7SjhTgBSA8L1BN4FpPzuEd06uy4lHwSoRWr0lndi9BKaNzPLKGOWZ2ocSGguozr08TTb2jhCLHaemruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/html2canvas-pro": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", + "integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -6594,11 +7393,12 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/http-signature": { @@ -6655,34 +7455,35 @@ } }, "node_modules/i18next-parser": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.0.1.tgz", - "integrity": "sha512-/Pr93/yEBdwsMKRsk4Zn63K368ALhzh8BRVrM6JNGOHy86ZKpiNJI6m8l1S/4T4Ofy1J4dlwkD7N98M70GP4aA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.3.0.tgz", + "integrity": "sha512-VaQqk/6nLzTFx1MDiCZFtzZXKKyBV6Dv0cJMFM/hOt4/BWHWRgYafzYfVQRUzotwUwjqeNCprWnutzD/YAGczg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", + "@babel/runtime": "^7.25.0", "broccoli-plugin": "^4.0.7", - "cheerio": "^1.0.0-rc.2", - "colors": "1.4.0", - "commander": "~12.1.0", + "cheerio": "^1.0.0", + "colors": "^1.4.0", + "commander": "^12.1.0", "eol": "^0.9.1", - "esbuild": "^0.20.1", - "fs-extra": "^11.1.0", + "esbuild": "^0.25.0", + "fs-extra": "^11.2.0", "gulp-sort": "^2.0.0", - "i18next": "^23.5.1", - "js-yaml": "4.1.0", - "lilconfig": "^3.0.0", - "rsvp": "^4.8.2", + "i18next": "^23.5.1 || ^24.2.0", + "js-yaml": "^4.1.0", + "lilconfig": "^3.1.3", + "rsvp": "^4.8.5", "sort-keys": "^5.0.0", "typescript": "^5.0.4", - "vinyl": "~3.0.0", + "vinyl": "^3.0.0", "vinyl-fs": "^4.0.0" }, "bin": { "i18next": "bin/cli.js" }, "engines": { - "node": ">=18.0.0 || >=20.0.0 || >=22.0.0", + "node": "^18.0.0 || ^20.0.0 || ^22.0.0", "npm": ">=6", "yarn": ">=1" } @@ -6817,6 +7618,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { "node": ">=12" } @@ -7024,33 +7826,6 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-sha256": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz", @@ -7121,6 +7896,24 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", + "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.7", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsprim": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", @@ -7198,6 +7991,28 @@ "phonemizer": "^1.2.1" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "license": "MIT" + }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -7234,11 +8049,263 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-elixir": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz", + "integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==", + "dependencies": { + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/lightningcss": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", + "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^1.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.1", + "lightningcss-darwin-x64": "1.29.1", + "lightningcss-freebsd-x64": "1.29.1", + "lightningcss-linux-arm-gnueabihf": "1.29.1", + "lightningcss-linux-arm64-gnu": "1.29.1", + "lightningcss-linux-arm64-musl": "1.29.1", + "lightningcss-linux-x64-gnu": "1.29.1", + "lightningcss-linux-x64-musl": "1.29.1", + "lightningcss-win32-arm64-msvc": "1.29.1", + "lightningcss-win32-x64-msvc": "1.29.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", + "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", + "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", + "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", + "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", + "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", + "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", + "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", + "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", + "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", + "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14" }, @@ -7246,12 +8313,6 @@ "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -7369,7 +8430,8 @@ "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" }, "node_modules/lodash.castarray": { "version": "4.4.0", @@ -7582,41 +8644,6 @@ "node": "*" } }, - "node_modules/mdast-util-from-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", - "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "mdast-util-to-string": "^3.1.0", - "micromark": "^3.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-decode-string": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "unist-util-stringify-position": "^3.0.0", - "uvu": "^0.5.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", - "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", - "dependencies": { - "@types/mdast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -7642,453 +8669,58 @@ } }, "node_modules/mermaid": { - "version": "10.9.3", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.3.tgz", - "integrity": "sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==", + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz", + "integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==", + "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^6.0.1", - "@types/d3-scale": "^4.0.3", - "@types/d3-scale-chromatic": "^3.0.0", - "cytoscape": "^3.28.1", + "@braintree/sanitize-url": "^7.0.4", + "@iconify/utils": "^2.1.33", + "@mermaid-js/parser": "^0.4.0", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", - "d3": "^7.4.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.10", - "dayjs": "^1.11.7", - "dompurify": "^3.0.5 <3.1.7", - "elkjs": "^0.9.0", + "dagre-d3-es": "7.0.11", + "dayjs": "^1.11.13", + "dompurify": "^3.2.4", "katex": "^0.16.9", - "khroma": "^2.0.0", + "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "mdast-util-from-markdown": "^1.3.0", - "non-layered-tidy-tree-layout": "^2.0.2", - "stylis": "^4.1.3", + "marked": "^15.0.7", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^9.0.0", - "web-worker": "^1.2.0" + "uuid": "^11.1.0" } }, - "node_modules/micromark": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", - "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "node_modules/mermaid/node_modules/marked": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", + "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "micromark-core-commonmark": "^1.0.1", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" } }, - "node_modules/micromark-core-commonmark": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", - "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-factory-destination": "^1.0.0", - "micromark-factory-label": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-factory-title": "^1.0.0", - "micromark-factory-whitespace": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-html-tag-name": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", - "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", - "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", - "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", - "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", - "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", - "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", - "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", - "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", - "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", - "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", - "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", - "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", - "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", - "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", - "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -8141,10 +8773,10 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8220,21 +8852,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/minizlib/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/minizlib/node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -8272,15 +8889,32 @@ } }, "node_modules/mlly": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.0.tgz", - "integrity": "sha512-U9SDaXGEREBYQgfejV97coK0UL1r+qnF2SyO9A3qcI8MzKnsIFKHNVEkrDyNncQTKQQumsasmeq84eNMdBfsNQ==", - "dev": true, + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "license": "MIT", "dependencies": { - "acorn": "^8.11.3", - "pathe": "^1.1.2", - "pkg-types": "^1.1.0", - "ufo": "^1.5.3" + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, "node_modules/mri": { @@ -8305,17 +8939,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", @@ -8345,17 +8968,6 @@ "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.2.tgz", "integrity": "sha512-JsUbEOzANskax+WSYiAPETemLWYXmixuPAlmZmhIbIj6FH/WDgEGCGnRwUQBK0GjOnVm8Ui+e5IJ+5VZ4e32eQ==" }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "node_modules/non-layered-tidy-tree-layout": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", - "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8364,15 +8976,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/now-and-later": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-3.0.0.tgz", @@ -8402,6 +9005,7 @@ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" }, @@ -8418,15 +9022,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -8583,6 +9178,15 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, "node_modules/paneforge": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-0.0.6.tgz", @@ -8617,30 +9221,51 @@ } }, "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", "dev": true, + "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dev": true, + "license": "MIT", "dependencies": { - "domhandler": "^5.0.2", + "domhandler": "^5.0.3", "parse5": "^7.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8701,15 +9326,6 @@ "node": "14 || >=16.14" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -8736,7 +9352,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "devOptional": true }, "node_modules/periscopic": { "version": "3.1.0", @@ -8795,15 +9411,6 @@ "node": ">=0.10.0" } }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/pkg-types": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.1.tgz", @@ -8826,6 +9433,22 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/polyscript": { "version": "0.12.8", "resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.12.8.tgz", @@ -8871,42 +9494,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, "node_modules/postcss-load-config": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", @@ -8945,36 +9532,14 @@ "node": ">=10" } }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, + "license": "ISC", "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" + "node": ">= 6" } }, "node_modules/postcss-safe-parser": { @@ -9032,12 +9597,6 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -9271,9 +9830,10 @@ } }, "node_modules/prosemirror-model": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.23.0.tgz", - "integrity": "sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.0.tgz", + "integrity": "sha512-/8XUmxWf0pkj2BmtqZHYJipTBMHIdVjuvFzMvEoxrtyGNmfvdhBiRwYt/eFwy2wA9DtBW3RLqvZnjurEkHaFCw==", + "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" } @@ -9287,9 +9847,10 @@ } }, "node_modules/prosemirror-schema-list": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz", - "integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -9307,16 +9868,16 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.1.tgz", - "integrity": "sha512-p8WRJNA96jaNQjhJolmbxTzd6M4huRE5xQ8OxjvMhQUP0Nzpo4zz6TztEiwk6aoqGBhz9lxRWR1yRZLlpQN98w==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", + "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", "license": "MIT", "dependencies": { - "prosemirror-keymap": "^1.1.2", - "prosemirror-model": "^1.8.1", - "prosemirror-state": "^1.3.1", - "prosemirror-transform": "^1.2.1", - "prosemirror-view": "^1.13.3" + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" } }, "node_modules/prosemirror-trailing-node": { @@ -9335,18 +9896,18 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", - "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", + "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.36.0.tgz", - "integrity": "sha512-U0GQd5yFvV5qUtT41X1zCQfbw14vkbbKwLlQXhdylEmgpYVHkefXYcC4HHwWOfZa3x6Y8wxDLUBv7dxN5XQ3nA==", + "version": "1.39.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.1.tgz", + "integrity": "sha512-GhLxH1xwnqa5VjhJ29LfcQITNDp+f1jzmMPXQfGW9oNrF0lfjPzKvV5y/bjIQkyKpwCX3Fp+GA4dBpMMk8g+ZQ==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -9423,9 +9984,9 @@ } }, "node_modules/pyodide": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.2.tgz", - "integrity": "sha512-sfA2kiUuQVRpWI4BYnU3sX5PaTTt/xrcVEmRzRcId8DzZXGGtPgCBC0gCqjUTUYSa8ofPaSjXmzESc86yvvCHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.3.tgz", + "integrity": "sha512-6NwKEbPk0M3Wic2T1TCZijgZH9VE4RkHp1VGljS1sou0NjGdsmY2R/fG5oLmdDkjTRMI1iW7WYaY9pofX8gg1g==", "license": "Apache-2.0", "dependencies": { "ws": "^8.5.0" @@ -9449,6 +10010,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", + "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -9545,6 +10122,16 @@ "rimraf": "bin.js" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9552,15 +10139,6 @@ "dev": true, "license": "MIT" }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -9693,6 +10271,16 @@ "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", "dev": true }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -9753,7 +10341,8 @@ "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" }, "node_modules/rollup": { "version": "4.22.4", @@ -9794,6 +10383,18 @@ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -9828,7 +10429,8 @@ "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" }, "node_modules/rxjs": { "version": "7.8.1", @@ -10455,15 +11057,6 @@ "node": ">=18" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -10584,6 +11177,16 @@ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", @@ -10760,62 +11363,10 @@ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" }, "node_modules/stylis": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", - "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -11018,6 +11569,16 @@ "@types/estree": "*" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -11053,121 +11614,20 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz", + "integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==", "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } + "license": "MIT" }, - "node_modules/tailwindcss/node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", - "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" + "node": ">=6" } }, "node_modules/tar": { @@ -11211,33 +11671,21 @@ "streamx": "^2.12.5" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/throttleit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", @@ -11263,21 +11711,18 @@ "xtend": "~4.0.1" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tinybench": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", "dev": true }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "license": "MIT" + }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", @@ -11376,15 +11821,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-dedent": { @@ -11395,12 +11841,6 @@ "node": ">=6.10" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", @@ -11427,6 +11867,12 @@ "@mixmark-io/domino": "^2.2.0" } }, + "node_modules/turndown-plugin-gfm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", + "license": "MIT" + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -11491,10 +11937,10 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, "node_modules/ufo": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", - "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", - "dev": true + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "license": "MIT" }, "node_modules/underscore.string": { "version": "3.3.6", @@ -11509,23 +11955,20 @@ "node": "*" } }, + "node_modules/undici": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.3.0.tgz", + "integrity": "sha512-Qy96NND4Dou5jKoSJ2gm8ax8AJM/Ey9o9mz7KN1bb9GP+G0l20Zw8afxTnY2f4b7hmhn/z8aC2kfArVQlAhFBw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, - "node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -11543,36 +11986,6 @@ "node": ">=8" } }, - "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -11598,6 +12011,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -11610,23 +12032,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/value-or-function": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz", @@ -11735,9 +12140,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.15", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", + "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -12417,6 +12822,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -12459,10 +12913,28 @@ "node": "*" } }, - "node_modules/web-worker": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", - "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==" + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/wheel": { "version": "1.0.0", @@ -12638,12 +13110,15 @@ } }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/yauzl": { diff --git a/package.json b/package.json index c1a76fd78e..b2a71845ed 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "open-webui", - "version": "0.5.12", + "version": "0.6.13", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", "dev:5050": "npm run pyodide:fetch && vite dev --port 5050", "build": "npm run pyodide:fetch && vite build", + "build:watch": "npm run pyodide:fetch && vite build --watch", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -26,10 +27,10 @@ "@sveltejs/kit": "^2.5.20", "@sveltejs/vite-plugin-svelte": "^3.1.1", "@tailwindcss/container-queries": "^0.1.1", + "@tailwindcss/postcss": "^4.0.0", "@tailwindcss/typography": "^0.5.13", - "@typescript-eslint/eslint-plugin": "^6.17.0", - "@typescript-eslint/parser": "^6.17.0", - "autoprefixer": "^10.4.16", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", "cypress": "^13.15.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -43,7 +44,7 @@ "svelte": "^4.2.18", "svelte-check": "^3.8.5", "svelte-confetti": "^1.3.2", - "tailwindcss": "^3.3.3", + "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", "vite": "^5.4.14", @@ -51,6 +52,7 @@ }, "type": "module", "dependencies": { + "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/language-data": "^6.5.1", @@ -60,34 +62,43 @@ "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", - "@tiptap/core": "^2.10.0", - "@tiptap/extension-code-block-lowlight": "^2.10.0", + "@tiptap/core": "^2.11.9", + "@tiptap/extension-code-block-lowlight": "^2.11.9", "@tiptap/extension-highlight": "^2.10.0", "@tiptap/extension-placeholder": "^2.10.0", + "@tiptap/extension-table": "^2.12.0", + "@tiptap/extension-table-cell": "^2.12.0", + "@tiptap/extension-table-header": "^2.12.0", + "@tiptap/extension-table-row": "^2.12.0", "@tiptap/extension-typography": "^2.10.0", - "@tiptap/pm": "^2.10.0", + "@tiptap/pm": "^2.11.7", "@tiptap/starter-kit": "^2.10.0", "@xyflow/svelte": "^0.1.19", "async": "^3.2.5", "bits-ui": "^0.19.7", "codemirror": "^6.0.1", - "codemirror-lang-hcl": "^0.0.0-beta.2", + "codemirror-lang-elixir": "^4.0.0", + "codemirror-lang-hcl": "^0.1.0", "crc-32": "^1.2.2", "dayjs": "^1.11.10", - "dompurify": "^3.1.6", + "dompurify": "^3.2.5", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", + "focus-trap": "^7.6.4", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html-entities": "^2.5.3", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", + "jspdf": "^3.0.0", "katex": "^0.16.21", "kokoro-js": "^1.1.1", "marked": "^9.1.0", - "mermaid": "^10.9.3", + "mermaid": "^11.6.0", "paneforge": "^0.0.6", "panzoom": "^9.4.3", "prosemirror-commands": "^1.6.0", @@ -97,17 +108,21 @@ "prosemirror-markdown": "^1.13.1", "prosemirror-model": "^1.23.0", "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", + "prosemirror-schema-list": "^1.5.1", "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.7.1", "prosemirror-view": "^1.34.3", - "pyodide": "^0.27.2", + "pyodide": "^0.27.3", "socket.io-client": "^4.2.0", "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "turndown": "^7.2.0", + "turndown-plugin-gfm": "^1.0.2", + "undici": "^7.3.0", "uuid": "^9.0.1", - "vite-plugin-static-copy": "^2.2.0" + "vite-plugin-static-copy": "^2.2.0", + "yaml": "^2.7.1" }, "engines": { "node": ">=18.13.0 <=22.x.x", diff --git a/postcss.config.js b/postcss.config.js index 0f7721681d..85b958cb59 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,5 @@ export default { plugins: { - tailwindcss: {}, - autoprefixer: {} + '@tailwindcss/postcss': {} } }; diff --git a/pyproject.toml b/pyproject.toml index dac8bbf788..51ea658909 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,12 @@ authors = [ license = { file = "LICENSE" } dependencies = [ "fastapi==0.115.7", - "uvicorn[standard]==0.30.6", - "pydantic==2.9.2", - "python-multipart==0.0.18", + "uvicorn[standard]==0.34.0", + "pydantic==2.10.6", + "python-multipart==0.0.20", - "python-socketio==5.11.3", - "python-jose==3.3.0", + "python-socketio==5.13.0", + "python-jose==3.4.0", "passlib[bcrypt]==1.7.4", "requests==2.32.3", @@ -21,14 +21,16 @@ dependencies = [ "aiocache", "aiofiles", - "sqlalchemy==2.0.32", + "starlette-compress==1.6.0", + + "sqlalchemy==2.0.38", "alembic==1.14.0", - "peewee==3.17.8", + "peewee==3.18.1", "peewee-migrate==1.12.2", "psycopg2-binary==2.9.9", - "pgvector==0.3.5", + "pgvector==0.4.0", "PyMySQL==1.1.1", - "bcrypt==4.2.0", + "bcrypt==4.3.0", "pymongo", "redis", @@ -40,24 +42,32 @@ dependencies = [ "RestrictedPython==8.0", + "loguru==0.7.3", + "asgiref==3.8.1", + "openai", "anthropic", - "google-generativeai==0.7.2", + "google-genai==1.15.0", + "google-generativeai==0.8.5", "tiktoken", - "langchain==0.3.7", - "langchain-community==0.3.7", + "langchain==0.3.24", + "langchain-community==0.3.23", - "fake-useragent==1.5.1", - "chromadb==0.6.2", + "fake-useragent==2.1.0", + "chromadb==0.6.3", "pymilvus==2.5.0", "qdrant-client~=1.12.0", "opensearch-py==2.8.0", + "playwright==1.49.1", + "elasticsearch==9.0.1", + "pinecone==6.0.2", "transformers", - "sentence-transformers==3.3.1", + "sentence-transformers==4.1.0", + "accelerate", "colbert-ai==0.2.21", - "einops==0.8.0", + "einops==0.8.1", "ftfy==6.2.3", "pypdf==4.3.1", @@ -65,10 +75,10 @@ dependencies = [ "pymdown-extensions==10.14.2", "docx2txt==0.8", "python-pptx==1.0.0", - "unstructured==0.16.11", + "unstructured==0.16.17", "nltk==3.9.1", "Markdown==3.7", - "pypandoc==1.13", + "pypandoc==1.15", "pandas==2.2.3", "openpyxl==3.1.5", "pyxlsb==1.0.10", @@ -77,24 +87,28 @@ dependencies = [ "psutil", "sentencepiece", "soundfile==0.13.1", + "azure-ai-documentintelligence==1.0.0", + "pillow==11.1.0", "opencv-python-headless==4.11.0.86", - "rapidocr-onnxruntime==1.3.24", + "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", + "onnxruntime==1.20.1", + "faster-whisper==1.1.1", "PyJWT[crypto]==2.10.1", "authlib==1.4.1", - "black==24.8.0", + "black==25.1.0", "langfuse==2.44.0", - "youtube-transcript-api==0.6.3", + "youtube-transcript-api==1.0.3", "pytube==15.0.0", "extract_msg", "pydub", - "duckduckgo-search~=7.3.2", + "duckduckgo-search==8.0.2", "google-api-python-client", "google-auth-httplib2", @@ -103,13 +117,23 @@ dependencies = [ "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.1.1", - "moto[s3]>=5.0.26", "googleapis-common-protos==1.63.2", "google-cloud-storage==2.19.0", + "azure-identity==1.20.0", + "azure-storage-blob==12.24.1", + "ldap3==2.9.1", + + "firecrawl-py==1.12.0", + + "tencentcloud-sdk-python==3.0.1336", + "gcp-storage-emulator>=2024.8.3", + + "moto[s3]>=5.0.26", + ] readme = "README.md" requires-python = ">= 3.11, < 3.13.0a1" diff --git a/run-compose.sh b/run-compose.sh index 21574e9599..4fafedc6f7 100755 --- a/run-compose.sh +++ b/run-compose.sh @@ -74,6 +74,7 @@ usage() { echo " --enable-api[port=PORT] Enable API and expose it on the specified port." echo " --webui[port=PORT] Set the port for the web user interface." echo " --data[folder=PATH] Bind mount for ollama data folder (by default will create the 'ollama' volume)." + echo " --playwright Enable Playwright support for web scraping." echo " --build Build the docker image before running the compose project." echo " --drop Drop the compose project." echo " -q, --quiet Run script in headless mode." @@ -100,6 +101,7 @@ webui_port=3000 headless=false build_image=false kill_compose=false +enable_playwright=false # Function to extract value from the parameter extract_value() { @@ -129,6 +131,9 @@ while [[ $# -gt 0 ]]; do value=$(extract_value "$key") data_dir=${value:-"./ollama-data"} ;; + --playwright) + enable_playwright=true + ;; --drop) kill_compose=true ;; @@ -182,6 +187,9 @@ else DEFAULT_COMPOSE_COMMAND+=" -f docker-compose.data.yaml" export OLLAMA_DATA_DIR=$data_dir # Set OLLAMA_DATA_DIR environment variable fi + if [[ $enable_playwright == true ]]; then + DEFAULT_COMPOSE_COMMAND+=" -f docker-compose.playwright.yaml" + fi if [[ -n $webui_port ]]; then export OPEN_WEBUI_PORT=$webui_port # Set OPEN_WEBUI_PORT environment variable fi @@ -201,6 +209,7 @@ echo -e " ${GREEN}${BOLD}GPU Count:${NC} ${OLLAMA_GPU_COUNT:-Not Enabled}" echo -e " ${GREEN}${BOLD}WebAPI Port:${NC} ${OLLAMA_WEBAPI_PORT:-Not Enabled}" echo -e " ${GREEN}${BOLD}Data Folder:${NC} ${data_dir:-Using ollama volume}" echo -e " ${GREEN}${BOLD}WebUI Port:${NC} $webui_port" +echo -e " ${GREEN}${BOLD}Playwright:${NC} ${enable_playwright:-false}" echo if [[ $headless == true ]]; then diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 71f2a2cb29..70f3cf5c6c 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -16,8 +16,39 @@ const packages = [ ]; import { loadPyodide } from 'pyodide'; +import { setGlobalDispatcher, ProxyAgent } from 'undici'; import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises'; +/** + * Loading network proxy configurations from the environment variables. + * And the proxy config with lowercase name has the highest priority to use. + */ +function initNetworkProxyFromEnv() { + // we assume all subsequent requests in this script are HTTPS: + // https://cdn.jsdelivr.net + // https://pypi.org + // https://files.pythonhosted.org + const allProxy = process.env.all_proxy || process.env.ALL_PROXY; + const httpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY; + const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY; + const preferedProxy = httpsProxy || allProxy || httpProxy; + /** + * use only http(s) proxy because socks5 proxy is not supported currently: + * @see https://github.com/nodejs/undici/issues/2224 + */ + if (!preferedProxy || !preferedProxy.startsWith('http')) return; + let preferedProxyURL; + try { + preferedProxyURL = new URL(preferedProxy).toString(); + } catch { + console.warn(`Invalid network proxy URL: "${preferedProxy}"`); + return; + } + const dispatcher = new ProxyAgent({ uri: preferedProxyURL }); + setGlobalDispatcher(dispatcher); + console.log(`Initialized network proxy "${preferedProxy}" from env`); +} + async function downloadPackages() { console.log('Setting up pyodide + micropip'); @@ -84,5 +115,6 @@ async function copyPyodide() { } } +initNetworkProxyFromEnv(); await downloadPackages(); await copyPyodide(); diff --git a/src/app.css b/src/app.css index dadfda78f1..ea0bd5fb0a 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,5 @@ +@reference "./tailwind.css"; + @font-face { font-family: 'Inter'; src: url('/assets/fonts/Inter-Variable.ttf'); @@ -22,6 +24,12 @@ font-display: swap; } +@font-face { + font-family: 'Vazirmatn'; + src: url('/assets/fonts/Vazirmatn-Variable.ttf'); + font-display: swap; +} + html { word-break: break-word; } @@ -44,6 +52,14 @@ math { @apply rounded-lg; } +input::placeholder { + direction: auto; +} + +textarea::placeholder { + direction: auto; +} + .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-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; } @@ -53,11 +69,11 @@ math { } .markdown-prose { - @apply prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-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-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; } .markdown-prose-xs { - @apply text-xs prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 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 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; } .markdown a { @@ -65,7 +81,7 @@ math { } .font-primary { - font-family: 'Archivo', sans-serif; + font-family: 'Archivo', 'Vazirmatn', sans-serif; } .drag-region { @@ -81,17 +97,13 @@ math { -webkit-app-region: no-drag; } -iframe { - @apply rounded-lg; -} - li p { display: inline; } ::-webkit-scrollbar-thumb { --tw-border-opacity: 1; - background-color: rgba(236, 236, 236, 0.8); + background-color: rgba(215, 215, 215, 0.8); border-color: rgba(255, 255, 255, var(--tw-border-opacity)); border-radius: 9999px; border-width: 1px; @@ -99,12 +111,12 @@ li p { /* Dark theme scrollbar styles */ .dark ::-webkit-scrollbar-thumb { - background-color: rgba(33, 33, 33, 0.8); /* Darker color for dark theme */ + background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */ border-color: rgba(0, 0, 0, var(--tw-border-opacity)); } ::-webkit-scrollbar { - height: 0.4rem; + height: 0.6rem; width: 0.4rem; } @@ -212,13 +224,45 @@ input[type='number'] { -moz-appearance: textfield; /* Firefox */ } +.katex-display { + @apply overflow-y-hidden overflow-x-auto max-w-full; +} + +.katex-display::-webkit-scrollbar { + height: 0.4rem; + width: 0.4rem; +} + +.katex-display:active::-webkit-scrollbar-thumb, +.katex-display:focus::-webkit-scrollbar-thumb, +.katex-display:hover::-webkit-scrollbar-thumb { + visibility: visible; +} +.katex-display::-webkit-scrollbar-thumb { + visibility: hidden; +} + +.katex-display::-webkit-scrollbar-corner { + display: none; +} + .cm-editor { height: 100%; width: 100%; } -.cm-scroller { - @apply scrollbar-hidden; +.cm-scroller:active::-webkit-scrollbar-thumb, +.cm-scroller:focus::-webkit-scrollbar-thumb, +.cm-scroller:hover::-webkit-scrollbar-thumb { + visibility: visible; +} + +.cm-scroller::-webkit-scrollbar-thumb { + visibility: hidden; +} + +.cm-scroller::-webkit-scrollbar-corner { + display: none; } .cm-editor.cm-focused { @@ -270,12 +314,20 @@ input[type='number'] { .ProseMirror p.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; - color: #adb5bd; + /* Below color is from tailwind, and has the proper contrast + text-gray-600 from: https://tailwindcss.com/docs/color */ + color: #676767; pointer-events: none; @apply line-clamp-1 absolute; } +@media (prefers-color-scheme: dark) { + .ProseMirror p.is-editor-empty:first-child::before { + color: #757575; + } +} + .ai-autocompletion::after { color: #a0a0a0; @@ -360,3 +412,29 @@ input[type='number'] { .hljs-strong { font-weight: 700; } + +/* Table styling for tiptap editors */ +.tiptap table { + @apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full; +} + +.tiptap thead { + @apply text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none; +} + +.tiptap th, +.tiptap td { + @apply px-3 py-1.5 border border-gray-100 dark:border-gray-850; +} + +.tiptap th { + @apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850; +} + +.tiptap td { + @apply text-gray-900 dark:text-white w-max; +} + +.tiptap tr { + @apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs; +} diff --git a/src/app.html b/src/app.html index 537e28dbe7..d19f3d227e 100644 --- a/src/app.html +++ b/src/app.html @@ -2,12 +2,14 @@ - - - - + + + + + - + + + @@ -175,10 +200,6 @@ background: #000; } - html.dark #splash-screen img { - filter: invert(1); - } - html.her #splash-screen { background: #983724; } diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index 5cd6ab949c..b2fed5739f 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -15,7 +15,7 @@ export const getAudioConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -52,7 +52,7 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -64,9 +64,12 @@ export const updateAudioConfig = async (token: string, payload: OpenAIConfigForm return res; }; -export const transcribeAudio = async (token: string, file: File) => { +export const transcribeAudio = async (token: string, file: File, language?: string) => { const data = new FormData(); data.append('file', file); + if (language) { + data.append('language', language); + } let error = null; const res = await fetch(`${AUDIO_API_BASE_URL}/transcriptions`, { @@ -83,7 +86,7 @@ export const transcribeAudio = async (token: string, file: File) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -120,7 +123,7 @@ export const synthesizeOpenAISpeech = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -152,7 +155,7 @@ export const getModels = async (token: string = ''): Promise { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -180,7 +183,7 @@ export const getVoices = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 40caebf5da..169a6c14fc 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -15,7 +15,7 @@ export const getAdminDetails = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -42,7 +42,7 @@ export const getAdminConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -70,7 +70,7 @@ export const updateAdminConfig = async (token: string, body: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -98,7 +98,7 @@ export const getSessionUser = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -129,7 +129,7 @@ export const ldapUserSignIn = async (user: string, password: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; @@ -157,7 +157,7 @@ export const getLdapConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -187,7 +187,7 @@ export const updateLdapConfig = async (token: string = '', enable_ldap: boolean) return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -214,7 +214,7 @@ export const getLdapServer = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -242,7 +242,7 @@ export const updateLdapServer = async (token: string = '', body: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -273,7 +273,7 @@ export const userSignIn = async (email: string, password: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; @@ -312,7 +312,7 @@ export const userSignUp = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -339,7 +339,7 @@ export const userSignOut = async () => { return res; }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -347,6 +347,7 @@ export const userSignOut = async () => { if (error) { throw error; } + return res; }; export const addUser = async ( @@ -354,7 +355,8 @@ export const addUser = async ( name: string, email: string, password: string, - role: string = 'pending' + role: string = 'pending', + profile_image_url: null | string = null ) => { let error = null; @@ -368,7 +370,8 @@ export const addUser = async ( name: name, email: email, password: password, - role: role + role: role, + ...(profile_image_url && { profile_image_url: profile_image_url }) }) }) .then(async (res) => { @@ -376,7 +379,7 @@ export const addUser = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -407,7 +410,7 @@ export const updateUserProfile = async (token: string, name: string, profileImag return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -438,7 +441,7 @@ export const updateUserPassword = async (token: string, password: string, newPas return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -465,7 +468,7 @@ export const getSignUpEnabledStatus = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -492,7 +495,7 @@ export const getDefaultUserRole = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -522,7 +525,7 @@ export const updateDefaultUserRole = async (token: string, role: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -549,7 +552,7 @@ export const toggleSignUpEnabledStatus = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -576,7 +579,7 @@ export const getJWTExpiresDuration = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -606,7 +609,7 @@ export const updateJWTExpiresDuration = async (token: string, duration: string) return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -633,7 +636,7 @@ export const createAPIKey = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -658,7 +661,7 @@ export const getAPIKey = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -683,7 +686,7 @@ export const deleteAPIKey = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index f16b43505f..548572c6fb 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -1,5 +1,4 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; -import { t } from 'i18next'; type ChannelForm = { name: string; @@ -29,7 +28,7 @@ export const createNewChannel = async (token: string = '', channel: ChannelForm) }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -60,7 +59,7 @@ export const getChannels = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -91,7 +90,7 @@ export const getChannelById = async (token: string = '', channel_id: string) => }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -127,7 +126,7 @@ export const updateChannelById = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -158,7 +157,7 @@ export const deleteChannelById = async (token: string = '', channel_id: string) }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -197,7 +196,7 @@ export const getChannelMessages = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -237,7 +236,7 @@ export const getChannelThreadMessages = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -276,7 +275,7 @@ export const sendMessage = async (token: string = '', channel_id: string, messag }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -316,7 +315,7 @@ export const updateMessage = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -356,7 +355,7 @@ export const addReaction = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -396,7 +395,7 @@ export const removeReaction = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -430,7 +429,7 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 7af504cc78..9d24b3971c 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -21,7 +21,7 @@ export const createNewChat = async (token: string, chat: object) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -61,7 +61,7 @@ export const importChat = async ( }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -97,7 +97,7 @@ export const getChatList = async (token: string = '', page: number | null = null }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -111,10 +111,79 @@ export const getChatList = async (token: string = '', page: number | null = null })); }; -export const getChatListByUserId = async (token: string = '', userId: string) => { +export const getChatListByUserId = async ( + token: string = '', + userId: string, + page: number = 1, + filter?: object +) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/chats/list/user/${userId}`, { + const searchParams = new URLSearchParams(); + + searchParams.append('page', `${page}`); + + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, value.toString()); + } + }); + } + + const res = await fetch( + `${WEBUI_API_BASE_URL}/chats/list/user/${userId}?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res.map((chat) => ({ + ...chat, + time_range: getTimeRange(chat.updated_at) + })); +}; + +export const getArchivedChatList = async ( + token: string = '', + page: number = 1, + filter?: object +) => { + let error = null; + + const searchParams = new URLSearchParams(); + searchParams.append('page', `${page}`); + + if (filter) { + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + searchParams.append(key, value.toString()); + } + }); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', @@ -131,7 +200,7 @@ export const getChatListByUserId = async (token: string = '', userId: string) => }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -145,37 +214,6 @@ export const getChatListByUserId = async (token: string = '', userId: string) => })); }; -export const getArchivedChatList = async (token: string = '') => { - let error = null; - - const res = await fetch(`${WEBUI_API_BASE_URL}/chats/archived`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...(token && { authorization: `Bearer ${token}` }) - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .then((json) => { - return json; - }) - .catch((err) => { - error = err; - console.log(err); - return null; - }); - - if (error) { - throw error; - } - - return res; -}; - export const getAllChats = async (token: string) => { let error = null; @@ -196,7 +234,7 @@ export const getAllChats = async (token: string) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -231,7 +269,7 @@ export const getChatListBySearchText = async (token: string, text: string, page: }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -265,7 +303,7 @@ export const getChatsByFolderId = async (token: string, folderId: string) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -296,7 +334,7 @@ export const getAllArchivedChats = async (token: string) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -327,7 +365,7 @@ export const getAllUserChats = async (token: string) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -358,7 +396,7 @@ export const getAllTags = async (token: string) => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -389,7 +427,7 @@ export const getPinnedChatList = async (token: string = '') => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -426,7 +464,7 @@ export const getChatListByTagName = async (token: string = '', tagName: string) }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -459,9 +497,9 @@ export const getChatById = async (token: string, id: string) => { return json; }) .catch((err) => { - error = err; + error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -493,7 +531,7 @@ export const getChatByShareId = async (token: string, share_id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -531,7 +569,7 @@ export const getChatPinnedStatusById = async (token: string, id: string) => { error = err; } - console.log(err); + console.error(err); return null; }); @@ -569,7 +607,7 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => { error = err; } - console.log(err); + console.error(err); return null; }); @@ -610,7 +648,7 @@ export const cloneChatById = async (token: string, id: string, title?: string) = error = err; } - console.log(err); + console.error(err); return null; }); @@ -648,7 +686,7 @@ export const cloneSharedChatById = async (token: string, id: string) => { error = err; } - console.log(err); + console.error(err); return null; }); @@ -680,7 +718,7 @@ export const shareChatById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -715,7 +753,7 @@ export const updateChatFolderIdById = async (token: string, id: string, folderId .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -747,7 +785,7 @@ export const archiveChatById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -779,7 +817,7 @@ export const deleteSharedChatById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -814,7 +852,7 @@ export const updateChatById = async (token: string, id: string, chat: object) => .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -846,7 +884,7 @@ export const deleteChatById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -878,7 +916,7 @@ export const getTagsById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -912,7 +950,7 @@ export const addTagById = async (token: string, id: string, tagName: string) => }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -947,7 +985,7 @@ export const deleteTagById = async (token: string, id: string, tagName: string) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -978,7 +1016,7 @@ export const deleteTagsById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -1010,7 +1048,7 @@ export const deleteAllChats = async (token: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -1042,7 +1080,7 @@ export const archiveAllChats = async (token: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index d7f02564ce..26dec26c9d 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -19,7 +19,7 @@ export const importConfig = async (token: string, config) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -46,7 +46,7 @@ export const exportConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -73,7 +73,7 @@ export const getDirectConnectionsConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -103,7 +103,7 @@ export const setDirectConnectionsConfig = async (token: string, config: object) return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -115,10 +115,10 @@ export const setDirectConnectionsConfig = async (token: string, config: object) return res; }; -export const getCodeInterpreterConfig = async (token: string) => { +export const getToolServerConnections = async (token: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_interpreter`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -130,7 +130,7 @@ export const getCodeInterpreterConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -142,10 +142,97 @@ export const getCodeInterpreterConfig = async (token: string) => { return res; }; -export const setCodeInterpreterConfig = async (token: string, config: object) => { +export const setToolServerConnections = async (token: string, connections: object) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_interpreter`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connections + }) + }) + .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 verifyToolServerConnection = async (token: string, connection: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/tool_servers/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connection + }) + }) + .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 getCodeExecutionConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, { + 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 setCodeExecutionConfig = async (token: string, config: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -160,7 +247,7 @@ export const setCodeInterpreterConfig = async (token: string, config: object) => return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -187,7 +274,7 @@ export const getModelsConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -217,7 +304,7 @@ export const setModelsConfig = async (token: string, config: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -247,7 +334,7 @@ export const setDefaultPromptSuggestions = async (token: string, promptSuggestio return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -274,7 +361,7 @@ export const getBanners = async (token: string): Promise => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -304,7 +391,7 @@ export const setBanners = async (token: string, banners: Banner[]) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts index f6f35f7c18..96a689fcb1 100644 --- a/src/lib/apis/evaluations/index.ts +++ b/src/lib/apis/evaluations/index.ts @@ -20,7 +20,7 @@ export const getConfig = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -51,7 +51,7 @@ export const updateConfig = async (token: string, config: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -82,7 +82,7 @@ export const getAllFeedbacks = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -113,7 +113,7 @@ export const exportAllFeedbacks = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -144,7 +144,7 @@ export const createNewFeedback = async (token: string, feedback: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -175,7 +175,7 @@ export const getFeedbackById = async (token: string, feedbackId: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -206,7 +206,7 @@ export const updateFeedbackById = async (token: string, feedbackId: string, feed }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -234,7 +234,7 @@ export const deleteFeedbackById = async (token: string, feedbackId: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts index 6a42ec6147..a58d7cb931 100644 --- a/src/lib/apis/files/index.ts +++ b/src/lib/apis/files/index.ts @@ -1,8 +1,12 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; -export const uploadFile = async (token: string, file: File) => { +export const uploadFile = async (token: string, file: File, metadata?: object | null) => { const data = new FormData(); data.append('file', file); + if (metadata) { + data.append('metadata', JSON.stringify(metadata)); + } + let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, { @@ -19,7 +23,7 @@ export const uploadFile = async (token: string, file: File) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -76,7 +80,7 @@ export const getFiles = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -107,7 +111,7 @@ export const getFileById = async (token: string, id: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -141,7 +145,7 @@ export const updateFileDataContentById = async (token: string, id: string, conte }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -168,7 +172,7 @@ export const getFileContentById = async (id: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -200,7 +204,7 @@ export const deleteFileById = async (token: string, id: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -231,7 +235,7 @@ export const deleteAllFiles = async (token: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index f1a1f5b483..21ec426b05 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -50,7 +50,7 @@ export const getFolders = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -81,7 +81,7 @@ export const getFolderById = async (token: string, id: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -115,7 +115,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -153,7 +153,7 @@ export const updateFolderIsExpandedById = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -187,7 +187,7 @@ export const updateFolderParentIdById = async (token: string, id: string, parent }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -226,7 +226,7 @@ export const updateFolderItemsById = async (token: string, id: string, items: Fo }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -257,7 +257,7 @@ export const deleteFolderById = async (token: string, id: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/functions/index.ts b/src/lib/apis/functions/index.ts index ed3306b321..60e88118b8 100644 --- a/src/lib/apis/functions/index.ts +++ b/src/lib/apis/functions/index.ts @@ -20,7 +20,7 @@ export const createNewFunction = async (token: string, func: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -51,7 +51,41 @@ export const getFunctions = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const loadFunctionByUrl = async (token: string = '', url: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/functions/load/url`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); return null; }); @@ -82,7 +116,7 @@ export const exportFunctions = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -114,7 +148,7 @@ export const getFunctionById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -149,7 +183,7 @@ export const updateFunctionById = async (token: string, id: string, func: object .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -181,7 +215,7 @@ export const deleteFunctionById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -213,7 +247,7 @@ export const toggleFunctionById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -245,7 +279,7 @@ export const toggleGlobalById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -277,7 +311,7 @@ export const getFunctionValvesById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -309,7 +343,7 @@ export const getFunctionValvesSpecById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -344,7 +378,7 @@ export const updateFunctionValvesById = async (token: string, id: string, valves .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -376,7 +410,7 @@ export const getUserValvesById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -408,7 +442,7 @@ export const getUserValvesSpecById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -443,7 +477,7 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/groups/index.ts b/src/lib/apis/groups/index.ts index b7d4f8ef9f..c55f477af5 100644 --- a/src/lib/apis/groups/index.ts +++ b/src/lib/apis/groups/index.ts @@ -20,7 +20,7 @@ export const createNewGroup = async (token: string, group: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -51,7 +51,7 @@ export const getGroups = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -83,7 +83,7 @@ export const getGroupById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -118,7 +118,7 @@ export const updateGroupById = async (token: string, id: string, group: object) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -150,7 +150,7 @@ export const deleteGroupById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/images/index.ts b/src/lib/apis/images/index.ts index 2e6510437b..a58d16085f 100644 --- a/src/lib/apis/images/index.ts +++ b/src/lib/apis/images/index.ts @@ -16,7 +16,7 @@ export const getConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -51,7 +51,7 @@ export const updateConfig = async (token: string = '', config: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -83,7 +83,7 @@ export const verifyConfigUrl = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -115,7 +115,7 @@ export const getImageGenerationConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -148,7 +148,7 @@ export const updateImageGenerationConfig = async (token: string = '', config: ob return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -180,7 +180,7 @@ export const getImageGenerationModels = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -215,7 +215,7 @@ export const imageGenerations = async (token: string = '', prompt: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 3fb4a5d01b..268be397bc 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,6 +1,10 @@ import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +import { convertOpenApiToToolPayload } from '$lib/utils'; import { getOpenAIModelsDirect } from './openai'; +import { parse } from 'yaml'; +import { toast } from 'svelte-sonner'; + export const getModels = async ( token: string = '', connections: object | null = null, @@ -21,7 +25,7 @@ export const getModels = async ( }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -114,6 +118,13 @@ export const getModels = async ( } } + const tags = apiConfig.tags; + if (tags) { + for (const model of models) { + model.tags = tags; + } + } + localModels = localModels.concat(models); } } @@ -162,7 +173,7 @@ export const chatCompleted = async (token: string, body: ChatCompletedForm) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -201,7 +212,7 @@ export const chatAction = async (token: string, action_id: string, body: ChatAct return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -233,7 +244,7 @@ export const stopTask = async (token: string, id: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -249,6 +260,225 @@ export const stopTask = async (token: string, id: string) => { return res; }; +export const getTaskIdsByChatId = async (token: string, chat_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${chat_id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getToolServerData = async (token: string, url: string) => { + let error = null; + + const res = await fetch(`${url}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + // Check if URL ends with .yaml or .yml to determine format + if (url.toLowerCase().endsWith('.yaml') || url.toLowerCase().endsWith('.yml')) { + if (!res.ok) throw await res.text(); + const text = await res.text(); + return parse(text); + } else { + if (!res.ok) throw await res.json(); + return res.json(); + } + }) + .catch((err) => { + console.error(err); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + const data = { + openapi: res, + info: res.info, + specs: convertOpenApiToToolPayload(res) + }; + + console.log(data); + return data; +}; + +export const getToolServersData = async (i18n, servers: object[]) => { + return ( + await Promise.all( + servers + .filter((server) => server?.config?.enable) + .map(async (server) => { + const data = await getToolServerData( + (server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token, + (server?.path ?? '').includes('://') + ? server?.path + : `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}` + ).catch((err) => { + toast.error( + i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, { + URL: (server?.path ?? '').includes('://') + ? server?.path + : `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}` + }) + ); + return null; + }); + + if (data) { + const { openapi, info, specs } = data; + return { + url: server?.url, + openapi: openapi, + info: info, + specs: specs + }; + } + }) + ) + ).filter((server) => server); +}; + +export const executeToolServer = async ( + token: string, + url: string, + name: string, + params: Record, + serverData: { openapi: any; info: any; specs: any } +) => { + let error = null; + + try { + // Find the matching operationId in the OpenAPI spec + const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) => + Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name) + ); + + if (!matchingRoute) { + throw new Error(`No matching route found for operationId: ${name}`); + } + + const [routePath, methods] = matchingRoute; + + const methodEntry = Object.entries(methods as any).find( + ([_, operation]: any) => operation.operationId === name + ); + + if (!methodEntry) { + throw new Error(`No matching method found for operationId: ${name}`); + } + + const [httpMethod, operation]: [string, any] = methodEntry; + + // Split parameters by type + const pathParams: Record = {}; + const queryParams: Record = {}; + let bodyParams: any = {}; + + if (operation.parameters) { + operation.parameters.forEach((param: any) => { + const paramName = param.name; + const paramIn = param.in; + if (params.hasOwnProperty(paramName)) { + if (paramIn === 'path') { + pathParams[paramName] = params[paramName]; + } else if (paramIn === 'query') { + queryParams[paramName] = params[paramName]; + } + } + }); + } + + let finalUrl = `${url}${routePath}`; + + // Replace path parameters (`{param}`) + Object.entries(pathParams).forEach(([key, value]) => { + finalUrl = finalUrl.replace(new RegExp(`{${key}}`, 'g'), encodeURIComponent(value)); + }); + + // Append query parameters to URL if any + if (Object.keys(queryParams).length > 0) { + const queryString = new URLSearchParams( + Object.entries(queryParams).map(([k, v]) => [k, String(v)]) + ).toString(); + finalUrl += `?${queryString}`; + } + + // Handle requestBody composite + if (operation.requestBody && operation.requestBody.content) { + const contentType = Object.keys(operation.requestBody.content)[0]; + if (params !== undefined) { + bodyParams = params; + } else { + // Optional: Fallback or explicit error if body is expected but not provided + throw new Error(`Request body expected for operation '${name}' but none found.`); + } + } + + // Prepare headers and request options + const headers: Record = { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }; + + let requestOptions: RequestInit = { + method: httpMethod.toUpperCase(), + headers + }; + + if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) { + requestOptions.body = JSON.stringify(bodyParams); + } + + const res = await fetch(finalUrl, requestOptions); + if (!res.ok) { + const resText = await res.text(); + throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`); + } + + return await res.json(); + } catch (err: any) { + error = err.message; + console.error('API Request Error:', error); + return { error }; + } +}; + export const getTaskConfig = async (token: string = '') => { let error = null; @@ -265,7 +495,7 @@ export const getTaskConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -294,7 +524,7 @@ export const updateTaskConfig = async (token: string, config: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -313,7 +543,7 @@ export const updateTaskConfig = async (token: string, config: object) => { export const generateTitle = async ( token: string = '', model: string, - messages: string[], + messages: object[], chat_id?: string ) => { let error = null; @@ -336,7 +566,7 @@ export const generateTitle = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -347,7 +577,39 @@ export const generateTitle = async ( throw error; } - return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat'; + try { + // Step 1: Safely extract the response string + const response = res?.choices[0]?.message?.content ?? ''; + + // Step 2: Attempt to fix common JSON format issues like single quotes + const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON + + // Step 3: Find the relevant JSON block within the response + const jsonStartIndex = sanitizedResponse.indexOf('{'); + const jsonEndIndex = sanitizedResponse.lastIndexOf('}'); + + // Step 4: Check if we found a valid JSON block (with both `{` and `}`) + if (jsonStartIndex !== -1 && jsonEndIndex !== -1) { + const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1); + + // Step 5: Parse the JSON block + const parsed = JSON.parse(jsonResponse); + + // Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array + if (parsed && parsed.title) { + return parsed.title; + } else { + return null; + } + } + + // If no valid JSON block found, return an empty array + return null; + } catch (e) { + // Catch and safely return empty array on any parsing errors + console.error('Failed to parse response: ', e); + return null; + } }; export const generateTags = async ( @@ -376,7 +638,7 @@ export const generateTags = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -448,7 +710,7 @@ export const generateEmoji = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -498,7 +760,7 @@ export const generateQueries = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -570,7 +832,7 @@ export const generateAutoCompletion = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -634,7 +896,7 @@ export const generateMoACompletion = async ( stream: true }) }).catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -662,7 +924,7 @@ export const getPipelinesList = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -696,7 +958,7 @@ export const uploadPipeline = async (token: string, file: File, urlIdx: string) return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -732,7 +994,7 @@ export const downloadPipeline = async (token: string, url: string, urlIdx: strin return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -768,7 +1030,7 @@ export const deletePipeline = async (token: string, id: string, urlIdx: string) return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -805,7 +1067,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -842,7 +1104,7 @@ export const getPipelineValves = async (token: string, pipeline_id: string, urlI return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -878,7 +1140,7 @@ export const getPipelineValvesSpec = async (token: string, pipeline_id: string, return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -920,7 +1182,7 @@ export const updatePipelineValves = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; @@ -952,7 +1214,7 @@ export const getBackendConfig = async () => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -978,7 +1240,7 @@ export const getChangelog = async () => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1005,7 +1267,7 @@ export const getVersionUpdates = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1032,7 +1294,7 @@ export const getModelFilterConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1067,7 +1329,7 @@ export const updateModelFilterConfig = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1094,7 +1356,7 @@ export const getWebhookUrl = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1124,7 +1386,7 @@ export const updateWebhookUrl = async (token: string, url: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1151,7 +1413,7 @@ export const getCommunitySharingEnabledStatus = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1178,7 +1440,7 @@ export const toggleCommunitySharingEnabledStatus = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -1205,7 +1467,7 @@ export const getModelConfig = async (token: string): Promise return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -1253,7 +1515,7 @@ export const updateModelConfig = async (token: string, config: GlobalModelConfig return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index c5fad1323d..c01c986a2a 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -27,7 +27,7 @@ export const createNewKnowledge = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -58,7 +58,7 @@ export const getKnowledgeBases = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -89,7 +89,7 @@ export const getKnowledgeBaseList = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -121,7 +121,7 @@ export const getKnowledgeById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -166,7 +166,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -201,7 +201,7 @@ export const addFileToKnowledgeById = async (token: string, id: string, fileId: .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -236,7 +236,7 @@ export const updateFileFromKnowledgeById = async (token: string, id: string, fil .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -271,7 +271,7 @@ export const removeFileFromKnowledgeById = async (token: string, id: string, fil .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -303,7 +303,7 @@ export const resetKnowledgeById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -335,7 +335,35 @@ export const deleteKnowledgeById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const reindexKnowledgeFiles = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/reindex`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); return null; }); diff --git a/src/lib/apis/memories/index.ts b/src/lib/apis/memories/index.ts index 3fd83ca9e0..d8fdc638fa 100644 --- a/src/lib/apis/memories/index.ts +++ b/src/lib/apis/memories/index.ts @@ -17,7 +17,7 @@ export const getMemories = async (token: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -48,7 +48,7 @@ export const addNewMemory = async (token: string, content: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -79,7 +79,7 @@ export const updateMemoryById = async (token: string, id: string, content: strin }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -110,7 +110,7 @@ export const queryMemory = async (token: string, content: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -142,7 +142,7 @@ export const deleteMemoryById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -174,7 +174,7 @@ export const deleteMemoriesByUserId = async (token: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts index 9cf625d035..3e6e0d0c0b 100644 --- a/src/lib/apis/models/index.ts +++ b/src/lib/apis/models/index.ts @@ -20,7 +20,7 @@ export const getModels = async (token: string = '') => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -51,7 +51,7 @@ export const getBaseModels = async (token: string = '') => { }) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -80,7 +80,7 @@ export const createNewModel = async (token: string, model: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -115,7 +115,7 @@ export const getModelById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -150,7 +150,7 @@ export const toggleModelById = async (token: string, id: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -186,7 +186,7 @@ export const updateModelById = async (token: string, id: string, model: object) .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); @@ -221,7 +221,7 @@ export const deleteModelById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -253,7 +253,7 @@ export const deleteAllModels = async (token: string) => { .catch((err) => { error = err; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts new file mode 100644 index 0000000000..df0be72627 --- /dev/null +++ b/src/lib/apis/notes/index.ts @@ -0,0 +1,187 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; +import { getTimeRange } from '$lib/utils'; + +type NoteItem = { + title: string; + data: object; + meta?: null | object; + access_control?: null | object; +}; + +export const createNewNote = async (token: string, note: NoteItem) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...note + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getNotes = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + if (!Array.isArray(res)) { + return {}; // or throw new Error("Notes response is not an array") + } + + // Build the grouped object + const grouped: Record = {}; + for (const note of res) { + const timeRange = getTimeRange(note.updated_at / 1000000000); + if (!grouped[timeRange]) { + grouped[timeRange] = []; + } + grouped[timeRange].push({ + ...note, + timeRange + }); + } + + return grouped; +}; + +export const getNoteById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateNoteById = async (token: string, id: string, note: NoteItem) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...note + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteNoteById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index b96567e639..489055c1bb 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -1,10 +1,6 @@ import { OLLAMA_API_BASE_URL } from '$lib/constants'; -export const verifyOllamaConnection = async ( - token: string = '', - url: string = '', - key: string = '' -) => { +export const verifyOllamaConnection = async (token: string = '', connection: dict = {}) => { let error = null; const res = await fetch(`${OLLAMA_API_BASE_URL}/verify`, { @@ -15,8 +11,7 @@ export const verifyOllamaConnection = async ( 'Content-Type': 'application/json' }, body: JSON.stringify({ - url, - key + ...connection }) }) .then(async (res) => { @@ -51,7 +46,7 @@ export const getOllamaConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -92,7 +87,7 @@ export const updateOllamaConfig = async (token: string = '', config: OllamaConfi return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -124,7 +119,7 @@ export const getOllamaUrls = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -159,7 +154,7 @@ export const updateOllamaUrls = async (token: string = '', urls: string[]) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -191,7 +186,7 @@ export const getOllamaVersion = async (token: string, urlIdx?: number) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -223,7 +218,7 @@ export const getOllamaModels = async (token: string = '', urlIdx: null | number return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -268,7 +263,7 @@ export const generatePrompt = async (token: string = '', model: string, conversa ` }) }).catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } @@ -360,6 +355,31 @@ export const generateChatCompletion = async (token: string = '', body: object) = return [res, controller]; }; +export const unloadModel = async (token: string, tagName: string) => { + let error = null; + + const res = await fetch(`${OLLAMA_API_BASE_URL}/api/unload`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + name: tagName + }) + }).catch((err) => { + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => { let error = null; @@ -408,11 +428,11 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string return res.json(); }) .then((json) => { - console.log(json); + console.debug(json); return true; }) .catch((err) => { - console.log(err); + console.error(err); error = err; if ('detail' in err) { @@ -445,7 +465,7 @@ export const pullModel = async (token: string, tagName: string, urlIdx: number | name: tagName }) }).catch((err) => { - console.log(err); + console.error(err); error = err; if ('detail' in err) { @@ -481,7 +501,7 @@ export const downloadModel = async ( }) } ).catch((err) => { - console.log(err); + console.error(err); error = err; if ('detail' in err) { @@ -512,7 +532,7 @@ export const uploadModel = async (token: string, file: File, urlIdx: string | nu body: formData } ).catch((err) => { - console.log(err); + console.error(err); error = err; if ('detail' in err) { diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index bab2d6e36a..070118a1a2 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -16,7 +16,7 @@ export const getOpenAIConfig = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -58,7 +58,7 @@ export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfi return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -90,7 +90,7 @@ export const getOpenAIUrls = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -125,7 +125,7 @@ export const updateOpenAIUrls = async (token: string = '', urls: string[]) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -157,7 +157,7 @@ export const getOpenAIKeys = async (token: string = '') => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -192,7 +192,7 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); if ('detail' in err) { error = err.detail; } else { @@ -267,10 +267,10 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => { export const verifyOpenAIConnection = async ( token: string = '', - url: string = 'https://api.openai.com/v1', - key: string = '', + connection: dict = {}, direct: boolean = false ) => { + const { url, key, config } = connection; if (!url) { throw 'OpenAI: URL is required'; } @@ -309,7 +309,8 @@ export const verifyOpenAIConnection = async ( }, body: JSON.stringify({ url, - key + key, + config }) }) .then(async (res) => { @@ -346,7 +347,7 @@ export const chatCompletion = async ( }, body: JSON.stringify(body) }).catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -409,7 +410,7 @@ export const synthesizeOpenAISpeech = async ( voice: speaker }) }).catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); diff --git a/src/lib/apis/prompts/index.ts b/src/lib/apis/prompts/index.ts index f1c54b1098..4129ea62aa 100644 --- a/src/lib/apis/prompts/index.ts +++ b/src/lib/apis/prompts/index.ts @@ -28,7 +28,7 @@ export const createNewPrompt = async (token: string, prompt: PromptItem) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -59,7 +59,7 @@ export const getPrompts = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -90,7 +90,7 @@ export const getPromptList = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -122,7 +122,7 @@ export const getPromptByCommand = async (token: string, command: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -158,7 +158,7 @@ export const updatePromptByCommand = async (token: string, prompt: PromptItem) = .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -192,7 +192,7 @@ export const deletePromptByCommand = async (token: string, command: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index c35c37847b..6df927fec6 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -15,7 +15,7 @@ export const getRAGConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -32,9 +32,15 @@ type ChunkConfigForm = { chunk_overlap: number; }; +type DocumentIntelligenceConfigForm = { + key: string; + endpoint: string; +}; + type ContentExtractConfigForm = { engine: string; tika_server_url: string | null; + document_intelligence_config: DocumentIntelligenceConfigForm | null; }; type YoutubeConfigForm = { @@ -44,8 +50,9 @@ type YoutubeConfigForm = { }; type RAGConfigForm = { - pdf_extract_images?: boolean; - enable_google_drive_integration?: boolean; + PDF_EXTRACT_IMAGES?: boolean; + ENABLE_GOOGLE_DRIVE_INTEGRATION?: boolean; + ENABLE_ONEDRIVE_INTEGRATION?: boolean; chunk?: ChunkConfigForm; content_extraction?: ContentExtractConfigForm; web_loader_ssl_verification?: boolean; @@ -70,7 +77,7 @@ export const updateRAGConfig = async (token: string, payload: RAGConfigForm) => return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -82,33 +89,6 @@ export const updateRAGConfig = async (token: string, payload: RAGConfigForm) => return res; }; -export const getRAGTemplate = async (token: string) => { - let error = null; - - const res = await fetch(`${RETRIEVAL_API_BASE_URL}/template`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - } - }) - .then(async (res) => { - if (!res.ok) throw await res.json(); - return res.json(); - }) - .catch((err) => { - console.log(err); - error = err.detail; - return null; - }); - - if (error) { - throw error; - } - - return res?.template ?? ''; -}; - export const getQuerySettings = async (token: string) => { let error = null; @@ -124,7 +104,7 @@ export const getQuerySettings = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -160,7 +140,7 @@ export const updateQuerySettings = async (token: string, settings: QuerySettings return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -187,7 +167,7 @@ export const getEmbeddingConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -204,8 +184,15 @@ type OpenAIConfigForm = { url: string; }; +type AzureOpenAIConfigForm = { + key: string; + url: string; + version: string; +}; + type EmbeddingModelUpdateForm = { openai_config?: OpenAIConfigForm; + azure_openai_config?: AzureOpenAIConfigForm; embedding_engine: string; embedding_model: string; embedding_batch_size?: number; @@ -229,7 +216,7 @@ export const updateEmbeddingConfig = async (token: string, payload: EmbeddingMod return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -256,7 +243,7 @@ export const getRerankingConfig = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -290,7 +277,7 @@ export const updateRerankingConfig = async (token: string, payload: RerankingMod return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -333,7 +320,7 @@ export const processFile = async ( }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -364,7 +351,7 @@ export const processYoutubeVideo = async (token: string, url: string) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -396,7 +383,7 @@ export const processWeb = async (token: string, collection_name: string, url: st }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -430,7 +417,7 @@ export const processWebSearch = async ( return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index d1dc11c16f..2038e46ac6 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -20,7 +20,41 @@ export const createNewTool = async (token: string, tool: object) => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const loadToolByUrl = async (token: string = '', url: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/load/url`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + url + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); return null; }); @@ -51,7 +85,7 @@ export const getTools = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -82,7 +116,7 @@ export const getToolList = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -113,7 +147,7 @@ export const exportTools = async (token: string = '') => { }) .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -145,7 +179,7 @@ export const getToolById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -180,7 +214,7 @@ export const updateToolById = async (token: string, id: string, tool: object) => .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -212,7 +246,7 @@ export const deleteToolById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -244,7 +278,7 @@ export const getToolValvesById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -276,7 +310,7 @@ export const getToolValvesSpecById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -311,7 +345,7 @@ export const updateToolValvesById = async (token: string, id: string, valves: ob .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -343,7 +377,7 @@ export const getUserValvesById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -375,7 +409,7 @@ export const getUserValvesSpecById = async (token: string, id: string) => { .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); @@ -410,7 +444,7 @@ export const updateUserValvesById = async (token: string, id: string, valves: ob .catch((err) => { error = err.detail; - console.log(err); + console.error(err); return null; }); diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index b0efe39d27..f8ab88ff53 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -16,7 +16,7 @@ export const getUserGroups = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -43,7 +43,7 @@ export const getUserDefaultPermissions = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -73,7 +73,7 @@ export const updateUserDefaultPermissions = async (token: string, permissions: o return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -104,7 +104,7 @@ export const updateUserRole = async (token: string, id: string, role: string) => return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -116,10 +116,33 @@ export const updateUserRole = async (token: string, id: string, role: string) => return res; }; -export const getUsers = async (token: string) => { +export const getUsers = async ( + token: string, + query?: string, + orderBy?: string, + direction?: string, + page = 1 +) => { let error = null; + let res = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, { + let searchParams = new URLSearchParams(); + + searchParams.set('page', `${page}`); + + if (query) { + searchParams.set('query', query); + } + + if (orderBy) { + searchParams.set('order_by', orderBy); + } + + if (direction) { + searchParams.set('direction', direction); + } + + res = await fetch(`${WEBUI_API_BASE_URL}/users/?${searchParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -131,7 +154,7 @@ export const getUsers = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -140,7 +163,35 @@ export const getUsers = async (token: string) => { throw error; } - return res ? res : []; + return res; +}; + +export const getAllUsers = async (token: string) => { + let error = null; + let res = null; + + res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; }; export const getUserSettings = async (token: string) => { @@ -157,7 +208,7 @@ export const getUserSettings = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -187,7 +238,7 @@ export const updateUserSettings = async (token: string, settings: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -214,7 +265,7 @@ export const getUserById = async (token: string, userId: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -240,7 +291,7 @@ export const getUserInfo = async (token: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -270,7 +321,7 @@ export const updateUserInfo = async (token: string, info: object) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -284,14 +335,16 @@ export const updateUserInfo = async (token: string, info: object) => { export const getAndUpdateUserLocation = async (token: string) => { const location = await getUserPosition().catch((err) => { - throw err; + console.error(err); + return null; }); if (location) { await updateUserInfo(token, { location: location }); return location; } else { - throw new Error('Failed to get user location'); + console.info('Failed to get user location'); + return null; } }; @@ -310,7 +363,7 @@ export const deleteUserById = async (token: string, userId: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -350,7 +403,7 @@ export const updateUserById = async (token: string, userId: string, user: UserUp return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts index 40fdbfcfa2..1fc30ddbba 100644 --- a/src/lib/apis/utils/index.ts +++ b/src/lib/apis/utils/index.ts @@ -1,12 +1,13 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; -export const getGravatarUrl = async (email: string) => { +export const getGravatarUrl = async (token: string, email: string) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, { method: 'GET', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` } }) .then(async (res) => { @@ -14,7 +15,7 @@ export const getGravatarUrl = async (email: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -22,13 +23,14 @@ export const getGravatarUrl = async (email: string) => { return res; }; -export const formatPythonCode = async (code: string) => { +export const executeCode = async (token: string, code: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/execute`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, body: JSON.stringify({ code: code @@ -39,7 +41,7 @@ export const formatPythonCode = async (code: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; if (err.detail) { @@ -55,13 +57,48 @@ export const formatPythonCode = async (code: string) => { return res; }; -export const downloadChatAsPDF = async (title: string, messages: object[]) => { +export const formatPythonCode = async (token: string, code: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + code: code + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + + error = err; + if (err.detail) { + error = err.detail; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const downloadChatAsPDF = async (token: string, title: string, messages: object[]) => { let error = null; const blob = await fetch(`${WEBUI_API_BASE_URL}/utils/pdf`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, body: JSON.stringify({ title: title, @@ -73,7 +110,7 @@ export const downloadChatAsPDF = async (title: string, messages: object[]) => { return res.blob(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -81,13 +118,14 @@ export const downloadChatAsPDF = async (title: string, messages: object[]) => { return blob; }; -export const getHTMLFromMarkdown = async (md: string) => { +export const getHTMLFromMarkdown = async (token: string, md: string) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/utils/markdown`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` }, body: JSON.stringify({ md: md @@ -98,7 +136,7 @@ export const getHTMLFromMarkdown = async (md: string) => { return res.json(); }) .catch((err) => { - console.log(err); + console.error(err); error = err; return null; }); @@ -132,7 +170,7 @@ export const downloadDatabase = async (token: string) => { window.URL.revokeObjectURL(url); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); @@ -168,7 +206,7 @@ export const downloadLiteLLMConfig = async (token: string) => { window.URL.revokeObjectURL(url); }) .catch((err) => { - console.log(err); + console.error(err); error = err.detail; return null; }); diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 95074c2581..8a708d4d2e 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -14,6 +14,7 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; + import Tags from './common/Tags.svelte'; export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; @@ -29,8 +30,18 @@ let url = ''; let key = ''; + let connectionType = 'external'; + let azure = false; + $: azure = + (url.includes('azure.com') || url.includes('cognitive.microsoft.com')) && !direct + ? true + : false; + let prefixId = ''; let enable = true; + let apiVersion = ''; + + let tags = []; let modelId = ''; let modelIds = []; @@ -38,7 +49,10 @@ let loading = false; const verifyOllamaHandler = async () => { - const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => { + const res = await verifyOllamaConnection(localStorage.token, { + url, + key + }).catch((error) => { toast.error(`${error}`); }); @@ -48,11 +62,20 @@ }; const verifyOpenAIHandler = async () => { - const res = await verifyOpenAIConnection(localStorage.token, url, key, direct).catch( - (error) => { - toast.error(`${error}`); - } - ); + const res = await verifyOpenAIConnection( + localStorage.token, + { + url, + key, + config: { + azure: azure, + api_version: apiVersion + } + }, + direct + ).catch((error) => { + toast.error(`${error}`); + }); if (res) { toast.success($i18n.t('Server connection verified')); @@ -77,19 +100,47 @@ const submitHandler = async () => { loading = true; - if (!ollama && (!url || !key)) { + if (!ollama && !url) { loading = false; - toast.error('URL and Key are required'); + toast.error('URL is required'); return; } + if (azure) { + if (!apiVersion) { + loading = false; + + toast.error('API Version is required'); + return; + } + + if (!key) { + loading = false; + + toast.error('Key is required'); + return; + } + + if (modelIds.length === 0) { + loading = false; + toast.error('Deployment names are required'); + return; + } + } + + // remove trailing slash from url + url = url.replace(/\/$/, ''); + const connection = { url, key, config: { enable: enable, + tags: tags, prefix_id: prefixId, - model_ids: modelIds + model_ids: modelIds, + connection_type: connectionType, + ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}) } }; @@ -101,6 +152,7 @@ url = ''; key = ''; prefixId = ''; + tags = []; modelIds = []; }; @@ -110,8 +162,17 @@ key = connection.key; enable = connection.config?.enable ?? true; + tags = connection.config?.tags ?? []; prefixId = connection.config?.prefix_id ?? ''; modelIds = connection.config?.model_ids ?? []; + + if (ollama) { + connectionType = connection.config?.connection_type ?? 'local'; + } else { + connectionType = connection.config?.connection_type ?? 'external'; + azure = connection.config?.azure ?? false; + apiVersion = connection.config?.api_version ?? ''; + } } }; @@ -126,7 +187,7 @@
    -
    +
    {#if edit} {$i18n.t('Edit Connection')} @@ -163,13 +224,37 @@ }} >
    -
    + {#if !direct} +
    +
    +
    {$i18n.t('Connection Type')}
    + +
    + +
    +
    +
    + {/if} + +
    {$i18n.t('URL')}
    - + -
    +
    @@ -215,10 +300,10 @@
    @@ -233,7 +318,7 @@ )} >
    + {#if azure} +
    +
    +
    {$i18n.t('API Version')}
    + +
    + +
    +
    +
    + {/if} + +
    +
    +
    {$i18n.t('Tags')}
    + +
    + { + tags = [ + ...tags, + { + name: e.detail + } + ]; + }} + on:delete={(e) => { + tags = tags.filter((tag) => tag.name !== e.detail); + }} + /> +
    +
    +
    +
    @@ -258,7 +385,7 @@
    {modelId}
    -
    +
    -
    +
    diff --git a/src/lib/components/AddFilesPlaceholder.svelte b/src/lib/components/AddFilesPlaceholder.svelte index d3d7007955..6d72ee0e61 100644 --- a/src/lib/components/AddFilesPlaceholder.svelte +++ b/src/lib/components/AddFilesPlaceholder.svelte @@ -21,7 +21,7 @@ {#if content} {content} {:else} - {$i18n.t('Drop any files here to add to the conversation')} + {$i18n.t('Drop any files here to upload')} {/if}
    diff --git a/src/lib/components/AddServerModal.svelte b/src/lib/components/AddServerModal.svelte new file mode 100644 index 0000000000..1c9ce46e24 --- /dev/null +++ b/src/lib/components/AddServerModal.svelte @@ -0,0 +1,391 @@ + + + +
    +
    +
    + {#if edit} + {$i18n.t('Edit Connection')} + {:else} + {$i18n.t('Add Connection')} + {/if} +
    + +
    + +
    +
    +
    { + e.preventDefault(); + submitHandler(); + }} + > +
    +
    +
    +
    +
    {$i18n.t('URL')}
    +
    + +
    + + + + + + + + + +
    + +
    + +
    +
    +
    + +
    + {$i18n.t(`WebUI will make requests to "{{url}}"`, { + url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}` + })} +
    + +
    +
    +
    {$i18n.t('Auth')}
    + +
    +
    + +
    + +
    + {#if auth_type === 'bearer'} + + {:else if auth_type === 'session'} +
    + {$i18n.t('Forwards system user session credentials to authenticate')} +
    + {/if} +
    +
    +
    +
    + + {#if !direct} +
    + +
    +
    +
    {$i18n.t('Name')}
    + +
    + +
    +
    +
    + +
    +
    {$i18n.t('Description')}
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    + {/if} +
    + +
    + {#if edit} + + {/if} + + +
    +
    +
    +
    +
    +
    diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index b395ddcbd6..2e7dfa1993 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -43,6 +43,7 @@ fill="currentColor" class="w-5 h-5" > +

    {$i18n.t('Close')}

    @@ -68,7 +69,7 @@ v{version} - {changelog[version].date}
    -
    +
    {#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
    diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte new file mode 100644 index 0000000000..e0a64a7a58 --- /dev/null +++ b/src/lib/components/ImportModal.svelte @@ -0,0 +1,154 @@ + + + +
    +
    +
    {$i18n.t('Import')}
    + +
    + +
    +
    +
    { + submitHandler(); + }} + > +
    +
    +
    {$i18n.t('URL')}
    + +
    + + + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte index 59129d3140..0d3f69af32 100644 --- a/src/lib/components/NotificationToast.svelte +++ b/src/lib/components/NotificationToast.svelte @@ -31,13 +31,13 @@ -
    {$i18n.t(`Get started`)}
    +
    + {$i18n.t(`Get started`)} +
    - -
    {/if} diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index e73adb027b..726028664a 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -115,7 +115,7 @@ {feedbacks.length}
    -
    + {#if feedbacks.length > 0}
    -
    + {/if}
    -
    +
    {#if (feedbacks ?? []).length === 0}
    {$i18n.t('No feedbacks found')}
    {:else}
    -
    +
    {feedback?.user?.name}
    { @@ -300,7 +300,9 @@
    -
    +
    {#if loadingLeaderboard}
    @@ -349,7 +351,7 @@
    -
    +
    {model.name} { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); - window.removeEventListener('blur', onBlur); + window.removeEventListener('blur-sm', onBlur); }; }); - {$i18n.t('Functions')} | {$WEBUI_NAME} + {$i18n.t('Functions')} • {$WEBUI_NAME} + { + return await loadFunctionByUrl(localStorage.token, url); + }} + onImport={(func) => { + sessionStorage.function = JSON.stringify({ + ...func + }); + goto('/admin/functions/create'); + }} +/> +
    @@ -211,19 +230,40 @@
    + + {#if query} +
    + +
    + {/if}
    @@ -241,14 +281,14 @@
    {func.type}
    {#if func?.meta?.manifest?.version}
    v{func?.meta?.manifest?.version ?? ''}
    @@ -260,7 +300,7 @@
    -
    {func.id}
    +
    {func.id}
    {func.meta.description} @@ -430,39 +470,43 @@
    - + if (_functions) { + let blob = new Blob([JSON.stringify(_functions)], { + type: 'application/json' + }); + saveAs(blob, `functions-export-${Date.now()}.json`); + } + }} + > +
    + {$i18n.t('Export Functions')} ({$functions.length}) +
    + +
    + + + +
    + + {/if}
    diff --git a/src/lib/components/admin/Functions/AddFunctionMenu.svelte b/src/lib/components/admin/Functions/AddFunctionMenu.svelte new file mode 100644 index 0000000000..6c0f59e1ff --- /dev/null +++ b/src/lib/components/admin/Functions/AddFunctionMenu.svelte @@ -0,0 +1,77 @@ + + + { + if (e.detail === false) { + onClose(); + } + }} +> + + + + +
    + + + + + +
    +
    diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte index 187110be09..1ef7bddc16 100644 --- a/src/lib/components/admin/Functions/FunctionEditor.svelte +++ b/src/lib/components/admin/Functions/FunctionEditor.svelte @@ -1,8 +1,7 @@ + +
    { + await submitHandler(); + saveHandler(); + }} +> +
    + {#if config} +
    +
    +
    {$i18n.t('General')}
    + +
    + +
    +
    +
    + {$i18n.t('Enable Code Execution')} +
    + + +
    +
    + +
    +
    +
    {$i18n.t('Code Execution Engine')}
    +
    + +
    +
    + + {#if config.CODE_EXECUTION_ENGINE === 'jupyter'} +
    + {$i18n.t( + 'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.' + )} +
    + {/if} +
    + + {#if config.CODE_EXECUTION_ENGINE === 'jupyter'} +
    +
    + {$i18n.t('Jupyter URL')} +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + {$i18n.t('Jupyter Auth')} +
    + +
    + +
    +
    + + {#if config.CODE_EXECUTION_JUPYTER_AUTH} +
    +
    + {#if config.CODE_EXECUTION_JUPYTER_AUTH === 'password'} + + {:else} + + {/if} +
    +
    + {/if} +
    + +
    +
    + {$i18n.t('Code Execution Timeout')} +
    + +
    + + + +
    +
    + {/if} +
    + +
    +
    {$i18n.t('Code Interpreter')}
    + +
    + +
    +
    +
    + {$i18n.t('Enable Code Interpreter')} +
    + + +
    +
    + + {#if config.ENABLE_CODE_INTERPRETER} +
    +
    +
    + {$i18n.t('Code Interpreter Engine')} +
    +
    + +
    +
    + + {#if config.CODE_INTERPRETER_ENGINE === 'jupyter'} +
    + {$i18n.t( + 'Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.' + )} +
    + {/if} +
    + + {#if config.CODE_INTERPRETER_ENGINE === 'jupyter'} +
    +
    + {$i18n.t('Jupyter URL')} +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + {$i18n.t('Jupyter Auth')} +
    + +
    + +
    +
    + + {#if config.CODE_INTERPRETER_JUPYTER_AUTH} +
    +
    + {#if config.CODE_INTERPRETER_JUPYTER_AUTH === 'password'} + + {:else} + + {/if} +
    +
    + {/if} +
    + +
    +
    + {$i18n.t('Code Execution Timeout')} +
    + +
    + + + +
    +
    + {/if} + +
    + +
    +
    +
    + {$i18n.t('Code Interpreter Prompt Template')} +
    + + +