diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index a1ea5c8e20..420633a0f6 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -11,7 +11,9 @@ body:
## Important Notes
- - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
+ - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues AND discussions. No matter open or closed.**
+
+ - Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed on the dev branch!**
- **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.
@@ -19,13 +21,19 @@ body:
- **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!
+ - **Scope**: If you want to report a SECURITY VULNERABILITY, then do so through our [GitHub security page](https://github.com/open-webui/open-webui/security).
+
- 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.
+ - label: I have searched for any existing and/or related issues.
+ required: true
+ - label: I have searched for any existing and/or related discussions.
+ required: true
+ - label: I have also searched in the CLOSED issues AND CLOSED discussions and found no related items (your issue might already be addressed on the development branch!).
required: true
- label: I am using the latest version of Open WebUI.
required: true
@@ -47,7 +55,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
- description: Specify the version (e.g., v0.3.11)
+ description: Specify the version (e.g., v0.6.26)
validations:
required: true
@@ -63,7 +71,7 @@ body:
id: operating-system
attributes:
label: Operating System
- description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
+ description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
validations:
required: true
@@ -126,6 +134,7 @@ body:
description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
+ **If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
placeholder: |
Example (include every detail):
@@ -163,5 +172,5 @@ body:
attributes:
value: |
## Note
- If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
+ **If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
Thank you for contributing to Open WebUI!
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 2a326f65e4..05dc6cfa94 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -8,10 +8,21 @@ body:
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.
+
+ Please check the **open AND closed** [Issues](https://github.com/open-webui/open-webui/issues) AND [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.
+ #### Scope
+
+ If your feature request is likely to take more than a quick coding session to implement, test and verify, then open it in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions) instead.
+ **We will close and force move your feature request to the Ideas section, if we believe your feature request is not trivial/quick to implement.**
+ This is to ensure the issues tab is used only for issues, quickly addressable feature requests and tracking tickets by the maintainers.
+ Other feature requests belong in the **Ideas** section of the [Discussions](https://github.com/open-webui/open-webui/discussions).
+
+ If your feature request might impact others in the community, definitely open a discussion instead and evaluate whether and how to implement it.
+
+ 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**.
@@ -22,7 +33,6 @@ body:
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.
@@ -35,14 +45,22 @@ body:
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.
+ - label: I have searched for all existing **open AND closed** issues and discussions for similar requests. I have found none that is comparable to my request.
+ required: true
+ - type: checkboxes
+ id: feature-scope
+ attributes:
+ label: Verify Feature Scope
+ description: Please confirm the feature's scope is within the described scope
+ options:
+ - label: I have read through and understood the scope definition for feature requests in the Issues section. I believe my feature request meets the definition and belongs in the Issues section instead of the 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..."
+ placeholder: "Ex. I'm always frustrated when... / Not related to a problem"
validations:
required: true
- type: textarea
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ed93957ea4..1c83fd305b 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -12,12 +12,6 @@ updates:
interval: monthly
target-branch: 'dev'
- - package-ecosystem: npm
- directory: '/'
- schedule:
- interval: monthly
- target-branch: 'dev'
-
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 7f603cb10c..75b9359168 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,17 +1,20 @@
# Pull Request Checklist
-### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) and describe your changes before submitting a pull request.
+### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
+
+This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
**Before submitting, make sure you've checked the following:**
-- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch.
-- [ ] **Description:** Provide a concise description of the changes made in this pull request.
+- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
+- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **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?
+- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, 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 to validate the changes?
+- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
+- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **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 clearly categorize this pull request, prefix the pull request title using one of the following:
+- [ ] **Title 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
@@ -73,4 +76,7 @@
### Contributor License Agreement
-By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
+By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
+
+> [!NOTE]
+> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index 443d904199..019fbb6bae 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
@@ -36,7 +36,7 @@ jobs:
echo "::set-output name=content::$CHANGELOG_ESCAPED"
- name: Create GitHub release
- uses: actions/github-script@v7
+ uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -61,7 +61,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger Docker build workflow
- uses: actions/github-script@v7
+ uses: actions/github-script@v8
with:
script: |
github.rest.actions.createWorkflowDispatch({
diff --git a/.github/workflows/deploy-to-hf-spaces.yml b/.github/workflows/deploy-to-hf-spaces.yml
index 7fc66acf5c..a30046af89 100644
--- a/.github/workflows/deploy-to-hf-spaces.yml
+++ b/.github/workflows/deploy-to-hf-spaces.yml
@@ -27,7 +27,7 @@ jobs:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
lfs: true
diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml
index 821ffb7206..7a5dc651c4 100644
--- a/.github/workflows/docker-build.yaml
+++ b/.github/workflows/docker-build.yaml
@@ -43,7 +43,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -141,8 +141,11 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
+ - name: Delete huge unnecessary tools folder
+ run: rm -rf /opt/hostedtoolcache
+
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -243,8 +246,11 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
+ - name: Delete huge unnecessary tools folder
+ run: rm -rf /opt/hostedtoolcache
+
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -347,7 +353,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -419,6 +425,108 @@ jobs:
if-no-files-found: error
retention-days: 1
+ build-slim-image:
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ packages: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ runner: ubuntu-latest
+ - platform: linux/arm64
+ runner: ubuntu-24.04-arm
+
+ steps:
+ # GitHub Packages requires the entire repository name to be in lowercase
+ # although the repository owner has a lowercase username, this prevents some people from running actions after forking
+ - name: Set repository and image name to lowercase
+ run: |
+ echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
+ echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
+ env:
+ IMAGE_NAME: '${{ github.repository }}'
+
+ - name: Prepare
+ run: |
+ platform=${{ matrix.platform }}
+ echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
+
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata for Docker images (slim tag)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.FULL_IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha,prefix=git-
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
+ flavor: |
+ latest=${{ github.ref == 'refs/heads/main' }}
+ suffix=-slim,onlatest=true
+
+ - name: Extract metadata for Docker cache
+ id: cache-meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.FULL_IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ ${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
+ flavor: |
+ prefix=cache-slim-${{ matrix.platform }}-
+ latest=false
+
+ - name: Build Docker image (slim)
+ uses: docker/build-push-action@v5
+ id: build
+ with:
+ context: .
+ push: true
+ platforms: ${{ matrix.platform }}
+ labels: ${{ steps.meta.outputs.labels }}
+ outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
+ cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
+ cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
+ build-args: |
+ BUILD_HASH=${{ github.sha }}
+ USE_SLIM=true
+
+ - name: Export digest
+ run: |
+ mkdir -p /tmp/digests
+ digest="${{ steps.build.outputs.digest }}"
+ touch "/tmp/digests/${digest#sha256:}"
+
+ - name: Upload digest
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-slim-${{ env.PLATFORM_PAIR }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
@@ -433,7 +541,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
@@ -487,7 +595,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
@@ -543,7 +651,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
@@ -599,7 +707,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
@@ -640,3 +748,59 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+
+ merge-slim-images:
+ runs-on: ubuntu-latest
+ needs: [build-slim-image]
+ steps:
+ # GitHub Packages requires the entire repository name to be in lowercase
+ # although the repository owner has a lowercase username, this prevents some people from running actions after forking
+ - name: Set repository and image name to lowercase
+ run: |
+ echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
+ echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
+ env:
+ IMAGE_NAME: '${{ github.repository }}'
+
+ - name: Download digests
+ uses: actions/download-artifact@v5
+ with:
+ pattern: digests-slim-*
+ path: /tmp/digests
+ merge-multiple: true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata for Docker images (default slim tag)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.FULL_IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha,prefix=git-
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
+ flavor: |
+ latest=${{ github.ref == 'refs/heads/main' }}
+ suffix=-slim,onlatest=true
+
+ - name: Create manifest list and push
+ working-directory: /tmp/digests
+ run: |
+ docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
+
+ - name: Inspect image
+ run: |
+ docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml
index 1bcdd92c1d..562e6aa1c1 100644
--- a/.github/workflows/format-backend.yaml
+++ b/.github/workflows/format-backend.yaml
@@ -30,10 +30,10 @@ jobs:
- 3.12.x
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: '${{ matrix.python-version }}'
diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml
index 9a007581ff..eaa1072fbc 100644
--- a/.github/workflows/format-build-frontend.yaml
+++ b/.github/workflows/format-build-frontend.yaml
@@ -24,15 +24,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
- run: npm install
+ run: npm install --force
- name: Format Frontend
run: npm run format
@@ -51,15 +51,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '22'
- name: Install Dependencies
- run: npm ci
+ run: npm ci --force
- name: Run vitest
run: npm run test:frontend
diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml
index fd1adab3a9..9995ccedae 100644
--- a/.github/workflows/release-pypi.yml
+++ b/.github/workflows/release-pypi.yml
@@ -16,15 +16,15 @@ jobs:
id-token: write
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Git
run: sudo apt-get update && sudo apt-get install -y git
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: 22
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
with:
python-version: 3.11
- name: Build
diff --git a/.gitignore b/.gitignore
index 32271f8087..07494bd151 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+x.py
+yarn.lock
.DS_Store
node_modules
/build
@@ -12,7 +14,8 @@ vite.config.ts.timestamp-*
__pycache__/
*.py[cod]
*$py.class
-
+.nvmrc
+CLAUDE.md
# C extensions
*.so
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3294d80949..8cacf29521 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,960 @@ 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.40] - 2025-11-25
+
+### Fixed
+
+- 🗄️ A critical PostgreSQL user listing performance issue was resolved by removing a redundant count operation that caused severe database slowdowns and potential timeouts when viewing user lists in admin panels.
+
+## [0.6.39] - 2025-11-25
+
+### Added
+
+- 💬 A user list modal was added to channels, displaying all users with access and featuring search, sorting, and pagination capabilities. [Commit](https://github.com/open-webui/open-webui/commit/c0e120353824be00a2ef63cbde8be5d625bd6fd0)
+- 💬 Channel navigation now displays the total number of users with access to the channel. [Commit](https://github.com/open-webui/open-webui/commit/3b5710d0cd445cf86423187f5ee7c40472a0df0b)
+- 🔌 Tool servers and MCP connections now support function name filtering, allowing administrators to selectively enable or block specific functions using allow/block lists. [Commit](https://github.com/open-webui/open-webui/commit/743199f2d097ae1458381bce450d9025a0ab3f3d)
+- ⚡ A toggle to disable parallel embedding processing was added via "ENABLE_ASYNC_EMBEDDING", allowing sequential processing for rate-limited or resource-constrained local embedding setups. [#19444](https://github.com/open-webui/open-webui/pull/19444)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Localization improvements were made for German (de-DE) and Portuguese (Brazil) translations.
+
+### Fixed
+
+- 📝 Inline citations now render correctly within markdown lists and nested elements instead of displaying as "undefined" values. [#19452](https://github.com/open-webui/open-webui/issues/19452)
+- 👥 Group member selection now works correctly without randomly selecting other users or causing the user list to jump around. [#19426](https://github.com/open-webui/open-webui/issues/19426)
+- 👥 Admin panel user list now displays the correct total user count and properly paginates 30 items per page after fixing database query issues with group member joins. [#19429](https://github.com/open-webui/open-webui/issues/19429)
+- 🔍 Knowledge base reindexing now works correctly after resolving async execution chain issues by implementing threadpool workers for embedding operations. [#19434](https://github.com/open-webui/open-webui/pull/19434)
+- 🖼️ OpenAI image generation now works correctly after fixing a connection adapter error caused by incorrect URL formatting. [#19435](https://github.com/open-webui/open-webui/pull/19435)
+
+### Changed
+
+- 🔧 BREAKING: Docling configuration has been consolidated from individual environment variables into a single "DOCLING_PARAMS" JSON configuration and now supports API key authentication via "DOCLING_API_KEY", requiring users to migrate existing Docling settings to the new format. [#16841](https://github.com/open-webui/open-webui/issues/16841), [#19427](https://github.com/open-webui/open-webui/pull/19427)
+- 🔧 The environment variable "REPLACE_IMAGE_URLS_IN_CHAT_RESPONSE" has been renamed to "ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION" for naming consistency.
+
+## [0.6.38] - 2025-11-24
+
+### Fixed
+
+- 🔍 Hybrid search now works reliably after recent changes.
+- 🛠️ Tool server saving now handles errors gracefully, preventing failed saves from impacting the UI.
+- 🔐 SSO/OIDC code fixed to improve login reliability and better handle edge cases.
+
+## [0.6.37] - 2025-11-24
+
+### Added
+
+- 🔐 Granular sharing permissions are now available with two-tiered control separating group sharing from public sharing, allowing administrators to independently configure whether users can share workspace items with groups or make them publicly accessible, with separate permission toggles for models, knowledge bases, prompts, tools, and notes, configurable via "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", and corresponding environment variables for other workspace item types, while groups can now be configured to opt-out of sharing via the "Allow Group Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/7be750bcbb40da91912a0a66b7ab791effdcc3b6), [Commit](https://github.com/open-webui/open-webui/commit/f69e37a8507d6d57382d6670641b367f3127f90a)
+- 🔐 Password policy enforcement is now available with configurable validation rules, allowing administrators to require specific password complexity requirements via "ENABLE_PASSWORD_VALIDATION" and "PASSWORD_VALIDATION_REGEX_PATTERN" environment variables, with default pattern requiring minimum 8 characters including uppercase, lowercase, digit, and special character. [#17794](https://github.com/open-webui/open-webui/pull/17794)
+- 🔐 Granular import and export permissions are now available for workspace items, introducing six separate permission toggles for models, prompts, and tools that are disabled by default for enhanced security. [#19242](https://github.com/open-webui/open-webui/pull/19242)
+- 👥 Default group assignment is now available for new users, allowing administrators to automatically assign newly registered users to a specified group for streamlined access control to models, prompts, and tools, particularly useful for organizations with group-based model access policies. [#19325](https://github.com/open-webui/open-webui/pull/19325), [#17842](https://github.com/open-webui/open-webui/issues/17842)
+- 🔒 Password-based authentication can now be fully disabled via "ENABLE_PASSWORD_AUTH" environment variable, enforcing SSO-only authentication and preventing password login fallback when SSO is configured. [#19113](https://github.com/open-webui/open-webui/pull/19113)
+- 🖼️ Large stream chunk handling was implemented to support models that generate images directly in their output responses, with configurable buffer size via "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE" environment variable, resolving compatibility issues with models like Gemini 2.5 Flash Image. [#18884](https://github.com/open-webui/open-webui/pull/18884), [#17626](https://github.com/open-webui/open-webui/issues/17626)
+- 🖼️ Streaming response middleware now handles images in delta updates with automatic base64 conversion, enabling proper display of images from models using the "choices[0].delta.images.image_url" format such as Gemini 2.5 Flash Image Preview on OpenRouter. [#19073](https://github.com/open-webui/open-webui/pull/19073), [#19019](https://github.com/open-webui/open-webui/issues/19019)
+- 📈 Model list API performance was optimized by pre-fetching user group memberships and removing profile image URLs from response payloads, significantly reducing both database queries and payload size for instances with large model lists, with profile images now served dynamically via dedicated endpoints. [#19097](https://github.com/open-webui/open-webui/pull/19097), [#18950](https://github.com/open-webui/open-webui/issues/18950)
+- ⏩ Batch file processing performance was improved by reducing database queries by 67% while ensuring data consistency between vector and relational databases. [#18953](https://github.com/open-webui/open-webui/pull/18953)
+- 🚀 Chat import performance was dramatically improved by replacing individual per-chat API requests with a bulk import endpoint, reducing import time by up to 95% for large chat collections and providing user feedback via toast notifications displaying the number of successfully imported chats. [#17861](https://github.com/open-webui/open-webui/pull/17861)
+- ⚡ Socket event broadcasting performance was optimized by implementing user-specific rooms, significantly reducing server overhead particularly for users with multiple concurrent sessions. [#18996](https://github.com/open-webui/open-webui/pull/18996)
+- 🗄️ Weaviate is now supported as a vector database option, providing an additional choice for RAG document storage alongside existing ChromaDB, Milvus, Qdrant, and OpenSearch integrations. [#14747](https://github.com/open-webui/open-webui/pull/14747)
+- 🗄️ PostgreSQL pgvector now supports HNSW index types and large dimensional embeddings exceeding 2000 dimensions through automatic halfvec type selection, with configurable index methods via "PGVECTOR_INDEX_METHOD", "PGVECTOR_HNSW_M", "PGVECTOR_HNSW_EF_CONSTRUCTION", and "PGVECTOR_IVFFLAT_LISTS" environment variables. [#19158](https://github.com/open-webui/open-webui/pull/19158), [#16890](https://github.com/open-webui/open-webui/issues/16890)
+- 🔍 Azure AI Search is now supported as a web search provider, enabling integration with Azure's cognitive search services via "AZURE_AI_SEARCH_API_KEY", "AZURE_AI_SEARCH_ENDPOINT", and "AZURE_AI_SEARCH_INDEX_NAME" configuration. [#19104](https://github.com/open-webui/open-webui/pull/19104)
+- ⚡ External embedding generation now processes API requests in parallel instead of sequential batches, reducing document processing time by 10-50x when using OpenAI, Azure OpenAI, or Ollama embedding providers, with large PDFs now processing in seconds instead of minutes. [#19296](https://github.com/open-webui/open-webui/pull/19296)
+- 💨 Base64 image conversion is now available for markdown content in chat responses, automatically uploading embedded images exceeding 1KB and replacing them with file URLs to reduce payload size and resource consumption, configurable via "REPLACE_IMAGE_URLS_IN_CHAT_RESPONSE" environment variable. [#19076](https://github.com/open-webui/open-webui/pull/19076)
+- 🎨 OpenAI image generation now supports additional API parameters including quality settings for GPT Image 1, configurable via "IMAGES_OPENAI_API_PARAMS" environment variable or through the admin interface, enabling cost-effective image generation with low, medium, or high quality options. [#19228](https://github.com/open-webui/open-webui/issues/19228)
+- 🖼️ Image editing can now be independently enabled or disabled via admin settings, allowing administrators to control whether sequential image prompts trigger image editing or new image generation, configurable via "ENABLE_IMAGE_EDIT" environment variable. [#19284](https://github.com/open-webui/open-webui/issues/19284)
+- 🔐 SSRF protection was implemented with a configurable URL blocklist that prevents access to cloud metadata endpoints and private networks, with default protections for AWS, Google Cloud, Azure, and Alibaba Cloud metadata services, customizable via "WEB_FETCH_FILTER_LIST" environment variable. [#19201](https://github.com/open-webui/open-webui/pull/19201)
+- ⚡ Workspace models page now supports server-side pagination dramatically improving load times and usability for instances with large numbers of workspace models.
+- 🔍 Hybrid search now indexes file metadata including filenames, titles, headings, sources, and snippets alongside document content, enabling keyword queries to surface documents where search terms appear only in metadata, configurable via "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS" environment variable. [#19095](https://github.com/open-webui/open-webui/pull/19095)
+- 📂 Knowledge base upload page now supports folder drag-and-drop with recursive directory handling, enabling batch uploads of entire directory structures instead of requiring individual file selection. [#19320](https://github.com/open-webui/open-webui/pull/19320)
+- 🤖 Model cloning is now available in admin settings, allowing administrators to quickly create workspace models based on existing base models through a "Clone" option in the model dropdown menu. [#17937](https://github.com/open-webui/open-webui/pull/17937)
+- 🎨 UI scale adjustment is now available in interface settings, allowing users to increase the size of the entire interface from 1.0x to 1.5x for improved accessibility and readability, particularly beneficial for users with visual impairments. [#19186](https://github.com/open-webui/open-webui/pull/19186)
+- 📌 Default pinned models can now be configured by administrators for all new users, mirroring the behavior of default models where admin-configured defaults apply only to users who haven't customized their pinned models, configurable via "DEFAULT_PINNED_MODELS" environment variable. [#19273](https://github.com/open-webui/open-webui/pull/19273)
+- 🎙️ Text-to-Speech and Speech-to-Text services now receive user information headers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing external TTS and STT providers to implement user-specific personalization, rate limiting, and usage tracking. [#19323](https://github.com/open-webui/open-webui/pull/19323), [#19312](https://github.com/open-webui/open-webui/issues/19312)
+- 🎙️ Voice mode now supports custom system prompts via "VOICE_MODE_PROMPT_TEMPLATE" configuration, allowing administrators to control response style and behavior for voice interactions. [#18607](https://github.com/open-webui/open-webui/pull/18607)
+- 🔧 WebSocket and Redis configuration options are now available including debug logging controls, custom ping timeout and interval settings, and arbitrary Redis connection options via "WEBSOCKET_SERVER_LOGGING", "WEBSOCKET_SERVER_ENGINEIO_LOGGING", "WEBSOCKET_SERVER_PING_TIMEOUT", "WEBSOCKET_SERVER_PING_INTERVAL", and "WEBSOCKET_REDIS_OPTIONS" environment variables. [#19091](https://github.com/open-webui/open-webui/pull/19091)
+- 🔧 MCP OAuth dynamic client registration now automatically detects and uses the appropriate token endpoint authentication method from server-supported options, enabling compatibility with OAuth servers that only support "client_secret_basic" instead of "client_secret_post". [#19193](https://github.com/open-webui/open-webui/issues/19193)
+- 🔧 Custom headers can now be configured for remote MCP and OpenAPI tool server connections, enabling integration with services that require additional authentication headers. [#18918](https://github.com/open-webui/open-webui/issues/18918)
+- 🔍 Perplexity Search now supports custom API endpoints via "PERPLEXITY_SEARCH_API_URL" configuration and automatically forwards user information headers to enable personalized search experiences. [#19147](https://github.com/open-webui/open-webui/pull/19147)
+- 🔍 User information headers can now be optionally forwarded to external web search engines when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#19043](https://github.com/open-webui/open-webui/pull/19043)
+- 📊 Daily active user metric is now available for monitoring, tracking unique users active since midnight UTC via the "webui.users.active.today" Prometheus gauge. [#19236](https://github.com/open-webui/open-webui/pull/19236), [#19234](https://github.com/open-webui/open-webui/issues/19234)
+- 📊 Audit log file path is now configurable via "AUDIT_LOGS_FILE_PATH" environment variable, enabling storage in separate volumes or custom locations. [#19173](https://github.com/open-webui/open-webui/pull/19173)
+- 🎨 Sidebar collapse states for model lists and group information are now persistent across page refreshes, remembering user preferences through browser-based storage. [#19159](https://github.com/open-webui/open-webui/issues/19159)
+- 🎨 Background image display was enhanced with semi-transparent overlays for navbar and sidebar, creating a seamless and visually cohesive design across the entire interface. [#19157](https://github.com/open-webui/open-webui/issues/19157)
+- 📋 Tables in chat messages now include a copy button that appears on hover, enabling quick copying of table content alongside the existing CSV export functionality. [#19162](https://github.com/open-webui/open-webui/issues/19162)
+- 📝 Notes can now be created directly via the "/notes/new" URL endpoint with optional title and content query parameters, enabling faster note creation through bookmarks and shortcuts. [#19195](https://github.com/open-webui/open-webui/issues/19195)
+- 🏷️ Tag suggestions are now context-aware, displaying only relevant tags when creating or editing models versus chat conversations, preventing confusion between model and chat tags. [#19135](https://github.com/open-webui/open-webui/issues/19135)
+- ✍️ Prompt autocompletion is now available independently of the rich text input setting, improving accessibility to the feature. [#19150](https://github.com/open-webui/open-webui/issues/19150)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Simplified Chinese, Traditional Chinese, Portuguese (Brazil), Catalan, Spanish (Spain), Finnish, Irish, Farsi, Swedish, Danish, German, Korean, and Thai were improved and expanded.
+
+### Fixed
+
+- 🤖 Model update functionality now works correctly, resolving a database parameter binding error that prevented saving changes to model configurations via the Save & Update button. [#19335](https://github.com/open-webui/open-webui/issues/19335)
+- 🖼️ Multiple input images for image editing and generation are now correctly passed as an array using the "image[]" parameter syntax, enabling proper multi-image reference functionality with models like GPT Image 1. [#19339](https://github.com/open-webui/open-webui/issues/19339)
+- 📱 PWA installations on iOS now properly refresh after server container restarts, resolving freezing issues by automatically unregistering service workers when version or deployment changes are detected. [#19316](https://github.com/open-webui/open-webui/pull/19316)
+- 🗄️ S3 Vectors collection detection now correctly handles buckets with more than 2000 indexes by using direct index lookup instead of paginated list scanning, improving performance by approximately 8x and enabling RAG queries to work reliably at scale. [#19238](https://github.com/open-webui/open-webui/pull/19238), [#19233](https://github.com/open-webui/open-webui/issues/19233)
+- 📈 Feedback retrieval performance was optimized by eliminating N+1 query patterns through database joins, adding server-side pagination and sorting, significantly reducing database load for instances with large feedback datasets. [#17976](https://github.com/open-webui/open-webui/pull/17976)
+- 🔍 Chat search now works correctly with PostgreSQL when chat data contains null bytes, with comprehensive sanitization preventing null bytes during data writes, cleaning existing data on read, and stripping null bytes during search queries to ensure reliable search functionality. [#15616](https://github.com/open-webui/open-webui/issues/15616)
+- 🔍 Hybrid search with reranking now correctly handles attribute validation, preventing errors when collection results lack expected structure. [#19025](https://github.com/open-webui/open-webui/pull/19025), [#17046](https://github.com/open-webui/open-webui/issues/17046)
+- 🔎 Reranking functionality now works correctly after recent refactoring, resolving crashes caused by incorrect function argument handling. [#19270](https://github.com/open-webui/open-webui/pull/19270)
+- 🤖 Azure OpenAI models now support the "reasoning_effort" parameter, enabling proper configuration of reasoning capabilities for models like GPT-5.1 which default to no reasoning without this setting. [#19290](https://github.com/open-webui/open-webui/issues/19290)
+- 🤖 Models with very long IDs can now be deleted correctly, resolving URL length limitations that previously prevented management operations on such models. [#18230](https://github.com/open-webui/open-webui/pull/18230)
+- 🤖 Model-level streaming settings now correctly apply to API requests, ensuring "Stream Chat Response" toggle properly controls the streaming parameter. [#19154](https://github.com/open-webui/open-webui/issues/19154)
+- 🖼️ Image editing configuration now correctly preserves independent OpenAI API endpoints and keys, preventing them from being overwritten by image generation settings. [#19003](https://github.com/open-webui/open-webui/issues/19003)
+- 🎨 Gemini image edit settings now display correctly in the admin panel, fixing an incorrect configuration key reference that prevented proper rendering of edit options. [#19200](https://github.com/open-webui/open-webui/pull/19200)
+- 🖌️ Image generation settings menu now loads correctly, resolving validation errors with AUTOMATIC1111 API authentication parameters. [#19187](https://github.com/open-webui/open-webui/issues/19187), [#19246](https://github.com/open-webui/open-webui/issues/19246)
+- 📅 Date formatting in chat search and admin user chat search now correctly respects the "DEFAULT_LOCALE" environment variable, displaying dates according to the configured locale instead of always using MM/DD/YYYY format. [#19305](https://github.com/open-webui/open-webui/pull/19305), [#19020](https://github.com/open-webui/open-webui/issues/19020)
+- 📝 RAG template query placeholder escaping logic was corrected to prevent unintended replacements of context values when query placeholders appear in retrieved content. [#19102](https://github.com/open-webui/open-webui/pull/19102), [#19101](https://github.com/open-webui/open-webui/issues/19101)
+- 📄 RAG template prompt duplication was eliminated by removing redundant user query section from the default template. [#19099](https://github.com/open-webui/open-webui/pull/19099), [#19098](https://github.com/open-webui/open-webui/issues/19098)
+- 📋 MinerU local mode configuration no longer incorrectly requires an API key, allowing proper use of local content extraction without external API credentials. [#19258](https://github.com/open-webui/open-webui/issues/19258)
+- 📊 Excel file uploads now work correctly with the addition of the missing msoffcrypto-tool dependency, resolving import errors introduced by the unstructured package upgrade. [#19153](https://github.com/open-webui/open-webui/issues/19153)
+- 📑 Docling parameters now properly handle JSON serialization, preventing exceptions and ensuring configuration changes are saved correctly. [#19072](https://github.com/open-webui/open-webui/pull/19072)
+- 🛠️ UserValves configuration now correctly isolates settings per tool, preventing configuration contamination when multiple tools with UserValves are used simultaneously. [#19185](https://github.com/open-webui/open-webui/pull/19185), [#15569](https://github.com/open-webui/open-webui/issues/15569)
+- 🔧 Tool selection prompt now correctly handles user messages without duplication, removing redundant query prefixes and improving prompt clarity. [#19122](https://github.com/open-webui/open-webui/pull/19122), [#19121](https://github.com/open-webui/open-webui/issues/19121)
+- 📝 Notes chat feature now correctly submits messages to the completions endpoint, resolving errors that prevented AI model interactions. [#19079](https://github.com/open-webui/open-webui/pull/19079)
+- 📝 Note PDF downloads now sanitize HTML content using DOMPurify before rendering, preventing potential DOM-based XSS attacks from malicious content in notes. [Commit](https://github.com/open-webui/open-webui/commit/03cc6ce8eb5c055115406e2304fbf7e3338b8dce)
+- 📁 Archived chats now have their folder associations automatically removed to prevent unintended deletion when their previous folder is deleted. [#14578](https://github.com/open-webui/open-webui/issues/14578)
+- 🔐 ElevenLabs API key is now properly obfuscated in the admin settings page, preventing plain text exposure of sensitive credentials. [#19262](https://github.com/open-webui/open-webui/pull/19262), [#19260](https://github.com/open-webui/open-webui/issues/19260)
+- 🔧 MCP OAuth server metadata discovery now follows the correct specification order, ensuring proper authentication flow compliance. [#19244](https://github.com/open-webui/open-webui/pull/19244)
+- 🔒 API key endpoint restrictions now properly enforce access controls for all endpoints including SCIM, preventing unintended access when "API_KEY_ALLOWED_ENDPOINTS" is configured. [#19168](https://github.com/open-webui/open-webui/issues/19168)
+- 🔓 OAuth role claim parsing now supports both flat and nested claim structures, enabling compatibility with OAuth providers that deliver claims as direct properties on the user object rather than nested structures. [#19286](https://github.com/open-webui/open-webui/pull/19286)
+- 🔑 OAuth MCP server verification now correctly extracts the access token value for authorization headers instead of sending the entire token dictionary. [#19149](https://github.com/open-webui/open-webui/pull/19149), [#19148](https://github.com/open-webui/open-webui/issues/19148)
+- ⚙️ OAuth dynamic client registration now correctly converts empty strings to None for optional fields, preventing validation failures in MCP package integration. [#19144](https://github.com/open-webui/open-webui/pull/19144), [#19129](https://github.com/open-webui/open-webui/issues/19129)
+- 🔐 OIDC authentication now correctly passes client credentials in access token requests, ensuring compatibility with providers that require these parameters per RFC 6749. [#19132](https://github.com/open-webui/open-webui/pull/19132), [#19131](https://github.com/open-webui/open-webui/issues/19131)
+- 🔗 OAuth client creation now respects configured token endpoint authentication methods instead of defaulting to basic authentication, preventing failures with servers that don't support basic auth. [#19165](https://github.com/open-webui/open-webui/pull/19165)
+- 📋 Text copied from chat responses in Chrome now pastes without background formatting, improving readability when pasting into word processors. [#19083](https://github.com/open-webui/open-webui/issues/19083)
+
+### Changed
+
+- 🗄️ Group membership data storage was refactored from JSON arrays to a dedicated relational database table, significantly improving query performance and scalability for instances with large numbers of users and groups, while API responses now return member counts instead of full user ID arrays. [#19239](https://github.com/open-webui/open-webui/pull/19239)
+- 📄 MinerU parameter handling was refactored to pass parameters directly to the API, improving flexibility and fixing VLM backend configuration. [#19105](https://github.com/open-webui/open-webui/pull/19105), [#18446](https://github.com/open-webui/open-webui/discussions/18446)
+- 🔐 API key creation is now controlled by granular user and group permissions, with the "ENABLE_API_KEY" environment variable renamed to "ENABLE_API_KEYS" and disabled by default, requiring explicit configuration at both the global and user permission levels, while related environment variables "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS" and "API_KEY_ALLOWED_ENDPOINTS" were renamed to "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS" and "API_KEYS_ALLOWED_ENDPOINTS" respectively. [#18336](https://github.com/open-webui/open-webui/pull/18336)
+
+## [0.6.36] - 2025-11-07
+
+### Added
+
+- 🔐 OAuth group parsing now supports configurable separators via the "OAUTH_GROUPS_SEPARATOR" environment variable, enabling proper handling of semicolon-separated group claims from providers like CILogon. [#18987](https://github.com/open-webui/open-webui/pull/18987), [#18979](https://github.com/open-webui/open-webui/issues/18979)
+
+### Fixed
+
+- 🛠️ Tool calling functionality is restored by correcting asynchronous function handling in tool parameter updates. [#18981](https://github.com/open-webui/open-webui/issues/18981)
+- 🖼️ The ComfyUI image edit workflow editor modal now opens correctly when clicking the Edit button. [#18978](https://github.com/open-webui/open-webui/issues/18978)
+- 🔥 Firecrawl import errors are resolved by implementing lazy loading and using the correct class name. [#18973](https://github.com/open-webui/open-webui/issues/18973)
+- 🔌 Socket.IO CORS warning is resolved by properly configuring CORS origins for Socket.IO connections. [Commit](https://github.com/open-webui/open-webui/commit/639d26252e528c9c37a5f553b11eb94376d8792d)
+
+## [0.6.35] - 2025-11-06
+
+### Added
+
+- 🖼️ Image generation system received a comprehensive overhaul with major new capabilities including full image editing support allowing users to modify existing images using text prompts with OpenAI, Gemini, or ComfyUI engines, adding Gemini 2.5 Flash Image (Nano Banana) support, Qwen Image Edit integration, resolution of base64-encoded image display issues, streamlined AUTOMATIC1111 configuration by consolidating parameters into a flexible JSON parameters field, and enhanced UI with a code editor modal for ComfyUI workflow management. [#17434](https://github.com/open-webui/open-webui/pull/17434), [#16976](https://github.com/open-webui/open-webui/issues/16976), [Commit](https://github.com/open-webui/open-webui/commit/8e5690aab4f632a57027e2acf880b8f89a8717c0), [Commit](https://github.com/open-webui/open-webui/commit/72f8539fd2e679fec0762945f22f4b8a6920afa0), [Commit](https://github.com/open-webui/open-webui/commit/8d34fcb586eeee1fac6da2f991518b8a68b00b72), [Commit](https://github.com/open-webui/open-webui/commit/72900cd686de1fa6be84b5a8a2fc857cff7b91b8)
+- 🔒 CORS origin validation was added to WebSocket connections as a defense-in-depth security measure against cross-site WebSocket hijacking attacks. [#18411](https://github.com/open-webui/open-webui/pull/18411), [#18410](https://github.com/open-webui/open-webui/issues/18410)
+- 🔄 Automatic page refresh now occurs when a version update is detected via WebSocket connection, ensuring users always run the latest version without cache issues. [Commit](https://github.com/open-webui/open-webui/commit/989f192c92d2fe55daa31336e7971e21798b96ae)
+- 🐍 Experimental initial preparations for Python 3.13 compatibility by updating dependencies with security enhancements and cryptographic improvements. [#18430](https://github.com/open-webui/open-webui/pull/18430), [#18424](https://github.com/open-webui/open-webui/pull/18424)
+- ⚡ Image compression now preserves the original image format instead of converting to PNG, significantly reducing file sizes and improving chat loading performance. [#18506](https://github.com/open-webui/open-webui/pull/18506)
+- 🎤 Mistral Voxtral model support was added for text-to-speech, including voxtral-small and voxtral-mini models with both transcription and chat completion API support. [#18934](https://github.com/open-webui/open-webui/pull/18934)
+- 🔊 Text-to-speech now uses a global audio queue system to prevent overlapping playback, ensuring only one TTS instance plays at a time with proper stop/start controls and automatic cleanup when switching between messages. [#16152](https://github.com/open-webui/open-webui/pull/16152), [#18744](https://github.com/open-webui/open-webui/pull/18744), [#16150](https://github.com/open-webui/open-webui/issues/16150)
+- 🔊 ELEVENLABS_API_BASE_URL environment variable now allows configuration of custom ElevenLabs API endpoints, enabling support for EU residency API requirements. [#18402](https://github.com/open-webui/open-webui/issues/18402)
+- 🔐 OAUTH_ROLES_SEPARATOR environment variable now allows custom role separators for OAuth roles that contain commas, useful for roles specified in LDAP syntax. [#18572](https://github.com/open-webui/open-webui/pull/18572)
+- 📄 External document loaders can now optionally forward user information headers when ENABLE_FORWARD_USER_INFO_HEADERS is enabled, enabling cost tracking, audit logs, and usage analytics for external services. [#18731](https://github.com/open-webui/open-webui/pull/18731)
+- 📄 MISTRAL_OCR_API_BASE_URL environment variable now allows configuration of custom Mistral OCR API endpoints for flexible deployment options. [Commit](https://github.com/open-webui/open-webui/commit/415b93c7c35c2e2db4425e6da1b88b3750f496b0)
+- ⌨️ Keyboard shortcut hints are now displayed on sidebar buttons with a refactored shortcuts modal that accurately reflects all available hotkeys across different keyboard layouts. [#18473](https://github.com/open-webui/open-webui/pull/18473)
+- 🛠️ Tooltips now display tool descriptions when hovering over tool names on the model edit page, improving usability and providing immediate context. [#18707](https://github.com/open-webui/open-webui/pull/18707)
+- 📝 "Create a new note" from the search modal now immediately creates a new private note and opens it in the editor instead of navigating to the generic notes page. [#18255](https://github.com/open-webui/open-webui/pull/18255)
+- 🖨️ Code block output now preserves whitespace formatting with monospace font to accurately reflect terminal behavior. [#18352](https://github.com/open-webui/open-webui/pull/18352)
+- ✏️ Edit button is now available in the three-dot menu of models in the workspace section for quick access to model editing, with the menu reorganized for better user experience and Edit, Clone, Copy Link, and Share options logically grouped. [#18574](https://github.com/open-webui/open-webui/pull/18574)
+- 📌 Sidebar models section is now collapsible, allowing users to expand and collapse the pinned models list for better sidebar organization. [Commit](https://github.com/open-webui/open-webui/commit/82c08a3b5d189f81c96b6548cc872198771015b0)
+- 🌙 Dark mode styles for select elements were added using Tailwind CSS classes, improving consistency across the interface. [#18636](https://github.com/open-webui/open-webui/pull/18636)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Portuguese (Brazil), Greek, German, Traditional Chinese, Simplified Chinese, Spanish, Georgian, Danish, and Estonian were enhanced and expanded.
+
+### Fixed
+
+- 🔒 Server-Sent Event (SSE) code injection vulnerability in Direct Connections is resolved by blocking event emission from untrusted external model servers; event emitters from direct connected model servers are no longer supported, preventing arbitrary JavaScript execution in user browsers. [Commit](https://github.com/open-webui/open-webui/commit/8af6a4cf21b756a66cd58378a01c60f74c39b7ca)
+- 🛡️ DOM XSS vulnerability in "Insert Prompt as Rich Text" is resolved by sanitizing HTML content with DOMPurify before rendering. [Commit](https://github.com/open-webui/open-webui/commit/eb9c4c0e358c274aea35f21c2856c0a20051e5f1)
+- ⚙️ MCP server cancellation scope corruption is prevented by reversing disconnection order to follow LIFO and properly handling exceptions, resolving 100% CPU usage when resuming chats with expired tokens or using multiple streamable MCP servers. [#18537](https://github.com/open-webui/open-webui/pull/18537)
+- 🔧 UI freeze when querying models with knowledge bases containing inconsistent distance metrics is resolved by properly initializing the distances array in citations. [#18585](https://github.com/open-webui/open-webui/pull/18585)
+- 🤖 Duplicate model IDs from multiple OpenAI endpoints are now automatically deduplicated server-side, preventing frontend crashes for users with unified gateway proxies that aggregate multiple providers. [Commit](https://github.com/open-webui/open-webui/commit/fdf7ca11d4f3cc8fe63e81c98dc0d1e48e52ba36)
+- 🔐 Login failures with passwords longer than 72 bytes are resolved by safely truncating oversized passwords for bcrypt compatibility. [#18157](https://github.com/open-webui/open-webui/issues/18157)
+- 🔐 OAuth 2.1 MCP tool connections now automatically re-register clients when stored client IDs become stale, preventing unauthorized_client errors after editing tool endpoints and providing detailed error messages for callback failures. [#18415](https://github.com/open-webui/open-webui/pull/18415), [#18309](https://github.com/open-webui/open-webui/issues/18309)
+- 🔓 OAuth 2.1 discovery, metadata fetching, and dynamic client registration now correctly use HTTP proxy environment variables when trust_env is enabled. [Commit](https://github.com/open-webui/open-webui/commit/bafeb76c411483bd6b135f0edbcdce048120f264)
+- 🔌 MCP server connection failures now display clear error messages in the chat interface instead of silently failing. [#18892](https://github.com/open-webui/open-webui/pull/18892), [#18889](https://github.com/open-webui/open-webui/issues/18889)
+- 💬 Chat titles are now properly generated even when title auto-generation is disabled in interface settings, fixing an issue where chats would remain labeled as "New chat". [#18761](https://github.com/open-webui/open-webui/pull/18761), [#18717](https://github.com/open-webui/open-webui/issues/18717), [#6478](https://github.com/open-webui/open-webui/issues/6478)
+- 🔍 Chat query errors are prevented by properly validating and handling the "order_by" parameter to ensure requested columns exist. [#18400](https://github.com/open-webui/open-webui/pull/18400), [#18452](https://github.com/open-webui/open-webui/pull/18452)
+- 🔧 Root-level max_tokens parameter is no longer dropped when proxying to Ollama, properly converting to num_predict to limit output token length as intended. [#18618](https://github.com/open-webui/open-webui/issues/18618)
+- 🔑 Self-hosted Marker instances can now be used without requiring an API key, while keeping it optional for datalab Marker service users. [#18617](https://github.com/open-webui/open-webui/issues/18617)
+- 🔧 OpenAPI specification endpoint conflict between "/api/v1/models" and "/api/v1/models/" is resolved by changing the models router endpoint to "/list", preventing duplicate operationId errors when generating TypeScript API clients. [#18758](https://github.com/open-webui/open-webui/issues/18758)
+- 🏷️ Model tags are now de-duplicated case-insensitively in both the model selector and workspace models page, preventing duplicate entries with different capitalization from appearing in filter dropdowns. [#18716](https://github.com/open-webui/open-webui/pull/18716), [#18711](https://github.com/open-webui/open-webui/issues/18711)
+- 📄 Docling RAG parameter configuration is now correctly saved in the admin UI by fixing the typo in the "DOCLING_PARAMS" parameter name. [#18390](https://github.com/open-webui/open-webui/pull/18390)
+- 📃 Tika document processing now automatically detects content types instead of relying on potentially incorrect browser-provided mime-types, improving file handling accuracy for formats like RTF. [#18765](https://github.com/open-webui/open-webui/pull/18765), [#18683](https://github.com/open-webui/open-webui/issues/18683)
+- 🖼️ Image and video uploads to knowledge bases now display proper error messages instead of showing an infinite spinner when the content extraction engine does not support these file types. [#18514](https://github.com/open-webui/open-webui/issues/18514)
+- 📝 Notes PDF export now properly detects and applies dark mode styling consistently across both the notes list and individual note pages, with a shared utility function to eliminate code duplication. [#18526](https://github.com/open-webui/open-webui/issues/18526)
+- 💭 Details tags for reasoning content are now correctly identified and rendered even when the same tag is present in user messages. [#18840](https://github.com/open-webui/open-webui/pull/18840), [#18294](https://github.com/open-webui/open-webui/issues/18294)
+- 📊 Mermaid and Vega rendering errors now display inline with the code instead of showing repetitive toast notifications, improving user experience when models generate invalid diagram syntax. [Commit](https://github.com/open-webui/open-webui/commit/fdc0f04a8b7dd0bc9f9dc0e7e30854f7a0eea3e9)
+- 📈 Mermaid diagram rendering errors no longer cause UI unavailability or display error messages below the input box. [#18493](https://github.com/open-webui/open-webui/pull/18493), [#18340](https://github.com/open-webui/open-webui/issues/18340)
+- 🔗 Web search SSL verification is now asynchronous, preventing the website from hanging during web search operations. [#18714](https://github.com/open-webui/open-webui/pull/18714), [#18699](https://github.com/open-webui/open-webui/issues/18699)
+- 🌍 Web search results now correctly use HTTP proxy environment variables when WEB_SEARCH_TRUST_ENV is enabled. [#18667](https://github.com/open-webui/open-webui/pull/18667), [#7008](https://github.com/open-webui/open-webui/discussions/7008)
+- 🔍 Google Programmable Search Engine now properly includes referer headers, enabling API keys with HTTP referrer restrictions configured in Google Cloud Console. [#18871](https://github.com/open-webui/open-webui/pull/18871), [#18870](https://github.com/open-webui/open-webui/issues/18870)
+- ⚡ YouTube video transcript fetching now works correctly when using a proxy connection. [#18419](https://github.com/open-webui/open-webui/pull/18419)
+- 🎙️ Speech-to-text transcription no longer deletes or replaces existing text in the prompt input field, properly preserving any previously entered content. [#18540](https://github.com/open-webui/open-webui/issues/18540)
+- 🎙️ The "Instant Auto-Send After Voice Transcription" setting now functions correctly and automatically sends transcribed text when enabled. [#18466](https://github.com/open-webui/open-webui/issues/18466)
+- ⚙️ Chat settings now load properly when reopening a tab or starting a new session by initializing defaults when sessionStorage is empty. [#18438](https://github.com/open-webui/open-webui/pull/18438)
+- 🔎 Folder tag search in the sidebar now correctly handles folder names with multiple spaces by replacing all spaces with underscores. [Commit](https://github.com/open-webui/open-webui/commit/a8fe979af68e47e4e4bb3eb76e48d93d60cd2a45)
+- 🛠️ Functions page now updates immediately after deleting a function, removing the need for a manual page reload. [#18912](https://github.com/open-webui/open-webui/pull/18912), [#18908](https://github.com/open-webui/open-webui/issues/18908)
+- 🛠️ Native tool calling now properly supports sequential tool calls with shared context, allowing tools to access images and data from previous tool executions in the same conversation. [#18664](https://github.com/open-webui/open-webui/pull/18664)
+- 🎯 Globally enabled actions in the model editor now correctly apply as global instead of being treated as disabled. [#18577](https://github.com/open-webui/open-webui/pull/18577)
+- 📋 Clipboard images pasted via the "{{CLIPBOARD}}" prompt variable are now correctly converted to base64 format before being sent to the backend, resolving base64 encoding errors. [#18432](https://github.com/open-webui/open-webui/pull/18432), [#18425](https://github.com/open-webui/open-webui/issues/18425)
+- 📋 File list is now cleared when switching to models that do not support file uploads, preventing files from being sent to incompatible models. [#18496](https://github.com/open-webui/open-webui/pull/18496)
+- 📂 Move menu no longer displays when folders are empty. [#18484](https://github.com/open-webui/open-webui/pull/18484)
+- 📁 Folder and channel creation now validates that names are not empty, preventing creation of folders or channels with no name and showing an error toast if attempted. [#18564](https://github.com/open-webui/open-webui/pull/18564)
+- 🖊️ Rich text input no longer removes text between equals signs when pasting code with comparison operators. [#18551](https://github.com/open-webui/open-webui/issues/18551)
+- ⌨️ Keyboard shortcuts now display the correct keys for international and non-QWERTY keyboard layouts by detecting the user's layout using the Keyboard API. [#18533](https://github.com/open-webui/open-webui/pull/18533)
+- 🌐 "Attach Webpage" button now displays with correct disabled styling when a model does not support file uploads. [#18483](https://github.com/open-webui/open-webui/pull/18483)
+- 🎚️ Divider no longer displays in the integrations menu when no integrations are enabled. [#18487](https://github.com/open-webui/open-webui/pull/18487)
+- 📱 Chat controls button is now properly hidden on mobile for users without admin or explicit chat control permissions. [#18641](https://github.com/open-webui/open-webui/pull/18641)
+- 📍 User menu, download submenu, and move submenu are now repositioned to prevent overlap with the Chat Controls sidebar when it is open. [Commit](https://github.com/open-webui/open-webui/commit/414ab51cb6df1ab0d6c85ac6c1f2c5c9a5f8e2aa)
+- 🎯 Artifacts button no longer appears in the chat menu when there are no artifacts to display. [Commit](https://github.com/open-webui/open-webui/commit/ed6449d35f84f68dc75ee5c6b3f4748a3fda0096)
+- 🎨 Artifacts view now automatically displays when opening an existing conversation containing artifacts, improving user experience. [#18215](https://github.com/open-webui/open-webui/pull/18215)
+- 🖌️ Formatting toolbar is no longer hidden under images or code blocks in chat and now displays correctly above all message content.
+- 🎨 Layout shift near system instructions is prevented by properly rendering the chat component when system prompts are empty. [#18594](https://github.com/open-webui/open-webui/pull/18594)
+- 📐 Modal layout shift caused by scrollbar appearance is prevented by adding a stable scrollbar gutter. [#18591](https://github.com/open-webui/open-webui/pull/18591)
+- ✨ Spacing between icon and label in the user menu dropdown items is now consistent. [#18595](https://github.com/open-webui/open-webui/pull/18595)
+- 💬 Duplicate prompt suggestions no longer cause the webpage to freeze or throw JavaScript errors by implementing proper key management with composite keys. [#18841](https://github.com/open-webui/open-webui/pull/18841), [#18566](https://github.com/open-webui/open-webui/issues/18566)
+- 🔍 Chat preview loading in the search modal now works correctly for all search results by fixing an index boundary check that previously caused out-of-bounds errors. [#18911](https://github.com/open-webui/open-webui/pull/18911)
+- ♿ Screen reader support was enhanced by wrapping messages in semantic elements with descriptive aria-labels, adding "Assistant is typing" and "Response complete" announcements for improved accessibility. [#18735](https://github.com/open-webui/open-webui/pull/18735)
+- 🔒 Incorrect await call in the OAuth 2.1 flow is removed, eliminating a logged exception during authentication. [#18236](https://github.com/open-webui/open-webui/pull/18236)
+- 🛡️ Duplicate crossorigin attribute in the manifest file was removed. [#18413](https://github.com/open-webui/open-webui/pull/18413)
+
+### Changed
+
+- 🔄 Firecrawl integration was refactored to use the official Firecrawl SDK instead of direct HTTP requests and langchain_community FireCrawlLoader, improving reliability and performance with batch scraping support and enhanced error handling. [#18635](https://github.com/open-webui/open-webui/pull/18635)
+- 📄 MinerU content extraction engine now only supports PDF files following the upstream removal of LibreOffice document conversion in version 2.0.0; users needing to process office documents should convert them to PDF format first. [#18448](https://github.com/open-webui/open-webui/issues/18448)
+
+## [0.6.34] - 2025-10-16
+
+### Added
+
+- 📄 MinerU is now supported as a document parser backend, with support for both local and managed API deployments. [#18306](https://github.com/open-webui/open-webui/pull/18306)
+- 🔒 JWT token expiration default is now set to 4 weeks instead of never expiring, with security warnings displayed in backend logs and admin UI when set to unlimited. [#18261](https://github.com/open-webui/open-webui/pull/18261), [#18262](https://github.com/open-webui/open-webui/pull/18262)
+- ⚡ Page loading performance is improved by preventing unnecessary API requests when sidebar folders are not expanded. [#18179](https://github.com/open-webui/open-webui/pull/18179), [#17476](https://github.com/open-webui/open-webui/issues/17476)
+- 📁 File hash values are now included in the knowledge endpoint response, enabling efficient file synchronization through hash comparison. [#18284](https://github.com/open-webui/open-webui/pull/18284), [#18283](https://github.com/open-webui/open-webui/issues/18283)
+- 🎨 Chat dialog scrollbar visibility is improved by increasing its width, making it easier to use for navigation. [#18369](https://github.com/open-webui/open-webui/pull/18369), [#11782](https://github.com/open-webui/open-webui/issues/11782)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Catalan, Chinese, Czech, Finnish, German, Kabyle, Korean, Portuguese (Brazil), Spanish, Thai, and Turkish were enhanced and expanded.
+
+### Fixed
+
+- 📚 Focused retrieval mode now works correctly, preventing the system from forcing full context mode and loading all documents in a knowledge base regardless of settings. [#18133](https://github.com/open-webui/open-webui/issues/18133)
+- 🔧 Filter inlet functions now correctly execute on tool call continuations, ensuring parameter persistence throughout tool interactions. [#18222](https://github.com/open-webui/open-webui/issues/18222)
+- 🛠️ External tool servers now properly support DELETE requests with body data. [#18289](https://github.com/open-webui/open-webui/pull/18289), [#18287](https://github.com/open-webui/open-webui/issues/18287)
+- 🗄️ Oracle23ai vector database client now correctly handles variable initialization, resolving UnboundLocalError when retrieving items from collections. [#18356](https://github.com/open-webui/open-webui/issues/18356)
+- 🔧 Model auto-pull functionality now works correctly even when user settings remain unmodified. [#18324](https://github.com/open-webui/open-webui/pull/18324)
+- 🎨 Duplicate HTML content in artifacts is now prevented by improving code block detection logic. [#18195](https://github.com/open-webui/open-webui/pull/18195), [#6154](https://github.com/open-webui/open-webui/issues/6154)
+- 💬 Pinned chats now appear in the Reference Chats list and can be referenced in conversations. [#18288](https://github.com/open-webui/open-webui/issues/18288)
+- 📝 Misleading knowledge base warning text in documents settings is clarified to correctly instruct users about reindexing vectors. [#18263](https://github.com/open-webui/open-webui/pull/18263)
+- 🔔 Toast notifications can now be dismissed even when a modal is open. [#18260](https://github.com/open-webui/open-webui/pull/18260)
+- 🔘 The "Chats" button in the sidebar now correctly toggles chat list visibility without navigating away from the current page. [#18232](https://github.com/open-webui/open-webui/pull/18232)
+- 🎯 The Integrations menu no longer closes prematurely when clicking outside the Valves modal. [#18310](https://github.com/open-webui/open-webui/pull/18310)
+- 🛠️ Tool ID display issues where "undefined" was incorrectly shown in the interface are now resolved. [#18178](https://github.com/open-webui/open-webui/pull/18178)
+- 🛠️ Model management issues caused by excessively long model IDs are now prevented through validation that limits model IDs to 256 characters. [#18125](https://github.com/open-webui/open-webui/issues/18125)
+
+## [0.6.33] - 2025-10-08
+
+### Added
+
+- 🎨 Workspace interface received a comprehensive redesign across Models, Knowledge, Prompts, and Tools sections, featuring reorganized controls, view filters for created vs shared items, tag selectors, improved visual hierarchy, and streamlined import/export functionality. [Commit](https://github.com/open-webui/open-webui/commit/2c59a288603d8c5f004f223ee00fef37cc763a8e), [Commit](https://github.com/open-webui/open-webui/commit/6050c86ab6ef6b8c96dd3f99c62a6867011b67a4), [Commit](https://github.com/open-webui/open-webui/commit/96ecb47bc71c072aa34ef2be10781b042bef4e8c), [Commit](https://github.com/open-webui/open-webui/commit/2250d102b28075a9611696e911536547abb8b38a), [Commit](https://github.com/open-webui/open-webui/commit/23c8f6d507bfee75ab0015a3e2972d5c26f7e9bf), [Commit](https://github.com/open-webui/open-webui/commit/a743b16728c6ae24b8befbc2d7f24eb9e20c4ad5)
+- 🛠️ Functions admin interface received a comprehensive redesign with creator attribution display, ownership filters for created vs shared items, improved organization, and refined styling. [Commit](https://github.com/open-webui/open-webui/commit/f5e1a42f51acc0b9d5b63a33c1ca2e42470239c1)
+- ⚡ Page initialization performance is significantly improved through parallel data loading and optimized folder API calls, reducing initial page load time. [#17559](https://github.com/open-webui/open-webui/pull/17559), [#17889](https://github.com/open-webui/open-webui/pull/17889)
+- ⚡ Chat overview component is now dynamically loaded on demand, reducing initial page bundle size by approximately 470KB and improving first-screen loading speed. [#17595](https://github.com/open-webui/open-webui/pull/17595)
+- 📁 Folders can now be attached to chats using the "#" command, automatically expanding to include all files within the folder for streamlined knowledge base integration. [Commit](https://github.com/open-webui/open-webui/commit/d2cb78179d66dc85188172a08622d4c97a2ea1ee)
+- 📱 Progressive Web App now supports Android share target functionality, allowing users to share web pages, YouTube videos, and text directly to Open WebUI from the system share menu. [#17633](https://github.com/open-webui/open-webui/pull/17633), [#17125](https://github.com/open-webui/open-webui/issues/17125)
+- 🗄️ Redis session storage is now available as an experimental option for OAuth authentication flows via the ENABLE_STAR_SESSIONS_MIDDLEWARE environment variable, providing shared session state across multi-replica deployments to address CSRF errors, though currently only basic Redis setups are supported. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373), [Docs:Commit](https://github.com/open-webui/docs/commit/14052347f165d1b597615370373d7289ce44c7f9)
+- 📊 Vega and Vega-Lite chart visualization renderers are now supported in code blocks, enabling inline rendering of data visualizations with automatic compilation of Vega-Lite specifications. [#18033](https://github.com/open-webui/open-webui/pull/18033), [#18040](https://github.com/open-webui/open-webui/pull/18040), [#18022](https://github.com/open-webui/open-webui/issues/18022)
+- 🔗 OpenAI connections now support custom HTTP headers, enabling users to configure authentication and routing headers for specific deployment requirements. [#18021](https://github.com/open-webui/open-webui/pull/18021), [#9732](https://github.com/open-webui/open-webui/discussions/9732)
+- 🔐 OpenID Connect authentication now supports OIDC providers without email scope via the ENABLE_OAUTH_WITHOUT_EMAIL environment variable, enabling compatibility with identity providers that don't expose email addresses. [#18047](https://github.com/open-webui/open-webui/pull/18047), [#18045](https://github.com/open-webui/open-webui/issues/18045)
+- 🤖 Ollama model management modal now features individual model update cancellation, comprehensive tooltips for all buttons, and streamlined notification behavior to reduce toast spam. [#16863](https://github.com/open-webui/open-webui/pull/16863)
+- ☁️ OneDrive file picker now includes search functionality and "My Organization" pivot for business accounts, enabling easier file discovery across organizational content. [#17930](https://github.com/open-webui/open-webui/pull/17930), [#17929](https://github.com/open-webui/open-webui/issues/17929)
+- 📊 Chat overview flow diagram now supports toggling between vertical and horizontal layout orientations for improved visualization flexibility. [#17941](https://github.com/open-webui/open-webui/pull/17941)
+- 🔊 OpenAI Text-to-Speech engine now supports additional parameters, allowing users to customize TTS behavior with provider-specific options via JSON configuration. [#17985](https://github.com/open-webui/open-webui/issues/17985), [#17188](https://github.com/open-webui/open-webui/pull/17188)
+- 🛠️ Tool server list now displays server name, URL, and type (OpenAPI or MCP) for easier identification and management. [#18062](https://github.com/open-webui/open-webui/issues/18062)
+- 📁 Folders now remember the last selected model, automatically applying it when starting new chats within that folder. [#17836](https://github.com/open-webui/open-webui/issues/17836)
+- 🔢 Ollama embedding endpoint now supports the optional dimensions parameter for controlling embedding output size, compatible with Ollama v0.11.11 and later. [#17942](https://github.com/open-webui/open-webui/pull/17942)
+- ⚡ Workspace knowledge page load time is improved by removing redundant API calls, enhancing overall responsiveness. [#18057](https://github.com/open-webui/open-webui/pull/18057)
+- ⚡ File metadata query performance is enhanced by selecting only relevant columns instead of retrieving entire records, reducing database overhead. [#18013](https://github.com/open-webui/open-webui/pull/18013)
+- 📄 Note PDF exports now include titles and properly render in dark mode with appropriate background colors. [Commit](https://github.com/open-webui/open-webui/commit/216fb5c3db1a223ffe6e72d97aa9551fe0e2d028)
+- 📄 Docling document extraction now supports additional parameters for VLM pipeline configuration, enabling customized vision model settings. [#17363](https://github.com/open-webui/open-webui/pull/17363)
+- ⚙️ Server startup script now supports passing arbitrary arguments to uvicorn, enabling custom server configuration options. [#17919](https://github.com/open-webui/open-webui/pull/17919), [#17918](https://github.com/open-webui/open-webui/issues/17918)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for German, Danish, Spanish, Korean, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
+
+### Fixed
+
+- 💬 System prompts are no longer duplicated in chat requests, eliminating confusion and excessive token usage caused by repeated instructions being sent to models. [#17198](https://github.com/open-webui/open-webui/issues/17198), [#16855](https://github.com/open-webui/open-webui/issues/16855)
+- 🔐 MCP OAuth 2.1 authentication now complies with the standard by implementing PKCE with S256 code challenge method and explicitly passing client credentials during token authorization, resolving "code_challenge: Field required" and "client_id: Field required" errors when connecting to OAuth-secured MCP servers. [Commit](https://github.com/open-webui/open-webui/commit/911a114ad459f5deebd97543c13c2b90196efb54), [#18010](https://github.com/open-webui/open-webui/issues/18010), [#18087](https://github.com/open-webui/open-webui/pull/18087)
+- 🔐 OAuth signup flow now handles password hashing correctly by migrating from passlib to native bcrypt, preventing failures when passwords exceed 72 bytes. [#17917](https://github.com/open-webui/open-webui/issues/17917)
+- 🔐 OAuth token refresh errors are resolved by properly registering and storing OAuth clients, fixing "Constructor parameter should be str" exceptions for Google, Microsoft, and OIDC providers. [#17829](https://github.com/open-webui/open-webui/issues/17829)
+- 🔐 OAuth server metadata URL is now correctly accessed via the proper attribute, fixing automatic token refresh and logout functionality for Microsoft OAuth provider when OPENID_PROVIDER_URL is not set. [#18065](https://github.com/open-webui/open-webui/pull/18065)
+- 🔐 OAuth credential decryption failures now allow the application to start gracefully with clear error messages instead of crashing, preventing complete service outages when WEBUI_SECRET_KEY mismatches occur during database migrations or environment changes. [#18094](https://github.com/open-webui/open-webui/pull/18094), [#18092](https://github.com/open-webui/open-webui/issues/18092)
+- 🔐 OAuth 2.1 server discovery now correctly attempts all configured discovery URLs in sequence instead of only trying the first URL. [#17906](https://github.com/open-webui/open-webui/pull/17906), [#17904](https://github.com/open-webui/open-webui/issues/17904), [#18026](https://github.com/open-webui/open-webui/pull/18026)
+- 🔐 Login redirect now correctly honors the redirect query parameter after authentication, ensuring users are returned to their intended destination with query parameters intact instead of defaulting to the homepage. [#18071](https://github.com/open-webui/open-webui/issues/18071)
+- ☁️ OneDrive Business integration authentication regression is resolved, ensuring the popup now properly triggers when connecting to OneDrive accounts. [#17902](https://github.com/open-webui/open-webui/pull/17902), [#17825](https://github.com/open-webui/open-webui/discussions/17825), [#17816](https://github.com/open-webui/open-webui/issues/17816)
+- 👥 Default group settings now persist correctly after page navigation, ensuring configuration changes are properly saved and retained. [#17899](https://github.com/open-webui/open-webui/issues/17899), [#18003](https://github.com/open-webui/open-webui/issues/18003)
+- 📁 Folder data integrity is now verified on retrieval, automatically fixing orphaned folders with invalid parent references and ensuring proper cascading deletion of nested folder structures. [Commit](https://github.com/open-webui/open-webui/commit/5448618dd5ea181b9635b77040cef60926a902ff)
+- 🗄️ Redis Sentinel and Redis Cluster configurations with the experimental ENABLE_STAR_SESSIONS_MIDDLEWARE feature are now properly isolated by making the feature opt-in only, preventing ReadOnlyError failures when connecting to read replicas in multi-node Redis deployments. [#18073](https://github.com/open-webui/open-webui/issues/18073)
+- 📊 Mermaid and Vega diagram rendering now displays error toast notifications when syntax errors are detected, helping users identify and fix diagram issues instead of silently failing. [#18068](https://github.com/open-webui/open-webui/pull/18068)
+- 🤖 Reasoning models that return reasoning_content instead of content no longer cause NoneType errors during chat title generation, follow-up suggestions, and tag generation. [#18080](https://github.com/open-webui/open-webui/pull/18080)
+- 📚 Citation rendering now correctly handles multiple source references in a single bracket, parsing formats like [1,2] and [1, 2] into separate clickable citation links. [#18120](https://github.com/open-webui/open-webui/pull/18120)
+- 🔍 Web search now handles individual source failures gracefully, continuing to process remaining sources instead of failing entirely when a single URL is unreachable or returns an error. [Commit](https://github.com/open-webui/open-webui/commit/e000494e488090c5f66989a2b3f89d3eaeb7946b), [Commit](https://github.com/open-webui/open-webui/commit/53e98620bff38ab9280aee5165af0a704bdd99b9)
+- 🔍 Hybrid search with reranking now handles empty result sets gracefully instead of crashing with ValueError when all results are filtered out due to relevance thresholds. [#18096](https://github.com/open-webui/open-webui/issues/18096)
+- 🔍 Reranking models without defined padding tokens now work correctly by automatically falling back to eos_token_id as pad_token_id, fixing "Cannot handle batch sizes > 1" errors for models like Qwen3-Reranker. [#18108](https://github.com/open-webui/open-webui/pull/18108), [#16027](https://github.com/open-webui/open-webui/discussions/16027)
+- 🔍 Model selector search now correctly returns results for non-admin users by dynamically updating the search index when the model list changes, fixing a race condition that caused empty search results. [#17996](https://github.com/open-webui/open-webui/pull/17996), [#17960](https://github.com/open-webui/open-webui/pull/17960)
+- ⚡ Task model function calling performance is improved by excluding base64 image data from payloads, significantly reducing token count and memory usage when images are present in conversations. [#17897](https://github.com/open-webui/open-webui/pull/17897)
+- 🤖 Text selection "Ask" action now correctly recognizes and uses local models configured via direct connections instead of only showing external provider models. [#17896](https://github.com/open-webui/open-webui/issues/17896)
+- 🛑 Task cancellation API now returns accurate response status, correctly reporting successful cancellations instead of incorrectly indicating failures. [#17920](https://github.com/open-webui/open-webui/issues/17920)
+- 💬 Follow-up query suggestions are now generated and displayed in temporary chats, matching the behavior of saved chats. [#14987](https://github.com/open-webui/open-webui/issues/14987)
+- 🔊 Azure Text-to-Speech now properly escapes special characters like ampersands in SSML, preventing HTTP 400 errors and ensuring audio generation succeeds for all text content. [#17962](https://github.com/open-webui/open-webui/issues/17962)
+- 🛠️ OpenAPI tool server calls with optional parameters now execute successfully even when no arguments are provided, removing the incorrect requirement for a request body. [#18036](https://github.com/open-webui/open-webui/issues/18036)
+- 🛠️ MCP mode tool server connections no longer incorrectly validate the OpenAPI path field, allowing seamless switching between OpenAPI and MCP connection types. [#17989](https://github.com/open-webui/open-webui/pull/17989), [#17988](https://github.com/open-webui/open-webui/issues/17988)
+- 🛠️ Third-party tool responses containing non-UTF8 or invalid byte sequences are now handled gracefully without causing request failures. [#17882](https://github.com/open-webui/open-webui/pull/17882)
+- 🎨 Workspace filter dropdown now correctly renders model tags as strings instead of displaying individual characters, fixing broken filtering interface when models have multiple tags. [#18034](https://github.com/open-webui/open-webui/issues/18034)
+- ⌨️ Ctrl+Enter keyboard shortcut now correctly sends messages in mobile and narrow browser views on Chrome instead of inserting newlines. [#17975](https://github.com/open-webui/open-webui/issues/17975)
+- ⌨️ Tab characters are now preserved when pasting code or formatted text into the chat input box in plain text mode. [#17958](https://github.com/open-webui/open-webui/issues/17958)
+- 📋 Text selection copying from the chat input box now correctly copies only the selected text instead of the entire textbox content. [#17911](https://github.com/open-webui/open-webui/issues/17911)
+- 🔍 Web search query logging now uses debug level instead of info level, preventing user search queries from appearing in production logs. [#17888](https://github.com/open-webui/open-webui/pull/17888)
+- 📝 Debug print statements in middleware were removed to prevent excessive log pollution and respect configured logging levels. [#17943](https://github.com/open-webui/open-webui/issues/17943)
+
+### Changed
+
+- 🗄️ Milvus vector database dependency is updated from pymilvus 2.5.0 to 2.6.2, ensuring compatibility with newer Milvus versions but requiring users on older Milvus instances to either upgrade their database or manually downgrade the pymilvus package. [#18066](https://github.com/open-webui/open-webui/pull/18066)
+
+## [0.6.32] - 2025-09-29
+
+### Added
+
+- ⚡ JSON model import moved to backend processing for significant performance improvements when importing large model files. [#17871](https://github.com/open-webui/open-webui/pull/17871)
+- ⚠️ Visual warnings for group permissions that display when a permission is disabled in a group but remains enabled in the default user role, clarifying inheritance behavior for administrators. [#17848](https://github.com/open-webui/open-webui/pull/17848)
+- 🗄️ Milvus multi-tenancy mode using shared collections with resource ID filtering for improved scalability, mirroring the existing Qdrant implementation and configurable via ENABLE_MILVUS_MULTITENANCY_MODE environment variable. [#17837](https://github.com/open-webui/open-webui/pull/17837)
+- 🛠️ Enhanced tool result processing with improved error handling, better MCP tool result handling, and performance improvements for embedded UI components. [Commit](https://github.com/open-webui/open-webui/commit/4f06f29348b2c9d71c87d1bbe5b748a368f5101f)
+- 👥 New user groups now automatically inherit default group permissions, streamlining the admin setup process by eliminating manual permission configuration. [#17843](https://github.com/open-webui/open-webui/pull/17843)
+- 🗂️ Bulk unarchive functionality for all chats, providing a single backend endpoint to efficiently restore all archived chats at once. [#17857](https://github.com/open-webui/open-webui/pull/17857)
+- 🏷️ Browser tab title toggle setting allows users to control whether chat titles appear in the browser tab or display only "Open WebUI". [#17851](https://github.com/open-webui/open-webui/pull/17851)
+- 💬 Reply-to-message functionality in channels, allowing users to reply directly to specific messages with visual threading and context display. [Commit](https://github.com/open-webui/open-webui/commit/1a18928c94903ad1f1f0391b8ade042c3e60205b)
+- 🔧 Tool server import and export functionality, allowing direct upload of openapi.json and openapi.yaml files as an alternative to URL-based configuration. [#14446](https://github.com/open-webui/open-webui/issues/14446)
+- 🔧 User valve configuration for Functions is now available in the integration menu, providing consistent management alongside Tools. [#17784](https://github.com/open-webui/open-webui/issues/17784)
+- 🔐 Admin permission toggle for controlling public sharing of notes, configurable via USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING environment variable. [#17801](https://github.com/open-webui/open-webui/pull/17801), [Docs:#715](https://github.com/open-webui/docs/pull/715)
+- 🗄️ DISKANN index type support for Milvus vector database with configurable maximum degree and search list size parameters. [#17770](https://github.com/open-webui/open-webui/pull/17770), [Docs:Commit](https://github.com/open-webui/docs/commit/cec50ab4d4b659558ca1ccd4b5e6fc024f05fb83)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Chinese (Simplified & Traditional) and Bosnian (Latin) were enhanced and expanded.
+
+### Fixed
+
+- 🛠️ MCP tool calls are now correctly routed to the appropriate server when multiple streamable-http MCP servers are enabled, preventing "Tool not found" errors. [#17817](https://github.com/open-webui/open-webui/issues/17817)
+- 🛠️ External tool servers (OpenAPI/MCP) now properly process and return tool results to the model, restoring functionality that was broken in v0.6.31. [#17764](https://github.com/open-webui/open-webui/issues/17764)
+- 🔧 User valve detection now correctly identifies valves in imported tool code, ensuring gear icons appear in the integrations menu for all tools with user valves. [#17765](https://github.com/open-webui/open-webui/issues/17765)
+- 🔐 MCP OAuth discovery now correctly handles multi-tenant configurations by including subpaths in metadata URL discovery. [#17768](https://github.com/open-webui/open-webui/issues/17768)
+- 🗄️ Milvus query operations now correctly use -1 instead of None for unlimited queries, preventing TypeError exceptions. [#17769](https://github.com/open-webui/open-webui/pull/17769), [#17088](https://github.com/open-webui/open-webui/issues/17088)
+- 📁 File upload error messages are now displayed when files are modified during upload, preventing user confusion on Android and Windows devices. [#17777](https://github.com/open-webui/open-webui/pull/17777)
+- 🎨 MessageInput Integrations button hover effect now displays correctly with proper visual feedback. [#17767](https://github.com/open-webui/open-webui/pull/17767)
+- 🎯 "Set as default" label positioning is fixed to ensure it remains clickable in all scenarios, including multi-model configurations. [#17779](https://github.com/open-webui/open-webui/pull/17779)
+- 🎛️ Floating buttons now correctly retrieve message context by using the proper messageId parameter in createMessagesList calls. [#17823](https://github.com/open-webui/open-webui/pull/17823)
+- 📌 Pinned chats are now properly cleared from the sidebar after archiving all chats, ensuring UI consistency without requiring a page refresh. [#17832](https://github.com/open-webui/open-webui/pull/17832)
+- 🗑️ Delete confirmation modals now properly truncate long names for Notes, Prompts, Tools, and Functions to prevent modal overflow. [#17812](https://github.com/open-webui/open-webui/pull/17812)
+- 🌐 Internationalization function calls now use proper Svelte store subscription syntax, preventing "i18n.t is not a function" errors on the model creation page. [#17819](https://github.com/open-webui/open-webui/pull/17819)
+- 🎨 Playground chat interface button layout is corrected to prevent vertical text rendering for Assistant/User role buttons. [#17819](https://github.com/open-webui/open-webui/pull/17819)
+- 🏷️ UI text truncation is improved across multiple components including usernames in admin panels, arena model names, model tags, and filter tags to prevent layout overflow issues. [#17805](https://github.com/open-webui/open-webui/pull/17805), [#17803](https://github.com/open-webui/open-webui/pull/17803), [#17791](https://github.com/open-webui/open-webui/pull/17791), [#17796](https://github.com/open-webui/open-webui/pull/17796)
+
+## [0.6.31] - 2025-09-25
+
+### Added
+
+- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
+- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
+- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
+- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
+- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
+- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
+- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
+- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
+- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
+- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
+- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
+- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
+- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
+- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
+- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
+- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
+- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
+- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
+- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
+- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
+- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
+
+### Fixed
+
+- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
+- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
+- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
+- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
+- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
+- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
+- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
+- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
+- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
+- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
+- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
+- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
+- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
+- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
+- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
+- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
+- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
+- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
+
+### Changed
+
+- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
+- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
+
+## [0.6.30] - 2025-09-17
+
+### Added
+
+- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
+
+### Fixed
+
+- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
+
+## [0.6.29] - 2025-09-17
+
+### Added
+
+- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1)
+- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145)
+- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054)
+- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418)
+- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748)
+- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2)
+- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402)
+- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690)
+- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308)
+- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697)
+- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477)
+- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479)
+- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498)
+- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499)
+- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502)
+- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503)
+- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572)
+- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50)
+- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e)
+- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275)
+- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62)
+- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5)
+- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad)
+- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c)
+- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685)
+- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533)
+- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356)
+- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd)
+- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f)
+- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded.
+
+### Fixed
+
+- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12)
+- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f)
+- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0)
+- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66)
+- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396)
+- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c)
+- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4)
+- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8)
+- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab)
+- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9)
+- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa)
+- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423)
+- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3)
+
+### Changed
+
+- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696)
+- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5)
+- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956)
+- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161)
+
+## [0.6.28] - 2025-09-10
+
+### Added
+
+- 🔍 The "@" command for model selection now supports real-time search and filtering, improving usability and aligning its behavior with other input commands. [#17307](https://github.com/open-webui/open-webui/issues/17307), [Commit](https://github.com/open-webui/open-webui/commit/f2a09c71499489ee71599af4a179e7518aaf658b)
+- 🛠️ External tool server data handling is now more robust, automatically attempting to parse specifications as JSON before falling back to YAML, regardless of the URL extension. [Commit](https://github.com/open-webui/open-webui/commit/774c0056bde88ed4831422efa81506488e3d6641)
+- 🎯 The "Title" field is now automatically focused when creating a new chat folder, streamlining the folder creation process. [#17315](https://github.com/open-webui/open-webui/issues/17315), [Commit](https://github.com/open-webui/open-webui/commit/c51a651a2d5e2a27546416666812e9b92205562d)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Brazilian Portuguese and Simplified Chinese translations were expanded and refined.
+
+### Fixed
+
+- 🔊 A regression affecting Text-to-Speech for local providers using the OpenAI engine was fixed by reverting a URL joining change. [#17316](https://github.com/open-webui/open-webui/issues/17316), [Commit](https://github.com/open-webui/open-webui/commit/8339f59cdfc63f2d58c8e26933d1bf1438479d75)
+- 🪧 A regression was fixed where the input modal for prompts with placeholders would not open, causing the raw prompt text to be pasted into the chat input field instead. [#17325](https://github.com/open-webui/open-webui/issues/17325), [Commit](https://github.com/open-webui/open-webui/commit/d5cb65527eaa4831459a4c7dbf187daa9c0525ae)
+- 🔑 An issue was resolved where modified connection keys in the OpenAIConnection component did not take effect. [#17324](https://github.com/open-webui/open-webui/pull/17324)
+
+## [0.6.27] - 2025-09-09
+
+### Added
+
+- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255)
+- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5)
+- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a)
+- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158)
+- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679)
+- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166)
+- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222)
+- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229)
+- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7)
+- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9)
+- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672)
+- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880)
+- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
+- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df)
+- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae)
+- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3)
+- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
+- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded.
+
+### Fixed
+
+- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046)
+- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195)
+- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095)
+- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b)
+- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175)
+- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9)
+- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012)
+- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061)
+- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064)
+- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000)
+- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b)
+- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085)
+- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186)
+- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191)
+- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213)
+- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225)
+- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
+- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb)
+- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6)
+- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de)
+- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6)
+- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4)
+- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958)
+- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e)
+
+### Changed
+
+- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f)
+- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285)
+
+## [0.6.26] - 2025-08-28
+
+### Added
+
+- 🛂 **Granular Chat Interaction Permissions**: Added fine-grained permission controls for individual chat actions including "Continue Response", "Regenerate Response", "Rate Response", and "Delete Messages". Administrators can now configure these permissions per user group or set system defaults via environment variables, providing enhanced security and governance by preventing potential system prompt leakage through response continuation and enabling precise control over user interactions with AI responses.
+- 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility.
+- 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA.
+- 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows.
+- 🔐 **Azure Document Intelligence DefaultAzureCredential Support**: Added support for authenticating with Azure Document Intelligence using DefaultAzureCredential in addition to API key authentication, enabling seamless integration with Azure Entra ID and managed identity authentication for enterprise Azure environments.
+- 🔐 **Authentication Bootstrapping Enhancements**: Added "ENABLE_INITIAL_ADMIN_SIGNUP" environment variable and "?form=true" URL parameter to enable initial admin user creation and forced login form display in SSO-only deployments. This resolves bootstrap issues where administrators couldn't create the first user when login forms were disabled, allowing proper initialization of SSO-configured deployments without requiring temporary configuration changes.
+- ⚡ **Query Generation Caching**: Added "ENABLE_QUERIES_CACHE" environment variable to enable request-scoped caching of generated search queries. When both web search and file retrieval are active, queries generated for web search are automatically reused for file retrieval, eliminating duplicate LLM API calls and reducing token usage and costs while maintaining search quality.
+- 🔧 **Configurable Tool Call Retry Limit**: Added "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" environment variable to control the maximum number of sequential tool calls allowed before safety stopping a chat session. This replaces the previous hardcoded limit of 10, enabling administrators to configure higher limits for complex workflows requiring extensive tool interactions.
+- 📦 **Slim Docker Image Variant**: Added new slim Docker image option via "USE_SLIM" build argument that excludes embedded AI models and Ollama installation, reducing image size by approximately 1GB. This variant enables faster image pulls and deployments for environments where AI models are managed externally, particularly beneficial for auto-scaling clusters and distributed deployments.
+- 🗂️ **Shift-to-Delete Functionality for Workspace Prompts**: Added keyboard shortcut support for quick prompt deletion on the Workspace Prompts page. Hold Shift and hover over any prompt to reveal a trash icon for instant deletion, bringing consistent interaction patterns across all workspace sections (Models, Tools, Functions, and now Prompts) and streamlining prompt management workflows.
+- ♿ **Accessibility Enhancements**: Enhanced user interface accessibility with improved keyboard navigation, ARIA labels, and screen reader compatibility across key platform components.
+- 📄 **Optimized PDF Export for Smaller File Size**: PDF exports are now significantly optimized, producing much smaller files for faster downloads and easier sharing or archiving of your chats and documents.
+- 📦 **Slimmed Default Install with Optional Full Dependencies**: Installing Open WebUI via pip now defaults to a slimmer package; PostgreSQL support is no longer included by default—simply use 'pip install open-webui[all]' to include all optional dependencies for full feature compatibility.
+- 🔄 **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users.
+- 🌐 **Localization & Internationalization Improvements**: Enhanced and expanded translations for Finnish, Spanish, Japanese, Polish, Portuguese (Brazil), and Chinese, including missing translations and typo corrections, providing a more natural and professional user experience for speakers of these languages across the entire interface.
+
+### Fixed
+
+- ⚠️ **Chat Error Feedback Restored**: Fixed an issue where various backend errors (tool server failures, API connection issues, malformed responses) would cause chats to load indefinitely without providing user feedback. The system now properly displays error messages when failures occur during chat generation, allowing users to understand issues and retry as needed instead of waiting indefinitely.
+- 🖼️ **Image Generation Steps Setting Visibility Fixed**: Fixed a UI issue where the "Set Steps" configuration option was incorrectly displayed for OpenAI and Gemini image generation engines that don't support this parameter. The setting is now only visible for compatible engines like ComfyUI and Automatic1111, eliminating user confusion about non-functional configuration options.
+- 📄 **Datalab Marker API Document Loader Fixed**: Fixed broken Datalab Marker API document loader functionality by correcting URL path handling for both hosted Datalab services and self-hosted Marker servers. Removed hardcoded "/marker" paths from the loader code and restored proper default URL structure, ensuring PDF and document processing works correctly with both deployment types.
+- 📋 **Citation Error Handling Improved**: Fixed an issue where malformed citation or source objects from external tools, pipes, or filters would cause JavaScript errors and make the chat interface completely unresponsive. The system now gracefully handles missing or undefined citation properties, allowing conversations to load properly even when tools generate defective citation events.
+- 👥 **Group User Add API Endpoint Fixed**: Fixed an issue where the "/api/v1/groups/id/{group_id}/users/add" API endpoint would accept requests without errors but fail to actually add users to groups. The system now properly initializes and deduplicates user ID lists, ensuring users are correctly added to and removed from groups via API calls.
+- 🛠️ **External Tool Server Error Handling Improved**: Fixed an issue where unreachable or misconfigured external tool servers would cause JavaScript errors and prevent the interface from loading properly. The system now gracefully handles connection failures, displays appropriate error messages, and filters out inaccessible servers while maintaining full functionality for working connections.
+- 📋 **Code Block Copy Button Content Fixed**: Fixed an issue where the copy button in code blocks would copy the original AI-generated code instead of any user-edited content, ensuring the copy function always captures the currently displayed code as modified by users.
+- 📄 **PDF Export Content Mismatch Fixed**: Resolved an issue where exporting a PDF of one chat while viewing another chat would incorrectly generate the PDF using the currently viewed chat's content instead of the intended chat's content. Additionally optimized the PDF generation algorithm with improved canvas slicing, better memory management, and enhanced image quality, while removing the problematic PDF export option from individual chat menus to prevent further confusion.
+- 🖱️ **Windows Sidebar Cursor Icon Corrected**: Fixed confusing cursor icons on Windows systems where sidebar toggle buttons displayed resize cursors (ew-resize) instead of appropriate pointer cursors. The sidebar buttons now show standard pointer cursors on Windows, eliminating user confusion about whether the buttons expand/collapse the sidebar or resize it.
+- 📝 **Safari IME Composition Bug Fixed**: Resolved an issue where pressing Enter while composing Chinese text using Input Method Editors (IMEs) on Safari would prematurely send messages instead of completing text composition. The system now properly detects composition states and ignores keydown events that occur immediately after composition ends, ensuring smooth multilingual text input across all browsers.
+- 🔍 **Hybrid Search Parameter Handling Fixed**: Fixed an issue where the "hybrid" parameter in collection query requests was not being properly evaluated, causing the system to ignore user-specified hybrid search preferences and only check global configuration. Additionally resolved a division by zero error that occurred in hybrid search when BM25Retriever was called with empty document lists, ensuring robust search functionality across all collection states.
+- 💬 **RTL Text Orientation in Messages Fixed**: Fixed text alignment issues in user messages and AI responses for Right-to-Left languages, ensuring proper text direction based on user language settings. Code blocks now consistently use Left-to-Right orientation regardless of the user's language preference, maintaining code readability across all supported languages.
+- 📁 **File Content Preview in Modal Restored**: Fixed an issue where clicking on uploaded files would display an empty preview modal, even when the files were successfully processed and available for AI context. File content now displays correctly in the preview modal, ensuring users can verify and review their uploaded documents as intended.
+- 🌐 **Playwright Timeout Configuration Corrected**: Fixed an issue where Playwright timeout values were incorrectly converted from milliseconds to seconds with an additional 1000x multiplier, causing excessively long web loading timeouts. The timeout parameter now correctly uses the configured millisecond values as intended, ensuring responsive web search and document loading operations.
+
+### Changed
+
+- 🔄 **Follow-Up Question Language Constraint Removed**: Follow-up question suggestions no longer strictly adhere to the chat's primary language setting, allowing for more flexible and diverse suggestion generation that may include questions in different languages based on conversation context and relevance rather than enforced language matching.
+
+## [0.6.25] - 2025-08-22
+
+### Fixed
+
+- 🖼️ **Image Generation Reliability Restored**: Fixed a key issue causing image generation failures.
+- 🏆 **Reranking Functionality Restored**: Resolved errors with rerank feature.
+
+## [0.6.24] - 2025-08-21
+
+### Added
+
+- ♿ **High Contrast Mode in Chat Messages**: Implemented enhanced High Contrast Mode support for chat messages, making text and important details easier to read and improving accessibility for users with visual preferences or requirements.
+- 🌎 **Localization & Internationalization Improvements**: Enhanced and expanded translations for a more natural and professional user experience for speakers of these languages across the entire interface.
+
+### Fixed
+
+- 🖼️ **ComfyUI Image Generation Restored**: Fixed a critical bug where ComfyUI-based image generation was not functioning, ensuring users can once again effortlessly create and interact with AI-generated visuals in their workflows.
+- 🛠️ **Tool Server Loading and Visibility Restored**: Resolved an issue where connected tool servers were not loading or visible, restoring seamless integration and uninterrupted access to all external and custom tools directly within the platform.
+- 🛡️ **Redis User Session Reliability**: Fixed a problem affecting the saving of user sessions in Redis, ensuring reliable login sessions, stable authentication, and secure multi-user environments.
+
+## [0.6.23] - 2025-08-21
+
+### Added
+
+- ⚡ **Asynchronous Chat Payload Processing**: Refactored the chat completion pipeline to return a response immediately for streaming requests involving web search or tool calls. This enables users to stop ongoing generations promptly and preventing network timeouts during lengthy preprocessing phases, thus significantly improving user experience and responsiveness.
+- 📁 **Asynchronous File Upload with Polling**: Implemented an asynchronous file upload process with frontend polling to resolve gateway timeouts and improve reliability when uploading large files. This ensures that even lengthy file processing, such as embedding or transcription, does not block the user interface or lead to connection timeouts, providing a smoother experience for all file operations.
+- 📈 **Database Performance Indexes and Migration Script**: Introduced new database indexes on the "chat", "tag", and "function" tables to significantly enhance query performance for SQLite and PostgreSQL installations. For existing deployments, a new Alembic migration script is included to seamlessly apply these indexes, ensuring faster filtering and sorting operations across the platform.
+- ✨ **Enhanced Database Performance Options**: Introduced new configurable options to significantly improve database performance, especially for SQLite. This includes "DATABASE_ENABLE_SQLITE_WAL" to enable SQLite WAL (Write-Ahead Logging) mode for concurrent operations, and "DATABASE_DEDUPLICATE_INTERVAL" which, in conjunction with a new deduplication mechanism, reduces redundant updates to "user.last_active_at", minimizing write conflicts across all database types.
+- 💾 **Save Temporary Chats Button**: Introduced a new 'Save Chat' button for conversations initiated in temporary mode. This allows users to permanently save valuable temporary conversations to their chat history, providing greater flexibility and ensuring important discussions are not lost.
+- 📂 **Chat Movement Options in Menu**: Added the ability to move chats directly to folders from the chat menu. This enhances chat organization and allows users to manage their conversations more efficiently by relocating them between folders with ease.
+- 💬 **Language-Aware Follow-Up Suggestions**: Enhanced the AI's follow-up question generation to dynamically adapt to the primary language of the current chat. Follow-up prompts will now be suggested in the same language the user and AI are conversing in, ensuring more natural and contextually relevant interactions.
+- 👤 **Expanded User Profile Details**: Introduced new user profile fields including username, bio, gender, and date of birth, allowing for more comprehensive user customization and information management. This enhancement includes corresponding updates to the database schema, API, and user interface for seamless integration.
+- 👥 **Direct Navigation to User Groups from User Edit**: Enhanced the user edit modal to include a direct link to the associated user group. This allows administrators to quickly navigate from a user's profile to their group settings, streamlining user and group management workflows.
+- 🔧 **Enhanced External Tool Server Compatibility**: Improved handling of responses from external tool servers, allowing both the backend and frontend to process plain text content in addition to JSON, ensuring greater flexibility and integration with diverse tool outputs.
+- 🗣️ **Enhanced Audio Transcription Language Fallback and Deepgram Support**: Implemented a robust language fallback mechanism for both OpenAI and Deepgram Speech-to-Text (STT) API calls. If a specified language parameter is not supported by the model or provider, the system will now intelligently retry the transcription without the language parameter or with a default, ensuring greater reliability and preventing failed API calls. This also specifically adds and refines support for the audio language parameter in Deepgram API integrations.
+- ⚡ **Optimized Hybrid Search Performance for BM25 Weight Configuration**: Enhanced hybrid search to significantly improve performance when the BM25 weight is set to 0 or less. This optimization intelligently disables unnecessary collection retrieval and BM25 ranking calculations, leading to faster search results without impacting accuracy for configurations that do not utilize lexical search contributions.
+- 🔒 **Configurable Code Interpreter Module Blacklist**: Introduced the "CODE_INTERPRETER_BLACKLISTED_MODULES" environment variable, allowing administrators to specify Python modules that are forbidden from being imported or executed within the code interpreter. This significantly enhances the security posture by mitigating risks associated with arbitrary code execution, such as unauthorized data access, system manipulation, or outbound connections.
+- 🔐 **Enhanced OAuth Role Claim Handling**: Improved compatibility with diverse OAuth providers by allowing role claims to be supplied as single strings or integers, in addition to arrays. The system now automatically normalizes these single-value claims into arrays for consistent processing, streamlining integration with identity providers that format role data differently.
+- ⚙️ **Configurable Tool Call Timeout**: Introduced the "AIOHTTP_CLIENT_TIMEOUT" environment variable, allowing administrators to specify custom timeout durations for external tool calls, which is crucial for integrations with tools that have varying or extended response times.
+- 🛠️ **Improved Tool Callable Generation for Google genai SDK**: Enhanced the creation of tool callables to directly support native function calling within the Google 'genai' SDK. This refactoring ensures proper signature inference and removes extraneous parameters, enabling seamless integration for advanced AI workflows using Google's generative AI models.
+- ✨ **Dynamic Loading of 'kokoro-js'**: Implemented dynamic loading for the 'kokoro-js' library, preventing failures and improving compatibility on older iOS browsers that may not support direct imports or certain modern JavaScript APIs like 'DecompressionStream'.
+- 🖥️ **Improved Command List Visibility on Small Screens**: Resolved an issue where the top items in command lists (e.g., Knowledge Base, Models, Prompts) were hidden or overlapped by the header on smaller screen sizes or specific browser zoom levels. The command option lists now dynamically adjust their height, ensuring all items are fully visible and accessible with proper scrolling.
+- 📦 **Improved Docker Image Compatibility for Arbitrary UIDs**: Fixed issues preventing the Open WebUI container from running in environments with arbitrary User IDs (UIDs), such as OpenShift's restricted Security Context Constraints (SCC). The Dockerfile has been updated to correctly set file system permissions for "/app" and "/root" directories, ensuring they are writable by processes running with a supplemental GID 0, thus resolving permission errors for Python libraries and application caches.
+- ♿ **Accessibility Enhancements**: Significantly improved the semantic structure of chat messages by using "section", "h2", "ul", and "li" HTML tags, and enhanced screen reader compatibility by explicitly hiding decorative images with "aria-hidden" attributes. This refactoring provides clearer structural context and improves overall accessibility and web standards compliance for the conversation flow.
+- 🌐 **Localization & Internationalization Improvements**: Significantly expanded internationalization support throughout the user interface, translating numerous user-facing strings in toast messages, placeholders, and other UI elements. This, alongside continuous refinement and expansion of translations for languages including Brazilian Portuguese, Kabyle (Taqbaylit), Czech, Finnish, Chinese (Simplified), Chinese (Traditional), and German, and general fixes for several other translation files, further enhances linguistic coverage and user experience.
+
+### Fixed
+
+- 🛡️ **Resolved Critical OIDC SSO Login Failure**: Fixed a critical issue where OIDC Single Sign-On (SSO) logins failed due to an error in setting the authentication token as a cookie during the redirect process. This ensures reliable and seamless authentication for users utilizing OIDC providers, restoring full login functionality that was impacted by previous security hardening.
+- ⚡ **Prevented UI Blocking by Unreachable Webhooks**: Resolved a critical performance and user experience issue where synchronous webhook calls to unreachable or slow endpoints would block the entire user interface for all users. Webhook requests are now processed asynchronously using "aiohttp", ensuring that the UI remains responsive and functional even if webhook delivery encounters delays or failures.
+- 🔒 **Password Change Option Hidden for Externally Authenticated Users**: Resolved an issue where the password change dialog was visible to users authenticated via external methods (e.g., LDAP, OIDC, Trusted Header). The option to change a password in user settings is now correctly hidden for these users, as their passwords are managed externally, streamlining the user interface and preventing confusion.
+- 💬 **Resolved Temporary Chat and Permission Enforcement Issues**: Fixed a bug where temporary chats (identified by "chat_id = local") incorrectly triggered database checks, leading to 404 errors. This also resolves the issue where the 'USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED' setting was not functioning as intended, ensuring temporary chat mode now works correctly for user roles.
+- 🔐 **Admin Model Visibility for Administrators**: Private models remained visible and usable for administrators in the chat model selector, even when the intended privacy setting ("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" - now renamed to "BYPASS_ADMIN_ACCESS_CONTROL") was disabled. This ensures consistent enforcement of model access controls and adherence to the principle of least privilege.
+- 🔍 **Clarified Web Search Engine Label for DDGS**: Addressed user confusion and inaccurate labeling by renaming "duckduckgo" to "DDGS" (Dux Distributed Global Search) in the web search engine selector. This clarifies that the system utilizes DDGS, a metasearch library that aggregates results from various search providers, accurately reflecting its underlying functionality rather than implying exclusive use of DuckDuckGo's search engine.
+- 🛠️ **Improved Settings UI Reactivity and Visibility**: Resolved an issue where settings tabs for 'Connections' and 'Tools' did not dynamically update their visibility based on global administrative feature flags (e.g., 'enable_direct_connections'). The UI now reactively shows or hides these sections, ensuring a consistent and clear experience when administrators control feature availability.
+- 🎚️ **Restored Model and Banner Reordering Functionality**: Fixed a bug that prevented administrators from reordering models in the Admin Panel's 'Models' settings and banners in the 'Interface' settings via drag-and-drop. The sortable functionality has been restored, allowing for proper customization of display order.
+- 📝 **Restored Custom Pending User Overlay Visibility**: Fixed an issue where the custom title and description configured for pending users were not visible. The application now correctly exposes these UI configuration settings to pending users, ensuring that the custom onboarding messages are displayed as intended.
+- 📥 **Fixed Community Function Import Compatibility**: Resolved an issue that prevented the successful import of function files downloaded from openwebui.com due to schema differences. The system now correctly processes these files, allowing for seamless integration of community-contributed functions.
+- 📦 **Fixed Stale Ollama Version in Docker Images**: Resolved an issue where the Ollama installation within Docker images could become stale due to caching during the build process. The Dockerfile now includes a mechanism to invalidate the build cache for the Ollama installation step, ensuring that the latest version of Ollama is always installed.
+- 🗄️ **Improved Milvus Query Handling for Large Datasets**: Fixed a "MilvusException" that occurred when attempting to query more than 16384 entries from a Milvus collection. The query logic has been refactored to use "query_iterator()", enabling efficient fetching of larger result sets in batches and resolving the previous limitation on the number of entries that could be retrieved.
+- 🐛 **Restored Message Toolbar Icons for Empty Messages with Files**: Fixed an issue where the edit, copy, and delete icons were not displayed on user messages that contained an attached file but no text content. This ensures full interaction capabilities for all message types, allowing users to manage their messages consistently.
+- 💬 **Resolved Streaming Interruption for Kimi-Dev Models**: Fixed an issue where streaming responses from Kimi-Dev models would halt prematurely upon encountering specific 'thinking' tokens (◁think▷, ◁/think▷). The system now correctly processes these tokens, ensuring uninterrupted streaming and proper handling of hidden or collapsible thinking sections.
+- 🔍 **Enhanced Knowledge Base Search Functionality**: Improved the search capability within the 'Knowledge' section of the Workspace. Previously, searching for knowledge bases required exact term matches or starting with the first letter. Now, the search algorithm has been refined to allow broader, less exact matches, making it easier and more intuitive to find relevant knowledge bases.
+- 📝 **Resolved Chinese Input 'Enter' Key Issue (macOS & iOS Safari)**: Fixed a bug where pressing the 'Enter' key during text composition with Input Method Editors (IMEs) on macOS and iOS Safari browsers would prematurely send the message. The system now robustly handles the composition state by addressing a 'compositionend' event bug specific to Safari, ensuring a smooth and expected typing experience for users of various languages, including Chinese and Korean.
+- 🔐 **Resolved OAUTH_GROUPS_CLAIM Configuration Issue**: Fixed a bug where the "OAUTH_GROUPS_CLAIM" environment variable was not correctly parsed due to a typo in the configuration file. This ensures that OAuth group management features, including automatic group creation, now correctly utilize the specified claim from the identity provider, allowing for seamless integration with external user directories like Keycloak.
+- 🗄️ **Resolved Azure PostgreSQL pgvector Extension Permissions**: Fixed an issue preventing the creation of "pgvector" and "pgcrypto" extensions on Azure PostgreSQL Flexible Servers due to permission limitations (e.g., 'Only members of "azure_pg_admin" are allowed to use "CREATE EXTENSION"'). The extension creation process now includes a conditional check, ensuring seamless deployment and compatibility with Azure PostgreSQL environments even with restricted database user permissions.
+- 🛠️ **Improved Backend Path Resolution and Alembic Stability**: Fixed issues causing Alembic database migrations to fail due to incorrect path resolution within the application. By implementing canonical path resolution for core directories and refining Alembic configuration, the robustness and correctness of internal pathing have been significantly enhanced, ensuring reliable database operations.
+- 📊 **Resolved Arena Model Identification in Feedback History**: Fixed an issue where the model used for feedback in arena settings was incorrectly reported as 'arena-model' in the evaluation history. The system now correctly logs and displays the actual model ID that received the feedback, restoring clarity and enabling proper analysis of model performance in arena environments.
+- 🎨 **Resolved Icon Overlap in 'Her' Theme**: Fixed a visual glitch in the 'Her' theme where icons would overlap on the loading screen and certain icons appeared incongruous. The display has been corrected to ensure proper visual presentation and theme consistency.
+- 🛠️ **Resolved Model Sorting TypeError with Null Names**: Fixed a "TypeError" that occurred in the "/api/models" endpoint when sorting models with null or missing names. The model sorting logic has been improved to gracefully handle such edge cases by ensuring that model IDs and names are treated as empty strings if their values are null or undefined, preventing comparison errors and improving API stability.
+- 💬 **Resolved Silently Dropped Streaming Response Chunks**: Fixed an issue where the final partial chunks of streaming chat responses could be silently dropped, leading to incomplete message delivery. The system now reliably flush any pending delta data upon stream termination, early breaks (e.g., code interpreter tags), or connection closure, ensuring complete and accurate response delivery.
+- 📱 **Disabled Overscroll for iOS Frontend**: Fixed an issue where overscrolling was enabled on iOS devices, causing unexpected scrolling behavior over fixed or sticky elements within the PWA. Overscroll has now been disabled, providing a more native application-like experience for iOS users.
+- 📝 **Resolved Code Block Input Issue with Shift+Enter**: Fixed a bug where typing three backticks followed by a language and then pressing Shift+Enter would cause the code block prefix to disappear, preventing proper code formatting. The system now correctly preserves the code block syntax, ensuring consistent behavior for multi-line code input.
+- 🛠️ **Improved OpenAI Model List Handling for Null Names**: Fixed an edge case where some OpenAI-compatible API providers might return models with a null value for their 'name' field. This could lead to issues like broken model list sorting. The system now gracefully handles these instances by removing the null 'name' key, ensuring stable model retrieval and display.
+- 🔍 **Resolved DDGS Concurrent Request Configuration**: Fixed an issue where the configured number of concurrent requests was not being honored for the DDGS (Dux Distributed Global Search) metasearch engine. The system now correctly applies the specified concurrency setting, improving efficiency for web searches.
+- 🛠️ **Improved Tool List Synchronization in Multi-Replica Deployments**: Resolved an issue where tool updates were not consistently reflected across all instances in multi-replica environments, leading to stale tool lists for users on other replicas. The tool list in the message input menu is now automatically refreshed each time it is accessed, ensuring all users always see the most current set of available tools.
+- 🛠️ **Resolved Duplicate Tool Name Collision**: Fixed an issue where tools with identical names from different external servers were silently removed, preventing their simultaneous use. The system now correctly handles tool name collisions by internally prefixing tools with their server identifier, allowing multiple instances of similarly named tools from different servers to be active and usable by LLMs.
+- 🖼️ **Resolved Image Generation API Size Parameter Issue**: Fixed a bug where the "/api/v1/images/generations" API endpoint did not correctly apply the 'size' parameter specified in the request payload for image generation. The system now properly honors the requested image dimensions (e.g., '1980x1080'), ensuring that generated images match the user's explicit size preference rather than defaulting to settings.
+- 🗄️ **Resolved S3 Vector Upload Limitations**: Fixed an issue that prevented uploading more than 500 vectors to S3 Vector buckets due to API limitations, which resulted in a "ValidationException". S3 vector uploads are now batched in groups of 500, ensuring successful processing of larger datasets.
+- 🛠️ **Fixed Tool Installation Error During Startup**: Resolved a "NoneType" error that occurred during tool installation at startup when 'tool.user' was unexpectedly null. The system now includes a check to ensure 'tool.user' exists before attempting to access its properties, preventing crashes and ensuring robust tool initialization.
+- 🛠️ **Improved Azure OpenAI GPT-5 Parameter Handling**: Fixed an issue with Azure OpenAI SDK parameter handling to correctly support GPT-5 models. The 'max_tokens' parameter is now appropriately converted to 'max_completion_tokens' for GPT-5 models, ensuring consistent behavior and proper function execution similar to existing o-series models.
+- 🐛 **Resolved Exception with Missing Group Permissions**: Fixed an exception that occurred in the access control logic when group permission objects were missing or null. The system now correctly handles cases where groups may not have explicit permission definitions, ensuring that 'None' checks prevent errors and maintain application stability when processing user permissions.
+- 🛠️ **Improved OpenAI API Base URL Handling**: Fixed an issue where a trailing slash in the 'OPENAI_API_BASE_URL' configuration could lead to models not being detected or the endpoint failing. The system now automatically removes trailing slashes from the configured URL, ensuring robust and consistent connections to OpenAI-compatible APIs.
+- 🖼️ **Resolved S3-Compatible Storage Upload Failures**: Fixed an issue where uploads to S3-compatible storage providers would fail with an "XAmzContentSHA256Mismatch" error. The system now correctly handles checksum calculations, ensuring reliable file and image uploads to S3-compatible services.
+- 🌐 **Corrected 'Releases' Link**: Fixed an issue where the 'Releases' button in the user menu directed to an incorrect URL, now correctly linking to the Open WebUI GitHub releases page.
+- 🛠️ **Resolved Model Sorting Errors with Null or Undefined Names**: Fixed multiple "TypeError" instances that occurred when attempting to sort model lists where model names were null or undefined. The sorting logic across various UI components (including Ollama model selection, leaderboard, and admin model settings) has been made more robust by gracefully handling absent model names, preventing crashes and ensuring consistent alphabetical sorting based on available name or ID.
+- 🎨 **Resolved Banner Dismissal Issue with Iteration IDs**: Fixed a bug where dismissing banners could lead to unintended multiple banner dismissals or other incorrect behavior, especially when banners lacked unique iteration IDs. Unique IDs are now assigned during banner iteration, ensuring proper individual dismissal and consistent display behavior.
+
+### Changed
+
+- 🛂 **Environment Variable for Admin Access Control**: The environment variable "ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS" has been renamed to "BYPASS_ADMIN_ACCESS_CONTROL". This new name more accurately reflects its function as a control to allow administrators to bypass model access restrictions. Users are encouraged to update their configurations to use the new variable name; existing configurations using the old name will still be honored for backward compatibility.
+- 🗂️ **Core Directory Path Resolution Updated**: The internal mechanism for resolving core application directory paths ("OPEN_WEBUI_DIR", "BACKEND_DIR", "BASE_DIR") has been updated to use canonical resolution via "Path().resolve()". This change improves path reliability but may require adjustments for any external scripts or configurations that previously relied on specific non-canonical path interpretations.
+- 🗃️ **Database Performance Options**: New database performance options, "DATABASE_ENABLE_SQLITE_WAL" and "DATABASE_DEDUPLICATE_INTERVAL", are now available. If "DATABASE_ENABLE_SQLITE_WAL" is enabled, SQLite will operate in WAL mode, which may alter SQLite's file locking behavior. If "DATABASE_DEDUPLICATE_INTERVAL" is set to a non-zero value, the "user.last_active_at" timestamp will be updated less frequently, leading to slightly less real-time accuracy for this specific field but significantly reducing database write conflicts and improving overall performance. Both options are disabled by default.
+- 🌐 **Renamed Web Search Concurrency Setting**: The environment variable "WEB_SEARCH_CONCURRENT_REQUESTS" has been renamed to "WEB_LOADER_CONCURRENT_REQUESTS". This change clarifies its scope, explicitly applying to the concurrency of the web loader component (which fetches content from search results) rather than the initial search engine query. Users relying on the old environment variable name for configuring web search concurrency must update their configurations to use "WEB_LOADER_CONCURRENT_REQUESTS".
+
+## [0.6.22] - 2025-08-11
+
+### Added
+
+- 🔗 **OpenAI API '/v1' Endpoint Compatibility**: Enhanced API compatibility by supporting requests to paths like '/v1/models', '/v1/embeddings', and '/v1/chat/completions'. This allows Open WebUI to integrate more seamlessly with tools that expect OpenAI's '/v1' API structure.
+- 🪄 **Toggle for Guided Response Regeneration Menu**: Introduced a new setting in 'Interface' settings, providing the ability to enable or disable the expanded guided response regeneration menu. This offers users more control over their chat workflow and interface preferences.
+- ✨ **General UI/UX Enhancements**: Implemented various user interface and experience improvements, including more rounded corners for cards in the Knowledge, Prompts, and Tools sections, and minor layout adjustments within the chat Navbar for improved visual consistency.
+- 🌐 **Localization & Internationalization Improvements**: Introduced support for the Kabyle (Taqbaylit) language, refined and expanded translations for Chinese, expanding the platform's linguistic coverage.
+
+### Fixed
+
+- 🐞 **OpenAI Error Message Propagation**: Resolved an issue where specific OpenAI API errors (e.g., 'Organization Not Verified') were obscured by generic 'JSONResponse' iterable errors. The system now correctly propagates detailed and actionable error messages from OpenAI to the user.
+- 🌲 **Pinecone Insert Issue**: Fixed a bug that prevented proper insertion of items into Pinecone vector databases.
+- 📦 **S3 Vector Issue**: Resolved a bug where s3vector functionality failed due to incorrect import paths.
+- 🏠 **Landing Page Option Setting Not Working**: Fixed an issue where the landing page option in settings was not functioning as intended.
+
+## [0.6.21] - 2025-08-10
+
+### Added
+
+- 👥 **User Groups in Edit Modal**: Added display of user groups information in the user edit modal, allowing administrators to view and manage group memberships directly when editing a user.
+
+### Fixed
+
+- 🐞 **Chat Completion 'model_id' Error**: Resolved a critical issue where chat completions failed with an "undefined model_id" error after upgrading to version 0.6.20, ensuring all models now function correctly and reliably.
+- 🛠️ **Audit Log User Information Logging**: Fixed an issue where user information was not being correctly logged in the audit trail due to an unreflected function prototype change, ensuring complete logging for administrative oversight.
+- 🛠️ **OpenTelemetry Configuration Consistency**: Fixed an issue where OpenTelemetry metric and log exporters' 'insecure' settings did not correctly default to the general OpenTelemetry 'insecure' flag, ensuring consistent security configurations across all OpenTelemetry exports.
+- 📝 **Reply Input Content Display**: Fixed an issue where replying to a message incorrectly displayed '{{INPUT_CONTENT}}' instead of the actual message content, ensuring proper content display in replies.
+- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Korean, Spanish and Irish, ensuring a more fluent and native experience for global users.
+
+## [0.6.20] - 2025-08-10
+
+### Fixed
+
+- 🛠️ **Quick Actions "Add" Behavior**: Fixed a bug where using the "Add" button in Quick Actions would add the resulting message as the very first message in the chat, instead of appending it to the latest message.
+
+## [0.6.19] - 2025-08-09
+
+### Added
+
+- ✨ **Modernized Sidebar and Major UI Refinements**: The main navigation sidebar has been completely redesigned with a modern, cleaner aesthetic, featuring a sticky header and footer to keep key controls accessible. Core sidebar logic, like the pinned models list, was also refactored into dedicated components for better performance and maintainability.
+- 🪄 **Guided Response Regeneration**: The "Regenerate" button has been transformed into a powerful new menu. You can now guide the AI's next attempt by suggesting changes in a text prompt, or use one-click options like "Try Again," "Add Details," or "More Concise" to instantly refine and reshape the response to better fit your needs.
+- 🛠️ **Improved Tool Call Handling for GPT-OSS Models**: Implemented robust handling for tool calls specifically for GPT-OSS models, ensuring proper function execution and integration.
+- 🛑 **Stop Button for Merge Responses**: Added a dedicated stop button to immediately halt the generation of merged AI responses, providing users with more control over ongoing outputs.
+- 🔄 **Experimental SCIM 2.0 Support**: Implemented SCIM 2.0 (System for Cross-domain Identity Management) protocol support, enabling enterprise-grade automated user and group provisioning from identity providers like Okta, Azure AD, and Google Workspace for seamless user lifecycle management. Configuration is managed securely via environment variables.
+- 🗂️ **Amazon S3 Vector Support**: You can now use Amazon S3 Vector as a high-performance vector database for your Retrieval-Augmented Generation (RAG) workflows. This provides a scalable, cloud-native storage option for users deeply integrated into the AWS ecosystem, simplifying infrastructure and enabling enterprise-scale knowledge management.
+- 🗄️ **Oracle 23ai Vector Search Support**: Added support for Oracle 23ai's new vector search capabilities as a supported vector database, providing a robust and scalable option for managing large-scale documents and integrating vector search with existing business data at the database level.
+- ⚡ **Qdrant Performance and Configuration Enhancements**: The Qdrant client has been significantly improved with faster data retrieval logic for 'get' and 'query' operations. New environment variables ('QDRANT_TIMEOUT', 'QDRANT_HNSW_M') provide administrators with finer control over query timeouts and HNSW index parameters, enabling better performance tuning for large-scale deployments.
+- 🔐 **Encrypted SQLite Database with SQLCipher**: You can now encrypt your entire SQLite database at rest using SQLCipher. By setting the 'DATABASE_TYPE' to 'sqlite+sqlcipher' and providing a 'DATABASE_PASSWORD', all data is transparently encrypted, providing an essential security layer for protecting sensitive information in self-hosted deployments. Note that this requires additional system libraries and the 'sqlcipher3-wheels' Python package.
+- 🚀 **Efficient Redis Connection Management**: Implemented a shared connection pool cache to reuse Redis connections, dramatically reducing the number of active clients. This prevents connection exhaustion errors, improves performance, and ensures greater stability in high-concurrency deployments and those using Redis Sentinel.
+- ⚡ **Batched Response Streaming for High Performance**: Dramatically improve performance and stability during high-speed response streaming by batching multiple tokens together before sending them to the client. A new 'Stream Delta Chunk Size' advanced parameter can be set per-model or in user/chat settings, significantly reducing CPU load on the server, Redis, and client, and preventing connection issues in high-concurrency environments.
+- ⚙️ **Global Batched Streaming Configuration**: Administrators can now set a system-wide default for response streaming using the new 'CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE' environment variable. This allows for global performance tuning, while still letting per-model or per-chat settings override the default for more granular control.
+- 🔎 **Advanced Chat Search with Status Filters**: Quickly find any conversation with powerful new search filters. You can now instantly narrow down your chats using prefixes like 'pinned:true', 'shared:true', and 'archived:true' directly in the search bar. An intelligent dropdown menu assists you by suggesting available filter options as you type, streamlining your workflow and making chat management more efficient than ever.
+- 🛂 **Granular Chat Controls Permissions**: Administrators can now manage chat settings with greater detail. The main "Chat Controls" permission now acts as a master switch, while new granular toggles for "Valves", "System Prompts", and "Advanced Parameters" allow for more specific control over which sections are visible to users inside the panel.
+- ✍️ **Formatting Toolbar for Chat Input**: Introduced a dedicated formatting toolbar for the rich text chat input field, providing users with more accessible options for text styling and editing, configurable via interface settings.
+- 📑 **Tabbed View for Multi-Model Responses**: You can now enable a new tabbed interface to view responses from multiple models. Instead of side-scrolling cards, this compact view organizes each model's response into its own tab, making it easier to compare outputs and saving vertical space. This feature can be toggled on or off in Interface settings.
+- ↕️ **Reorder Pinned Models via Drag-and-Drop**: You can now organize your pinned models in the sidebar by simply dragging and dropping them into your preferred order. This custom layout is saved automatically, giving you more flexible control over your workspace.
+- 📌 **Quick Model Unpin Shortcut**: You can now quickly unpin a model by holding the Shift key and hovering over it to reveal an instant unpin button, streamlining your workspace customization.
+- ⚡ **Improved Chat Input Performance**: The chat input is now significantly more responsive, especially when pasting or typing large amounts of text. This was achieved by implementing a debounce mechanism for the auto-save feature, which prevents UI lag and ensures a smooth, uninterrupted typing experience.
+- ✍️ **Customizable Floating Quick Actions with Tool Support**: Take full control of your text interaction workflow with new customizable floating quick actions. In Settings, you can create, edit, or disable these actions and even integrate tools using the '{{TOOL:tool_id}}' syntax in your prompts, enabling powerful one-click automations on selected text. This is in addition to using placeholders like '{{CONTENT}}' and '{{INPUT_CONTENT}}' for custom text transformations.
+- 🔒 **Admin Workspace Privacy Control**: Introduced the 'ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS' environment variable (defaults to 'True') allowing administrators to control their access privileges to workspace items (Knowledge, Models, Prompts, Tools). When disabled, administrators adhere to the same access control rules as regular users, enhancing data separation for multi-tenant deployments.
+- 🗄️ **Comprehensive Model Configuration Management**: Administrators can now export the entire model configuration to a file and use a new declarative sync endpoint to manage models in bulk. This powerful feature enables seamless backups, migrations, and state replication across multiple instances.
+- 📦 **Native Redis Cluster Mode Support**: Added full support for connecting to Redis in cluster mode, allowing for scalable and highly available Redis deployments beyond Sentinel-managed setups. New environment variables 'REDIS_CLUSTER' and 'WEBSOCKET_REDIS_CLUSTER' enable the use of 'redis.cluster.RedisCluster' clients.
+- 📊 **Granular OpenTelemetry Metrics Configuration**: Introduced dedicated environment variables and enhanced configuration options for OpenTelemetry metrics, allowing for separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for metrics export, independent of trace settings. This provides greater flexibility for integrating with diverse observability stacks.
+- 🪵 **Granular OpenTelemetry Logging Configuration**: Enhanced the OpenTelemetry logging integration by introducing dedicated environment variables for logs, allowing separate OTLP endpoints, basic authentication credentials, and protocol (HTTP/gRPC) specifically for log export, independent of general OTel settings. The application's default Python logger now leverages this configuration to automatically send logs to your OTel endpoint when enabled via 'ENABLE_OTEL_LOGS'.
+- 📁 **Enhanced Folder Chat Management with Sorting and Time Blocks**: The chat list within folders now supports comprehensive sorting options by title and updated time, along with intelligent time-based grouping (e.g., "Today," "Yesterday") similar to the main chat view, making navigation and organization of project-specific conversations significantly easier.
+- ⚙️ **Configurable Datalab Marker API & Advanced Processing Options**: Enhanced Datalab Marker API integration, allowing administrators to configure custom API base URLs for self-hosting and to specify comprehensive processing options via a new 'additional_config' JSON parameter. This replaces the deprecated language selection feature and provides granular control over document extraction, with streamlined API endpoint resolution for more robust self-hosted deployments.
+- 🧑💼 **Export All Users to CSV**: Administrators can now export a complete list of all users to a CSV file directly from the Admin Panel's database settings. This provides a simple, one-click way to generate user data for auditing, reporting, or management purposes.
+- 🛂 **Customizable OAuth 'sub' Claim**: Administrators can now use the 'OAUTH_SUB_CLAIM_OVERRIDE' environment variable to specify which claim from the identity provider should be used as the unique user identifier ('sub'). This provides greater flexibility and control for complex enterprise authentication setups where modifying the IDP's default claims is not possible.
+- 👁️ **Password Visibility Toggle for Input Fields**: Password fields across the application (login, registration, user management, and account settings) now utilize a new 'SensitiveInput' component, providing a consistent toggle to reveal/hide passwords for improved usability and security.
+- 🛂 **Optional "Confirm Password" on Sign-Up**: To help prevent password typos during account creation, administrators can now enable a "Confirm Password" field on the sign-up page. This feature is disabled by default and can be activated via an environment variable for enhanced user experience.
+- 💬 **View Full Chat from User Feedback**: Administrators can now easily navigate to the full conversation associated with a user feedback entry directly from the feedback modal, streamlining the review and troubleshooting process.
+- 🎚️ **Intuitive Hybrid Search BM25-Weight Slider**: The numerical input for the BM25-Weight parameter in Hybrid Search has been replaced with an interactive slider, offering a more intuitive way to adjust the balance between lexical and semantic search. A "Default/Custom" toggle and clearer labels enhance usability and understanding of this key parameter.
+- ⚙️ **Enhanced Bulk Function Synchronization**: The API endpoint for synchronizing functions has been significantly improved to reliably handle bulk updates. This ensures that importing and managing large libraries of functions is more robust and error-free for administrators.
+- 🖼️ **Option to Disable Image Compression in Channels**: Introduced a new setting under Interface options to allow users to force-disable image compression specifically for images posted in channels, ensuring higher resolution for critical visual content.
+- 🔗 **Custom CORS Scheme Support**: Introduced a new environment variable 'CORS_ALLOW_CUSTOM_SCHEME' that allows administrators to define custom URL schemes (e.g., 'app://') for CORS origins, enabling greater flexibility for local development or desktop client integrations.
+- ♿ **Translatable and Accessible Banners**: Enhanced banner elements with translatable badge text and proper ARIA attributes (aria-label, aria-hidden) for SVG icons, significantly improving accessibility and screen reader compatibility.
+- ⚠️ **OAuth Configuration Warning for Missing OPENID_PROVIDER_URL**: Added a proactive startup warning that notifies administrators when OAuth providers (Google, Microsoft, or GitHub) are configured but the essential 'OPENID_PROVIDER_URL' environment variable is missing. This prevents silent OAuth logout failures and guides administrators to complete their setup correctly.
+- ♿ **Major Accessibility Enhancements**: Key parts of the interface have been made significantly more accessible. The user profile menu is now fully navigable via keyboard, essential controls in the Playground now include proper ARIA labels for screen readers, and decorative images have been hidden from assistive technologies to reduce audio clutter. Menu buttons also feature enhanced accessibility with 'aria-label', 'aria-hidden' for SVGs, and 'aria-pressed' for toggle buttons.
+- ⚙️ **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users, including refining logging output to be cleaner and more efficient by conditionally including 'extra_json' fields and improving consistent metadata handling in vector database operations, and laying preliminary scaffolding for future analytics features.
+- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Catalan, Danish, Korean, Persian, Polish, Simplified Chinese, and Spanish, ensuring a more fluent and native experience for global users across all supported languages.
+
+### Fixed
+
+- 🛡️ **Hardened Channel Message Security**: Fixed a key permission flaw that allowed users with channel access to edit or delete messages belonging to others. The system now correctly enforces that users can only modify their own messages, protecting data integrity in shared channels.
+- 🛡️ **Hardened OAuth Security by Removing JWT from URL**: Fixed a critical security vulnerability where the authentication token was exposed in the URL after a successful OAuth login. The token is now transferred via a browser cookie, preventing potential leaks through browser history or server logs and protecting user sessions.
+- 🛡️ **Hardened Chat Completion API Security**: The chat completion API endpoint now includes an explicit ownership check, ensuring non-admin users cannot access chats that do not belong to them and preventing potential unauthorized access.
+- 🛠️ **Resilient Model Loading**: Fixed an issue where a failure in loading the model list (e.g., from a misconfigured provider) would prevent the entire user interface, including the admin panel, from loading. The application now gracefully handles these errors, ensuring the UI remains accessible.
+- 🔒 **Resolved FIPS Self-Test Failure**: Fixed a critical issue that prevented Open WebUI from running on FIPS-compliant systems, specifically resolving the "FATAL FIPS SELFTEST FAILURE" error related to OpenSSL and SentenceTransformers, restoring compatibility with secure environments.
+- 📦 **Redis Cluster Connection Restored**: Fixed an issue where the backend was unable to connect to Redis in cluster mode, now ensuring seamless integration with scalable Redis cluster deployments.
+- 📦 **PGVector Connection Stability**: Fixed an issue where read-only operations could leave database transactions idle, preventing potential connection errors and improving overall database stability and resource management.
+- 🛠️ **OpenAPI Tool Integration for Array Parameters Fixed**: Resolved a critical bug where external tools using array parameters (e.g., for tags) would fail when used with OpenAI models. The system now correctly generates the required 'items' property in the function schema, restoring functionality and preventing '400 Bad Request' errors.
+- 🛠️ **Tool Creation for Users Restored**: Fixed a bug in the code editor where status messages were incorrectly prepended to tool scripts, causing a syntax error upon saving. All authorized users can now reliably create and save new tools.
+- 📁 **Folder Knowledge Processing Restored**: Fixed a bug where files uploaded to folder and model knowledge bases were not being extracted or analyzed for Retrieval-Augmented Generation (RAG) when the 'Max Upload Count' setting was empty, ensuring seamless document processing and knowledge augmentation.
+- 🧠 **Custom Model Knowledge Base Updates Recognized**: Fixed a bug where custom models linked to to knowledge bases did not automatically recognize newly added files to those knowledge bases. Models now correctly incorporate the latest information from updated knowledge collections.
+- 📦 **Comprehensive Redis Key Prefixing**: Corrected hardcoded prefixes to ensure the REDIS_KEY_PREFIX is now respected across all WebSocket and task management keys. This prevents data collisions in multi-instance deployments and improves compatibility with Redis cluster mode.
+- ✨ **More Descriptive OpenAI Router Errors**: The OpenAI-compatible API router now propagates detailed upstream error messages instead of returning a generic 'Bad Request'. This provides clear, actionable feedback for developers and API users, making it significantly easier to debug and resolve issues with model requests.
+- 🔐 **Hardened OIDC Signout Flow**: The OpenID Connect signout process now verifies that the 'OPENID_PROVIDER_URL' is configured before attempting to communicate with it, preventing potential errors and ensuring a more reliable logout experience.
+- 🍓 **Raspberry Pi Compatibility Restored**: Pinned the pyarrow library to version 20.0.0, resolving an "Illegal Instruction" crash on ARM-based devices like the Raspberry Pi and ensuring stable operation on this hardware.
+- 📁 **Folder System Prompt Variables Restored**: Fixed a bug where prompt variables (e.g., '{{CURRENT_DATETIME}}') were not being rendered in Folder-level System Prompts. This restores an important capability for creating dynamic, context-aware instructions for all chats within a project folder.
+- 📝 **Note Access in Knowledge Retrieval Fixed**: Corrected a permission oversight in knowledge retrieval, ensuring users can always use their own notes as a source for RAG without needing explicit sharing permissions.
+- 🤖 **Title Generation Compatibility for GPT-5 Models**: Added support for 'gpt-5' models in the payload handler, which correctly converts the deprecated 'max_tokens' parameter to 'max_completion_tokens'. This resolves title generation failures and ensures seamless operation with the latest generation of models.
+- ⚙️ **Correct API 'finish_reason' in Streaming Responses**: Fixed an issue where intermediate 'reasoning_content' chunks in streaming API responses incorrectly reported a 'finish_reason' of 'stop'. The 'finish_reason' is now correctly set to 'null' for these chunks, ensuring compatibility with third-party applications that rely on this field.
+- 📈 **Evaluation Pages Stability**: Resolved a crash on the Leaderboard and Feedbacks pages when processing legacy feedback entries that were missing a 'rating' field. The system now gracefully handles this older data, ensuring both pages load reliably for all users.
+- 🤝 **Reliable Collaborative Session Cleanup**: Fixed an asynchronous bug in the real-time collaboration engine that prevented document sessions from being properly cleaned up after all users had left. This ensures greater stability and resource management for features like Collaborative Notes.
+- 🧠 **Enhanced Memory Stability and Security**: Refactored memory update and delete operations to strictly enforce user ownership, preventing potential data integrity issues. Additionally, improved error handling for memory queries now provides clearer feedback when no memories exists.
+- 🧑⚖️ **Restored Admin Access to User Feedback**: Fixed a permission issue that blocked administrators from viewing or editing user feedback they didn't create, ensuring they can properly manage all evaluations across the platform.
+- 🔐 **PGVector Encryption Fix for Metadata**: Corrected a SQL syntax error in the experimental 'PGVECTOR_PGCRYPTO' feature that prevented encrypted metadata from being saved. Document uploads to encrypted PGVector collections now work as intended.
+- 🔍 **Serply Web Search Integration Restored**: Fixed an issue where incorrect parameters were passed to the Serply web search engine, restoring its functionality for RAG and web search workflows.
+- 🔍 **Resilient Web Search Processing**: Web search retrieval now gracefully handles search results that are missing a 'snippet', preventing crashes and ensuring that RAG workflows complete successfully even with incomplete data from search engines.
+- 🖼️ **Table Pasting in Rich Text Input Displayed Correctly**: Fixed an issue where pasting table text into the rich text input would incorrectly display it as code. Tables are now properly rendered as expected, improving content formatting and user experience.
+- ✍️ **Rich Text Input TypeError Resolution**: Addressed a potential 'TypeError: ue.getWordAtDocPos is not a function' in 'MessageInput.svelte' by refactoring how the 'getWordAtDocPos' function is accessed and referenced from 'RichTextInput.svelte', ensuring stable rich text input behavior, especially after production restarts.
+- ✏️ **Manual Code Block Creation in Chat Restored**: Fixed an issue where typing three backticks and then pressing Shift+Enter would incorrectly remove the backticks when "Enter to Send" mode was active. This ensures users can reliably create multi-line code blocks manually.
+- 🎨 **Consistent Dark Mode Background**: Fixed an issue where the application background could incorrectly flash or remain white during page loads and refreshes in dark mode, ensuring a seamless and consistent visual experience.
+- 🎨 **'Her' Theme Rendering Fixed**: Corrected a bug that caused the "Her" theme to incorrectly render as a dark theme in some situations. The theme now reliably applies its intended light appearance across all sessions.
+- 📜 **Corrected Markdown Table Line Break Rendering**: Fixed an issue where line breaks (' ') within Markdown tables were displayed as raw HTML instead of being rendered correctly. This ensures that tables with multi-line cell content are now displayed as intended.
+- 🚦 **Corrected App Configuration for Pending Users**: Fixed an issue where users awaiting approval could incorrectly load the full application interface, leading to a confusing or broken UI. This ensures that only fully approved users receive the standard app 'config', resulting in a smoother and more reliable onboarding experience.
+- 🔄 **Chat Cloning Now Includes Tags, Folder Status, and Pinned Status**: When cloning a chat or shared chat, its associated tags, folder organization, and pinned status are now correctly replicated, ensuring consistent chat management.
+- ⚙️ **Enhanced Backend Reliability**: Resolved a potential crash in knowledge base retrieval when referencing a deleted note. Additionally, chat processing was refactored to ensure model information is saved more reliably, enhancing overall system stability.
+- ⚙️ **Floating 'Ask/Explain' Modal Stability**: Fixed an issue that spammed the console with errors when navigating away while a model was generating a response in the floating 'Ask' or 'Explain' modals. In-flight requests are now properly cancelled, improving application stability.
+- ⚡ **Optimized User Count Checks**: Improved performance for user count and existence checks across the application by replacing resource-intensive 'COUNT' queries with more efficient 'EXISTS' queries, reducing database load.
+- 🔐 **Hardened OpenTelemetry Exporter Configuration**: The OTLP HTTP exporter no longer uses a potentially insecure explicit flag, improving security by relying on the connection URL's protocol (HTTP/HTTPS) to ensure transport safety.
+- 📱 **Mobile User Menu Closing Behavior Fixed**: Resolved an issue where the user menu would remain open on mobile devices after selecting an option, ensuring the menu correctly closes and returns focus to the main interface for a smoother mobile experience.
+- 📱 **OnBoarding Page Display Fixed on Mobile**: Resolved an issue where buttons on the OnBoarding page were not consistently visible on certain mobile browsers, ensuring a functional and complete user experience across devices.
+- ↕️ **Improved Pinned Models Drag-and-Drop Behavior**: The drag-and-drop functionality for reordering pinned models is now explicitly disabled on mobile devices, ensuring better usability and preventing potential UI conflicts or unexpected behavior.
+- 📱 **PWA Rotation Behavior Corrected**: The Progressive Web App now correctly respects the device's screen orientation lock, preventing unwanted rotation and ensuring a more native mobile experience.
+- ✏️ **Improved Chat Title Editing Behavior**: Changes to a chat title are now reliably saved when the user clicks away or presses Enter, replacing a less intuitive behavior that could accidentally discard edits. This makes renaming chats a smoother and more predictable experience.
+- ✏️ **Underscores Allowed in Prompt Commands**: Fixed the validation for prompt commands to correctly allow the use of underscores ('\_'), aligning with documentation examples and improving flexibility in naming custom prompts.
+- 💡 **Title Generation Button Behavior Fixed**: Resolved an issue where clicking the "Generate Title" button while editing a chat or note title would incorrectly save the title before generation could start. The focus is now managed correctly, ensuring a smooth and predictable user experience.
+- ✏️ **Consistent Chat Input Height**: Fixed a minor visual bug where the chat input field's height would change slightly when toggling the "Rich Text Input for Chat" setting, ensuring a more stable and consistent layout.
+- 🙈 **Admin UI Toggle Stability**: Fixed a visual glitch in the Admin settings where toggle switches could briefly display an incorrect state on page load, ensuring the UI always accurately reflects the saved settings.
+- 🙈 **Community Sharing Button Visibility**: The "Share to Community" button on the feedback page is now correctly hidden when the Enable Community Sharing feature is disabled in the admin settings, ensuring the UI respects the configured sharing policy.
+- 🙈 **"Help Us Translate" Link Visibility**: The "Help us translate" link in settings is now correctly hidden in deployments with specific license configurations, ensuring a cleaner interface for enterprise users.
+- 🔗 **Robust Tool Server URL Handling**: Fixed an issue where providing a full URL for a tool server's OpenAPI specification resulted in an invalid path. The system now correctly handles both absolute URLs and relative paths, improving configuration flexibility.
+- 🔧 **Improved Azure URL Detection**: The logic for identifying Azure OpenAI endpoints has been made more robust, ensuring all valid Azure URLs are now correctly detected for a smoother connection setup.
+- ⚙️ **Corrected Direct Connection Save Logic**: Fixed a bug in the Admin Connections settings page by removing a redundant save action for 'Direct Connections', leading to more reliable and predictable behavior when updating settings.
+- 🔗 **Corrected "Discover" Links**: The "Discover" links for models, prompts, tools, and functions now point to their specific, relevant pages on openwebui.com, improving content discovery for users.
+- ⏱️ **Refined Display of AI Thought Duration**: Adjusted the display logic for AI thought (reasoning) durations to more accurately show very short thought times as "less than a second," improving clarity in AI process feedback.
+- 📜 **Markdown Line Break Rendering Refinement**: Improved handling of line breaks within Markdown rendering for better visual consistency.
+- 🛠️ **Corrected OpenTelemetry Docker Compose Example**: The docker-compose.otel.yaml file has been fixed and enhanced by removing duplicates, adding necessary environment variables, and hardening security settings, ensuring a more reliable out-of-box observability setup.
+- 🛠️ **Development Script CORS Fix**: Corrected the CORS origin URL in the local development script (dev.sh) by removing the trailing slash, ensuring a more reliable and consistent setup for developers.
+- ⬆️ **OpenTelemetry Libraries Updated**: Upgraded all OpenTelemetry-related libraries to their latest versions, ensuring better performance, stability, and compatibility for observability.
+
+### Changed
+
+- ❗ **Docling Integration Upgraded to v1 API (Breaking Change)**: The integration with the Docling document processing engine has been updated to its new, stable '/v1' API. This is required for compatibility with Docling version 1.0.0 and newer. As a result, older versions of Docling are no longer supported. Users who rely on Docling for document ingestion **must upgrade** their docling-serve instance to ensure continued functionality.
+- 🗣️ **Admin-First Whisper Language Priority**: The global WHISPER_LANGUAGE setting now acts as a strict override for audio transcriptions. If set, it will be used for all speech-to-text tasks, ignoring any language specified by the user on a per-request basis. This gives administrators more control over transcription consistency.
+- ✂️ **Datalab Marker API Language Selection Removed**: The separate language selection option for the Datalab Marker API has been removed, as its functionality is now integrated and superseded by the more comprehensive 'additional_config' parameter. Users should transition to using 'additional_config' for relevant language and processing settings.
+- 📄 **Documentation and Releases Links Visibility**: The "Documentation" and "Releases" links in the user menu are now visible only to admin users, streamlining the user interface for non-admin roles.
+
+## [0.6.18] - 2025-07-19
+
+### Fixed
+
+- 🚑 **Users Not Loading in Groups**: Resolved an issue where user list was not displaying within user groups, restoring full visibility and management of group memberships for teams and admins.
+
+## [0.6.17] - 2025-07-19
+
+### Added
+
+- 📂 **Dedicated Folder View with Chat List**: Clicking a folder now reveals a brand-new landing page showcasing a list of all chats within that folder, making navigation simpler and giving teams immediate visibility into project-specific conversations.
+- 🆕 **Streamlined Folder Creation Modal**: Creating a new folder is now a seamless, unified experience with a dedicated modal that visually and functionally matches the edit folder flow, making workspace organization more intuitive and error-free for all users.
+- 🗃️ **Direct File Uploads to Folder Knowledge**: You can now upload files straight to a folder’s knowledge—empowering you to enrich project spaces by adding resources and documents directly, without the need to pre-create knowledge bases beforehand.
+- 🔎 **Chat Preview in Search**: When searching chats, instantly preview results in context without having to open them—making discovery, auditing, and recall dramatically quicker, especially in large, active teams.
+- 🖼️ **Image Upload and Inline Insertion in Notes**: Notes now support inserting images directly among your text, letting you create rich, visually structured documentation, brainstorms, or reports in a more natural and engaging way—no more images just as attachments.
+- 📱 **Enhanced Note Selection Editing and Q&A**: Select any portion of your notes to either edit just the highlighted part or ask focused questions about that content—streamlining workflows, boosting productivity, and making reviews or AI-powered enhancements more targeted.
+- 📝 **Copy Notes as Rich Text**: Copy entire notes—including all formatting, images, and structure—directly as rich text for seamless pasting into emails, reports, or other tools, maintaining clarity and consistency outside the WebUI.
+- ⚡ **Fade-In Streaming Text Experience**: Live-generated responses now elegantly fade in as the AI streams them, creating a more natural and visually engaging reading experience; easily toggled off in Interface settings if you prefer static displays.
+- 🔄 **Settings for Follow-Up Prompts**: Fine-tune your follow-up prompt experience—with new controls, you can choose to keep them visible or have them inserted directly into the message input instead of auto-submitting, giving you more flexibility and control over your workflow.
+- 🔗 **Prompt Variable Documentation Quick Link**: Access documentation for prompt variables in one click from the prompt editor modal—shortening the learning curve and making advanced prompt-building more accessible.
+- 📈 **Active and Total User Metrics for Telemetry**: Gain valuable insights into usage patterns and platform engagement with new metrics tracking active and total users—enhancing auditability and planning for large organizations.
+- 🏷️ **Traceability with Log Trace and Span IDs**: Each log entry now carries detailed trace and span IDs, making it much easier for admins to pinpoint and resolve issues across distributed systems or in complex troubleshooting.
+- 👥 **User Group Add/Remove Endpoints**: Effortlessly add or remove users from groups with new, improved endpoints—giving admins and team leads faster, clearer control over collaboration and permissions.
+- ⚙️ **Note Settings and Controls Streamlined**: The main “Settings” for notes are now simply called “Controls”, and note files now reside in a dedicated controls section, decluttering navigation and making it easier to find and configure note-related options.
+- 🚀 **Faster Admin User Page Loads**: The user list endpoint for admins has been optimized to exclude heavy profile images, speeding up load times for large teams and reducing waiting during administrative tasks.
+- 📡 **Chat ID Header Forwarding**: Ollama and OpenAI router requests now include the chat ID in request headers, enabling better request correlation and debugging capabilities across AI model integrations.
+- 🧠 **Enhanced Reasoning Tag Processing**: Improved and expanded reasoning tag parsing to handle various tag formats more robustly, including standard XML-style tags and custom delimiters, ensuring better AI reasoning transparency and debugging capabilities.
+- 🔐 **OAuth Token Endpoint Authentication Method**: Added configurable OAuth token endpoint authentication method support, providing enhanced flexibility and security options for enterprise OAuth integrations and identity provider compatibility.
+- 🛡️ **Redis Sentinel High Availability Support**: Comprehensive Redis Sentinel failover implementation with automatic master discovery, intelligent retry logic for connection failures, and seamless operation during master node outages—eliminating single points of failure and ensuring continuous service availability in production deployments.
+- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Simplified Chinese, Traditional Chinese, French, German, Korean, and Polish, ensuring a more fluent and native experience for global users across all supported languages.
+
+### Fixed
+
+- 🏷️ **Hybrid Search Functionality Restored**: Hybrid search now works seamlessly again—enabling more accurate, relevant, and comprehensive knowledge discovery across all RAG-powered workflows.
+- 🚦 **Note Chat - Edit Button Disabled During AI Generation**: The edit button when chatting with a note is now disabled while the AI is responding—preventing accidental edits and ensuring workflow clarity during chat sessions.
+- 🧹 **Cleaner Database Credentials**: Database connection no longer duplicates ‘@’ in credentials, preventing potential connection issues and ensuring smoother, more reliable integrations.
+- 🧑💻 **File Deletion Now Removes Related Vector Data**: When files are deleted from storage, they are now purged from the vector database as well, ensuring clean data management and preventing clutter or stale search results.
+- 📁 **Files Modal Translation Issues Fixed**: All modal dialog strings—including “Using Entire Document” and “Using Focused Retrieval”—are now fully translated for a more consistent and localized UI experience.
+- 🚫 **Drag-and-Drop File Upload Disabled for Unsupported Models**: File upload by drag-and-drop is disabled when using models that do not support attachments—removing confusion and preventing workflow interruptions.
+- 🔑 **Ollama Tool Calls Now Reliable**: Fixed issues with Ollama-based tool calls, ensuring uninterrupted AI augmentation and tool use for every chat.
+- 📄 **MIME Type Help String Correction**: Cleaned up mimetype help text by removing extraneous characters, providing clearer guidance for file upload configurations.
+- 📝 **Note Editor Permission Fix**: Removed unnecessary admin-only restriction from note chat functionality, allowing all authorized users to access note editing features as intended.
+- 📋 **Chat Sources Handling Improved**: Fixed sources handling logic to prevent duplicate source assignments in chat messages, ensuring cleaner and more accurate source attribution during conversations.
+- 😀 **Emoji Generation Error Handling**: Improved error handling in audio router and fixed metadata structure for emoji generation tasks, preventing crashes and ensuring more reliable emoji generation functionality.
+- 🔒 **Folder System Prompt Permission Enforcement**: System prompt fields in folder edit modal are now properly hidden for users without system prompt permissions, ensuring consistent security policy enforcement across all folder management interfaces.
+- 🌐 **WebSocket Redis Lock Timeout Type Conversion**: Fixed proper integer type conversion for WebSocket Redis lock timeout configuration with robust error handling, preventing potential configuration errors and ensuring stable WebSocket connections.
+- 📦 **PostHog Dependency Added**: Added PostHog 5.4.0 library to resolve ChromaDB compatibility issues, ensuring stable vector database operations and preventing library version conflicts during deployment.
+
+### Changed
+
+- 👀 **Tiptap Editor Upgraded to v3**: The underlying rich text editor has been updated for future-proofing, though some supporting libraries remain on v2 for compatibility. For now, please install dependencies using 'npm install --force' to avoid installation errors.
+- 🚫 **Removed Redundant or Unused Strings and Elements**: Miscellaneous unused, duplicate, or obsolete code and translations have been cleaned up to maintain a streamlined and high-performance experience.
+
+## [0.6.16] - 2025-07-14
+
+### Added
+
+- 🗂️ **Folders as Projects**: Organize your workflow with folder-based projects—set folder-level system prompts and associate custom knowledge, bringing seamless, context-rich management to teams and users handling multiple initiatives or clients.
+- 📁 **Instant Folder-Based Chat Creation**: Start a new chat directly from any folder; just click and your new conversation is automatically embedded in the right project context—no more manual dragging or setup, saving time and eliminating mistakes.
+- 🧩 **Prompt Variables with Automatic Input Modal**: Prompts containing variables now display a clean, auto-generated input modal that **autofocuses on the first field** for instant value entry—just select the prompt and fill in exactly what’s needed, reducing friction and guesswork.
+- 🔡 **Variable Input Typing in Prompts**: Define input types for prompt variables (e.g., text, textarea, number, select, color, date, map and more), giving everyone a clearer and more precise prompt-building experience for advanced automation or workflows.
+- 🚀 **Base Model List Caching**: Cache your base model list to speed up model selection and reduce repeated API calls; toggle this in Admin Settings > Connections for responsive model management even in large or multi-provider setups.
+- ⏱️ **Configurable Model List Cache TTL**: Take control over model list caching with the new MODEL_LIST_CACHE_TTL environment variable. Set a custom cache duration in seconds to balance performance and freshness, reducing API requests in stable environments or ensuring rapid updates when models change frequently.
+- 🔖 **Reference Notes as Knowledge or in Chats**: Use any note as knowledge for a model or folder, or reference it directly from chat—integrate living documentation into your Retrieval Augmented Generation workflows or discussions, bridging knowledge and action.
+- 📝 **Chat Directly with Notes (Experimental)**: Ask questions about any note, and directly edit or update notes from within a chat—unlock direct AI-powered brainstorming, summarization, and cleanup, like having your own collaborative AI canvas.
+- 🤝 **Collaborative Notes with Multi-User Editing**: Share notes with others and collaborate live—multiple users can edit a note in real-time, boosting cooperative knowledge building and workflow documentation.
+- 🛡️ **Collaborative Note Permissions**: Control who can view or edit each note with robust sharing permissions, ensuring privacy or collaboration per your organizational needs.
+- 🔗 **Copy Link to Notes**: Quickly copy and share direct links to notes for easier knowledge transfer within your team or external collaborators.
+- 📋 **Task List Support in Notes**: Add, organize, and manage checklists or tasks inside your notes—plan projects, track to-dos, and keep everything actionable in a single space.
+- 🧠 **AI-Generated Note Titles**: Instantly generate relevant and concise titles for your notes using AI—keep your knowledge library organized without tedious manual editing.
+- 🔄 **Full Undo/Redo Support in Notes**: Effortlessly undo or redo your latest note changes—never fear mistakes or accidental edits while collaborating or writing.
+- 📝 **Enhanced Note Word/Character Counter**: Always know the size of your notes with built-in counters, making it easier to adhere to length guidelines for shared or published content.
+- 🖊️ **Floating & Bubble Formatting Menus in Note Editor**: Access text formatting tools through both a floating menu and an intuitive bubble menu directly in the note editor—making rich text editing faster, more discoverable, and easier than ever.
+- ✍️ **Rich Text Prompt Insertion**: A new setting allows prompts to be inserted directly into the chat box as fully-formatted rich text, preserving Markdown elements like headings, lists, and bold text for a more intuitive and visually consistent editing experience.
+- 🌐 **Configurable Database URL**: WebUI now supports more flexible database configuration via new environment variables—making deployment and scaling simpler across various infrastructure setups.
+- 🎛️ **Completely Frontend-Handled File Upload in Temporary Chats**: When using temporary chats, file extraction now occurs fully in your browser with zero files sent to the backend, further strengthening privacy and giving you instant feedback.
+- 🔄 **Enhanced Banner and Chat Command Visibility**: Banner handling and command feedback in chat are now clearer and more contextually visible, making alerts, suggestions, and automation easier to spot and interact with for all users.
+- 📱 **Mobile Experience Polished**: The "new chat" button is back in mobile, plus core navigation and input controls have been smoothed out for better usability on phones and tablets.
+- 📄 **OpenDocument Text (.odt) Support**: Seamlessly upload and process .odt files from open-source office suites like LibreOffice and OpenOffice, expanding your ability to build knowledge from a wider range of document formats.
+- 📑 **Enhanced Markdown Document Splitting**: Improve knowledge retrieval from Markdown files with a new header-aware splitting strategy. This method intelligently chunks documents based on their header structure, preserving the original context and hierarchy for more accurate and relevant RAG results.
+- 📚 **Full Context Mode for Knowledge Bases**: When adding a knowledge base to a folder or custom model, you can now toggle full context mode for the entire knowledge base. This bypasses the usual chunking and retrieval process, making it perfect for leaner knowledge bases.
+- 🕰️ **Configurable OAuth Timeout**: Enhance login reliability by setting a custom timeout (OAUTH_TIMEOUT) for all OAuth providers (Google, Microsoft, GitHub, OIDC), preventing authentication failures on slow or restricted networks.
+- 🎨 **Accessibility & High-Contrast Theme Enhancements**: Major accessibility overhaul with significant updates to the high-contrast theme. Improved focus visibility, ARIA labels, and semantic HTML ensure core components like the chat interface and model selector are fully compliant and readable for visually impaired users.
+- ↕️ **Resizable System Prompt Fields**: Conveniently resize system prompt input fields to comfortably view and edit lengthy or complex instructions, improving the user experience for advanced model configuration.
+- 🔧 **Granular Update Check Control**: Gain finer control over outbound connections with the new ENABLE_VERSION_UPDATE_CHECK flag. This allows administrators to disable version update checks independently of the full OFFLINE_MODE, perfect for environments with restricted internet access that still need to download embedding models.
+- 🗃️ **Configurable Qdrant Collection Prefix**: Enhance scalability by setting a custom QDRANT_COLLECTION_PREFIX. This allows multiple Open WebUI instances to share a single Qdrant cluster safely, ensuring complete data isolation between separate deployments without conflicts.
+- ⚙️ **Improved Default Database Performance**: Enhanced out-of-the-box performance by setting smarter database connection pooling defaults, reducing API response times for users on non-SQLite databases without requiring manual configuration.
+- 🔧 **Configurable Redis Key Prefix**: Added support for the REDIS_KEY_PREFIX environment variable, allowing multiple Open WebUI instances to share a Redis cluster with isolated key namespaces for improved multi-tenancy.
+- ➡️ **Forward User Context to Reranker**: For advanced RAG integrations, user information (ID, name, email, role) can now be forwarded as HTTP headers to external reranking services, enabling personalized results or per-user access control.
+- ⚙️ **PGVector Connection Pooling**: Enhance performance and stability for PGVector-based RAG by enabling and configuring the database connection pool. New environment variables allow fine-tuning of pool size, timeout, and overflow settings to handle high-concurrency workloads efficiently.
+- ⚙️ **General Backend Refactoring**: Extensive refactoring delivers a faster, more reliable, and robust backend experience—improving chat speed, model management, and day-to-day reliability.
+- 🌍 **Expanded & Improved Translations**: Enjoy a more accessible and intuitive experience thanks to comprehensive updates and enhancements for Chinese (Simplified and Traditional), German, French, Catalan, Irish, and Spanish translations throughout the interface.
+
+### Fixed
+
+- 🛠️ **Rich Text Input Stability and Performance**: Multiple improvements ensure faster, cleaner text editing and rendering with reduced glitches—especially supporting links, color picking, checkbox controls, and code blocks in notes and chats.
+- 📷 **Seamless iPhone Image Uploads**: Effortlessly upload photos from iPhones and other devices using HEIC format—images are now correctly recognized and processed, eliminating compatibility issues.
+- 🔄 **Audio MIME Type Registration**: Issues with audio file content types have been resolved, guaranteeing smoother, error-free uploads and playback for transcription or note attachments.
+- 🖍️ **Input Commands Now Always Visible**: Input commands (like prompts or knowledge) dynamically adjust their height on small screens, ensuring nothing is cut off and every tool remains easily accessible.
+- 🛑 **Tool Result Rendering**: Fixed display problems with tool results, providing fast, clear feedback when using external or internal tools.
+- 🗂️ **Table Alignment in Markdown**: Markdown tables are now rendered and aligned as expected, keeping reports and documentation readable.
+- 🖼️ **Thread Image Handling**: Fixed an issue where messages containing only images in threads weren’t displayed correctly.
+- 🗝️ **Note Access Control Security**: Tightened access control logic for notes to guarantee that shared or collaborative notes respect all user permissions and privacy safeguards.
+- 🧾 **Ollama API Compatibility**: Fixed model parameter naming in the API to ensure uninterrupted compatibility for all Ollama endpoints.
+- 🛠️ **Detection for 'text/html' Files**: Files loaded with docling/tika are now reliably detected as the correct type, improving knowledge ingestion and document parsing.
+- 🔐 **OAuth Login Stability**: Resolved a critical OAuth bug that caused login failures on subsequent attempts after logging out. The user session is now completely cleared on logout, ensuring reliable and secure authentication across all supported providers (Google, Microsoft, GitHub, OIDC).
+- 🚪 **OAuth Logout and Redirect Reliability**: The OAuth logout process has been made more robust. Logout requests now correctly use proxy environment variables, ensuring they succeed in corporate networks. Additionally, the custom WEBUI_AUTH_SIGNOUT_REDIRECT_URL is now properly respected for all OAuth/OIDC configurations, ensuring a seamless sign-out experience.
+- 📜 **Banner Newline Rendering**: Banners now correctly render newline characters, ensuring that multi-line announcements and messages are displayed with their intended formatting.
+- ℹ️ **Consistent Model Description Rendering**: Model descriptions now render Markdown correctly in the main chat interface, matching the formatting seen in the model selection dropdown for a consistent user experience.
+- 🔄 **Offline Mode Update Check Display**: Corrected a UI bug where the "Checking for Updates..." message would display indefinitely when the application was set to offline mode.
+- 🛠️ **Tool Result Encoding**: Fixed a bug where tool calls returning non-ASCII characters would fail, ensuring robust handling of international text and special characters in tool outputs.
+
## [0.6.15] - 2025-06-16
### Added
diff --git a/Dockerfile b/Dockerfile
index d7de72f015..ad393338d8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,8 @@
# use build args in the docker build command with --build-arg="BUILDARG=true"
ARG USE_CUDA=false
ARG USE_OLLAMA=false
+ARG USE_SLIM=false
+ARG USE_PERMISSION_HARDENING=false
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
ARG USE_CUDA_VER=cu128
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
@@ -24,13 +26,16 @@ ARG GID=0
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
ARG BUILD_HASH
+# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
+# ENV NODE_OPTIONS="--max-old-space-size=4096"
+
WORKDIR /app
# to store git revision in build
RUN apk add --no-cache git
COPY package.json package-lock.json ./
-RUN npm ci
+RUN npm ci --force
COPY . .
ENV APP_BUILD_HASH=${BUILD_HASH}
@@ -43,6 +48,8 @@ FROM python:3.11-slim-bookworm AS base
ARG USE_CUDA
ARG USE_OLLAMA
ARG USE_CUDA_VER
+ARG USE_SLIM
+ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG UID
@@ -54,6 +61,7 @@ ENV ENV=prod \
# pass build args to the build
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
USE_CUDA_DOCKER=${USE_CUDA} \
+ USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
@@ -108,29 +116,13 @@ RUN echo -n 00000000-0000-0000-0000-000000000000 > $HOME/.cache/chroma/telemetry
# Make sure the user has access to the app and root directory
RUN chown -R $UID:$GID /app $HOME
-RUN if [ "$USE_OLLAMA" = "true" ]; then \
- apt-get update && \
- # Install pandoc and netcat
- apt-get install -y --no-install-recommends git build-essential pandoc netcat-openbsd curl && \
- apt-get install -y --no-install-recommends gcc python3-dev && \
- # for RAG OCR
- apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
- # install helper tools
- apt-get install -y --no-install-recommends curl jq && \
- # install ollama
- curl -fsSL https://ollama.com/install.sh | sh && \
- # cleanup
- rm -rf /var/lib/apt/lists/*; \
- else \
- apt-get update && \
- # Install pandoc, netcat and gcc
- apt-get install -y --no-install-recommends git build-essential pandoc gcc netcat-openbsd curl jq && \
- apt-get install -y --no-install-recommends gcc python3-dev && \
- # for RAG OCR
- apt-get install -y --no-install-recommends ffmpeg libsm6 libxext6 && \
- # cleanup
- rm -rf /var/lib/apt/lists/*; \
- fi
+# Install common system dependencies
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ git build-essential pandoc gcc netcat-openbsd curl jq \
+ python3-dev \
+ ffmpeg libsm6 libxext6 \
+ && rm -rf /var/lib/apt/lists/*
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
@@ -146,13 +138,22 @@ RUN pip3 install --no-cache-dir uv && \
else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
+ if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
fi; \
- chown -R $UID:$GID /app/backend/data/
-
+ fi; \
+ mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
+ rm -rf /var/lib/apt/lists/*;
+# Install Ollama if requested
+RUN if [ "$USE_OLLAMA" = "true" ]; then \
+ date +%s > /tmp/ollama_build_hash && \
+ echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
+ curl -fsSL https://ollama.com/install.sh | sh && \
+ rm -rf /var/lib/apt/lists/*; \
+ fi
# copy embedding weight from build
# RUN mkdir -p /root/.cache/chroma/onnx_models/all-MiniLM-L6-v2
@@ -170,6 +171,17 @@ EXPOSE 8080
HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq -ne 'input.status == true' || exit 1
+# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
+# - Group 0 owns /app and /root
+# - Directories are group-writable and have SGID so new files inherit GID 0
+RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
+ set -eux; \
+ chgrp -R 0 /app /root || true; \
+ chmod -R g+rwX /app /root || true; \
+ find /app -type d -exec chmod g+s {} + || true; \
+ find /root -type d -exec chmod g+s {} + || true; \
+ fi
+
USER $UID:$GID
ARG BUILD_HASH
diff --git a/LICENSE_HISTORY b/LICENSE_HISTORY
new file mode 100644
index 0000000000..a9eb5e259d
--- /dev/null
+++ b/LICENSE_HISTORY
@@ -0,0 +1,53 @@
+All code and materials created before commit `60d84a3aae9802339705826e9095e272e3c83623` are subject to the following copyright and license:
+
+Copyright (c) 2023-2025 Timothy Jaeryang Baek
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+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
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+All code and materials created before commit `a76068d69cd59568b920dfab85dc573dbbb8f131` are subject to the following copyright and license:
+
+MIT License
+
+Copyright (c) 2023 Timothy Jaeryang Baek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSE_NOTICE b/LICENSE_NOTICE
new file mode 100644
index 0000000000..4e00d46d9a
--- /dev/null
+++ b/LICENSE_NOTICE
@@ -0,0 +1,11 @@
+# Open WebUI Multi-License Notice
+
+This repository contains code governed by multiple licenses based on the date and origin of contribution:
+
+1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
+
+2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
+
+3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
+
+For details on which commits are covered by which license, refer to LICENSE_HISTORY.
diff --git a/README.md b/README.md
index 9d6a66e410..638cdacabb 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,12 @@
**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**.
+Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
+

> [!TIP]
-> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](mailto:sales@openwebui.com)**
+> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
>
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
@@ -35,24 +37,38 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
- ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
-- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features, allowing for a more dynamic and interactive chat environment.
+- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features using multiple Speech-to-Text providers (Local Whisper, OpenAI, Deepgram, Azure) and Text-to-Speech engines (Azure, ElevenLabs, OpenAI, Transformers, WebAPI), allowing for dynamic and interactive chat environments.
- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs.
-- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
+- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
-- 🔍 **Web Search for RAG**: Perform web searches using providers like `SearXNG`, `Google PSE`, `Brave Search`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `TavilySearch`, `SearchApi` and `Bing` and inject the results directly into your chat experience.
+- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
+
+- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
-- 🎨 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API or ComfyUI (local), and OpenAI's DALL-E (external), enriching your chat experience with dynamic visual content.
+- 🎨 **Image Generation & Editing Integration**: Create and edit images using multiple engines including OpenAI's DALL-E, Gemini, ComfyUI (local), and AUTOMATIC1111 (local), with support for both generation and prompt-based editing workflows.
- ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
- 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
+- 🗄️ **Flexible Database & Storage Options**: Choose from SQLite (with optional encryption), PostgreSQL, or configure cloud storage backends (S3, Google Cloud Storage, Azure Blob Storage) for scalable deployments.
+
+- 🔍 **Advanced Vector Database Support**: Select from 9 vector database options including ChromaDB, PGVector, Qdrant, Milvus, Elasticsearch, OpenSearch, Pinecone, S3Vector, and Oracle 23ai for optimal RAG performance.
+
+- 🔐 **Enterprise Authentication**: Full support for LDAP/Active Directory integration, SCIM 2.0 automated provisioning, and SSO via trusted headers alongside OAuth providers. Enterprise-grade user and group provisioning through SCIM 2.0 protocol, enabling seamless integration with identity providers like Okta, Azure AD, and Google Workspace for automated user lifecycle management.
+
+- ☁️ **Cloud-Native Integration**: Native support for Google Drive and OneDrive/SharePoint file picking, enabling seamless document import from enterprise cloud storage.
+
+- 📊 **Production Observability**: Built-in OpenTelemetry support for traces, metrics, and logs, enabling comprehensive monitoring with your existing observability stack.
+
+- ⚖️ **Horizontal Scalability**: Redis-backed session management and WebSocket support for multi-worker and multi-node deployments behind load balancers.
+
- 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more.
@@ -61,43 +77,6 @@ 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!
-## Sponsors 🙌
-
-#### Emerald
-
-
-
-
-
-
-
-
-
- n8n • Does your interface have a backend yet? Try n8n
-
-
-
-
-
-
-
-
-
- Warp • The intelligent terminal for developers
-
-
-
-
-
-
-
-
-
- Tailscale • Connect self-hosted AI to any device with Tailscale
-
-
-
-
---
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!
@@ -244,7 +223,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜
-This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
+This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
## Support 💬
diff --git a/backend/dev.sh b/backend/dev.sh
index 5449ab7777..042fbd9efa 100755
--- a/backend/dev.sh
+++ b/backend/dev.sh
@@ -1,2 +1,3 @@
+export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}"
-uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
\ No newline at end of file
+uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
diff --git a/backend/open_webui/alembic.ini b/backend/open_webui/alembic.ini
index 4eff85f0c6..dccd8a3c12 100644
--- a/backend/open_webui/alembic.ini
+++ b/backend/open_webui/alembic.ini
@@ -10,7 +10,7 @@ script_location = migrations
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
-prepend_sys_path = .
+prepend_sys_path = ..
# timezone to use when rendering the date within the migration file
# as well as the filename.
diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py
index 898ac1b594..5a9844c067 100644
--- a/backend/open_webui/config.py
+++ b/backend/open_webui/config.py
@@ -7,18 +7,21 @@ import redis
from datetime import datetime
from pathlib import Path
-from typing import Generic, Optional, TypeVar
+from typing import Generic, Union, Optional, TypeVar
from urllib.parse import urlparse
import requests
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
+from authlib.integrations.starlette_client import OAuth
+
from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENV,
REDIS_URL,
+ REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
FRONTEND_BUILD_DIR,
@@ -165,9 +168,19 @@ 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 and ENABLE_PERSISTENT_CONFIG:
- log.info(f"'{env_name}' loaded from the latest database entry")
- self.value = self.config_value
+ if (
+ self.config_path.startswith("oauth.")
+ and not ENABLE_OAUTH_PERSISTENT_CONFIG
+ ):
+ log.info(
+ f"Skipping loading of '{env_name}' as OAuth persistent config is disabled"
+ )
+ self.value = env_value
+ else:
+ log.info(f"'{env_name}' loaded from the latest database entry")
+ self.value = self.config_value
else:
self.value = env_value
@@ -209,19 +222,32 @@ class PersistentConfig(Generic[T]):
class AppConfig:
+ _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
+ _redis_key_prefix: str
+
_state: dict[str, PersistentConfig]
- _redis: Optional[redis.Redis] = None
def __init__(
- self, redis_url: Optional[str] = None, redis_sentinels: Optional[list] = []
+ self,
+ redis_url: Optional[str] = None,
+ redis_sentinels: Optional[list] = [],
+ redis_cluster: Optional[bool] = False,
+ redis_key_prefix: str = "open-webui",
):
- super().__setattr__("_state", {})
if redis_url:
+ super().__setattr__("_redis_key_prefix", redis_key_prefix)
super().__setattr__(
"_redis",
- get_redis_connection(redis_url, redis_sentinels, decode_responses=True),
+ get_redis_connection(
+ redis_url,
+ redis_sentinels,
+ redis_cluster,
+ decode_responses=True,
+ ),
)
+ super().__setattr__("_state", {})
+
def __setattr__(self, key, value):
if isinstance(value, PersistentConfig):
self._state[key] = value
@@ -230,7 +256,7 @@ class AppConfig:
self._state[key].save()
if self._redis:
- redis_key = f"open-webui:config:{key}"
+ redis_key = f"{self._redis_key_prefix}:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
def __getattr__(self, key):
@@ -239,7 +265,7 @@ class AppConfig:
# If Redis is available, check for an updated value
if self._redis:
- redis_key = f"open-webui:config:{key}"
+ redis_key = f"{self._redis_key_prefix}:config:{key}"
redis_value = self._redis.get(redis_key)
if redis_value is not None:
@@ -261,33 +287,47 @@ class AppConfig:
# WEBUI_AUTH (Required for security)
####################################
-ENABLE_API_KEY = PersistentConfig(
- "ENABLE_API_KEY",
- "auth.api_key.enable",
- os.environ.get("ENABLE_API_KEY", "True").lower() == "true",
+ENABLE_API_KEYS = PersistentConfig(
+ "ENABLE_API_KEYS",
+ "auth.enable_api_keys",
+ os.environ.get("ENABLE_API_KEYS", "False").lower() == "true",
)
-ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = PersistentConfig(
- "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS",
+ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = PersistentConfig(
+ "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
"auth.api_key.endpoint_restrictions",
- os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False").lower() == "true",
+ os.environ.get(
+ "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
+ os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False"),
+ ).lower()
+ == "true",
)
-API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
- "API_KEY_ALLOWED_ENDPOINTS",
+API_KEYS_ALLOWED_ENDPOINTS = PersistentConfig(
+ "API_KEYS_ALLOWED_ENDPOINTS",
"auth.api_key.allowed_endpoints",
- os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""),
+ os.environ.get(
+ "API_KEYS_ALLOWED_ENDPOINTS", os.environ.get("API_KEY_ALLOWED_ENDPOINTS", "")
+ ),
)
-
JWT_EXPIRES_IN = PersistentConfig(
- "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
+ "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "4w")
)
+if JWT_EXPIRES_IN.value == "-1":
+ log.warning(
+ "⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n"
+ " See: https://docs.openwebui.com/getting-started/env-configuration\n"
+ )
+
####################################
# OAuth config
####################################
+ENABLE_OAUTH_PERSISTENT_CONFIG = (
+ os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true"
+)
ENABLE_OAUTH_SIGNUP = PersistentConfig(
"ENABLE_OAUTH_SIGNUP",
@@ -431,6 +471,18 @@ OAUTH_SCOPES = PersistentConfig(
os.environ.get("OAUTH_SCOPES", "openid email profile"),
)
+OAUTH_TIMEOUT = PersistentConfig(
+ "OAUTH_TIMEOUT",
+ "oauth.oidc.oauth_timeout",
+ os.environ.get("OAUTH_TIMEOUT", ""),
+)
+
+OAUTH_TOKEN_ENDPOINT_AUTH_METHOD = PersistentConfig(
+ "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
+ "oauth.oidc.token_endpoint_auth_method",
+ os.environ.get("OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", None),
+)
+
OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
"OAUTH_CODE_CHALLENGE_METHOD",
"oauth.oidc.code_challenge_method",
@@ -443,6 +495,12 @@ OAUTH_PROVIDER_NAME = PersistentConfig(
os.environ.get("OAUTH_PROVIDER_NAME", "SSO"),
)
+OAUTH_SUB_CLAIM = PersistentConfig(
+ "OAUTH_SUB_CLAIM",
+ "oauth.oidc.sub_claim",
+ os.environ.get("OAUTH_SUB_CLAIM", None),
+)
+
OAUTH_USERNAME_CLAIM = PersistentConfig(
"OAUTH_USERNAME_CLAIM",
"oauth.oidc.username_claim",
@@ -465,7 +523,31 @@ OAUTH_EMAIL_CLAIM = PersistentConfig(
OAUTH_GROUPS_CLAIM = PersistentConfig(
"OAUTH_GROUPS_CLAIM",
"oauth.oidc.group_claim",
- os.environ.get("OAUTH_GROUP_CLAIM", "groups"),
+ os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
+)
+
+FEISHU_CLIENT_ID = PersistentConfig(
+ "FEISHU_CLIENT_ID",
+ "oauth.feishu.client_id",
+ os.environ.get("FEISHU_CLIENT_ID", ""),
+)
+
+FEISHU_CLIENT_SECRET = PersistentConfig(
+ "FEISHU_CLIENT_SECRET",
+ "oauth.feishu.client_secret",
+ os.environ.get("FEISHU_CLIENT_SECRET", ""),
+)
+
+FEISHU_OAUTH_SCOPE = PersistentConfig(
+ "FEISHU_OAUTH_SCOPE",
+ "oauth.feishu.scope",
+ os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"),
+)
+
+FEISHU_REDIRECT_URI = PersistentConfig(
+ "FEISHU_REDIRECT_URI",
+ "oauth.feishu.redirect_uri",
+ os.environ.get("FEISHU_REDIRECT_URI", ""),
)
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
@@ -493,25 +575,34 @@ OAUTH_BLOCKED_GROUPS = PersistentConfig(
os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"),
)
+OAUTH_GROUPS_SEPARATOR = os.environ.get("OAUTH_GROUPS_SEPARATOR", ";")
+
OAUTH_ROLES_CLAIM = PersistentConfig(
"OAUTH_ROLES_CLAIM",
"oauth.roles_claim",
os.environ.get("OAUTH_ROLES_CLAIM", "roles"),
)
+SEP = os.environ.get("OAUTH_ROLES_SEPARATOR", ",")
+
OAUTH_ALLOWED_ROLES = PersistentConfig(
"OAUTH_ALLOWED_ROLES",
"oauth.allowed_roles",
[
role.strip()
- for role in os.environ.get("OAUTH_ALLOWED_ROLES", "user,admin").split(",")
+ for role in os.environ.get("OAUTH_ALLOWED_ROLES", f"user{SEP}admin").split(SEP)
+ if role
],
)
OAUTH_ADMIN_ROLES = PersistentConfig(
"OAUTH_ADMIN_ROLES",
"oauth.admin_roles",
- [role.strip() for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(",")],
+ [
+ role.strip()
+ for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(SEP)
+ if role
+ ],
)
OAUTH_ALLOWED_DOMAINS = PersistentConfig(
@@ -529,20 +620,33 @@ OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig(
os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true",
)
+OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
+ os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower()
+ == "true"
+)
+
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
- def google_oauth_register(client):
- client.register(
+ def google_oauth_register(oauth: OAuth):
+ client = oauth.register(
name="google",
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
- client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
+ client_kwargs={
+ "scope": GOOGLE_OAUTH_SCOPE.value,
+ **(
+ {"timeout": int(OAUTH_TIMEOUT.value)}
+ if OAUTH_TIMEOUT.value
+ else {}
+ ),
+ },
redirect_uri=GOOGLE_REDIRECT_URI.value,
)
+ return client
OAUTH_PROVIDERS["google"] = {
"redirect_uri": GOOGLE_REDIRECT_URI.value,
@@ -555,17 +659,23 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_TENANT_ID.value
):
- def microsoft_oauth_register(client):
- client.register(
+ def microsoft_oauth_register(oauth: OAuth):
+ client = oauth.register(
name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value,
server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}",
client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value,
+ **(
+ {"timeout": int(OAUTH_TIMEOUT.value)}
+ if OAUTH_TIMEOUT.value
+ else {}
+ ),
},
redirect_uri=MICROSOFT_REDIRECT_URI.value,
)
+ return client
OAUTH_PROVIDERS["microsoft"] = {
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
@@ -575,8 +685,8 @@ def load_oauth_providers():
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
- def github_oauth_register(client):
- client.register(
+ def github_oauth_register(oauth: OAuth):
+ client = oauth.register(
name="github",
client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value,
@@ -584,9 +694,17 @@ def load_oauth_providers():
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com",
userinfo_endpoint="https://api.github.com/user",
- client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
+ client_kwargs={
+ "scope": GITHUB_CLIENT_SCOPE.value,
+ **(
+ {"timeout": int(OAUTH_TIMEOUT.value)}
+ if OAUTH_TIMEOUT.value
+ else {}
+ ),
+ },
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
)
+ return client
OAUTH_PROVIDERS["github"] = {
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
@@ -596,13 +714,23 @@ def load_oauth_providers():
if (
OAUTH_CLIENT_ID.value
- and OAUTH_CLIENT_SECRET.value
+ and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value)
and OPENID_PROVIDER_URL.value
):
- def oidc_oauth_register(client):
+ def oidc_oauth_register(oauth: OAuth):
client_kwargs = {
"scope": OAUTH_SCOPES.value,
+ **(
+ {
+ "token_endpoint_auth_method": OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value
+ }
+ if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value
+ else {}
+ ),
+ **(
+ {"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
+ ),
}
if (
@@ -616,7 +744,7 @@ def load_oauth_providers():
% ("S256", OAUTH_CODE_CHALLENGE_METHOD.value)
)
- client.register(
+ client = oauth.register(
name="oidc",
client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value,
@@ -624,6 +752,7 @@ def load_oauth_providers():
client_kwargs=client_kwargs,
redirect_uri=OPENID_REDIRECT_URI.value,
)
+ return client
OAUTH_PROVIDERS["oidc"] = {
"name": OAUTH_PROVIDER_NAME.value,
@@ -631,6 +760,53 @@ def load_oauth_providers():
"register": oidc_oauth_register,
}
+ if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
+
+ def feishu_oauth_register(oauth: OAuth):
+ client = oauth.register(
+ name="feishu",
+ client_id=FEISHU_CLIENT_ID.value,
+ client_secret=FEISHU_CLIENT_SECRET.value,
+ access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token",
+ authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize",
+ api_base_url="https://open.feishu.cn/open-apis",
+ userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info",
+ client_kwargs={
+ "scope": FEISHU_OAUTH_SCOPE.value,
+ **(
+ {"timeout": int(OAUTH_TIMEOUT.value)}
+ if OAUTH_TIMEOUT.value
+ else {}
+ ),
+ },
+ redirect_uri=FEISHU_REDIRECT_URI.value,
+ )
+ return client
+
+ OAUTH_PROVIDERS["feishu"] = {
+ "register": feishu_oauth_register,
+ "sub_claim": "user_id",
+ }
+
+ configured_providers = []
+ if GOOGLE_CLIENT_ID.value:
+ configured_providers.append("Google")
+ if MICROSOFT_CLIENT_ID.value:
+ configured_providers.append("Microsoft")
+ if GITHUB_CLIENT_ID.value:
+ configured_providers.append("GitHub")
+ if FEISHU_CLIENT_ID.value:
+ configured_providers.append("Feishu")
+
+ if configured_providers and not OPENID_PROVIDER_URL.value:
+ provider_list = ", ".join(configured_providers)
+ log.warning(
+ f"⚠️ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!"
+ )
+ log.warning(
+ f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint to fix logout functionality."
+ )
+
load_oauth_providers()
@@ -640,6 +816,17 @@ load_oauth_providers()
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
+try:
+ if STATIC_DIR.exists():
+ for item in STATIC_DIR.iterdir():
+ if item.is_file() or item.is_symlink():
+ try:
+ item.unlink()
+ except Exception as e:
+ pass
+except Exception as e:
+ pass
+
for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"):
if file_path.is_file():
target_path = STATIC_DIR / file_path.relative_to(
@@ -719,12 +906,6 @@ if CUSTOM_NAME:
pass
-####################################
-# LICENSE_KEY
-####################################
-
-LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
-
####################################
# STORAGE PROVIDER
####################################
@@ -775,7 +956,7 @@ CACHE_DIR.mkdir(parents=True, exist_ok=True)
ENABLE_DIRECT_CONNECTIONS = PersistentConfig(
"ENABLE_DIRECT_CONNECTIONS",
"direct.enable",
- os.environ.get("ENABLE_DIRECT_CONNECTIONS", "True").lower() == "true",
+ os.environ.get("ENABLE_DIRECT_CONNECTIONS", "False").lower() == "true",
)
####################################
@@ -857,6 +1038,9 @@ 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"
+else:
+ if OPENAI_API_BASE_URL.endswith("/"):
+ OPENAI_API_BASE_URL = OPENAI_API_BASE_URL[:-1]
OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "")
OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY
@@ -895,6 +1079,18 @@ except Exception:
pass
OPENAI_API_BASE_URL = "https://api.openai.com/v1"
+
+####################################
+# MODELS
+####################################
+
+ENABLE_BASE_MODELS_CACHE = PersistentConfig(
+ "ENABLE_BASE_MODELS_CACHE",
+ "models.base_models_cache",
+ os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true",
+)
+
+
####################################
# TOOL_SERVERS
####################################
@@ -938,6 +1134,7 @@ ENABLE_LOGIN_FORM = PersistentConfig(
os.environ.get("ENABLE_LOGIN_FORM", "True").lower() == "true",
)
+ENABLE_PASSWORD_AUTH = os.environ.get("ENABLE_PASSWORD_AUTH", "True").lower() == "true"
DEFAULT_LOCALE = PersistentConfig(
"DEFAULT_LOCALE",
@@ -949,6 +1146,12 @@ DEFAULT_MODELS = PersistentConfig(
"DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None)
)
+DEFAULT_PINNED_MODELS = PersistentConfig(
+ "DEFAULT_PINNED_MODELS",
+ "ui.default_pinned_models",
+ os.environ.get("DEFAULT_PINNED_MODELS", None),
+)
+
try:
default_prompt_suggestions = json.loads(
os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]")
@@ -1005,6 +1208,12 @@ DEFAULT_USER_ROLE = PersistentConfig(
os.getenv("DEFAULT_USER_ROLE", "pending"),
)
+DEFAULT_GROUP_ID = PersistentConfig(
+ "DEFAULT_GROUP_ID",
+ "ui.default_group_id",
+ os.environ.get("DEFAULT_GROUP_ID", ""),
+)
+
PENDING_USER_OVERLAY_TITLE = PersistentConfig(
"PENDING_USER_OVERLAY_TITLE",
"ui.pending_user_overlay_title",
@@ -1044,6 +1253,40 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
+USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT", "False").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT", "False").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT", "False").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT", "False").lower() == "true"
+)
+
+USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT", "False").lower() == "true"
+)
+
+
+USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "False").lower()
+ == "true"
+)
+
USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False"
@@ -1051,6 +1294,13 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
== "true"
)
+USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
+ os.environ.get(
+ "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
+ ).lower()
+ == "true"
+)
+
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
@@ -1058,6 +1308,11 @@ USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
== "true"
)
+USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING", "False").lower()
+ == "true"
+)
+
USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False"
@@ -1065,6 +1320,12 @@ USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = (
== "true"
)
+
+USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING = (
+ os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING", "False").lower()
+ == "true"
+)
+
USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False"
@@ -1073,14 +1334,33 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
)
+USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
+ os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
+ os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
+ == "true"
+)
+
+
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
+USER_PERMISSIONS_CHAT_VALVES = (
+ os.environ.get("USER_PERMISSIONS_CHAT_VALVES", "True").lower() == "true"
+)
+
USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = (
os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true"
)
+USER_PERMISSIONS_CHAT_PARAMS = (
+ os.environ.get("USER_PERMISSIONS_CHAT_PARAMS", "True").lower() == "true"
+)
+
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
@@ -1089,6 +1369,23 @@ USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
+USER_PERMISSIONS_CHAT_DELETE_MESSAGE = (
+ os.environ.get("USER_PERMISSIONS_CHAT_DELETE_MESSAGE", "True").lower() == "true"
+)
+
+USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE = (
+ os.environ.get("USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE", "True").lower() == "true"
+)
+
+USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE = (
+ os.environ.get("USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE", "True").lower()
+ == "true"
+)
+
+USER_PERMISSIONS_CHAT_RATE_RESPONSE = (
+ os.environ.get("USER_PERMISSIONS_CHAT_RATE_RESPONSE", "True").lower() == "true"
+)
+
USER_PERMISSIONS_CHAT_EDIT = (
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
)
@@ -1150,6 +1447,10 @@ USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
)
+USER_PERMISSIONS_FEATURES_API_KEYS = (
+ os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
+)
+
DEFAULT_USER_PERMISSIONS = {
"workspace": {
@@ -1157,18 +1458,36 @@ DEFAULT_USER_PERMISSIONS = {
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
+ "models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
+ "models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
+ "prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
+ "prompts_export": USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT,
+ "tools_import": USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT,
+ "tools_export": USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT,
},
"sharing": {
+ "models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING,
"public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING,
+ "knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING,
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
+ "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
+ "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
+ "notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
+ "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
+ "valves": USER_PERMISSIONS_CHAT_VALVES,
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
+ "params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
+ "delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
+ "continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
+ "regenerate_response": USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE,
+ "rate_response": USER_PERMISSIONS_CHAT_RATE_RESPONSE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"share": USER_PERMISSIONS_CHAT_SHARE,
"export": USER_PERMISSIONS_CHAT_EXPORT,
@@ -1180,6 +1499,7 @@ DEFAULT_USER_PERMISSIONS = {
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
},
"features": {
+ "api_keys": USER_PERMISSIONS_FEATURES_API_KEYS,
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
@@ -1233,6 +1553,18 @@ WEBHOOK_URL = PersistentConfig(
ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true"
+ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = (
+ os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true"
+)
+
+BYPASS_ADMIN_ACCESS_CONTROL = (
+ os.environ.get(
+ "BYPASS_ADMIN_ACCESS_CONTROL",
+ os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True"),
+ ).lower()
+ == "true"
+)
+
ENABLE_ADMIN_CHAT_ACCESS = (
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
)
@@ -1271,10 +1603,11 @@ if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
def validate_cors_origin(origin):
parsed_url = urlparse(origin)
- # Check if the scheme is either http or https
- if parsed_url.scheme not in ["http", "https"]:
+ # Check if the scheme is either http or https, or a custom scheme
+ schemes = ["http", "https"] + CORS_ALLOW_CUSTOM_SCHEME
+ if parsed_url.scheme not in schemes:
raise ValueError(
- f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' are allowed."
+ f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' and CORS_ALLOW_CUSTOM_SCHEME are allowed."
)
# Ensure that the netloc (domain + port) is present, indicating it's a valid URL
@@ -1289,6 +1622,11 @@ def validate_cors_origin(origin):
# in your .env file depending on your frontend port, 5173 in this case.
CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
+# Allows custom URL schemes (e.g., app://) to be used as origins for CORS.
+# Useful for local development or desktop clients with schemes like app:// or other custom protocols.
+# Provide a semicolon-separated list of allowed schemes in the environment variable CORS_ALLOW_CUSTOM_SCHEMES.
+CORS_ALLOW_CUSTOM_SCHEME = os.environ.get("CORS_ALLOW_CUSTOM_SCHEME", "").split(";")
+
if CORS_ALLOW_ORIGIN == ["*"]:
log.warning(
"\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n"
@@ -1575,6 +1913,38 @@ Output:
#### Output:
"""
+
+VOICE_MODE_PROMPT_TEMPLATE = PersistentConfig(
+ "VOICE_MODE_PROMPT_TEMPLATE",
+ "task.voice.prompt_template",
+ os.environ.get("VOICE_MODE_PROMPT_TEMPLATE", ""),
+)
+
+DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant.
+
+Everything you say will be spoken aloud.
+Keep responses short, clear, and natural.
+
+STYLE:
+- Use simple words and short sentences.
+- Sound warm and conversational.
+- Avoid long explanations, lists, or complex phrasing.
+
+BEHAVIOR:
+- Give the quickest helpful answer first.
+- Offer extra detail only if needed.
+- Ask for clarification only when necessary.
+
+VOICE OPTIMIZATION:
+- Break information into small, easy-to-hear chunks.
+- Avoid dense wording or anything that sounds like reading text.
+
+ERROR HANDLING:
+- If unsure, say so briefly and offer options.
+- If something is unsafe or impossible, decline kindly and suggest a safe alternative.
+
+Stay consistent, helpful, and easy to listen to."""
+
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig(
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE",
"task.tools.prompt_template",
@@ -1729,6 +2099,11 @@ CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig(
),
)
+CODE_INTERPRETER_BLOCKED_MODULES = [
+ library.strip()
+ for library in os.environ.get("CODE_INTERPRETER_BLOCKED_MODULES", "").split(",")
+ if library.strip()
+]
DEFAULT_CODE_INTERPRETER_PROMPT = """
#### Tools Available
@@ -1779,26 +2154,41 @@ if VECTOR_DB == "chroma":
# 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
-
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"))
+MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
+MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
+ os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
+)
+ENABLE_MILVUS_MULTITENANCY_MODE = (
+ os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "false").lower() == "true"
+)
+# Hyphens not allowed, need to use underscores in collection names
+MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
# 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_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true"
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
+QDRANT_TIMEOUT = int(os.environ.get("QDRANT_TIMEOUT", "5"))
+QDRANT_HNSW_M = int(os.environ.get("QDRANT_HNSW_M", "16"))
ENABLE_QDRANT_MULTITENANCY_MODE = (
- os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "false").lower() == "true"
+ os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true"
)
+QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
+
+WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
+WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
+WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
+WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -1830,6 +2220,19 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
+PGVECTOR_USE_HALFVEC = os.getenv("PGVECTOR_USE_HALFVEC", "false").lower() == "true"
+
+if PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH > 2000 and not PGVECTOR_USE_HALFVEC:
+ raise ValueError(
+ "PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH is set to "
+ f"{PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH}, which exceeds the 2000 dimension limit of the "
+ "'vector' type. Set PGVECTOR_USE_HALFVEC=true to enable the 'halfvec' "
+ "type required for high-dimensional embeddings."
+ )
+
+PGVECTOR_CREATE_EXTENSION = (
+ os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true"
+)
PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true"
PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None)
if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
@@ -1837,6 +2240,79 @@ if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
"PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key."
)
+
+PGVECTOR_POOL_SIZE = os.environ.get("PGVECTOR_POOL_SIZE", None)
+
+if PGVECTOR_POOL_SIZE != None:
+ try:
+ PGVECTOR_POOL_SIZE = int(PGVECTOR_POOL_SIZE)
+ except Exception:
+ PGVECTOR_POOL_SIZE = None
+
+PGVECTOR_POOL_MAX_OVERFLOW = os.environ.get("PGVECTOR_POOL_MAX_OVERFLOW", 0)
+
+if PGVECTOR_POOL_MAX_OVERFLOW == "":
+ PGVECTOR_POOL_MAX_OVERFLOW = 0
+else:
+ try:
+ PGVECTOR_POOL_MAX_OVERFLOW = int(PGVECTOR_POOL_MAX_OVERFLOW)
+ except Exception:
+ PGVECTOR_POOL_MAX_OVERFLOW = 0
+
+PGVECTOR_POOL_TIMEOUT = os.environ.get("PGVECTOR_POOL_TIMEOUT", 30)
+
+if PGVECTOR_POOL_TIMEOUT == "":
+ PGVECTOR_POOL_TIMEOUT = 30
+else:
+ try:
+ PGVECTOR_POOL_TIMEOUT = int(PGVECTOR_POOL_TIMEOUT)
+ except Exception:
+ PGVECTOR_POOL_TIMEOUT = 30
+
+PGVECTOR_POOL_RECYCLE = os.environ.get("PGVECTOR_POOL_RECYCLE", 3600)
+
+if PGVECTOR_POOL_RECYCLE == "":
+ PGVECTOR_POOL_RECYCLE = 3600
+else:
+ try:
+ PGVECTOR_POOL_RECYCLE = int(PGVECTOR_POOL_RECYCLE)
+ except Exception:
+ PGVECTOR_POOL_RECYCLE = 3600
+
+PGVECTOR_INDEX_METHOD = os.getenv("PGVECTOR_INDEX_METHOD", "").strip().lower()
+if PGVECTOR_INDEX_METHOD not in ("ivfflat", "hnsw", ""):
+ PGVECTOR_INDEX_METHOD = ""
+
+PGVECTOR_HNSW_M = os.environ.get("PGVECTOR_HNSW_M", 16)
+
+if PGVECTOR_HNSW_M == "":
+ PGVECTOR_HNSW_M = 16
+else:
+ try:
+ PGVECTOR_HNSW_M = int(PGVECTOR_HNSW_M)
+ except Exception:
+ PGVECTOR_HNSW_M = 16
+
+PGVECTOR_HNSW_EF_CONSTRUCTION = os.environ.get("PGVECTOR_HNSW_EF_CONSTRUCTION", 64)
+
+if PGVECTOR_HNSW_EF_CONSTRUCTION == "":
+ PGVECTOR_HNSW_EF_CONSTRUCTION = 64
+else:
+ try:
+ PGVECTOR_HNSW_EF_CONSTRUCTION = int(PGVECTOR_HNSW_EF_CONSTRUCTION)
+ except Exception:
+ PGVECTOR_HNSW_EF_CONSTRUCTION = 64
+
+PGVECTOR_IVFFLAT_LISTS = os.environ.get("PGVECTOR_IVFFLAT_LISTS", 100)
+
+if PGVECTOR_IVFFLAT_LISTS == "":
+ PGVECTOR_IVFFLAT_LISTS = 100
+else:
+ try:
+ PGVECTOR_IVFFLAT_LISTS = int(PGVECTOR_IVFFLAT_LISTS)
+ except Exception:
+ PGVECTOR_IVFFLAT_LISTS = 100
+
# Pinecone
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
@@ -1845,6 +2321,37 @@ PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024
PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine")
PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure"
+# ORACLE23AI (Oracle23ai Vector Search)
+
+ORACLE_DB_USE_WALLET = os.environ.get("ORACLE_DB_USE_WALLET", "false").lower() == "true"
+ORACLE_DB_USER = os.environ.get("ORACLE_DB_USER", None) #
+ORACLE_DB_PASSWORD = os.environ.get("ORACLE_DB_PASSWORD", None) #
+ORACLE_DB_DSN = os.environ.get("ORACLE_DB_DSN", None) #
+ORACLE_WALLET_DIR = os.environ.get("ORACLE_WALLET_DIR", None)
+ORACLE_WALLET_PASSWORD = os.environ.get("ORACLE_WALLET_PASSWORD", None)
+ORACLE_VECTOR_LENGTH = os.environ.get("ORACLE_VECTOR_LENGTH", 768)
+
+ORACLE_DB_POOL_MIN = int(os.environ.get("ORACLE_DB_POOL_MIN", 2))
+ORACLE_DB_POOL_MAX = int(os.environ.get("ORACLE_DB_POOL_MAX", 10))
+ORACLE_DB_POOL_INCREMENT = int(os.environ.get("ORACLE_DB_POOL_INCREMENT", 1))
+
+
+if VECTOR_DB == "oracle23ai":
+ if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN:
+ raise ValueError(
+ "Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN."
+ )
+ if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD):
+ raise ValueError(
+ "Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication."
+ )
+
+log.info(f"VECTOR_DB: {VECTOR_DB}")
+
+# S3 Vector
+S3_VECTOR_BUCKET_NAME = os.environ.get("S3_VECTOR_BUCKET_NAME", None)
+S3_VECTOR_REGION = os.environ.get("S3_VECTOR_REGION", None)
+
####################################
# Information Retrieval (RAG)
####################################
@@ -1875,10 +2382,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
)
-ONEDRIVE_CLIENT_ID = PersistentConfig(
- "ONEDRIVE_CLIENT_ID",
- "onedrive.client_id",
- os.environ.get("ONEDRIVE_CLIENT_ID", ""),
+
+ENABLE_ONEDRIVE_PERSONAL = (
+ os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
+)
+ENABLE_ONEDRIVE_BUSINESS = (
+ os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
+)
+
+ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
+ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
+ "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
+)
+ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
+ "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
@@ -1906,10 +2423,16 @@ DATALAB_MARKER_API_KEY = PersistentConfig(
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_API_BASE_URL = PersistentConfig(
+ "DATALAB_MARKER_API_BASE_URL",
+ "rag.datalab_marker_api_base_url",
+ os.environ.get("DATALAB_MARKER_API_BASE_URL", ""),
+)
+
+DATALAB_MARKER_ADDITIONAL_CONFIG = PersistentConfig(
+ "DATALAB_MARKER_ADDITIONAL_CONFIG",
+ "rag.datalab_marker_additional_config",
+ os.environ.get("DATALAB_MARKER_ADDITIONAL_CONFIG", ""),
)
DATALAB_MARKER_USE_LLM = PersistentConfig(
@@ -1949,12 +2472,48 @@ DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig(
== "true",
)
+DATALAB_MARKER_FORMAT_LINES = PersistentConfig(
+ "DATALAB_MARKER_FORMAT_LINES",
+ "rag.datalab_marker_format_lines",
+ os.environ.get("DATALAB_MARKER_FORMAT_LINES", "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"),
)
+MINERU_API_MODE = PersistentConfig(
+ "MINERU_API_MODE",
+ "rag.mineru_api_mode",
+ os.environ.get("MINERU_API_MODE", "local"), # "local" or "cloud"
+)
+
+MINERU_API_URL = PersistentConfig(
+ "MINERU_API_URL",
+ "rag.mineru_api_url",
+ os.environ.get("MINERU_API_URL", "http://localhost:8000"),
+)
+
+MINERU_API_KEY = PersistentConfig(
+ "MINERU_API_KEY",
+ "rag.mineru_api_key",
+ os.environ.get("MINERU_API_KEY", ""),
+)
+
+mineru_params = os.getenv("MINERU_PARAMS", "")
+try:
+ mineru_params = json.loads(mineru_params)
+except json.JSONDecodeError:
+ mineru_params = {}
+
+MINERU_PARAMS = PersistentConfig(
+ "MINERU_PARAMS",
+ "rag.mineru_params",
+ mineru_params,
+)
+
EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig(
"EXTERNAL_DOCUMENT_LOADER_URL",
"rag.external_document_loader_url",
@@ -1979,58 +2538,24 @@ DOCLING_SERVER_URL = PersistentConfig(
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_API_KEY = PersistentConfig(
+ "DOCLING_API_KEY",
+ "rag.docling_api_key",
+ os.getenv("DOCLING_API_KEY", ""),
)
-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",
-)
-
-DOCLING_PICTURE_DESCRIPTION_MODE = PersistentConfig(
- "DOCLING_PICTURE_DESCRIPTION_MODE",
- "rag.docling_picture_description_mode",
- os.getenv("DOCLING_PICTURE_DESCRIPTION_MODE", ""),
-)
-
-
-docling_picture_description_local = os.getenv("DOCLING_PICTURE_DESCRIPTION_LOCAL", "")
+docling_params = os.getenv("DOCLING_PARAMS", "")
try:
- docling_picture_description_local = json.loads(docling_picture_description_local)
+ docling_params = json.loads(docling_params)
except json.JSONDecodeError:
- docling_picture_description_local = {}
+ docling_params = {}
-
-DOCLING_PICTURE_DESCRIPTION_LOCAL = PersistentConfig(
- "DOCLING_PICTURE_DESCRIPTION_LOCAL",
- "rag.docling_picture_description_local",
- docling_picture_description_local,
+DOCLING_PARAMS = PersistentConfig(
+ "DOCLING_PARAMS",
+ "rag.docling_params",
+ docling_params,
)
-docling_picture_description_api = os.getenv("DOCLING_PICTURE_DESCRIPTION_API", "")
-try:
- docling_picture_description_api = json.loads(docling_picture_description_api)
-except json.JSONDecodeError:
- docling_picture_description_api = {}
-
-
-DOCLING_PICTURE_DESCRIPTION_API = PersistentConfig(
- "DOCLING_PICTURE_DESCRIPTION_API",
- "rag.docling_picture_description_api",
- docling_picture_description_api,
-)
-
-
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
@@ -2043,6 +2568,12 @@ DOCUMENT_INTELLIGENCE_KEY = PersistentConfig(
os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""),
)
+MISTRAL_OCR_API_BASE_URL = PersistentConfig(
+ "MISTRAL_OCR_API_BASE_URL",
+ "rag.MISTRAL_OCR_API_BASE_URL",
+ os.getenv("MISTRAL_OCR_API_BASE_URL", "https://api.mistral.ai/v1"),
+)
+
MISTRAL_OCR_API_KEY = PersistentConfig(
"MISTRAL_OCR_API_KEY",
"rag.mistral_ocr_api_key",
@@ -2081,6 +2612,13 @@ ENABLE_RAG_HYBRID_SEARCH = PersistentConfig(
os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true",
)
+ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = PersistentConfig(
+ "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS",
+ "rag.enable_hybrid_search_enriched_texts",
+ os.environ.get("ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS", "False").lower()
+ == "true",
+)
+
RAG_FULL_CONTEXT = PersistentConfig(
"RAG_FULL_CONTEXT",
"rag.full_context",
@@ -2175,6 +2713,12 @@ RAG_EMBEDDING_BATCH_SIZE = PersistentConfig(
),
)
+ENABLE_ASYNC_EMBEDDING = PersistentConfig(
+ "ENABLE_ASYNC_EMBEDDING",
+ "rag.enable_async_embedding",
+ os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true",
+)
+
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
@@ -2268,10 +2812,6 @@ Provide a clear and direct response to the user's query, including inline citati
{{CONTEXT}}
-
-
-{{QUERY}}
-
"""
RAG_TEMPLATE = PersistentConfig(
@@ -2324,6 +2864,26 @@ ENABLE_RAG_LOCAL_WEB_FETCH = (
os.getenv("ENABLE_RAG_LOCAL_WEB_FETCH", "False").lower() == "true"
)
+
+DEFAULT_WEB_FETCH_FILTER_LIST = [
+ "!169.254.169.254",
+ "!fd00:ec2::254",
+ "!metadata.google.internal",
+ "!metadata.azure.com",
+ "!100.100.100.200",
+]
+
+web_fetch_filter_list = os.getenv("WEB_FETCH_FILTER_LIST", "")
+if web_fetch_filter_list == "":
+ web_fetch_filter_list = []
+else:
+ web_fetch_filter_list = [
+ item.strip() for item in web_fetch_filter_list.split(",") if item.strip()
+ ]
+
+WEB_FETCH_FILTER_LIST = list(set(DEFAULT_WEB_FETCH_FILTER_LIST + web_fetch_filter_list))
+
+
YOUTUBE_LOADER_LANGUAGE = PersistentConfig(
"YOUTUBE_LOADER_LANGUAGE",
"rag.youtube_loader_language",
@@ -2382,6 +2942,7 @@ WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
# "wikipedia.com",
# "wikimedia.org",
# "wikidata.org",
+ # "!stackoverflow.com",
],
)
@@ -2398,6 +2959,14 @@ WEB_LOADER_ENGINE = PersistentConfig(
os.environ.get("WEB_LOADER_ENGINE", ""),
)
+
+WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
+ "WEB_LOADER_CONCURRENT_REQUESTS",
+ "rag.web.loader.concurrent_requests",
+ int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
+)
+
+
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
"rag.web.loader.ssl_verification",
@@ -2411,6 +2980,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
)
+OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
+ "OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
+ "rag.web.search.ollama_cloud_api_key",
+ os.getenv("OLLAMA_CLOUD_API_KEY", ""),
+)
+
SEARXNG_QUERY_URL = PersistentConfig(
"SEARXNG_QUERY_URL",
"rag.web.search.searxng_query_url",
@@ -2539,6 +3114,24 @@ BING_SEARCH_V7_SUBSCRIPTION_KEY = PersistentConfig(
os.environ.get("BING_SEARCH_V7_SUBSCRIPTION_KEY", ""),
)
+AZURE_AI_SEARCH_API_KEY = PersistentConfig(
+ "AZURE_AI_SEARCH_API_KEY",
+ "rag.web.search.azure_ai_search_api_key",
+ os.environ.get("AZURE_AI_SEARCH_API_KEY", ""),
+)
+
+AZURE_AI_SEARCH_ENDPOINT = PersistentConfig(
+ "AZURE_AI_SEARCH_ENDPOINT",
+ "rag.web.search.azure_ai_search_endpoint",
+ os.environ.get("AZURE_AI_SEARCH_ENDPOINT", ""),
+)
+
+AZURE_AI_SEARCH_INDEX_NAME = PersistentConfig(
+ "AZURE_AI_SEARCH_INDEX_NAME",
+ "rag.web.search.azure_ai_search_index_name",
+ os.environ.get("AZURE_AI_SEARCH_INDEX_NAME", ""),
+)
+
EXA_API_KEY = PersistentConfig(
"EXA_API_KEY",
"rag.web.search.exa_api_key",
@@ -2563,6 +3156,12 @@ PERPLEXITY_SEARCH_CONTEXT_USAGE = PersistentConfig(
os.getenv("PERPLEXITY_SEARCH_CONTEXT_USAGE", "medium"),
)
+PERPLEXITY_SEARCH_API_URL = PersistentConfig(
+ "PERPLEXITY_SEARCH_API_URL",
+ "rag.web.search.perplexity_search_api_url",
+ os.getenv("PERPLEXITY_SEARCH_API_URL", "https://api.perplexity.ai/search"),
+)
+
SOUGOU_API_SID = PersistentConfig(
"SOUGOU_API_SID",
"rag.web.search.sougou_api_sid",
@@ -2639,16 +3238,30 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
# Images
####################################
+ENABLE_IMAGE_GENERATION = PersistentConfig(
+ "ENABLE_IMAGE_GENERATION",
+ "image_generation.enable",
+ os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
+)
+
IMAGE_GENERATION_ENGINE = PersistentConfig(
"IMAGE_GENERATION_ENGINE",
"image_generation.engine",
os.getenv("IMAGE_GENERATION_ENGINE", "openai"),
)
-ENABLE_IMAGE_GENERATION = PersistentConfig(
- "ENABLE_IMAGE_GENERATION",
- "image_generation.enable",
- os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
+IMAGE_GENERATION_MODEL = PersistentConfig(
+ "IMAGE_GENERATION_MODEL",
+ "image_generation.model",
+ os.getenv("IMAGE_GENERATION_MODEL", ""),
+)
+
+IMAGE_SIZE = PersistentConfig(
+ "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
+)
+
+IMAGE_STEPS = PersistentConfig(
+ "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50))
)
ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig(
@@ -2668,35 +3281,16 @@ AUTOMATIC1111_API_AUTH = PersistentConfig(
os.getenv("AUTOMATIC1111_API_AUTH", ""),
)
-AUTOMATIC1111_CFG_SCALE = PersistentConfig(
- "AUTOMATIC1111_CFG_SCALE",
- "image_generation.automatic1111.cfg_scale",
- (
- float(os.environ.get("AUTOMATIC1111_CFG_SCALE"))
- if os.environ.get("AUTOMATIC1111_CFG_SCALE")
- else None
- ),
-)
+automatic1111_params = os.getenv("AUTOMATIC1111_PARAMS", "")
+try:
+ automatic1111_params = json.loads(automatic1111_params)
+except json.JSONDecodeError:
+ automatic1111_params = {}
-
-AUTOMATIC1111_SAMPLER = PersistentConfig(
- "AUTOMATIC1111_SAMPLER",
- "image_generation.automatic1111.sampler",
- (
- os.environ.get("AUTOMATIC1111_SAMPLER")
- if os.environ.get("AUTOMATIC1111_SAMPLER")
- else None
- ),
-)
-
-AUTOMATIC1111_SCHEDULER = PersistentConfig(
- "AUTOMATIC1111_SCHEDULER",
- "image_generation.automatic1111.scheduler",
- (
- os.environ.get("AUTOMATIC1111_SCHEDULER")
- if os.environ.get("AUTOMATIC1111_SCHEDULER")
- else None
- ),
+AUTOMATIC1111_PARAMS = PersistentConfig(
+ "AUTOMATIC1111_PARAMS",
+ "image_generation.automatic1111.api_params",
+ automatic1111_params,
)
COMFYUI_BASE_URL = PersistentConfig(
@@ -2839,12 +3433,30 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
"image_generation.openai.api_base_url",
os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL),
)
+IMAGES_OPENAI_API_VERSION = PersistentConfig(
+ "IMAGES_OPENAI_API_VERSION",
+ "image_generation.openai.api_version",
+ os.getenv("IMAGES_OPENAI_API_VERSION", ""),
+)
+
IMAGES_OPENAI_API_KEY = PersistentConfig(
"IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_key",
os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY),
)
+images_openai_params = os.getenv("IMAGES_OPENAI_PARAMS", "")
+try:
+ images_openai_params = json.loads(images_openai_params)
+except json.JSONDecodeError:
+ images_openai_params = {}
+
+
+IMAGES_OPENAI_API_PARAMS = PersistentConfig(
+ "IMAGES_OPENAI_API_PARAMS", "image_generation.openai.params", images_openai_params
+)
+
+
IMAGES_GEMINI_API_BASE_URL = PersistentConfig(
"IMAGES_GEMINI_API_BASE_URL",
"image_generation.gemini.api_base_url",
@@ -2856,18 +3468,84 @@ IMAGES_GEMINI_API_KEY = PersistentConfig(
os.getenv("IMAGES_GEMINI_API_KEY", GEMINI_API_KEY),
)
-IMAGE_SIZE = PersistentConfig(
- "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
+IMAGES_GEMINI_ENDPOINT_METHOD = PersistentConfig(
+ "IMAGES_GEMINI_ENDPOINT_METHOD",
+ "image_generation.gemini.endpoint_method",
+ os.getenv("IMAGES_GEMINI_ENDPOINT_METHOD", ""),
)
-IMAGE_STEPS = PersistentConfig(
- "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50))
+ENABLE_IMAGE_EDIT = PersistentConfig(
+ "ENABLE_IMAGE_EDIT",
+ "images.edit.enable",
+ os.environ.get("ENABLE_IMAGE_EDIT", "").lower() == "true",
)
-IMAGE_GENERATION_MODEL = PersistentConfig(
- "IMAGE_GENERATION_MODEL",
- "image_generation.model",
- os.getenv("IMAGE_GENERATION_MODEL", ""),
+IMAGE_EDIT_ENGINE = PersistentConfig(
+ "IMAGE_EDIT_ENGINE",
+ "images.edit.engine",
+ os.getenv("IMAGE_EDIT_ENGINE", "openai"),
+)
+
+IMAGE_EDIT_MODEL = PersistentConfig(
+ "IMAGE_EDIT_MODEL",
+ "images.edit.model",
+ os.getenv("IMAGE_EDIT_MODEL", ""),
+)
+
+IMAGE_EDIT_SIZE = PersistentConfig(
+ "IMAGE_EDIT_SIZE", "images.edit.size", os.getenv("IMAGE_EDIT_SIZE", "")
+)
+
+IMAGES_EDIT_OPENAI_API_BASE_URL = PersistentConfig(
+ "IMAGES_EDIT_OPENAI_API_BASE_URL",
+ "images.edit.openai.api_base_url",
+ os.getenv("IMAGES_EDIT_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL),
+)
+IMAGES_EDIT_OPENAI_API_VERSION = PersistentConfig(
+ "IMAGES_EDIT_OPENAI_API_VERSION",
+ "images.edit.openai.api_version",
+ os.getenv("IMAGES_EDIT_OPENAI_API_VERSION", ""),
+)
+
+IMAGES_EDIT_OPENAI_API_KEY = PersistentConfig(
+ "IMAGES_EDIT_OPENAI_API_KEY",
+ "images.edit.openai.api_key",
+ os.getenv("IMAGES_EDIT_OPENAI_API_KEY", OPENAI_API_KEY),
+)
+
+IMAGES_EDIT_GEMINI_API_BASE_URL = PersistentConfig(
+ "IMAGES_EDIT_GEMINI_API_BASE_URL",
+ "images.edit.gemini.api_base_url",
+ os.getenv("IMAGES_EDIT_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL),
+)
+IMAGES_EDIT_GEMINI_API_KEY = PersistentConfig(
+ "IMAGES_EDIT_GEMINI_API_KEY",
+ "images.edit.gemini.api_key",
+ os.getenv("IMAGES_EDIT_GEMINI_API_KEY", GEMINI_API_KEY),
+)
+
+
+IMAGES_EDIT_COMFYUI_BASE_URL = PersistentConfig(
+ "IMAGES_EDIT_COMFYUI_BASE_URL",
+ "images.edit.comfyui.base_url",
+ os.getenv("IMAGES_EDIT_COMFYUI_BASE_URL", ""),
+)
+IMAGES_EDIT_COMFYUI_API_KEY = PersistentConfig(
+ "IMAGES_EDIT_COMFYUI_API_KEY",
+ "images.edit.comfyui.api_key",
+ os.getenv("IMAGES_EDIT_COMFYUI_API_KEY", ""),
+)
+
+IMAGES_EDIT_COMFYUI_WORKFLOW = PersistentConfig(
+ "IMAGES_EDIT_COMFYUI_WORKFLOW",
+ "images.edit.comfyui.workflow",
+ os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""),
+)
+
+IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig(
+ "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
+ "images.edit.comfyui.nodes",
+ [],
)
####################################
@@ -2902,6 +3580,10 @@ DEEPGRAM_API_KEY = PersistentConfig(
os.getenv("DEEPGRAM_API_KEY", ""),
)
+# ElevenLabs configuration
+ELEVENLABS_API_BASE_URL = os.getenv(
+ "ELEVENLABS_API_BASE_URL", "https://api.elevenlabs.io"
+)
AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig(
"AUDIO_STT_OPENAI_API_BASE_URL",
@@ -2969,6 +3651,24 @@ AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig(
os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""),
)
+AUDIO_STT_MISTRAL_API_KEY = PersistentConfig(
+ "AUDIO_STT_MISTRAL_API_KEY",
+ "audio.stt.mistral.api_key",
+ os.getenv("AUDIO_STT_MISTRAL_API_KEY", ""),
+)
+
+AUDIO_STT_MISTRAL_API_BASE_URL = PersistentConfig(
+ "AUDIO_STT_MISTRAL_API_BASE_URL",
+ "audio.stt.mistral.api_base_url",
+ os.getenv("AUDIO_STT_MISTRAL_API_BASE_URL", "https://api.mistral.ai/v1"),
+)
+
+AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = PersistentConfig(
+ "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS",
+ "audio.stt.mistral.use_chat_completions",
+ os.getenv("AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", "false").lower() == "true",
+)
+
AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig(
"AUDIO_TTS_OPENAI_API_BASE_URL",
"audio.tts.openai.api_base_url",
@@ -2980,6 +3680,19 @@ AUDIO_TTS_OPENAI_API_KEY = PersistentConfig(
os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY),
)
+audio_tts_openai_params = os.getenv("AUDIO_TTS_OPENAI_PARAMS", "")
+try:
+ audio_tts_openai_params = json.loads(audio_tts_openai_params)
+except json.JSONDecodeError:
+ audio_tts_openai_params = {}
+
+AUDIO_TTS_OPENAI_PARAMS = PersistentConfig(
+ "AUDIO_TTS_OPENAI_PARAMS",
+ "audio.tts.openai.params",
+ audio_tts_openai_params,
+)
+
+
AUDIO_TTS_API_KEY = PersistentConfig(
"AUDIO_TTS_API_KEY",
"audio.tts.api_key",
diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py
index 59ee6aaacb..4d39d16cdb 100644
--- a/backend/open_webui/constants.py
+++ b/backend/open_webui/constants.py
@@ -38,13 +38,14 @@ class ERROR_MESSAGES(str, Enum):
ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string."
MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string."
NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string."
+ MODEL_ID_TOO_LONG = "The model id is too long. Please make sure your model id is less than 256 characters long."
INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again."
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
- INVALID_PASSWORD = (
+ INCORRECT_PASSWORD = (
"The password provided is incorrect. Please check for typos and try again."
)
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
@@ -104,6 +105,10 @@ class ERROR_MESSAGES(str, Enum):
)
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
+ INVALID_PASSWORD = lambda err="": (
+ err if err else "The password does not meet the required validation criteria."
+ )
+
class TASKS(str, Enum):
def __str__(self) -> str:
diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py
index 0f7b5611f5..e3c50ea8d1 100644
--- a/backend/open_webui/env.py
+++ b/backend/open_webui/env.py
@@ -7,6 +7,9 @@ import sys
import shutil
from uuid import uuid4
from pathlib import Path
+from cryptography.hazmat.primitives import serialization
+import re
+
import markdown
from bs4 import BeautifulSoup
@@ -16,14 +19,17 @@ from open_webui.constants import ERROR_MESSAGES
# Load .env file
####################################
-OPEN_WEBUI_DIR = Path(__file__).parent # the path containing this file
-print(OPEN_WEBUI_DIR)
+# Use .resolve() to get the canonical path, removing any '..' or '.' components
+ENV_FILE_PATH = Path(__file__).resolve()
-BACKEND_DIR = OPEN_WEBUI_DIR.parent # the path containing this file
-BASE_DIR = BACKEND_DIR.parent # the path containing the backend/
+# OPEN_WEBUI_DIR should be the directory where env.py resides (open_webui/)
+OPEN_WEBUI_DIR = ENV_FILE_PATH.parent
-print(BACKEND_DIR)
-print(BASE_DIR)
+# BACKEND_DIR is the parent of OPEN_WEBUI_DIR (backend/)
+BACKEND_DIR = OPEN_WEBUI_DIR.parent
+
+# BASE_DIR is the parent of BACKEND_DIR (open-webui-dev/)
+BASE_DIR = BACKEND_DIR.parent
try:
from dotenv import find_dotenv, load_dotenv
@@ -131,6 +137,9 @@ else:
PACKAGE_DATA = {"version": "0.0.0"}
VERSION = PACKAGE_DATA["version"]
+
+
+DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
@@ -199,6 +208,7 @@ CHANGELOG = changelog_json
SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true"
+
####################################
# ENABLE_FORWARD_USER_INFO_HEADERS
####################################
@@ -207,6 +217,11 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
+# Experimental feature, may be removed in future
+ENABLE_STAR_SESSIONS_MIDDLEWARE = (
+ os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
+)
+
####################################
# WEBUI_BUILD_HASH
####################################
@@ -266,21 +281,43 @@ else:
DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
+DATABASE_TYPE = os.environ.get("DATABASE_TYPE")
+DATABASE_USER = os.environ.get("DATABASE_USER")
+DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
+
+DATABASE_CRED = ""
+if DATABASE_USER:
+ DATABASE_CRED += f"{DATABASE_USER}"
+if DATABASE_PASSWORD:
+ DATABASE_CRED += f":{DATABASE_PASSWORD}"
+
+DB_VARS = {
+ "db_type": DATABASE_TYPE,
+ "db_cred": DATABASE_CRED,
+ "db_host": os.environ.get("DATABASE_HOST"),
+ "db_port": os.environ.get("DATABASE_PORT"),
+ "db_name": os.environ.get("DATABASE_NAME"),
+}
+
+if all(DB_VARS.values()):
+ DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}"
+elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"):
+ # Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set
+ DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db"
+
# Replace the postgres:// with postgresql://
if "postgres://" in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
-DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
+DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None)
-if DATABASE_POOL_SIZE == "":
- DATABASE_POOL_SIZE = 0
-else:
+if DATABASE_POOL_SIZE != None:
try:
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
except Exception:
- DATABASE_POOL_SIZE = 0
+ DATABASE_POOL_SIZE = None
DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0)
@@ -312,6 +349,21 @@ else:
except Exception:
DATABASE_POOL_RECYCLE = 3600
+DATABASE_ENABLE_SQLITE_WAL = (
+ os.environ.get("DATABASE_ENABLE_SQLITE_WAL", "False").lower() == "true"
+)
+
+DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get(
+ "DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL", None
+)
+if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
+ try:
+ DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(
+ DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
+ )
+ except Exception:
+ DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
+
RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
@@ -320,14 +372,29 @@ ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
+ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
+
####################################
# REDIS
####################################
REDIS_URL = os.environ.get("REDIS_URL", "")
+REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true"
+
+REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
+
REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
+# Maximum number of retries for Redis operations when using Sentinel fail-over
+REDIS_SENTINEL_MAX_RETRY_COUNT = os.environ.get("REDIS_SENTINEL_MAX_RETRY_COUNT", "2")
+try:
+ REDIS_SENTINEL_MAX_RETRY_COUNT = int(REDIS_SENTINEL_MAX_RETRY_COUNT)
+ if REDIS_SENTINEL_MAX_RETRY_COUNT < 1:
+ REDIS_SENTINEL_MAX_RETRY_COUNT = 2
+except ValueError:
+ REDIS_SENTINEL_MAX_RETRY_COUNT = 2
+
####################################
# UVICORN WORKERS
####################################
@@ -347,6 +414,14 @@ except ValueError:
####################################
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
+
+ENABLE_INITIAL_ADMIN_SIGNUP = (
+ os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true"
+)
+ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
+ os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
+)
+
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
@@ -356,6 +431,17 @@ WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get(
)
+ENABLE_PASSWORD_VALIDATION = (
+ os.environ.get("ENABLE_PASSWORD_VALIDATION", "False").lower() == "true"
+)
+PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
+ "PASSWORD_VALIDATION_REGEX_PATTERN",
+ "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
+)
+
+PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
+
+
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
)
@@ -396,18 +482,186 @@ WEBUI_AUTH_COOKIE_SECURE = (
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND)
+ENABLE_COMPRESSION_MIDDLEWARE = (
+ os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
+)
+
+####################################
+# OAUTH Configuration
+####################################
+ENABLE_OAUTH_EMAIL_FALLBACK = (
+ os.environ.get("ENABLE_OAUTH_EMAIL_FALLBACK", "False").lower() == "true"
+)
+
+ENABLE_OAUTH_ID_TOKEN_COOKIE = (
+ os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
+)
+
+OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
+ "OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
+)
+
+OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
+ "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
+)
+
+####################################
+# SCIM Configuration
+####################################
+
+ENABLE_SCIM = (
+ os.environ.get("ENABLE_SCIM", os.environ.get("SCIM_ENABLED", "False")).lower()
+ == "true"
+)
+SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
+
+####################################
+# LICENSE_KEY
+####################################
+
+LICENSE_KEY = os.environ.get("LICENSE_KEY", "")
+
+LICENSE_BLOB = None
+LICENSE_BLOB_PATH = os.environ.get("LICENSE_BLOB_PATH", DATA_DIR / "l.data")
+if LICENSE_BLOB_PATH and os.path.exists(LICENSE_BLOB_PATH):
+ with open(LICENSE_BLOB_PATH, "rb") as f:
+ LICENSE_BLOB = f.read()
+
+LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
+
+pk = None
+if LICENSE_PUBLIC_KEY:
+ pk = serialization.load_pem_public_key(
+ f"""
+-----BEGIN PUBLIC KEY-----
+{LICENSE_PUBLIC_KEY}
+-----END PUBLIC KEY-----
+""".encode(
+ "utf-8"
+ )
+ )
+
+
+####################################
+# MODELS
+####################################
+
+MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
+if MODELS_CACHE_TTL == "":
+ MODELS_CACHE_TTL = None
+else:
+ try:
+ MODELS_CACHE_TTL = int(MODELS_CACHE_TTL)
+ except Exception:
+ MODELS_CACHE_TTL = 1
+
+
+####################################
+# CHAT
+####################################
+
+ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION = (
+ os.environ.get("ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION", "False").lower()
+ == "true"
+)
+
+CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get(
+ "CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1"
+)
+
+if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "":
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
+else:
+ try:
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int(
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE
+ )
+ except Exception:
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
+
+
+CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
+ "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
+)
+
+if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
+else:
+ try:
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
+ except Exception:
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
+
+
+CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get(
+ "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE", ""
+)
+
+if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == "":
+ CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None
+else:
+ try:
+ CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = int(
+ CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
+ )
+ except Exception:
+ CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None
+
+
+####################################
+# WEBSOCKET SUPPORT
+####################################
+
ENABLE_WEBSOCKET_SUPPORT = (
os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true"
)
+
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
+WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
+if WEBSOCKET_REDIS_OPTIONS == "":
+ log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
+ WEBSOCKET_REDIS_OPTIONS = None
+else:
+ try:
+ WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
+ except Exception:
+ log.warning("Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None")
+ WEBSOCKET_REDIS_OPTIONS = None
+
WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
-WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60)
+WEBSOCKET_REDIS_CLUSTER = (
+ os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true"
+)
+
+websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
+
+try:
+ WEBSOCKET_REDIS_LOCK_TIMEOUT = int(websocket_redis_lock_timeout)
+except ValueError:
+ WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
-
WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379")
+WEBSOCKET_SERVER_LOGGING = (
+ os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
+)
+WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
+ os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
+)
+WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
+try:
+ WEBSOCKET_SERVER_PING_TIMEOUT = int(WEBSOCKET_SERVER_PING_TIMEOUT)
+except ValueError:
+ WEBSOCKET_SERVER_PING_TIMEOUT = 20
+
+WEBSOCKET_SERVER_PING_INTERVAL = os.environ.get("WEBSOCKET_SERVER_PING_INTERVAL", "25")
+try:
+ WEBSOCKET_SERVER_PING_INTERVAL = int(WEBSOCKET_SERVER_PING_INTERVAL)
+except ValueError:
+ WEBSOCKET_SERVER_PING_INTERVAL = 25
+
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
@@ -506,19 +760,32 @@ else:
# OFFLINE_MODE
####################################
+ENABLE_VERSION_UPDATE_CHECK = (
+ os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true"
+)
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"
-
+ ENABLE_VERSION_UPDATE_CHECK = False
####################################
# AUDIT LOGGING
####################################
# Where to store log file
-AUDIT_LOGS_FILE_PATH = f"{DATA_DIR}/audit.log"
+# Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to
+# provide the whole path, like: /app/audit.log
+AUDIT_LOGS_FILE_PATH = os.getenv("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")
+
+# Comma separated list of logger names to use for audit logging
+# Default is "uvicorn.access" which is the access log for Uvicorn
+# You can add more logger names to this list if you want to capture more logs
+AUDIT_UVICORN_LOGGER_NAMES = os.getenv(
+ "AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access"
+).split(",")
+
# METADATA | REQUEST | REQUEST_RESPONSE
AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper()
try:
@@ -539,10 +806,34 @@ AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true"
+ENABLE_OTEL_TRACES = os.environ.get("ENABLE_OTEL_TRACES", "False").lower() == "true"
ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true"
+ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true"
+
OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"
)
+OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
+ "OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
+)
+OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get(
+ "OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT
+)
+OTEL_EXPORTER_OTLP_INSECURE = (
+ os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true"
+)
+OTEL_METRICS_EXPORTER_OTLP_INSECURE = (
+ os.environ.get(
+ "OTEL_METRICS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
+ ).lower()
+ == "true"
+)
+OTEL_LOGS_EXPORTER_OTLP_INSECURE = (
+ os.environ.get(
+ "OTEL_LOGS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE)
+ ).lower()
+ == "true"
+)
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui")
OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
"OTEL_RESOURCE_ATTRIBUTES", ""
@@ -550,6 +841,33 @@ OTEL_RESOURCE_ATTRIBUTES = os.environ.get(
OTEL_TRACES_SAMPLER = os.environ.get(
"OTEL_TRACES_SAMPLER", "parentbased_always_on"
).lower()
+OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "")
+OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "")
+
+OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get(
+ "OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
+)
+OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get(
+ "OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
+)
+OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get(
+ "OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME
+)
+OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get(
+ "OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD
+)
+
+OTEL_OTLP_SPAN_EXPORTER = os.environ.get(
+ "OTEL_OTLP_SPAN_EXPORTER", "grpc"
+).lower() # grpc or http
+
+OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get(
+ "OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
+).lower() # grpc or http
+
+OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
+ "OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER
+).lower() # grpc or http
####################################
# TOOLS/FUNCTIONS PIP OPTIONS
diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py
index 6eb5c1bbdb..316efe18e7 100644
--- a/backend/open_webui/functions.py
+++ b/backend/open_webui/functions.py
@@ -19,6 +19,7 @@ from fastapi import (
from starlette.responses import Response, StreamingResponse
+from open_webui.constants import ERROR_MESSAGES
from open_webui.socket.main import (
get_event_call,
get_event_emitter,
@@ -47,7 +48,7 @@ from open_webui.utils.misc import (
)
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
- apply_model_system_prompt_to_body,
+ apply_system_prompt_to_body,
)
@@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
+ Valves = function_module.Valves
valves = Functions.get_function_valves_by_id(pipe_id)
- function_module.valves = function_module.Valves(**(valves if valves else {}))
+
+ if valves:
+ try:
+ function_module.valves = Valves(
+ **{k: v for k, v in valves.items() if v is not None}
+ )
+ except Exception as e:
+ log.exception(f"Error loading valves for function {pipe_id}: {e}")
+ raise e
+ else:
+ function_module.valves = Valves()
+
return function_module
@@ -70,65 +83,75 @@ async def get_function_models(request):
pipe_models = []
for pipe in pipes:
- function_module = get_function_module_by_id(request, pipe.id)
+ try:
+ function_module = get_function_module_by_id(request, pipe.id)
- # Check if function is a manifold
- if hasattr(function_module, "pipes"):
- sub_pipes = []
+ has_user_valves = False
+ if hasattr(function_module, "UserValves"):
+ has_user_valves = True
- # Handle pipes being a list, sync function, or async function
- try:
- if callable(function_module.pipes):
- if asyncio.iscoroutinefunction(function_module.pipes):
- sub_pipes = await function_module.pipes()
- else:
- sub_pipes = function_module.pipes()
- else:
- sub_pipes = function_module.pipes
- except Exception as e:
- log.exception(e)
+ # Check if function is a manifold
+ if hasattr(function_module, "pipes"):
sub_pipes = []
- log.debug(
- f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
- )
+ # Handle pipes being a list, sync function, or async function
+ try:
+ if callable(function_module.pipes):
+ if asyncio.iscoroutinefunction(function_module.pipes):
+ sub_pipes = await function_module.pipes()
+ else:
+ sub_pipes = function_module.pipes()
+ else:
+ sub_pipes = function_module.pipes
+ except Exception as e:
+ log.exception(e)
+ sub_pipes = []
- for p in sub_pipes:
- sub_pipe_id = f'{pipe.id}.{p["id"]}'
- sub_pipe_name = p["name"]
+ log.debug(
+ f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
+ )
- if hasattr(function_module, "name"):
- sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+ for p in sub_pipes:
+ sub_pipe_id = f'{pipe.id}.{p["id"]}'
+ sub_pipe_name = p["name"]
- pipe_flag = {"type": pipe.type}
+ if hasattr(function_module, "name"):
+ sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+
+ pipe_flag = {"type": pipe.type}
+
+ pipe_models.append(
+ {
+ "id": sub_pipe_id,
+ "name": sub_pipe_name,
+ "object": "model",
+ "created": pipe.created_at,
+ "owned_by": "openai",
+ "pipe": pipe_flag,
+ "has_user_valves": has_user_valves,
+ }
+ )
+ else:
+ pipe_flag = {"type": "pipe"}
+
+ log.debug(
+ f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
+ )
pipe_models.append(
{
- "id": sub_pipe_id,
- "name": sub_pipe_name,
+ "id": pipe.id,
+ "name": pipe.name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
+ "has_user_valves": has_user_valves,
}
)
- else:
- pipe_flag = {"type": "pipe"}
-
- log.debug(
- f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
- )
-
- pipe_models.append(
- {
- "id": pipe.id,
- "name": pipe.name,
- "object": "model",
- "created": pipe.created_at,
- "owned_by": "openai",
- "pipe": pipe_flag,
- }
- )
+ except Exception as e:
+ log.exception(e)
+ continue
return pipe_models
@@ -219,6 +242,16 @@ async def generate_function_chat_completion(
__task__ = metadata.get("task", None)
__task_body__ = metadata.get("task_body", None)
+ oauth_token = None
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+ except Exception as e:
+ log.error(f"Error getting OAuth token: {e}")
+
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
@@ -230,9 +263,10 @@ async def generate_function_chat_completion(
"__files__": files,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
+ "__oauth_token__": oauth_token,
"__request__": request,
}
- extra_params["__tools__"] = get_tools(
+ extra_params["__tools__"] = await get_tools(
request,
tool_ids,
user,
@@ -253,9 +287,7 @@ async def generate_function_chat_completion(
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
- )
+ form_data = apply_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/db.py b/backend/open_webui/internal/db.py
index 840f571cc9..b6913d87b0 100644
--- a/backend/open_webui/internal/db.py
+++ b/backend/open_webui/internal/db.py
@@ -1,3 +1,4 @@
+import os
import json
import logging
from contextlib import contextmanager
@@ -13,9 +14,10 @@ from open_webui.env import (
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
+ DATABASE_ENABLE_SQLITE_WAL,
)
from peewee_migrate import Router
-from sqlalchemy import Dialect, create_engine, MetaData, types
+from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool, NullPool
@@ -62,6 +64,9 @@ def handle_peewee_migration(DATABASE_URL):
except Exception as e:
log.error(f"Failed to initialize the database connection: {e}")
+ log.warning(
+ "Hint: If your database password contains special characters, you may need to URL-encode it."
+ )
raise
finally:
# Properly closing the database connection
@@ -76,25 +81,68 @@ handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
-if "sqlite" in SQLALCHEMY_DATABASE_URL:
+
+# Handle SQLCipher URLs
+if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
+ database_password = os.environ.get("DATABASE_PASSWORD")
+ if not database_password or database_password.strip() == "":
+ raise ValueError(
+ "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
+ )
+
+ # Extract database path from SQLCipher URL
+ db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
+ if db_path.startswith("/"):
+ db_path = db_path[1:] # Remove leading slash for relative paths
+
+ # Create a custom creator function that uses sqlcipher3
+ def create_sqlcipher_connection():
+ import sqlcipher3
+
+ conn = sqlcipher3.connect(db_path, check_same_thread=False)
+ conn.execute(f"PRAGMA key = '{database_password}'")
+ return conn
+
+ engine = create_engine(
+ "sqlite://", # Dummy URL since we're using creator
+ creator=create_sqlcipher_connection,
+ echo=False,
+ )
+
+ log.info("Connected to encrypted SQLite database using SQLCipher")
+
+elif "sqlite" in SQLALCHEMY_DATABASE_URL:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
+
+ def on_connect(dbapi_connection, connection_record):
+ cursor = dbapi_connection.cursor()
+ if DATABASE_ENABLE_SQLITE_WAL:
+ cursor.execute("PRAGMA journal_mode=WAL")
+ else:
+ cursor.execute("PRAGMA journal_mode=DELETE")
+ cursor.close()
+
+ event.listen(engine, "connect", on_connect)
else:
- if DATABASE_POOL_SIZE > 0:
- engine = create_engine(
- SQLALCHEMY_DATABASE_URL,
- pool_size=DATABASE_POOL_SIZE,
- max_overflow=DATABASE_POOL_MAX_OVERFLOW,
- pool_timeout=DATABASE_POOL_TIMEOUT,
- pool_recycle=DATABASE_POOL_RECYCLE,
- pool_pre_ping=True,
- poolclass=QueuePool,
- )
+ if isinstance(DATABASE_POOL_SIZE, int):
+ if DATABASE_POOL_SIZE > 0:
+ engine = create_engine(
+ SQLALCHEMY_DATABASE_URL,
+ pool_size=DATABASE_POOL_SIZE,
+ max_overflow=DATABASE_POOL_MAX_OVERFLOW,
+ pool_timeout=DATABASE_POOL_TIMEOUT,
+ pool_recycle=DATABASE_POOL_RECYCLE,
+ pool_pre_ping=True,
+ poolclass=QueuePool,
+ )
+ else:
+ engine = create_engine(
+ SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
+ )
else:
- engine = create_engine(
- SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
- )
+ engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(
diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py
index 5cf3529302..554a5effdd 100644
--- a/backend/open_webui/internal/wrappers.py
+++ b/backend/open_webui/internal/wrappers.py
@@ -1,4 +1,5 @@
import logging
+import os
from contextvars import ContextVar
from open_webui.env import SRC_LOG_LEVELS
@@ -43,24 +44,47 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url):
- db = connect(db_url, unquote_user=True, unquote_password=True)
- if isinstance(db, PostgresqlDatabase):
- # Enable autoconnect for SQLite databases, managed by Peewee
+ # Check if using SQLCipher protocol
+ if db_url.startswith("sqlite+sqlcipher://"):
+ database_password = os.environ.get("DATABASE_PASSWORD")
+ if not database_password or database_password.strip() == "":
+ raise ValueError(
+ "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
+ )
+ from playhouse.sqlcipher_ext import SqlCipherDatabase
+
+ # Parse the database path from SQLCipher URL
+ # Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
+ db_path = db_url.replace("sqlite+sqlcipher://", "")
+ if db_path.startswith("/"):
+ db_path = db_path[1:] # Remove leading slash for relative paths
+
+ # Use Peewee's native SqlCipherDatabase with encryption
+ db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
- log.info("Connected to PostgreSQL database")
+ log.info("Connected to encrypted SQLite database using SQLCipher")
- # Get the connection details
- connection = parse(db_url, unquote_user=True, unquote_password=True)
-
- # Use our custom database class that supports reconnection
- db = ReconnectingPostgresqlDatabase(**connection)
- db.connect(reuse_if_open=True)
- elif isinstance(db, SqliteDatabase):
- # Enable autoconnect for SQLite databases, managed by Peewee
- db.autoconnect = True
- db.reuse_if_open = True
- log.info("Connected to SQLite database")
else:
- raise ValueError("Unsupported database connection")
+ # Standard database connection (existing logic)
+ 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
+ db.reuse_if_open = True
+ log.info("Connected to PostgreSQL database")
+
+ # Get the connection details
+ connection = parse(db_url, unquote_user=True, unquote_password=True)
+
+ # Use our custom database class that supports reconnection
+ db = ReconnectingPostgresqlDatabase(**connection)
+ db.connect(reuse_if_open=True)
+ elif isinstance(db, SqliteDatabase):
+ # Enable autoconnect for SQLite databases, managed by Peewee
+ db.autoconnect = True
+ db.reuse_if_open = True
+ log.info("Connected to SQLite database")
+ else:
+ raise ValueError("Unsupported database connection")
return db
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index 544756a6e8..af8e670a53 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -8,6 +8,7 @@ import shutil
import sys
import time
import random
+import re
from uuid import uuid4
@@ -36,7 +37,6 @@ from fastapi import (
applications,
BackgroundTasks,
)
-
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.cors import CORSMiddleware
@@ -49,7 +49,13 @@ 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 starlette.datastructures import Headers
+from starsessions import (
+ SessionMiddleware as StarSessionsMiddleware,
+ SessionAutoloadMiddleware,
+)
+from starsessions.stores.redis import RedisStore
from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
@@ -57,6 +63,7 @@ from open_webui.utils.logger import start_logger
from open_webui.socket.main import (
app as socket_app,
periodic_usage_pool_cleanup,
+ get_event_emitter,
get_models_in_use,
get_active_user_ids,
)
@@ -85,10 +92,12 @@ from open_webui.routers import (
tools,
users,
utils,
+ scim,
)
from open_webui.routers.retrieval import (
get_embedding_function,
+ get_reranking_function,
get_ef,
get_rf,
)
@@ -101,21 +110,19 @@ 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,
+ # Model list
+ ENABLE_BASE_MODELS_CACHE,
# Thread pool size for FastAPI/AnyIO
THREAD_POOL_SIZE,
# Tool Server Configs
@@ -139,9 +146,7 @@ from open_webui.config import (
# Image
AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL,
- AUTOMATIC1111_CFG_SCALE,
- AUTOMATIC1111_SAMPLER,
- AUTOMATIC1111_SCHEDULER,
+ AUTOMATIC1111_PARAMS,
COMFYUI_BASE_URL,
COMFYUI_API_KEY,
COMFYUI_WORKFLOW,
@@ -153,9 +158,25 @@ from open_webui.config import (
IMAGE_SIZE,
IMAGE_STEPS,
IMAGES_OPENAI_API_BASE_URL,
+ IMAGES_OPENAI_API_VERSION,
IMAGES_OPENAI_API_KEY,
+ IMAGES_OPENAI_API_PARAMS,
IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY,
+ IMAGES_GEMINI_ENDPOINT_METHOD,
+ ENABLE_IMAGE_EDIT,
+ IMAGE_EDIT_ENGINE,
+ IMAGE_EDIT_MODEL,
+ IMAGE_EDIT_SIZE,
+ IMAGES_EDIT_OPENAI_API_BASE_URL,
+ IMAGES_EDIT_OPENAI_API_KEY,
+ IMAGES_EDIT_OPENAI_API_VERSION,
+ IMAGES_EDIT_GEMINI_API_BASE_URL,
+ IMAGES_EDIT_GEMINI_API_KEY,
+ IMAGES_EDIT_COMFYUI_BASE_URL,
+ IMAGES_EDIT_COMFYUI_API_KEY,
+ IMAGES_EDIT_COMFYUI_WORKFLOW,
+ IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
# Audio
AUDIO_STT_ENGINE,
AUDIO_STT_MODEL,
@@ -167,13 +188,17 @@ from open_webui.config import (
AUDIO_STT_AZURE_LOCALES,
AUDIO_STT_AZURE_BASE_URL,
AUDIO_STT_AZURE_MAX_SPEAKERS,
- AUDIO_TTS_API_KEY,
+ AUDIO_STT_MISTRAL_API_KEY,
+ AUDIO_STT_MISTRAL_API_BASE_URL,
+ AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
AUDIO_TTS_ENGINE,
AUDIO_TTS_MODEL,
+ AUDIO_TTS_VOICE,
AUDIO_TTS_OPENAI_API_BASE_URL,
AUDIO_TTS_OPENAI_API_KEY,
+ AUDIO_TTS_OPENAI_PARAMS,
+ AUDIO_TTS_API_KEY,
AUDIO_TTS_SPLIT_ON,
- AUDIO_TTS_VOICE,
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_BASE_URL,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
@@ -182,6 +207,7 @@ from open_webui.config import (
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
WEB_LOADER_ENGINE,
+ WEB_LOADER_CONCURRENT_REQUESTS,
WHISPER_MODEL,
WHISPER_VAD_FILTER,
WHISPER_LANGUAGE,
@@ -204,6 +230,7 @@ from open_webui.config import (
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_BATCH_SIZE,
+ ENABLE_ASYNC_EMBEDDING,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_RELEVANCE_THRESHOLD,
@@ -224,26 +251,29 @@ from open_webui.config import (
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
- DATALAB_MARKER_LANGS,
+ DATALAB_MARKER_API_BASE_URL,
+ DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
+ DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_OUTPUT_FORMAT,
+ MINERU_API_MODE,
+ MINERU_API_URL,
+ MINERU_API_KEY,
+ MINERU_PARAMS,
DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
- DOCLING_OCR_ENGINE,
- DOCLING_OCR_LANG,
- DOCLING_DO_PICTURE_DESCRIPTION,
- DOCLING_PICTURE_DESCRIPTION_MODE,
- DOCLING_PICTURE_DESCRIPTION_LOCAL,
- DOCLING_PICTURE_DESCRIPTION_API,
+ DOCLING_API_KEY,
+ DOCLING_PARAMS,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
+ MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY,
RAG_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
@@ -259,6 +289,7 @@ from open_webui.config import (
WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV,
WEB_SEARCH_DOMAIN_FILTER_LIST,
+ OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
@@ -281,6 +312,7 @@ from open_webui.config import (
PERPLEXITY_API_KEY,
PERPLEXITY_MODEL,
PERPLEXITY_SEARCH_CONTEXT_USAGE,
+ PERPLEXITY_SEARCH_API_URL,
SOUGOU_API_SID,
SOUGOU_API_SK,
KAGI_SEARCH_API_KEY,
@@ -290,14 +322,18 @@ from open_webui.config import (
GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
- ONEDRIVE_CLIENT_ID,
+ ENABLE_ONEDRIVE_INTEGRATION,
+ ONEDRIVE_CLIENT_ID_PERSONAL,
+ ONEDRIVE_CLIENT_ID_BUSINESS,
ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID,
+ ENABLE_ONEDRIVE_PERSONAL,
+ ENABLE_ONEDRIVE_BUSINESS,
ENABLE_RAG_HYBRID_SEARCH,
+ ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_WEB_LOADER_SSL_VERIFICATION,
ENABLE_GOOGLE_DRIVE_INTEGRATION,
- ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR,
EXTERNAL_WEB_SEARCH_URL,
EXTERNAL_WEB_SEARCH_API_KEY,
@@ -313,21 +349,24 @@ from open_webui.config import (
JWT_EXPIRES_IN,
ENABLE_SIGNUP,
ENABLE_LOGIN_FORM,
- ENABLE_API_KEY,
- ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
- API_KEY_ALLOWED_ENDPOINTS,
+ ENABLE_API_KEYS,
+ ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
+ API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
ENABLE_EVALUATION_ARENA_MODELS,
+ BYPASS_ADMIN_ACCESS_CONTROL,
USER_PERMISSIONS,
DEFAULT_USER_ROLE,
+ DEFAULT_GROUP_ID,
PENDING_USER_OVERLAY_CONTENT,
PENDING_USER_OVERLAY_TITLE,
DEFAULT_PROMPT_SUGGESTIONS,
DEFAULT_MODELS,
+ DEFAULT_PINNED_MODELS,
DEFAULT_ARENA_MODEL,
MODEL_ORDER_LIST,
EVALUATION_ARENA_MODELS,
@@ -370,6 +409,7 @@ from open_webui.config import (
RESPONSE_WATERMARK,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
+ BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_EXPORT,
# Tasks
TASK_MODEL,
@@ -385,6 +425,7 @@ from open_webui.config import (
TAGS_GENERATION_PROMPT_TEMPLATE,
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
+ VOICE_MODE_PROMPT_TEMPLATE,
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
@@ -392,10 +433,13 @@ from open_webui.config import (
reset_config,
)
from open_webui.env import (
+ LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
CHANGELOG,
REDIS_URL,
+ REDIS_CLUSTER,
+ REDIS_KEY_PREFIX,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_PORT,
GLOBAL_LOG_LEVEL,
@@ -403,21 +447,28 @@ from open_webui.env import (
SAFE_MODE,
SRC_LOG_LEVELS,
VERSION,
+ DEPLOYMENT_ID,
INSTANCE_ID,
WEBUI_BUILD_HASH,
WEBUI_SECRET_KEY,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
+ ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
+ # SCIM
+ ENABLE_SCIM,
+ SCIM_TOKEN,
+ ENABLE_COMPRESSION_MIDDLEWARE,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
- OFFLINE_MODE,
+ ENABLE_VERSION_UPDATE_CHECK,
ENABLE_OTEL,
EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL,
+ ENABLE_STAR_SESSIONS_MIDDLEWARE,
)
@@ -425,6 +476,7 @@ from open_webui.utils.models import (
get_all_models,
get_all_base_models,
check_model_access,
+ get_filtered_models,
)
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
@@ -443,13 +495,21 @@ from open_webui.utils.auth import (
get_verified_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
-from open_webui.utils.oauth import OAuthManager
+from open_webui.utils.oauth import (
+ get_oauth_client_info_with_dynamic_client_registration,
+ encrypt_data,
+ decrypt_data,
+ OAuthManager,
+ OAuthClientManager,
+ OAuthClientInformationFull,
+)
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.redis import get_redis_connection
from open_webui.tasks import (
redis_task_command_listener,
- list_task_ids_by_chat_id,
+ list_task_ids_by_item_id,
+ create_task,
stop_task,
list_tasks,
) # Import from tasks.py
@@ -457,6 +517,9 @@ from open_webui.tasks import (
from open_webui.utils.redis import get_sentinels_from_env
+from open_webui.constants import ERROR_MESSAGES
+
+
if SAFE_MODE:
print("SAFE MODE ENABLED")
Functions.deactivate_all_functions()
@@ -519,6 +582,7 @@ async def lifespan(app: FastAPI):
redis_sentinels=get_sentinels_from_env(
REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT
),
+ redis_cluster=REDIS_CLUSTER,
async_mode=True,
)
@@ -533,6 +597,27 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup())
+ if app.state.config.ENABLE_BASE_MODELS_CACHE:
+ await get_all_models(
+ Request(
+ # Creating a mock request object to pass to get_all_models
+ {
+ "type": "http",
+ "asgi.version": "3.0",
+ "asgi.spec_version": "2.0",
+ "method": "GET",
+ "path": "/internal",
+ "query_string": b"",
+ "headers": Headers({}).raw,
+ "client": ("127.0.0.1", 12345),
+ "server": ("127.0.0.1", 80),
+ "scheme": "http",
+ "app": app,
+ }
+ ),
+ None,
+ )
+
yield
if hasattr(app.state, "redis_task_command_listener"):
@@ -547,12 +632,20 @@ app = FastAPI(
lifespan=lifespan,
)
+# For Open WebUI OIDC/OAuth2
oauth_manager = OAuthManager(app)
+app.state.oauth_manager = oauth_manager
+
+# For Integrations
+oauth_client_manager = OAuthClientManager(app)
+app.state.oauth_client_manager = oauth_client_manager
app.state.instance_id = None
app.state.config = AppConfig(
redis_url=REDIS_URL,
redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT),
+ redis_cluster=REDIS_CLUSTER,
+ redis_key_prefix=REDIS_KEY_PREFIX,
)
app.state.redis = None
@@ -615,6 +708,24 @@ app.state.TOOL_SERVERS = []
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
+########################################
+#
+# SCIM
+#
+########################################
+
+app.state.ENABLE_SCIM = ENABLE_SCIM
+app.state.SCIM_TOKEN = SCIM_TOKEN
+
+########################################
+#
+# MODELS
+#
+########################################
+
+app.state.config.ENABLE_BASE_MODELS_CACHE = ENABLE_BASE_MODELS_CACHE
+app.state.BASE_MODELS = []
+
########################################
#
# WEBUI
@@ -625,11 +736,11 @@ app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
-app.state.config.ENABLE_API_KEY = ENABLE_API_KEY
-app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
- ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
+app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS
+app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = (
+ ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
)
-app.state.config.API_KEY_ALLOWED_ENDPOINTS = API_KEY_ALLOWED_ENDPOINTS
+app.state.config.API_KEYS_ALLOWED_ENDPOINTS = API_KEYS_ALLOWED_ENDPOINTS
app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
@@ -638,8 +749,13 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
+app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS
+app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
+
+
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
+app.state.config.DEFAULT_GROUP_ID = DEFAULT_GROUP_ID
app.state.config.PENDING_USER_OVERLAY_CONTENT = PENDING_USER_OVERLAY_CONTENT
app.state.config.PENDING_USER_OVERLAY_TITLE = PENDING_USER_OVERLAY_TITLE
@@ -649,7 +765,6 @@ 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
-app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
@@ -727,11 +842,15 @@ app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = FILE_IMAGE_COMPRESSION_HEIGHT
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_HYBRID_SEARCH_ENRICHED_TEXTS = (
+ ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS
+)
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_API_BASE_URL = DATALAB_MARKER_API_BASE_URL
+app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = DATALAB_MARKER_ADDITIONAL_CONFIG
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
@@ -739,21 +858,23 @@ app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTI
app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = (
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION
)
+app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES
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.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
-app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
-app.state.config.DOCLING_PICTURE_DESCRIPTION_API = DOCLING_PICTURE_DESCRIPTION_API
+app.state.config.DOCLING_API_KEY = DOCLING_API_KEY
+app.state.config.DOCLING_PARAMS = DOCLING_PARAMS
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
+app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
+app.state.config.MINERU_API_MODE = MINERU_API_MODE
+app.state.config.MINERU_API_URL = MINERU_API_URL
+app.state.config.MINERU_API_KEY = MINERU_API_KEY
+app.state.config.MINERU_PARAMS = MINERU_PARAMS
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
@@ -764,6 +885,7 @@ 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.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING
app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
@@ -793,7 +915,10 @@ 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_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
+
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
@@ -802,6 +927,8 @@ 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.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
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
@@ -828,6 +955,7 @@ app.state.config.EXA_API_KEY = EXA_API_KEY
app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
app.state.config.PERPLEXITY_MODEL = PERPLEXITY_MODEL
app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = PERPLEXITY_SEARCH_CONTEXT_USAGE
+app.state.config.PERPLEXITY_SEARCH_API_URL = PERPLEXITY_SEARCH_API_URL
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
@@ -843,6 +971,7 @@ 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.RERANKING_FUNCTION = None
app.state.ef = None
app.state.rf = None
@@ -855,14 +984,19 @@ try:
app.state.config.RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
)
-
- 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,
- )
+ if (
+ app.state.config.ENABLE_RAG_HYBRID_SEARCH
+ and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
+ ):
+ 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,
+ )
+ else:
+ app.state.rf = None
except Exception as e:
log.error(f"Error updating models: {e}")
pass
@@ -871,8 +1005,8 @@ except Exception as e:
app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.RAG_EMBEDDING_ENGINE,
app.state.config.RAG_EMBEDDING_MODEL,
- app.state.ef,
- (
+ embedding_function=app.state.ef,
+ url=(
app.state.config.RAG_OPENAI_API_BASE_URL
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
else (
@@ -881,7 +1015,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
else app.state.config.RAG_AZURE_OPENAI_BASE_URL
)
),
- (
+ key=(
app.state.config.RAG_OPENAI_API_KEY
if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
else (
@@ -890,7 +1024,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
else app.state.config.RAG_AZURE_OPENAI_API_KEY
)
),
- app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ embedding_batch_size=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"
@@ -898,6 +1032,12 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
),
)
+app.state.RERANKING_FUNCTION = get_reranking_function(
+ app.state.config.RAG_RERANKING_ENGINE,
+ app.state.config.RAG_RERANKING_MODEL,
+ reranking_function=app.state.rf,
+)
+
########################################
#
# CODE EXECUTION
@@ -938,26 +1078,42 @@ app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
+app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
+app.state.config.IMAGE_SIZE = IMAGE_SIZE
+app.state.config.IMAGE_STEPS = IMAGE_STEPS
+
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
+app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
+app.state.config.IMAGES_OPENAI_API_PARAMS = IMAGES_OPENAI_API_PARAMS
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
app.state.config.IMAGES_GEMINI_API_KEY = IMAGES_GEMINI_API_KEY
-
-app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
+app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = IMAGES_GEMINI_ENDPOINT_METHOD
app.state.config.AUTOMATIC1111_BASE_URL = AUTOMATIC1111_BASE_URL
app.state.config.AUTOMATIC1111_API_AUTH = AUTOMATIC1111_API_AUTH
-app.state.config.AUTOMATIC1111_CFG_SCALE = AUTOMATIC1111_CFG_SCALE
-app.state.config.AUTOMATIC1111_SAMPLER = AUTOMATIC1111_SAMPLER
-app.state.config.AUTOMATIC1111_SCHEDULER = AUTOMATIC1111_SCHEDULER
+app.state.config.AUTOMATIC1111_PARAMS = AUTOMATIC1111_PARAMS
+
app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL
app.state.config.COMFYUI_API_KEY = COMFYUI_API_KEY
app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW
app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES
-app.state.config.IMAGE_SIZE = IMAGE_SIZE
-app.state.config.IMAGE_STEPS = IMAGE_STEPS
+
+app.state.config.ENABLE_IMAGE_EDIT = ENABLE_IMAGE_EDIT
+app.state.config.IMAGE_EDIT_ENGINE = IMAGE_EDIT_ENGINE
+app.state.config.IMAGE_EDIT_MODEL = IMAGE_EDIT_MODEL
+app.state.config.IMAGE_EDIT_SIZE = IMAGE_EDIT_SIZE
+app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = IMAGES_EDIT_OPENAI_API_BASE_URL
+app.state.config.IMAGES_EDIT_OPENAI_API_KEY = IMAGES_EDIT_OPENAI_API_KEY
+app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = IMAGES_EDIT_OPENAI_API_VERSION
+app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = IMAGES_EDIT_GEMINI_API_BASE_URL
+app.state.config.IMAGES_EDIT_GEMINI_API_KEY = IMAGES_EDIT_GEMINI_API_KEY
+app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = IMAGES_EDIT_COMFYUI_BASE_URL
+app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = IMAGES_EDIT_COMFYUI_API_KEY
+app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = IMAGES_EDIT_COMFYUI_WORKFLOW
+app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFLOW_NODES
########################################
@@ -983,11 +1139,21 @@ 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.AUDIO_STT_MISTRAL_API_KEY = AUDIO_STT_MISTRAL_API_KEY
+app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = AUDIO_STT_MISTRAL_API_BASE_URL
+app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = (
+ AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
+)
+
app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE
+
app.state.config.TTS_MODEL = AUDIO_TTS_MODEL
app.state.config.TTS_VOICE = AUDIO_TTS_VOICE
+
+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_OPENAI_PARAMS = AUDIO_TTS_OPENAI_PARAMS
+
app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY
app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON
@@ -1040,6 +1206,7 @@ app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = (
app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = (
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH
)
+app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE
########################################
@@ -1050,6 +1217,10 @@ app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = (
app.state.MODELS = {}
+# Add the middleware to the app
+if ENABLE_COMPRESSION_MIDDLEWARE:
+ app.add_middleware(CompressMiddleware)
+
class RedirectMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
@@ -1058,12 +1229,32 @@ class RedirectMiddleware(BaseHTTPMiddleware):
path = request.url.path
query_params = dict(parse_qs(urlparse(str(request.url)).query))
+ redirect_params = {}
+
# Check for the specific watch path and the presence of 'v' parameter
if path.endswith("/watch") and "v" in query_params:
# Extract the first 'v' parameter
- video_id = query_params["v"][0]
- encoded_video_id = urlencode({"youtube": video_id})
- redirect_url = f"/?{encoded_video_id}"
+ youtube_video_id = query_params["v"][0]
+ redirect_params["youtube"] = youtube_video_id
+
+ if "shared" in query_params and len(query_params["shared"]) > 0:
+ # PWA share_target support
+
+ text = query_params["shared"][0]
+ if text:
+ urls = re.match(r"https://\S+", text)
+ if urls:
+ from open_webui.retrieval.loaders.youtube import _parse_video_id
+
+ if youtube_video_id := _parse_video_id(urls[0]):
+ redirect_params["youtube"] = youtube_video_id
+ else:
+ redirect_params["load-url"] = urls[0]
+ else:
+ redirect_params["q"] = text
+
+ if redirect_params:
+ redirect_url = f"/?{urlencode(redirect_params)}"
return RedirectResponse(url=redirect_url)
# Proceed with the normal flow of other requests
@@ -1071,12 +1262,53 @@ class RedirectMiddleware(BaseHTTPMiddleware):
return response
-# Add the middleware to the app
-app.add_middleware(CompressMiddleware)
app.add_middleware(RedirectMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
+class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ auth_header = request.headers.get("Authorization")
+ token = None
+
+ if auth_header:
+ scheme, token = auth_header.split(" ")
+
+ # Only apply restrictions if an sk- API key is used
+ if token and token.startswith("sk-"):
+ # Check if restrictions are enabled
+ if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
+ allowed_paths = [
+ path.strip()
+ for path in str(
+ request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS
+ ).split(",")
+ if path.strip()
+ ]
+
+ request_path = request.url.path
+
+ # Match exact path or prefix path
+ is_allowed = any(
+ request_path == allowed or request_path.startswith(allowed + "/")
+ for allowed in allowed_paths
+ )
+
+ if not is_allowed:
+ return JSONResponse(
+ status_code=status.HTTP_403_FORBIDDEN,
+ content={
+ "detail": "API key not allowed to access this endpoint."
+ },
+ )
+
+ response = await call_next(request)
+ return response
+
+
+app.add_middleware(APIKeyRestrictionMiddleware)
+
+
@app.middleware("http")
async def commit_session_after_request(request: Request, call_next):
response = await call_next(request)
@@ -1092,7 +1324,7 @@ async def check_url(request: Request, call_next):
request.headers.get("Authorization")
)
- request.state.enable_api_key = app.state.config.ENABLE_API_KEY
+ request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
process_time = int(time.time()) - start_time
response.headers["X-Process-Time"] = str(process_time)
@@ -1166,6 +1398,10 @@ app.include_router(
)
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
+# SCIM 2.0 API for identity management
+if ENABLE_SCIM:
+ app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["scim"])
+
try:
audit_level = AuditLevel(AUDIT_LOG_LEVEL)
@@ -1188,31 +1424,11 @@ if audit_level != AuditLevel.NONE:
@app.get("/api/models")
-async def get_models(request: Request, user=Depends(get_verified_user)):
- def get_filtered_models(models, user):
- filtered_models = []
- for model in models:
- if model.get("arena"):
- if has_access(
- user.id,
- type="read",
- access_control=model.get("info", {})
- .get("meta", {})
- .get("access_control", {}),
- ):
- filtered_models.append(model)
- continue
-
- model_info = Models.get_model_by_id(model["id"])
- if model_info:
- if user.id == model_info.user_id or has_access(
- user.id, type="read", access_control=model_info.access_control
- ):
- filtered_models.append(model)
-
- return filtered_models
-
- all_models = await get_all_models(request, user=user)
+@app.get("/api/v1/models") # Experimental: Compatibility with OpenAI API
+async def get_models(
+ request: Request, refresh: bool = False, user=Depends(get_verified_user)
+):
+ all_models = await get_all_models(request, refresh=refresh, user=user)
models = []
for model in all_models:
@@ -1220,6 +1436,10 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
continue
+ # Remove profile image URL to reduce payload size
+ if model.get("info", {}).get("meta", {}).get("profile_image_url"):
+ model["info"]["meta"].pop("profile_image_url", None)
+
try:
model_tags = [
tag.get("name")
@@ -1241,15 +1461,16 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)}
# Sort models by order list priority, with fallback for those not in the list
models.sort(
- key=lambda x: (model_order_dict.get(x["id"], float("inf")), x["name"])
+ key=lambda model: (
+ model_order_dict.get(model.get("id", ""), float("inf")),
+ (model.get("name", "") or ""),
+ )
)
- # Filter out models that the user does not have access to
- if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
- models = get_filtered_models(models, user)
+ models = get_filtered_models(models, user)
log.debug(
- f"/api/models returned filtered models accessible to the user: {json.dumps([model['id'] for model in models])}"
+ f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
)
return {"data": models}
@@ -1266,6 +1487,7 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
@app.post("/api/embeddings")
+@app.post("/api/v1/embeddings") # Experimental: Compatibility with OpenAI API
async def embeddings(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
@@ -1292,6 +1514,7 @@ async def embeddings(
@app.post("/api/chat/completions")
+@app.post("/api/v1/chat/completions") # Experimental: Compatibility with OpenAI API
async def chat_completion(
request: Request,
form_data: dict,
@@ -1300,13 +1523,13 @@ async def chat_completion(
if not request.app.state.MODELS:
await get_all_models(request, user=user)
+ model_id = form_data.get("model", None)
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)
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
@@ -1314,7 +1537,9 @@ async def chat_completion(
model_info = Models.get_model_by_id(model_id)
# Check if user has access to the model
- if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
+ if not BYPASS_MODEL_ACCESS_CONTROL and (
+ user.role != "admin" or not BYPASS_ADMIN_ACCESS_CONTROL
+ ):
try:
check_model_access(user, model)
except Exception as e:
@@ -1326,6 +1551,26 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
+ model_info_params = (
+ model_info.params.model_dump() if model_info and model_info.params else {}
+ )
+
+ # Chat Params
+ stream_delta_chunk_size = form_data.get("params", {}).get(
+ "stream_delta_chunk_size"
+ )
+ reasoning_tags = form_data.get("params", {}).get("reasoning_tags")
+
+ # Model Params
+ if model_info_params.get("stream_response") is not None:
+ form_data["stream"] = model_info_params.get("stream_response")
+
+ if model_info_params.get("stream_delta_chunk_size"):
+ stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size")
+
+ if model_info_params.get("reasoning_tags") is not None:
+ reasoning_tags = model_info_params.get("reasoning_tags")
+
metadata = {
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
@@ -1339,64 +1584,125 @@ async def chat_completion(
"variables": form_data.get("variables", {}),
"model": model,
"direct": model_item.get("direct", False),
- **(
- {"function_calling": "native"}
- if form_data.get("params", {}).get("function_calling") == "native"
- or (
- model_info
- and model_info.params.model_dump().get("function_calling")
- == "native"
- )
- else {}
- ),
+ "params": {
+ "stream_delta_chunk_size": stream_delta_chunk_size,
+ "reasoning_tags": reasoning_tags,
+ "function_calling": (
+ "native"
+ if (
+ form_data.get("params", {}).get("function_calling") == "native"
+ or model_info_params.get("function_calling") == "native"
+ )
+ else "default"
+ ),
+ },
}
+ if metadata.get("chat_id") and (user and user.role != "admin"):
+ if not metadata["chat_id"].startswith("local:"):
+ chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
+ if chat is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=ERROR_MESSAGES.DEFAULT(),
+ )
+
request.state.metadata = metadata
form_data["metadata"] = metadata
- form_data, metadata, events = await process_chat_payload(
- 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)},
- },
- )
-
+ log.debug(f"Error processing chat metadata: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
- try:
- response = await chat_completion_handler(request, form_data, user)
-
- return await process_chat_response(
- request, response, form_data, user, metadata, model, events, tasks
- )
- except Exception as e:
- log.debug(f"Error in chat completion: {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)},
- },
+ async def process_chat(request, form_data, user, metadata, model):
+ try:
+ form_data, metadata, events = await process_chat_payload(
+ request, form_data, user, metadata, model
)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(e),
+ response = await chat_completion_handler(request, form_data, user)
+ if metadata.get("chat_id") and metadata.get("message_id"):
+ try:
+ if not metadata["chat_id"].startswith("local:"):
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "model": model_id,
+ },
+ )
+ except:
+ pass
+
+ return await process_chat_response(
+ request, response, form_data, user, metadata, model, events, tasks
+ )
+ except asyncio.CancelledError:
+ log.info("Chat processing was cancelled")
+ try:
+ event_emitter = get_event_emitter(metadata)
+ await asyncio.shield(
+ event_emitter(
+ {"type": "chat:tasks:cancel"},
+ )
+ )
+ except Exception as e:
+ pass
+ finally:
+ raise # re-raise to ensure proper task cancellation handling
+ except Exception as e:
+ log.debug(f"Error processing chat payload: {e}")
+ if metadata.get("chat_id") and metadata.get("message_id"):
+ # Update the chat message with the error
+ try:
+ if not metadata["chat_id"].startswith("local:"):
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "error": {"content": str(e)},
+ },
+ )
+
+ event_emitter = get_event_emitter(metadata)
+ await event_emitter(
+ {
+ "type": "chat:message:error",
+ "data": {"error": {"content": str(e)}},
+ }
+ )
+ await event_emitter(
+ {"type": "chat:tasks:cancel"},
+ )
+
+ except:
+ pass
+ finally:
+ try:
+ if mcp_clients := metadata.get("mcp_clients"):
+ for client in reversed(mcp_clients.values()):
+ await client.disconnect()
+ except Exception as e:
+ log.debug(f"Error cleaning up: {e}")
+ pass
+
+ if (
+ metadata.get("session_id")
+ and metadata.get("chat_id")
+ and metadata.get("message_id")
+ ):
+ # Asynchronous Chat Processing
+ task_id, _ = await create_task(
+ request.app.state.redis,
+ process_chat(request, form_data, user, metadata, model),
+ id=metadata["chat_id"],
)
+ return {"status": True, "task_id": task_id}
+ else:
+ return await process_chat(request, form_data, user, metadata, model)
# Alias for chat_completion (Legacy)
@@ -1447,7 +1753,7 @@ async def stop_task_endpoint(
request: Request, task_id: str, user=Depends(get_verified_user)
):
try:
- result = await stop_task(request, task_id)
+ result = await stop_task(request.app.state.redis, task_id)
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
@@ -1455,7 +1761,7 @@ async def stop_task_endpoint(
@app.get("/api/tasks")
async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)):
- return {"tasks": await list_tasks(request)}
+ return {"tasks": await list_tasks(request.app.state.redis)}
@app.get("/api/tasks/chat/{chat_id}")
@@ -1466,9 +1772,9 @@ async def list_tasks_by_chat_id_endpoint(
if chat is None or chat.user_id != user.id:
return {"task_ids": []}
- task_ids = await list_task_ids_by_chat_id(request, chat_id)
+ task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id)
- print(f"Task IDs for chat {chat_id}: {task_ids}")
+ log.debug(f"Task IDs for chat {chat_id}: {task_ids}")
return {"task_ids": task_ids}
@@ -1482,8 +1788,18 @@ async def list_tasks_by_chat_id_endpoint(
@app.get("/api/config")
async def get_app_config(request: Request):
user = None
- if "token" in request.cookies:
+ token = None
+
+ auth_header = request.headers.get("Authorization")
+ if auth_header:
+ cred = get_http_authorization_cred(auth_header)
+ if cred:
+ token = cred.credentials
+
+ if not token and "token" in request.cookies:
token = request.cookies.get("token")
+
+ if token:
try:
data = decode_token(token)
except Exception as e:
@@ -1516,11 +1832,13 @@ async def get_app_config(request: Request):
"features": {
"auth": WEBUI_AUTH,
"auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER),
+ "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
"enable_ldap": app.state.config.ENABLE_LDAP,
- "enable_api_key": app.state.config.ENABLE_API_KEY,
+ "enable_api_keys": app.state.config.ENABLE_API_KEYS,
"enable_signup": app.state.config.ENABLE_SIGNUP,
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
+ "enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
@@ -1538,6 +1856,14 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
+ **(
+ {
+ "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
+ "enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
+ }
+ if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
+ else {}
+ ),
}
if user is not None
else {}
@@ -1546,6 +1872,7 @@ async def get_app_config(request: Request):
**(
{
"default_models": app.state.config.DEFAULT_MODELS,
+ "default_pinned_models": app.state.config.DEFAULT_PINNED_MODELS,
"default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS,
"user_count": user_count,
"code": {
@@ -1575,7 +1902,8 @@ async def get_app_config(request: Request):
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {
- "client_id": ONEDRIVE_CLIENT_ID.value,
+ "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
+ "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
},
@@ -1593,8 +1921,33 @@ async def get_app_config(request: Request):
else {}
),
}
- if user is not None
- else {}
+ if user is not None and (user.role in ["admin", "user"])
+ else {
+ **(
+ {
+ "ui": {
+ "pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE,
+ "pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT,
+ }
+ }
+ if user and user.role == "pending"
+ else {}
+ ),
+ **(
+ {
+ "metadata": {
+ "login_footer": app.state.LICENSE_METADATA.get(
+ "login_footer", ""
+ ),
+ "auth_logo_position": app.state.LICENSE_METADATA.get(
+ "auth_logo_position", ""
+ ),
+ }
+ }
+ if app.state.LICENSE_METADATA
+ else {}
+ ),
+ }
),
}
@@ -1621,14 +1974,15 @@ async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)):
async def get_app_version():
return {
"version": VERSION,
+ "deployment_id": DEPLOYMENT_ID,
}
@app.get("/api/version/updates")
async def get_app_latest_release_version(user=Depends(get_verified_user)):
- if OFFLINE_MODE:
+ if not ENABLE_VERSION_UPDATE_CHECK:
log.debug(
- f"Offline mode is enabled, returning current version as latest version"
+ f"Version update check is disabled, returning current version as latest version"
)
return {"current": VERSION, "latest": VERSION}
try:
@@ -1670,17 +2024,177 @@ async def get_current_usage(user=Depends(get_verified_user)):
# OAuth Login & Callback
############################
-# SessionMiddleware is used by authlib for oauth
-if len(OAUTH_PROVIDERS) > 0:
+
+# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
+if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
+ for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
+ if tool_server_connection.get("type", "openapi") == "mcp":
+ server_id = tool_server_connection.get("info", {}).get("id")
+ auth_type = tool_server_connection.get("auth_type", "none")
+
+ if server_id and auth_type == "oauth_2.1":
+ oauth_client_info = tool_server_connection.get("info", {}).get(
+ "oauth_client_info", ""
+ )
+
+ try:
+ oauth_client_info = decrypt_data(oauth_client_info)
+ app.state.oauth_client_manager.add_client(
+ f"mcp:{server_id}",
+ OAuthClientInformationFull(**oauth_client_info),
+ )
+ except Exception as e:
+ log.error(
+ f"Error adding OAuth client for MCP tool server {server_id}: {e}"
+ )
+ pass
+
+try:
+ if ENABLE_STAR_SESSIONS_MIDDLEWARE:
+ redis_session_store = RedisStore(
+ url=REDIS_URL,
+ prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
+ )
+
+ app.add_middleware(SessionAutoloadMiddleware)
+ app.add_middleware(
+ StarSessionsMiddleware,
+ store=redis_session_store,
+ cookie_name="owui-session",
+ cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
+ cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
+ )
+ log.info("Using Redis for session")
+ else:
+ raise ValueError("No Redis URL provided")
+except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
- session_cookie="oui-session",
+ session_cookie="owui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)
+async def register_client(self, request, client_id: str) -> bool:
+ server_type, server_id = client_id.split(":", 1)
+
+ connection = None
+ connection_idx = None
+
+ for idx, conn in enumerate(request.app.state.config.TOOL_SERVER_CONNECTIONS or []):
+ if conn.get("type", "openapi") == server_type:
+ info = conn.get("info", {})
+ if info.get("id") == server_id:
+ connection = conn
+ connection_idx = idx
+ break
+
+ if connection is None or connection_idx is None:
+ log.warning(
+ f"Unable to locate MCP tool server configuration for client {client_id} during re-registration"
+ )
+ return False
+
+ server_url = connection.get("url")
+ oauth_server_key = (connection.get("config") or {}).get("oauth_server_key")
+
+ try:
+ oauth_client_info = (
+ await get_oauth_client_info_with_dynamic_client_registration(
+ request,
+ client_id,
+ server_url,
+ oauth_server_key,
+ )
+ )
+ except Exception as e:
+ log.error(f"Dynamic client re-registration failed for {client_id}: {e}")
+ return False
+
+ try:
+ request.app.state.config.TOOL_SERVER_CONNECTIONS[connection_idx] = {
+ **connection,
+ "info": {
+ **connection.get("info", {}),
+ "oauth_client_info": encrypt_data(
+ oauth_client_info.model_dump(mode="json")
+ ),
+ },
+ }
+ except Exception as e:
+ log.error(
+ f"Failed to persist updated OAuth client info for tool server {client_id}: {e}"
+ )
+ return False
+
+ oauth_client_manager.remove_client(client_id)
+ oauth_client_manager.add_client(client_id, oauth_client_info)
+ log.info(f"Re-registered OAuth client {client_id} for tool server")
+ return True
+
+
+@app.get("/oauth/clients/{client_id}/authorize")
+async def oauth_client_authorize(
+ client_id: str,
+ request: Request,
+ response: Response,
+ user=Depends(get_verified_user),
+):
+ # ensure_valid_client_registration
+ client = oauth_client_manager.get_client(client_id)
+ client_info = oauth_client_manager.get_client_info(client_id)
+ if client is None or client_info is None:
+ raise HTTPException(status.HTTP_404_NOT_FOUND)
+
+ if not await oauth_client_manager._preflight_authorization_url(client, client_info):
+ log.info(
+ "Detected invalid OAuth client %s; attempting re-registration",
+ client_id,
+ )
+
+ registered = await register_client(request, client_id)
+ if not registered:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to re-register OAuth client",
+ )
+
+ client = oauth_client_manager.get_client(client_id)
+ client_info = oauth_client_manager.get_client_info(client_id)
+ if client is None or client_info is None:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="OAuth client unavailable after re-registration",
+ )
+
+ if not await oauth_client_manager._preflight_authorization_url(
+ client, client_info
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="OAuth client registration is still invalid after re-registration",
+ )
+
+ return await oauth_client_manager.handle_authorize(request, client_id=client_id)
+
+
+@app.get("/oauth/clients/{client_id}/callback")
+async def oauth_client_callback(
+ client_id: str,
+ request: Request,
+ response: Response,
+ user=Depends(get_verified_user),
+):
+ return await oauth_client_manager.handle_callback(
+ request,
+ client_id=client_id,
+ user_id=user.id if user else None,
+ response=response,
+ )
+
+
@app.get("/oauth/{provider}/login")
async def oauth_login(provider: str, request: Request):
return await oauth_manager.handle_login(request, provider)
@@ -1692,8 +2206,9 @@ async def oauth_login(provider: str, request: Request):
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
# - 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):
+@app.get("/oauth/{provider}/login/callback")
+@app.get("/oauth/{provider}/callback") # Legacy endpoint
+async def oauth_login_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(request, provider, response)
@@ -1705,11 +2220,10 @@ async def get_manifest_json():
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.",
+ "description": f"{app.state.WEBUI_NAME} 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",
@@ -1724,6 +2238,11 @@ async def get_manifest_json():
"purpose": "maskable",
},
],
+ "share_target": {
+ "action": "/",
+ "method": "GET",
+ "params": {"text": "shared"},
+ },
}
diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py
index 1288816471..7db9251282 100644
--- a/backend/open_webui/migrations/env.py
+++ b/backend/open_webui/migrations/env.py
@@ -2,8 +2,8 @@ from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
-from open_webui.env import DATABASE_URL
-from sqlalchemy import engine_from_config, pool
+from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
+from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -62,11 +62,38 @@ def run_migrations_online() -> None:
and associate a connection with the context.
"""
- connectable = engine_from_config(
- config.get_section(config.config_ini_section, {}),
- prefix="sqlalchemy.",
- poolclass=pool.NullPool,
- )
+ # Handle SQLCipher URLs
+ if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
+ if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
+ raise ValueError(
+ "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
+ )
+
+ # Extract database path from SQLCipher URL
+ db_path = DB_URL.replace("sqlite+sqlcipher://", "")
+ if db_path.startswith("/"):
+ db_path = db_path[1:] # Remove leading slash for relative paths
+
+ # Create a custom creator function that uses sqlcipher3
+ def create_sqlcipher_connection():
+ import sqlcipher3
+
+ conn = sqlcipher3.connect(db_path, check_same_thread=False)
+ conn.execute(f"PRAGMA key = '{DATABASE_PASSWORD}'")
+ return conn
+
+ connectable = create_engine(
+ "sqlite://", # Dummy URL since we're using creator
+ creator=create_sqlcipher_connection,
+ echo=False,
+ )
+ else:
+ # Standard database connection (existing logic)
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
diff --git a/backend/open_webui/migrations/versions/018012973d35_add_indexes.py b/backend/open_webui/migrations/versions/018012973d35_add_indexes.py
new file mode 100644
index 0000000000..29af427108
--- /dev/null
+++ b/backend/open_webui/migrations/versions/018012973d35_add_indexes.py
@@ -0,0 +1,46 @@
+"""Add indexes
+
+Revision ID: 018012973d35
+Revises: d31026856c01
+Create Date: 2025-08-13 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "018012973d35"
+down_revision = "d31026856c01"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Chat table indexes
+ op.create_index("folder_id_idx", "chat", ["folder_id"])
+ op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
+ op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
+ op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
+ op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
+
+ # Tag table index
+ op.create_index("user_id_idx", "tag", ["user_id"])
+
+ # Function table index
+ op.create_index("is_global_idx", "function", ["is_global"])
+
+
+def downgrade():
+ # Chat table indexes
+ op.drop_index("folder_id_idx", table_name="chat")
+ op.drop_index("user_id_pinned_idx", table_name="chat")
+ op.drop_index("user_id_archived_idx", table_name="chat")
+ op.drop_index("updated_at_user_id_idx", table_name="chat")
+ op.drop_index("folder_id_user_id_idx", table_name="chat")
+
+ # Tag table index
+ op.drop_index("user_id_idx", table_name="tag")
+
+ # Function table index
+
+ op.drop_index("is_global_idx", table_name="function")
diff --git a/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py
new file mode 100644
index 0000000000..0c5cec1941
--- /dev/null
+++ b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py
@@ -0,0 +1,146 @@
+"""add_group_member_table
+
+Revision ID: 37f288994c47
+Revises: a5c220713937
+Create Date: 2025-11-17 03:45:25.123939
+
+"""
+
+import uuid
+import time
+import json
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "37f288994c47"
+down_revision: Union[str, None] = "a5c220713937"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # 1. Create new table
+ op.create_table(
+ "group_member",
+ sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
+ sa.Column(
+ "group_id",
+ sa.Text(),
+ sa.ForeignKey("group.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "user_id",
+ sa.Text(),
+ sa.ForeignKey("user.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("created_at", sa.BigInteger(), nullable=True),
+ sa.Column("updated_at", sa.BigInteger(), nullable=True),
+ sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
+ )
+
+ connection = op.get_bind()
+
+ # 2. Read existing group with user_ids JSON column
+ group_table = sa.Table(
+ "group",
+ sa.MetaData(),
+ sa.Column("id", sa.Text()),
+ sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
+ )
+
+ results = connection.execute(
+ sa.select(group_table.c.id, group_table.c.user_ids)
+ ).fetchall()
+
+ print(results)
+
+ # 3. Insert members into group_member table
+ gm_table = sa.Table(
+ "group_member",
+ sa.MetaData(),
+ sa.Column("id", sa.Text()),
+ sa.Column("group_id", sa.Text()),
+ sa.Column("user_id", sa.Text()),
+ sa.Column("created_at", sa.BigInteger()),
+ sa.Column("updated_at", sa.BigInteger()),
+ )
+
+ now = int(time.time())
+ for group_id, user_ids in results:
+ if not user_ids:
+ continue
+
+ if isinstance(user_ids, str):
+ try:
+ user_ids = json.loads(user_ids)
+ except Exception:
+ continue # skip invalid JSON
+
+ if not isinstance(user_ids, list):
+ continue
+
+ rows = [
+ {
+ "id": str(uuid.uuid4()),
+ "group_id": group_id,
+ "user_id": uid,
+ "created_at": now,
+ "updated_at": now,
+ }
+ for uid in user_ids
+ ]
+
+ if rows:
+ connection.execute(gm_table.insert(), rows)
+
+ # 4. Optionally drop the old column
+ with op.batch_alter_table("group") as batch:
+ batch.drop_column("user_ids")
+
+
+def downgrade():
+ # Reverse: restore user_ids column
+ with op.batch_alter_table("group") as batch:
+ batch.add_column(sa.Column("user_ids", sa.JSON()))
+
+ connection = op.get_bind()
+ gm_table = sa.Table(
+ "group_member",
+ sa.MetaData(),
+ sa.Column("group_id", sa.Text()),
+ sa.Column("user_id", sa.Text()),
+ sa.Column("created_at", sa.BigInteger()),
+ sa.Column("updated_at", sa.BigInteger()),
+ )
+
+ group_table = sa.Table(
+ "group",
+ sa.MetaData(),
+ sa.Column("id", sa.Text()),
+ sa.Column("user_ids", sa.JSON()),
+ )
+
+ # Build JSON arrays again
+ results = connection.execute(sa.select(group_table.c.id)).fetchall()
+
+ for (group_id,) in results:
+ members = connection.execute(
+ sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
+ ).fetchall()
+
+ member_ids = [m[0] for m in members]
+
+ connection.execute(
+ group_table.update()
+ .where(group_table.c.id == group_id)
+ .values(user_ids=member_ids)
+ )
+
+ # Drop the new table
+ op.drop_table("group_member")
diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py
new file mode 100644
index 0000000000..8ead6db6d4
--- /dev/null
+++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py
@@ -0,0 +1,52 @@
+"""Add oauth_session table
+
+Revision ID: 38d63c18f30f
+Revises: 3af16a1c9fb6
+Create Date: 2025-09-08 14:19:59.583921
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "38d63c18f30f"
+down_revision: Union[str, None] = "3af16a1c9fb6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Create oauth_session table
+ op.create_table(
+ "oauth_session",
+ sa.Column("id", sa.Text(), nullable=False),
+ sa.Column("user_id", sa.Text(), nullable=False),
+ sa.Column("provider", sa.Text(), nullable=False),
+ sa.Column("token", sa.Text(), nullable=False),
+ sa.Column("expires_at", sa.BigInteger(), nullable=False),
+ sa.Column("created_at", sa.BigInteger(), nullable=False),
+ sa.Column("updated_at", sa.BigInteger(), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
+ )
+
+ # Create indexes for better performance
+ op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
+ op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
+ op.create_index(
+ "idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
+ )
+
+
+def downgrade() -> None:
+ # Drop indexes first
+ op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
+ op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
+ op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
+
+ # Drop the table
+ op.drop_table("oauth_session")
diff --git a/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py b/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py
new file mode 100644
index 0000000000..ab980f27ce
--- /dev/null
+++ b/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py
@@ -0,0 +1,32 @@
+"""update user table
+
+Revision ID: 3af16a1c9fb6
+Revises: 018012973d35
+Create Date: 2025-08-21 02:07:18.078283
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "3af16a1c9fb6"
+down_revision: Union[str, None] = "018012973d35"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True))
+ op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
+ op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
+ op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("user", "username")
+ op.drop_column("user", "bio")
+ op.drop_column("user", "gender")
+ op.drop_column("user", "date_of_birth")
diff --git a/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py
new file mode 100644
index 0000000000..dd2b7d1a68
--- /dev/null
+++ b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py
@@ -0,0 +1,34 @@
+"""Add reply_to_id column to message
+
+Revision ID: a5c220713937
+Revises: 38d63c18f30f
+Create Date: 2025-09-27 02:24:18.058455
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "a5c220713937"
+down_revision: Union[str, None] = "38d63c18f30f"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Add 'reply_to_id' column to the 'message' table for replying to messages
+ op.add_column(
+ "message",
+ sa.Column("reply_to_id", sa.Text(), nullable=True),
+ )
+ pass
+
+
+def downgrade() -> None:
+ # Remove 'reply_to_id' column from the 'message' table
+ op.drop_column("message", "reply_to_id")
+
+ pass
diff --git a/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py b/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py
new file mode 100644
index 0000000000..3c916964e9
--- /dev/null
+++ b/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py
@@ -0,0 +1,23 @@
+"""Update folder table data
+
+Revision ID: d31026856c01
+Revises: 9f0c9cd09105
+Create Date: 2025-07-13 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "d31026856c01"
+down_revision = "9f0c9cd09105"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
+
+
+def downgrade():
+ op.drop_column("folder", "data")
diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py
index 3ad88bc119..39ff1cc7fb 100644
--- a/backend/open_webui/models/auths.py
+++ b/backend/open_webui/models/auths.py
@@ -7,7 +7,6 @@ from open_webui.models.users import UserModel, Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text
-from open_webui.utils.auth import verify_password
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -20,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Auth(Base):
__tablename__ = "auth"
- id = Column(String, primary_key=True)
+ id = Column(String, primary_key=True, unique=True)
email = Column(String)
password = Column(Text)
active = Column(Boolean)
@@ -73,11 +72,6 @@ class ProfileImageUrlForm(BaseModel):
profile_image_url: str
-class UpdateProfileForm(BaseModel):
- profile_image_url: str
- name: str
-
-
class UpdatePasswordForm(BaseModel):
password: str
new_password: str
@@ -127,7 +121,9 @@ class AuthsTable:
else:
return None
- def authenticate_user(self, email: str, password: str) -> Optional[UserModel]:
+ def authenticate_user(
+ self, email: str, verify_password: callable
+ ) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email)
@@ -138,7 +134,7 @@ class AuthsTable:
with get_db() as db:
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth:
- if verify_password(password, auth.password):
+ if verify_password(auth.password):
return user
else:
return None
diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py
index 92f238c3a0..5f4d1436d9 100644
--- a/backend/open_webui/models/channels.py
+++ b/backend/open_webui/models/channels.py
@@ -19,7 +19,7 @@ from sqlalchemy.sql import exists
class Channel(Base):
__tablename__ = "channel"
- id = Column(Text, primary_key=True)
+ id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
type = Column(Text, nullable=True)
@@ -57,6 +57,11 @@ class ChannelModel(BaseModel):
####################
+class ChannelResponse(ChannelModel):
+ write_access: bool = False
+ user_count: Optional[int] = None
+
+
class ChannelForm(BaseModel):
name: str
description: Optional[str] = None
diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py
index 0ac53a0233..187a4522c9 100644
--- a/backend/open_webui/models/chats.py
+++ b/backend/open_webui/models/chats.py
@@ -6,12 +6,14 @@ 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.models.folders import Folders
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, Index
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
+from sqlalchemy.sql.expression import bindparam
####################
# Chat DB Schema
@@ -24,7 +26,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Chat(Base):
__tablename__ = "chat"
- id = Column(String, primary_key=True)
+ id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
title = Column(Text)
chat = Column(JSON)
@@ -39,6 +41,20 @@ class Chat(Base):
meta = Column(JSON, server_default="{}")
folder_id = Column(Text, nullable=True)
+ __table_args__ = (
+ # Performance indexes for common queries
+ # WHERE folder_id = ...
+ Index("folder_id_idx", "folder_id"),
+ # WHERE user_id = ... AND pinned = ...
+ Index("user_id_pinned_idx", "user_id", "pinned"),
+ # WHERE user_id = ... AND archived = ...
+ Index("user_id_archived_idx", "user_id", "archived"),
+ # WHERE user_id = ... ORDER BY updated_at DESC
+ Index("updated_at_user_id_idx", "updated_at", "user_id"),
+ # WHERE folder_id = ... AND user_id = ...
+ Index("folder_id_user_id_idx", "folder_id", "user_id"),
+ )
+
class ChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -66,12 +82,18 @@ class ChatModel(BaseModel):
class ChatForm(BaseModel):
chat: dict
+ folder_id: Optional[str] = None
class ChatImportForm(ChatForm):
meta: Optional[dict] = {}
pinned: Optional[bool] = False
- folder_id: Optional[str] = None
+ created_at: Optional[int] = None
+ updated_at: Optional[int] = None
+
+
+class ChatsImportForm(BaseModel):
+ chats: list[ChatImportForm]
class ChatTitleMessagesForm(BaseModel):
@@ -105,6 +127,43 @@ class ChatTitleIdResponse(BaseModel):
class ChatTable:
+ def _clean_null_bytes(self, obj):
+ """
+ Recursively remove actual null bytes (\x00) and unicode escape \\u0000
+ from strings inside dict/list structures.
+ Safe for JSON objects.
+ """
+ if isinstance(obj, str):
+ return obj.replace("\x00", "").replace("\u0000", "")
+ elif isinstance(obj, dict):
+ return {k: self._clean_null_bytes(v) for k, v in obj.items()}
+ elif isinstance(obj, list):
+ return [self._clean_null_bytes(v) for v in obj]
+ return obj
+
+ def _sanitize_chat_row(self, chat_item):
+ """
+ Clean a Chat SQLAlchemy model's title + chat JSON,
+ and return True if anything changed.
+ """
+ changed = False
+
+ # Clean title
+ if chat_item.title:
+ cleaned = self._clean_null_bytes(chat_item.title)
+ if cleaned != chat_item.title:
+ chat_item.title = cleaned
+ changed = True
+
+ # Clean JSON
+ if chat_item.chat:
+ cleaned = self._clean_null_bytes(chat_item.chat)
+ if cleaned != chat_item.chat:
+ chat_item.chat = cleaned
+ changed = True
+
+ return changed
+
def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]:
with get_db() as db:
id = str(uuid.uuid4())
@@ -112,59 +171,76 @@ class ChatTable:
**{
"id": id,
"user_id": user_id,
- "title": (
+ "title": self._clean_null_bytes(
form_data.chat["title"]
if "title" in form_data.chat
else "New Chat"
),
- "chat": form_data.chat,
- "created_at": int(time.time()),
- "updated_at": int(time.time()),
- }
- )
-
- result = Chat(**chat.model_dump())
- db.add(result)
- db.commit()
- db.refresh(result)
- return ChatModel.model_validate(result) if result else None
-
- def import_chat(
- self, user_id: str, form_data: ChatImportForm
- ) -> Optional[ChatModel]:
- with get_db() as db:
- id = str(uuid.uuid4())
- chat = ChatModel(
- **{
- "id": id,
- "user_id": user_id,
- "title": (
- form_data.chat["title"]
- if "title" in form_data.chat
- else "New Chat"
- ),
- "chat": form_data.chat,
- "meta": form_data.meta,
- "pinned": form_data.pinned,
+ "chat": self._clean_null_bytes(form_data.chat),
"folder_id": form_data.folder_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
- result = Chat(**chat.model_dump())
- db.add(result)
+ chat_item = Chat(**chat.model_dump())
+ db.add(chat_item)
db.commit()
- db.refresh(result)
- return ChatModel.model_validate(result) if result else None
+ db.refresh(chat_item)
+ return ChatModel.model_validate(chat_item) if chat_item else None
+
+ def _chat_import_form_to_chat_model(
+ self, user_id: str, form_data: ChatImportForm
+ ) -> ChatModel:
+ id = str(uuid.uuid4())
+ chat = ChatModel(
+ **{
+ "id": id,
+ "user_id": user_id,
+ "title": self._clean_null_bytes(
+ form_data.chat["title"] if "title" in form_data.chat else "New Chat"
+ ),
+ "chat": self._clean_null_bytes(form_data.chat),
+ "meta": form_data.meta,
+ "pinned": form_data.pinned,
+ "folder_id": form_data.folder_id,
+ "created_at": (
+ form_data.created_at if form_data.created_at else int(time.time())
+ ),
+ "updated_at": (
+ form_data.updated_at if form_data.updated_at else int(time.time())
+ ),
+ }
+ )
+ return chat
+
+ def import_chats(
+ self, user_id: str, chat_import_forms: list[ChatImportForm]
+ ) -> list[ChatModel]:
+ with get_db() as db:
+ chats = []
+
+ for form_data in chat_import_forms:
+ chat = self._chat_import_form_to_chat_model(user_id, form_data)
+ chats.append(Chat(**chat.model_dump()))
+
+ db.add_all(chats)
+ db.commit()
+ return [ChatModel.model_validate(chat) for chat in chats]
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]:
try:
with get_db() as db:
chat_item = db.get(Chat, id)
- chat_item.chat = chat
- chat_item.title = chat["title"] if "title" in chat else "New Chat"
+ chat_item.chat = self._clean_null_bytes(chat)
+ chat_item.title = (
+ self._clean_null_bytes(chat["title"])
+ if "title" in chat
+ else "New Chat"
+ )
+
chat_item.updated_at = int(time.time())
+
db.commit()
db.refresh(chat_item)
@@ -209,7 +285,7 @@ class ChatTable:
return chat.chat.get("title", "New Chat")
- def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
+ def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
@@ -232,6 +308,10 @@ class ChatTable:
if chat is None:
return None
+ # Sanitize message content for null characters before upserting
+ if isinstance(message.get("content"), str):
+ message["content"] = message["content"].replace("\x00", "")
+
chat = chat.chat
history = chat.get("history", {})
@@ -266,6 +346,27 @@ class ChatTable:
chat["history"] = history
return self.update_chat_by_id(id, chat)
+ def add_message_files_by_id_and_message_id(
+ self, id: str, message_id: str, files: list[dict]
+ ) -> list[dict]:
+ chat = self.get_chat_by_id(id)
+ if chat is None:
+ return None
+
+ chat = chat.chat
+ history = chat.get("history", {})
+
+ message_files = []
+
+ if message_id in history.get("messages", {}):
+ message_files = history["messages"][message_id].get("files", [])
+ message_files = message_files + files
+ history["messages"][message_id]["files"] = message_files
+
+ chat["history"] = history
+ self.update_chat_by_id(id, chat)
+ return message_files
+
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
with get_db() as db:
# Get the existing chat to share
@@ -280,6 +381,9 @@ class ChatTable:
"user_id": f"shared-{chat_id}",
"title": chat.title,
"chat": chat.chat,
+ "meta": chat.meta,
+ "pinned": chat.pinned,
+ "folder_id": chat.folder_id,
"created_at": chat.created_at,
"updated_at": int(time.time()),
}
@@ -311,7 +415,9 @@ class ChatTable:
shared_chat.title = chat.title
shared_chat.chat = chat.chat
-
+ shared_chat.meta = chat.meta
+ shared_chat.pinned = chat.pinned
+ shared_chat.folder_id = chat.folder_id
shared_chat.updated_at = int(time.time())
db.commit()
db.refresh(shared_chat)
@@ -330,6 +436,15 @@ class ChatTable:
except Exception:
return False
+ def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
+ try:
+ with get_db() as db:
+ db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
+ db.commit()
+ return True
+ except Exception:
+ return False
+
def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str]
) -> Optional[ChatModel]:
@@ -360,6 +475,7 @@ class ChatTable:
with get_db() as db:
chat = db.get(Chat, id)
chat.archived = not chat.archived
+ chat.folder_id = None
chat.updated_at = int(time.time())
db.commit()
db.refresh(chat)
@@ -395,7 +511,10 @@ class ChatTable:
order_by = filter.get("order_by")
direction = filter.get("direction")
- if order_by and direction and getattr(Chat, order_by):
+ if order_by and direction:
+ if not getattr(Chat, order_by, None):
+ raise ValueError("Invalid order_by field")
+
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
@@ -456,12 +575,19 @@ class ChatTable:
self,
user_id: str,
include_archived: bool = False,
+ include_folders: bool = False,
+ include_pinned: bool = False,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[ChatTitleIdResponse]:
with get_db() as db:
- query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
- query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
+ query = db.query(Chat).filter_by(user_id=user_id)
+
+ if not include_folders:
+ query = query.filter_by(folder_id=None)
+
+ if not include_pinned:
+ query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived:
query = query.filter_by(archived=False)
@@ -506,8 +632,15 @@ class ChatTable:
def get_chat_by_id(self, id: str) -> Optional[ChatModel]:
try:
with get_db() as db:
- chat = db.get(Chat, id)
- return ChatModel.model_validate(chat)
+ chat_item = db.get(Chat, id)
+ if chat_item is None:
+ return None
+
+ if self._sanitize_chat_row(chat_item):
+ db.commit()
+ db.refresh(chat_item)
+
+ return ChatModel.model_validate(chat_item)
except Exception:
return None
@@ -580,7 +713,7 @@ class ChatTable:
"""
Filters chats based on a search query using Python, allowing pagination using skip and limit.
"""
- search_text = search_text.lower().strip()
+ search_text = search_text.replace("\u0000", "").lower().strip()
if not search_text:
return self.get_chat_list_by_user_id(
@@ -596,8 +729,45 @@ class ChatTable:
if word.startswith("tag:")
]
+ # Extract folder names - handle spaces and case insensitivity
+ folders = Folders.search_folders_by_names(
+ user_id,
+ [
+ word.replace("folder:", "")
+ for word in search_text_words
+ if word.startswith("folder:")
+ ],
+ )
+ folder_ids = [folder.id for folder in folders]
+
+ is_pinned = None
+ if "pinned:true" in search_text_words:
+ is_pinned = True
+ elif "pinned:false" in search_text_words:
+ is_pinned = False
+
+ is_archived = None
+ if "archived:true" in search_text_words:
+ is_archived = True
+ elif "archived:false" in search_text_words:
+ is_archived = False
+
+ is_shared = None
+ if "shared:true" in search_text_words:
+ is_shared = True
+ elif "shared:false" in search_text_words:
+ is_shared = False
+
search_text_words = [
- word for word in search_text_words if not word.startswith("tag:")
+ word
+ for word in search_text_words
+ if (
+ not word.startswith("tag:")
+ and not word.startswith("folder:")
+ and not word.startswith("pinned:")
+ and not word.startswith("archived:")
+ and not word.startswith("shared:")
+ )
]
search_text = " ".join(search_text_words)
@@ -605,30 +775,41 @@ class ChatTable:
with get_db() as db:
query = db.query(Chat).filter(Chat.user_id == user_id)
- if not include_archived:
+ if is_archived is not None:
+ query = query.filter(Chat.archived == is_archived)
+ elif not include_archived:
query = query.filter(Chat.archived == False)
+ if is_pinned is not None:
+ query = query.filter(Chat.pinned == is_pinned)
+
+ if is_shared is not None:
+ if is_shared:
+ query = query.filter(Chat.share_id.isnot(None))
+ else:
+ query = query.filter(Chat.share_id.is_(None))
+
+ if folder_ids:
+ query = query.filter(Chat.folder_id.in_(folder_ids))
+
query = query.order_by(Chat.updated_at.desc())
# Check if the database dialect is either 'sqlite' or 'postgresql'
dialect_name = db.bind.dialect.name
if dialect_name == "sqlite":
# SQLite case: using JSON1 extension for JSON searching
+ sqlite_content_sql = (
+ "EXISTS ("
+ " SELECT 1 "
+ " FROM json_each(Chat.chat, '$.messages') AS message "
+ " WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'"
+ ")"
+ )
+ sqlite_content_clause = text(sqlite_content_sql)
query = query.filter(
- (
- Chat.title.ilike(
- f"%{search_text}%"
- ) # Case-insensitive search in title
- | text(
- """
- EXISTS (
- SELECT 1
- FROM json_each(Chat.chat, '$.messages') AS message
- WHERE LOWER(message.value->>'content') LIKE '%' || :search_text || '%'
- )
- """
- )
- ).params(search_text=search_text)
+ or_(
+ Chat.title.ilike(bindparam("title_key")), sqlite_content_clause
+ ).params(title_key=f"%{search_text}%", content_key=search_text)
)
# Check if there are any tags to filter, it should have all the tags
@@ -662,23 +843,32 @@ class ChatTable:
)
elif dialect_name == "postgresql":
- # PostgreSQL relies on proper JSON query for search
- query = query.filter(
- (
- Chat.title.ilike(
- f"%{search_text}%"
- ) # Case-insensitive search in title
- | text(
- """
- EXISTS (
- SELECT 1
- FROM json_array_elements(Chat.chat->'messages') AS message
- WHERE LOWER(message->>'content') LIKE '%' || :search_text || '%'
- )
- """
- )
- ).params(search_text=search_text)
+ # PostgreSQL doesn't allow null bytes in text. We filter those out by checking
+ # the JSON representation for \u0000 before attempting text extraction
+
+ # Safety filter: JSON field must not contain \u0000
+ query = query.filter(text("Chat.chat::text NOT LIKE '%\\\\u0000%'"))
+
+ # Safety filter: title must not contain actual null bytes
+ query = query.filter(text("Chat.title::text NOT LIKE '%\\x00%'"))
+
+ postgres_content_sql = """
+ EXISTS (
+ SELECT 1
+ FROM json_array_elements(Chat.chat->'messages') AS message
+ WHERE json_typeof(message->'content') = 'string'
+ AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
)
+ """
+
+ postgres_content_clause = text(postgres_content_sql)
+
+ query = query.filter(
+ or_(
+ Chat.title.ilike(bindparam("title_key")),
+ postgres_content_clause,
+ )
+ ).params(title_key=f"%{search_text}%", content_key=search_text.lower())
# Check if there are any tags to filter, it should have all the tags
if "none" in tag_ids:
@@ -723,7 +913,7 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id(
- self, folder_id: str, user_id: str
+ self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
@@ -732,6 +922,11 @@ class ChatTable:
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]
@@ -861,6 +1056,16 @@ class ChatTable:
return count
+ def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
+ with get_db() as db:
+ query = db.query(Chat).filter_by(user_id=user_id)
+
+ query = query.filter_by(folder_id=folder_id)
+ count = query.count()
+
+ log.info(f"Count of chats for folder '{folder_id}': {count}")
+ return count
+
def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str
) -> bool:
@@ -938,6 +1143,20 @@ class ChatTable:
except Exception:
return False
+ def move_chats_by_user_id_and_folder_id(
+ self, user_id: str, folder_id: str, new_folder_id: Optional[str]
+ ) -> bool:
+ try:
+ with get_db() as db:
+ db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update(
+ {"folder_id": new_folder_id}
+ )
+ db.commit()
+
+ return True
+ except Exception:
+ return False
+
def delete_shared_chats_by_user_id(self, user_id: str) -> bool:
try:
with get_db() as db:
diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py
index 215e36aa24..5a91804b56 100644
--- a/backend/open_webui/models/feedbacks.py
+++ b/backend/open_webui/models/feedbacks.py
@@ -4,7 +4,7 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
-from open_webui.models.chats import Chats
+from open_webui.models.users import User
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
@@ -21,7 +21,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Feedback(Base):
__tablename__ = "feedback"
- id = Column(Text, primary_key=True)
+ id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
version = Column(BigInteger, default=0)
type = Column(Text)
@@ -92,6 +92,28 @@ class FeedbackForm(BaseModel):
model_config = ConfigDict(extra="allow")
+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
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class FeedbackUserResponse(FeedbackResponse):
+ user: Optional[UserResponse] = None
+
+
+class FeedbackListResponse(BaseModel):
+ items: list[FeedbackUserResponse]
+ total: int
+
+
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm
@@ -143,6 +165,70 @@ class FeedbackTable:
except Exception:
return None
+ def get_feedback_items(
+ self, filter: dict = {}, skip: int = 0, limit: int = 30
+ ) -> FeedbackListResponse:
+ with get_db() as db:
+ query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
+
+ if filter:
+ order_by = filter.get("order_by")
+ direction = filter.get("direction")
+
+ if order_by == "username":
+ if direction == "asc":
+ query = query.order_by(User.name.asc())
+ else:
+ query = query.order_by(User.name.desc())
+ elif order_by == "model_id":
+ # it's stored in feedback.data['model_id']
+ if direction == "asc":
+ query = query.order_by(
+ Feedback.data["model_id"].as_string().asc()
+ )
+ else:
+ query = query.order_by(
+ Feedback.data["model_id"].as_string().desc()
+ )
+ elif order_by == "rating":
+ # it's stored in feedback.data['rating']
+ if direction == "asc":
+ query = query.order_by(
+ Feedback.data["rating"].as_string().asc()
+ )
+ else:
+ query = query.order_by(
+ Feedback.data["rating"].as_string().desc()
+ )
+ elif order_by == "updated_at":
+ if direction == "asc":
+ query = query.order_by(Feedback.updated_at.asc())
+ else:
+ query = query.order_by(Feedback.updated_at.desc())
+
+ else:
+ query = query.order_by(Feedback.created_at.desc())
+
+ # Count BEFORE pagination
+ total = query.count()
+
+ if skip:
+ query = query.offset(skip)
+ if limit:
+ query = query.limit(limit)
+
+ items = query.all()
+
+ feedbacks = []
+ for feedback, user in items:
+ feedback_model = FeedbackModel.model_validate(feedback)
+ user_model = UserResponse.model_validate(user)
+ feedbacks.append(
+ FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)
+ )
+
+ return FeedbackListResponse(items=feedbacks, total=total)
+
def get_all_feedbacks(self) -> list[FeedbackModel]:
with get_db() as db:
return [
diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py
index 6f1511cd13..1ed743df87 100644
--- a/backend/open_webui/models/files.py
+++ b/backend/open_webui/models/files.py
@@ -17,7 +17,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class File(Base):
__tablename__ = "file"
- id = Column(String, primary_key=True)
+ id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
hash = Column(Text, nullable=True)
@@ -82,6 +82,7 @@ class FileModelResponse(BaseModel):
class FileMetadataResponse(BaseModel):
id: str
+ hash: Optional[str] = None
meta: dict
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -97,6 +98,12 @@ class FileForm(BaseModel):
access_control: Optional[dict] = None
+class FileUpdateForm(BaseModel):
+ hash: Optional[str] = None
+ data: Optional[dict] = None
+ meta: Optional[dict] = None
+
+
class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db:
@@ -130,12 +137,24 @@ class FilesTable:
except Exception:
return None
+ def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
+ with get_db() as db:
+ try:
+ file = db.query(File).filter_by(id=id, user_id=user_id).first()
+ if file:
+ return FileModel.model_validate(file)
+ else:
+ return None
+ except Exception:
+ return None
+
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db:
try:
file = db.get(File, id)
return FileMetadataResponse(
id=file.id,
+ hash=file.hash,
meta=file.meta,
created_at=file.created_at,
updated_at=file.updated_at,
@@ -147,6 +166,15 @@ class FilesTable:
with get_db() as db:
return [FileModel.model_validate(file) for file in db.query(File).all()]
+ def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
+ file = self.get_file_by_id(id)
+ if not file:
+ return False
+ if file.user_id == user_id:
+ return True
+ # Implement additional access control logic here as needed
+ return False
+
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db:
return [
@@ -162,11 +190,14 @@ class FilesTable:
return [
FileMetadataResponse(
id=file.id,
+ hash=file.hash,
meta=file.meta,
created_at=file.created_at,
updated_at=file.updated_at,
)
- for file in db.query(File)
+ for file in db.query(
+ File.id, File.hash, File.meta, File.created_at, File.updated_at
+ )
.filter(File.id.in_(ids))
.order_by(File.updated_at.desc())
.all()
@@ -179,6 +210,29 @@ class FilesTable:
for file in db.query(File).filter_by(user_id=user_id).all()
]
+ def update_file_by_id(
+ self, id: str, form_data: FileUpdateForm
+ ) -> Optional[FileModel]:
+ with get_db() as db:
+ try:
+ file = db.query(File).filter_by(id=id).first()
+
+ if form_data.hash is not None:
+ file.hash = form_data.hash
+
+ if form_data.data is not None:
+ file.data = {**(file.data if file.data else {}), **form_data.data}
+
+ if form_data.meta is not None:
+ file.meta = {**(file.meta if file.meta else {}), **form_data.meta}
+
+ file.updated_at = int(time.time())
+ db.commit()
+ return FileModel.model_validate(file)
+ except Exception as e:
+ log.exception(f"Error updating file completely by id: {e}")
+ return None
+
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
with get_db() as db:
try:
diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py
index 1c97de26c9..6e1735ecea 100644
--- a/backend/open_webui/models/folders.py
+++ b/backend/open_webui/models/folders.py
@@ -2,14 +2,14 @@ import logging
import time
import uuid
from typing import Optional
+import re
+
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from open_webui.internal.db import Base, get_db
-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__)
@@ -23,12 +23,13 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Folder(Base):
__tablename__ = "folder"
- id = Column(Text, primary_key=True)
+ id = Column(Text, primary_key=True, unique=True)
parent_id = Column(Text, nullable=True)
user_id = Column(Text)
name = Column(Text)
items = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
+ data = Column(JSON, nullable=True)
is_expanded = Column(Boolean, default=False)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -41,6 +42,7 @@ class FolderModel(BaseModel):
name: str
items: Optional[dict] = None
meta: Optional[dict] = None
+ data: Optional[dict] = None
is_expanded: bool = False
created_at: int
updated_at: int
@@ -48,6 +50,20 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class FolderMetadataResponse(BaseModel):
+ icon: Optional[str] = None
+
+
+class FolderNameIdResponse(BaseModel):
+ id: str
+ name: str
+ meta: Optional[FolderMetadataResponse] = None
+ parent_id: Optional[str] = None
+ is_expanded: bool = False
+ created_at: int
+ updated_at: int
+
+
####################
# Forms
####################
@@ -55,12 +71,21 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel):
name: str
+ data: Optional[dict] = None
+ meta: Optional[dict] = None
+ model_config = ConfigDict(extra="allow")
+
+
+class FolderUpdateForm(BaseModel):
+ name: Optional[str] = None
+ data: Optional[dict] = None
+ meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
class FolderTable:
def insert_new_folder(
- self, user_id: str, name: str, parent_id: Optional[str] = None
+ self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None
) -> Optional[FolderModel]:
with get_db() as db:
id = str(uuid.uuid4())
@@ -68,7 +93,7 @@ class FolderTable:
**{
"id": id,
"user_id": user_id,
- "name": name,
+ **(form_data.model_dump(exclude_unset=True) or {}),
"parent_id": parent_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
@@ -103,7 +128,7 @@ class FolderTable:
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
- ) -> Optional[FolderModel]:
+ ) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
folders = []
@@ -187,8 +212,8 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
- def update_folder_name_by_id_and_user_id(
- self, id: str, user_id: str, name: str
+ def update_folder_by_id_and_user_id(
+ self, id: str, user_id: str, form_data: FolderUpdateForm
) -> Optional[FolderModel]:
try:
with get_db() as db:
@@ -197,18 +222,35 @@ class FolderTable:
if not folder:
return None
+ form_data = form_data.model_dump(exclude_unset=True)
+
existing_folder = (
db.query(Folder)
- .filter_by(name=name, parent_id=folder.parent_id, user_id=user_id)
+ .filter_by(
+ name=form_data.get("name"),
+ parent_id=folder.parent_id,
+ user_id=user_id,
+ )
.first()
)
- if existing_folder:
+ if existing_folder and existing_folder.id != id:
return None
- folder.name = name
- folder.updated_at = int(time.time())
+ folder.name = form_data.get("name", folder.name)
+ if "data" in form_data:
+ folder.data = {
+ **(folder.data or {}),
+ **form_data["data"],
+ }
+ if "meta" in form_data:
+ folder.meta = {
+ **(folder.meta or {}),
+ **form_data["meta"],
+ }
+
+ folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
@@ -236,18 +278,15 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
- def delete_folder_by_id_and_user_id(
- self, id: str, user_id: str, delete_chats=True
- ) -> bool:
+ def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
try:
+ folder_ids = []
with get_db() as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
- return False
+ return folder_ids
- if delete_chats:
- # Delete all chats in the folder
- Chats.delete_chats_by_user_id_and_folder_id(user_id, folder.id)
+ folder_ids.append(folder.id)
# Delete all children folders
def delete_children(folder):
@@ -255,12 +294,9 @@ class FolderTable:
folder.id, user_id
)
for folder_child in folder_children:
- if delete_chats:
- Chats.delete_chats_by_user_id_and_folder_id(
- user_id, folder_child.id
- )
delete_children(folder_child)
+ folder_ids.append(folder_child.id)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
db.delete(folder)
@@ -269,10 +305,62 @@ class FolderTable:
delete_children(folder)
db.delete(folder)
db.commit()
- return True
+ return folder_ids
except Exception as e:
log.error(f"delete_folder: {e}")
- return False
+ return []
+
+ def normalize_folder_name(self, name: str) -> str:
+ # Replace _ and space with a single space, lower case, collapse multiple spaces
+ name = re.sub(r"[\s_]+", " ", name)
+ return name.strip().lower()
+
+ def search_folders_by_names(
+ self, user_id: str, queries: list[str]
+ ) -> list[FolderModel]:
+ """
+ Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
+ """
+ normalized_queries = [self.normalize_folder_name(q) for q in queries]
+ if not normalized_queries:
+ return []
+
+ results = {}
+ with get_db() as db:
+ folders = db.query(Folder).filter_by(user_id=user_id).all()
+ for folder in folders:
+ if self.normalize_folder_name(folder.name) in normalized_queries:
+ results[folder.id] = FolderModel.model_validate(folder)
+
+ # get children folders
+ children = self.get_children_folders_by_id_and_user_id(
+ folder.id, user_id
+ )
+ for child in children:
+ results[child.id] = child
+
+ # Return the results as a list
+ if not results:
+ return []
+ else:
+ results = list(results.values())
+ return results
+
+ def search_folders_by_name_contains(
+ self, user_id: str, query: str
+ ) -> list[FolderModel]:
+ """
+ Partial match: normalized name contains (as substring) the normalized query.
+ """
+ normalized_query = self.normalize_folder_name(query)
+ results = []
+ with get_db() as db:
+ folders = db.query(Folder).filter_by(user_id=user_id).all()
+ for folder in folders:
+ norm_name = self.normalize_folder_name(folder.name)
+ if normalized_query in norm_name:
+ results.append(FolderModel.model_validate(folder))
+ return results
Folders = FolderTable()
diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py
index e98771fa02..91736f949a 100644
--- a/backend/open_webui/models/functions.py
+++ b/backend/open_webui/models/functions.py
@@ -3,10 +3,10 @@ import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
-from open_webui.models.users import Users
+from open_webui.models.users import Users, UserModel
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Boolean, Column, String, Text
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -19,7 +19,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Function(Base):
__tablename__ = "function"
- id = Column(String, primary_key=True)
+ id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
name = Column(Text)
type = Column(Text)
@@ -31,10 +31,13 @@ class Function(Base):
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
+ __table_args__ = (Index("is_global_idx", "is_global"),)
+
class FunctionMeta(BaseModel):
description: Optional[str] = None
manifest: Optional[dict] = {}
+ model_config = ConfigDict(extra="allow")
class FunctionModel(BaseModel):
@@ -52,11 +55,31 @@ class FunctionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class FunctionWithValvesModel(BaseModel):
+ id: str
+ user_id: str
+ name: str
+ type: str
+ content: str
+ meta: FunctionMeta
+ valves: Optional[dict] = None
+ is_active: bool = False
+ is_global: bool = False
+ updated_at: int # timestamp in epoch
+ created_at: int # timestamp in epoch
+
+ model_config = ConfigDict(from_attributes=True)
+
+
####################
# Forms
####################
+class FunctionUserResponse(FunctionModel):
+ user: Optional[UserModel] = None
+
+
class FunctionResponse(BaseModel):
id: str
user_id: str
@@ -109,8 +132,8 @@ class FunctionsTable:
return None
def sync_functions(
- self, user_id: str, functions: list[FunctionModel]
- ) -> list[FunctionModel]:
+ self, user_id: str, functions: list[FunctionWithValvesModel]
+ ) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
@@ -164,19 +187,48 @@ class FunctionsTable:
except Exception:
return None
- def get_functions(self, active_only=False) -> list[FunctionModel]:
+ def get_functions(
+ self, active_only=False, include_valves=False
+ ) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db:
if active_only:
+ functions = db.query(Function).filter_by(is_active=True).all()
+
+ else:
+ functions = db.query(Function).all()
+
+ if include_valves:
return [
- FunctionModel.model_validate(function)
- for function in db.query(Function).filter_by(is_active=True).all()
+ FunctionWithValvesModel.model_validate(function)
+ for function in functions
]
else:
return [
- FunctionModel.model_validate(function)
- for function in db.query(Function).all()
+ FunctionModel.model_validate(function) for function in functions
]
+ def get_function_list(self) -> list[FunctionUserResponse]:
+ with get_db() as db:
+ functions = db.query(Function).order_by(Function.updated_at.desc()).all()
+ user_ids = list(set(func.user_id for func in functions))
+
+ users = Users.get_users_by_user_ids(user_ids) if user_ids else []
+ users_dict = {user.id: user for user in users}
+
+ return [
+ FunctionUserResponse.model_validate(
+ {
+ **FunctionModel.model_validate(func).model_dump(),
+ "user": (
+ users_dict.get(func.user_id).model_dump()
+ if func.user_id in users_dict
+ else None
+ ),
+ }
+ )
+ for func in functions
+ ]
+
def get_functions_by_type(
self, type: str, active_only=False
) -> list[FunctionModel]:
@@ -235,6 +287,29 @@ class FunctionsTable:
except Exception:
return None
+ def update_function_metadata_by_id(
+ self, id: str, metadata: dict
+ ) -> Optional[FunctionModel]:
+ with get_db() as db:
+ try:
+ function = db.get(Function, id)
+
+ if function:
+ if function.meta:
+ function.meta = {**function.meta, **metadata}
+ else:
+ function.meta = metadata
+
+ function.updated_at = int(time.time())
+ db.commit()
+ db.refresh(function)
+ return self.get_function_by_id(id)
+ else:
+ return None
+ except Exception as e:
+ log.exception(f"Error updating function metadata by id {id}: {e}")
+ return None
+
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[dict]:
@@ -250,9 +325,7 @@ class FunctionsTable:
return user_settings["functions"]["valves"].get(id, {})
except Exception as e:
- log.exception(
- f"Error getting user values by id {id} and user id {user_id}: {e}"
- )
+ log.exception(f"Error getting user values by id {id} and user id {user_id}")
return None
def update_user_valves_by_id_and_user_id(
diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py
index 096041e40f..e5c0612639 100644
--- a/backend/open_webui/models/groups.py
+++ b/backend/open_webui/models/groups.py
@@ -11,7 +11,7 @@ from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Column, String, Text, JSON, func
+from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey
log = logging.getLogger(__name__)
@@ -35,7 +35,6 @@ class Group(Base):
meta = Column(JSON, nullable=True)
permissions = Column(JSON, nullable=True)
- user_ids = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -53,12 +52,33 @@ class GroupModel(BaseModel):
meta: Optional[dict] = None
permissions: Optional[dict] = None
- user_ids: list[str] = []
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
+class GroupMember(Base):
+ __tablename__ = "group_member"
+
+ id = Column(Text, unique=True, primary_key=True)
+ group_id = Column(
+ Text,
+ ForeignKey("group.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ user_id = Column(Text, nullable=False)
+ created_at = Column(BigInteger, nullable=True)
+ updated_at = Column(BigInteger, nullable=True)
+
+
+class GroupMemberModel(BaseModel):
+ id: str
+ group_id: str
+ user_id: str
+ created_at: Optional[int] = None # timestamp in epoch
+ updated_at: Optional[int] = None # timestamp in epoch
+
+
####################
# Forms
####################
@@ -72,7 +92,7 @@ class GroupResponse(BaseModel):
permissions: Optional[dict] = None
data: Optional[dict] = None
meta: Optional[dict] = None
- user_ids: list[str] = []
+ member_count: Optional[int] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -81,10 +101,15 @@ class GroupForm(BaseModel):
name: str
description: str
permissions: Optional[dict] = None
+ data: Optional[dict] = None
+
+
+class UserIdsForm(BaseModel):
+ user_ids: Optional[list[str]] = None
class GroupUpdateForm(GroupForm):
- user_ids: Optional[list[str]] = None
+ pass
class GroupTable:
@@ -127,12 +152,8 @@ class GroupTable:
return [
GroupModel.model_validate(group)
for group in db.query(Group)
- .filter(
- func.json_array_length(Group.user_ids) > 0
- ) # Ensure array exists
- .filter(
- Group.user_ids.cast(String).like(f'%"{user_id}"%')
- ) # String-based check
+ .join(GroupMember, GroupMember.group_id == Group.id)
+ .filter(GroupMember.user_id == user_id)
.order_by(Group.updated_at.desc())
.all()
]
@@ -145,12 +166,63 @@ class GroupTable:
except Exception:
return None
- def get_group_user_ids_by_id(self, id: str) -> Optional[str]:
- group = self.get_group_by_id(id)
- if group:
- return group.user_ids
- else:
- return None
+ def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
+ with get_db() as db:
+ members = (
+ db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
+ )
+
+ if not members:
+ return None
+
+ return [m[0] for m in members]
+
+ def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]:
+ with get_db() as db:
+ members = (
+ db.query(GroupMember.group_id, GroupMember.user_id)
+ .filter(GroupMember.group_id.in_(group_ids))
+ .all()
+ )
+
+ group_user_ids: dict[str, list[str]] = {
+ group_id: [] for group_id in group_ids
+ }
+
+ for group_id, user_id in members:
+ group_user_ids[group_id].append(user_id)
+
+ return group_user_ids
+
+ def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
+ with get_db() as db:
+ # Delete existing members
+ db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
+
+ # Insert new members
+ now = int(time.time())
+ new_members = [
+ GroupMember(
+ id=str(uuid.uuid4()),
+ group_id=group_id,
+ user_id=user_id,
+ created_at=now,
+ updated_at=now,
+ )
+ for user_id in user_ids
+ ]
+
+ db.add_all(new_members)
+ db.commit()
+
+ def get_group_member_count_by_id(self, id: str) -> int:
+ with get_db() as db:
+ count = (
+ db.query(func.count(GroupMember.user_id))
+ .filter(GroupMember.group_id == id)
+ .scalar()
+ )
+ return count if count else 0
def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
@@ -191,20 +263,29 @@ class GroupTable:
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
try:
- groups = self.get_groups_by_member_id(user_id)
+ # Find all groups the user belongs to
+ groups = (
+ db.query(Group)
+ .join(GroupMember, GroupMember.group_id == Group.id)
+ .filter(GroupMember.user_id == user_id)
+ .all()
+ )
+ # Remove the user from each group
for group in groups:
- 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()),
- }
- )
- db.commit()
+ db.query(GroupMember).filter(
+ GroupMember.group_id == group.id, GroupMember.user_id == user_id
+ ).delete()
+ db.query(Group).filter_by(id=group.id).update(
+ {"updated_at": int(time.time())}
+ )
+
+ db.commit()
return True
+
except Exception:
+ db.rollback()
return False
def create_groups_by_group_names(
@@ -242,38 +323,129 @@ class GroupTable:
def sync_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]
+ now = int(time.time())
- # Remove user from groups not in the new list
- existing_groups = self.get_groups_by_member_id(user_id)
+ # 1. Groups that SHOULD contain the user
+ target_groups = (
+ db.query(Group).filter(Group.name.in_(group_names)).all()
+ )
+ target_group_ids = {g.id for g in target_groups}
- 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()),
- }
+ # 2. Groups the user is CURRENTLY in
+ existing_group_ids = {
+ g.id
+ for g in db.query(Group)
+ .join(GroupMember, GroupMember.group_id == Group.id)
+ .filter(GroupMember.user_id == user_id)
+ .all()
+ }
+
+ # 3. Determine adds + removals
+ groups_to_add = target_group_ids - existing_group_ids
+ groups_to_remove = existing_group_ids - target_group_ids
+
+ # 4. Remove in one bulk delete
+ if groups_to_remove:
+ db.query(GroupMember).filter(
+ GroupMember.user_id == user_id,
+ GroupMember.group_id.in_(groups_to_remove),
+ ).delete(synchronize_session=False)
+
+ db.query(Group).filter(Group.id.in_(groups_to_remove)).update(
+ {"updated_at": now}, synchronize_session=False
+ )
+
+ # 5. Bulk insert missing memberships
+ for group_id in groups_to_add:
+ db.add(
+ GroupMember(
+ id=str(uuid.uuid4()),
+ group_id=group_id,
+ user_id=user_id,
+ created_at=now,
+ updated_at=now,
)
+ )
- # 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()),
- }
- )
+ if groups_to_add:
+ db.query(Group).filter(Group.id.in_(groups_to_add)).update(
+ {"updated_at": now}, synchronize_session=False
+ )
db.commit()
return True
+
except Exception as e:
log.exception(e)
+ db.rollback()
return False
+ def add_users_to_group(
+ self, id: str, user_ids: Optional[list[str]] = None
+ ) -> Optional[GroupModel]:
+ try:
+ with get_db() as db:
+ group = db.query(Group).filter_by(id=id).first()
+ if not group:
+ return None
+
+ now = int(time.time())
+
+ for user_id in user_ids or []:
+ try:
+ db.add(
+ GroupMember(
+ id=str(uuid.uuid4()),
+ group_id=id,
+ user_id=user_id,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ db.flush() # Detect unique constraint violation early
+ except Exception:
+ db.rollback() # Clear failed INSERT
+ db.begin() # Start a new transaction
+ continue # Duplicate → ignore
+
+ group.updated_at = now
+ db.commit()
+ db.refresh(group)
+
+ return GroupModel.model_validate(group)
+
+ except Exception as e:
+ log.exception(e)
+ return None
+
+ def remove_users_from_group(
+ self, id: str, user_ids: Optional[list[str]] = None
+ ) -> Optional[GroupModel]:
+ try:
+ with get_db() as db:
+ group = db.query(Group).filter_by(id=id).first()
+ if not group:
+ return None
+
+ if not user_ids:
+ return GroupModel.model_validate(group)
+
+ # Remove each user from group_member
+ for user_id in user_ids:
+ db.query(GroupMember).filter(
+ GroupMember.group_id == id, GroupMember.user_id == user_id
+ ).delete()
+
+ # Update group timestamp
+ group.updated_at = int(time.time())
+
+ db.commit()
+ db.refresh(group)
+ return GroupModel.model_validate(group)
+
+ except Exception as e:
+ log.exception(e)
+ return None
+
Groups = GroupTable()
diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py
index bed3d5542e..cfef77e237 100644
--- a/backend/open_webui/models/knowledge.py
+++ b/backend/open_webui/models/knowledge.py
@@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import FileMetadataResponse
+from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@@ -128,11 +129,18 @@ class KnowledgeTable:
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
with get_db() as db:
- knowledge_bases = []
- for knowledge in (
+ all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
- ):
- user = Users.get_user_by_id(knowledge.user_id)
+ )
+
+ user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
+
+ users = Users.get_users_by_user_ids(user_ids) if user_ids else []
+ users_dict = {user.id: user for user in users}
+
+ knowledge_bases = []
+ for knowledge in all_knowledge:
+ user = users_dict.get(knowledge.user_id)
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
@@ -143,15 +151,27 @@ class KnowledgeTable:
)
return knowledge_bases
+ def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
+ knowledge = self.get_knowledge_by_id(id)
+ if not knowledge:
+ return False
+ if knowledge.user_id == user_id:
+ return True
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
+ return has_access(user_id, permission, knowledge.access_control, user_group_ids)
+
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases()
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
- or has_access(user_id, permission, knowledge_base.access_control)
+ or has_access(
+ user_id, permission, knowledge_base.access_control, user_group_ids
+ )
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py
index 8b10a77cf9..f5f2492b99 100644
--- a/backend/open_webui/models/memories.py
+++ b/backend/open_webui/models/memories.py
@@ -14,7 +14,7 @@ from sqlalchemy import BigInteger, Column, String, Text
class Memory(Base):
__tablename__ = "memory"
- id = Column(String, primary_key=True)
+ id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
content = Column(Text)
updated_at = Column(BigInteger)
@@ -71,9 +71,13 @@ class MemoriesTable:
) -> Optional[MemoryModel]:
with get_db() as db:
try:
- db.query(Memory).filter_by(id=id, user_id=user_id).update(
- {"content": content, "updated_at": int(time.time())}
- )
+ memory = db.get(Memory, id)
+ if not memory or memory.user_id != user_id:
+ return None
+
+ memory.content = content
+ memory.updated_at = int(time.time())
+
db.commit()
return self.get_memory_by_id(id)
except Exception:
@@ -127,7 +131,12 @@ class MemoriesTable:
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
try:
- db.query(Memory).filter_by(id=id, user_id=user_id).delete()
+ memory = db.get(Memory, id)
+ if not memory or memory.user_id != user_id:
+ return None
+
+ # Delete the memory
+ db.delete(memory)
db.commit()
return True
diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py
index a27ae52519..6aaf09ca46 100644
--- a/backend/open_webui/models/messages.py
+++ b/backend/open_webui/models/messages.py
@@ -5,6 +5,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.models.users import Users, UserNameResponse
from pydantic import BaseModel, ConfigDict
@@ -19,7 +20,7 @@ from sqlalchemy.sql import exists
class MessageReaction(Base):
__tablename__ = "message_reaction"
- id = Column(Text, primary_key=True)
+ id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
message_id = Column(Text)
name = Column(Text)
@@ -43,6 +44,7 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
+ reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
+ reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
+ reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
count: int
-class MessageResponse(MessageModel):
+class MessageUserResponse(MessageModel):
+ user: Optional[UserNameResponse] = None
+
+
+class MessageReplyToResponse(MessageUserResponse):
+ reply_to_message: Optional[MessageUserResponse] = None
+
+
+class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
@@ -107,6 +119,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
+ "reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
@@ -128,19 +141,32 @@ class MessageTable:
if not message:
return None
- reactions = self.get_reactions_by_message_id(id)
- replies = self.get_replies_by_message_id(id)
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
- return MessageResponse(
- **{
+ reactions = self.get_reactions_by_message_id(id)
+ thread_replies = self.get_thread_replies_by_message_id(id)
+
+ user = Users.get_user_by_id(message.user_id)
+ return MessageResponse.model_validate(
+ {
**MessageModel.model_validate(message).model_dump(),
- "latest_reply_at": replies[0].created_at if replies else None,
- "reply_count": len(replies),
+ "user": user.model_dump() if user else None,
+ "reply_to_message": (
+ reply_to_message.model_dump() if reply_to_message else None
+ ),
+ "latest_reply_at": (
+ thread_replies[0].created_at if thread_replies else None
+ ),
+ "reply_count": len(thread_replies),
"reactions": reactions,
}
)
- def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
+ def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -148,7 +174,27 @@ class MessageTable:
.order_by(Message.created_at.desc())
.all()
)
- return [MessageModel.model_validate(message) for message in all_messages]
+
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
@@ -159,7 +205,7 @@ class MessageTable:
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
- ) -> list[MessageModel]:
+ ) -> list[MessageReplyToResponse]:
with get_db() as db:
all_messages = (
db.query(Message)
@@ -169,11 +215,31 @@ class MessageTable:
.limit(limit)
.all()
)
- return [MessageModel.model_validate(message) for message in all_messages]
+
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
- ) -> list[MessageModel]:
+ ) -> list[MessageReplyToResponse]:
with get_db() as db:
message = db.get(Message, parent_id)
@@ -193,7 +259,26 @@ class MessageTable:
if len(all_messages) < limit:
all_messages.append(message)
- return [MessageModel.model_validate(message) for message in all_messages]
+ messages = []
+ for message in all_messages:
+ reply_to_message = (
+ self.get_message_by_id(message.reply_to_id)
+ if message.reply_to_id
+ else None
+ )
+ messages.append(
+ MessageReplyToResponse.model_validate(
+ {
+ **MessageModel.model_validate(message).model_dump(),
+ "reply_to_message": (
+ reply_to_message.model_dump()
+ if reply_to_message
+ else None
+ ),
+ }
+ )
+ )
+ return messages
def update_message_by_id(
self, id: str, form_data: MessageForm
@@ -201,8 +286,14 @@ class MessageTable:
with get_db() as db:
message = db.get(Message, id)
message.content = form_data.content
- message.data = form_data.data
- message.meta = form_data.meta
+ message.data = {
+ **(message.data if message.data else {}),
+ **(form_data.data if form_data.data else {}),
+ }
+ message.meta = {
+ **(message.meta if message.meta else {}),
+ **(form_data.meta if form_data.meta else {}),
+ }
message.updated_at = int(time.time_ns())
db.commit()
db.refresh(message)
diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py
index 7df8d8656b..e902a978d1 100755
--- a/backend/open_webui/models/models.py
+++ b/backend/open_webui/models/models.py
@@ -5,12 +5,13 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
-from open_webui.models.users import Users, UserResponse
+from open_webui.models.groups import Groups
+from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import or_, and_, func
+from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
@@ -132,6 +133,11 @@ class ModelResponse(ModelModel):
pass
+class ModelListResponse(BaseModel):
+ items: list[ModelUserResponse]
+ total: int
+
+
class ModelForm(BaseModel):
id: str
base_model_id: Optional[str] = None
@@ -175,9 +181,16 @@ class ModelsTable:
def get_models(self) -> list[ModelUserResponse]:
with get_db() as db:
+ all_models = db.query(Model).filter(Model.base_model_id != None).all()
+
+ user_ids = list(set(model.user_id for model in all_models))
+
+ users = Users.get_users_by_user_ids(user_ids) if user_ids else []
+ users_dict = {user.id: user for user in users}
+
models = []
- for model in db.query(Model).filter(Model.base_model_id != None).all():
- user = Users.get_user_by_id(model.user_id)
+ for model in all_models:
+ user = users_dict.get(model.user_id)
models.append(
ModelUserResponse.model_validate(
{
@@ -199,13 +212,97 @@ class ModelsTable:
self, user_id: str, permission: str = "write"
) -> list[ModelUserResponse]:
models = self.get_models()
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
model
for model in models
if model.user_id == user_id
- or has_access(user_id, permission, model.access_control)
+ or has_access(user_id, permission, model.access_control, user_group_ids)
]
+ def search_models(
+ self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
+ ) -> ModelListResponse:
+ with get_db() as db:
+ # Join GroupMember so we can order by group_id when requested
+ query = db.query(Model, User).outerjoin(User, User.id == Model.user_id)
+ query = query.filter(Model.base_model_id != None)
+
+ if filter:
+ query_key = filter.get("query")
+ if query_key:
+ query = query.filter(
+ or_(
+ Model.name.ilike(f"%{query_key}%"),
+ Model.base_model_id.ilike(f"%{query_key}%"),
+ )
+ )
+
+ if filter.get("user_id"):
+ query = query.filter(Model.user_id == filter.get("user_id"))
+
+ view_option = filter.get("view_option")
+
+ if view_option == "created":
+ query = query.filter(Model.user_id == user_id)
+ elif view_option == "shared":
+ query = query.filter(Model.user_id != user_id)
+
+ tag = filter.get("tag")
+ if tag:
+ # TODO: This is a simple implementation and should be improved for performance
+ like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array
+ meta_text = func.lower(cast(Model.meta, String))
+
+ query = query.filter(meta_text.like(like_pattern))
+
+ order_by = filter.get("order_by")
+ direction = filter.get("direction")
+
+ if order_by == "name":
+ if direction == "asc":
+ query = query.order_by(Model.name.asc())
+ else:
+ query = query.order_by(Model.name.desc())
+ elif order_by == "created_at":
+ if direction == "asc":
+ query = query.order_by(Model.created_at.asc())
+ else:
+ query = query.order_by(Model.created_at.desc())
+ elif order_by == "updated_at":
+ if direction == "asc":
+ query = query.order_by(Model.updated_at.asc())
+ else:
+ query = query.order_by(Model.updated_at.desc())
+
+ else:
+ query = query.order_by(Model.created_at.desc())
+
+ # Count BEFORE pagination
+ total = query.count()
+
+ if skip:
+ query = query.offset(skip)
+ if limit:
+ query = query.limit(limit)
+
+ items = query.all()
+
+ models = []
+ for model, user in items:
+ models.append(
+ ModelUserResponse(
+ **ModelModel.model_validate(model).model_dump(),
+ user=(
+ UserResponse(**UserModel.model_validate(user).model_dump())
+ if user
+ else None
+ ),
+ )
+ )
+
+ return ModelListResponse(items=models, total=total)
+
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
try:
with get_db() as db:
@@ -235,11 +332,9 @@ class ModelsTable:
try:
with get_db() as db:
# update only the fields that are present in the model
- result = (
- db.query(Model)
- .filter_by(id=id)
- .update(model.model_dump(exclude={"id"}))
- )
+ data = model.model_dump(exclude={"id"})
+ result = db.query(Model).filter_by(id=id).update(data)
+
db.commit()
model = db.get(Model, id)
@@ -269,5 +364,49 @@ class ModelsTable:
except Exception:
return False
+ def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
+ try:
+ with get_db() as db:
+ # Get existing models
+ existing_models = db.query(Model).all()
+ existing_ids = {model.id for model in existing_models}
+
+ # Prepare a set of new model IDs
+ new_model_ids = {model.id for model in models}
+
+ # Update or insert models
+ for model in models:
+ if model.id in existing_ids:
+ db.query(Model).filter_by(id=model.id).update(
+ {
+ **model.model_dump(),
+ "user_id": user_id,
+ "updated_at": int(time.time()),
+ }
+ )
+ else:
+ new_model = Model(
+ **{
+ **model.model_dump(),
+ "user_id": user_id,
+ "updated_at": int(time.time()),
+ }
+ )
+ db.add(new_model)
+
+ # Remove models that are no longer present
+ for model in existing_models:
+ if model.id not in new_model_ids:
+ db.delete(model)
+
+ db.commit()
+
+ return [
+ ModelModel.model_validate(model) for model in db.query(Model).all()
+ ]
+ except Exception as e:
+ log.exception(f"Error syncing models for user {user_id}: {e}")
+ return []
+
Models = ModelsTable()
diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py
index 114ccdc574..f1b11f071e 100644
--- a/backend/open_webui/models/notes.py
+++ b/backend/open_webui/models/notes.py
@@ -2,8 +2,10 @@ import json
import time
import uuid
from typing import Optional
+from functools import lru_cache
from open_webui.internal.db import Base, get_db
+from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
@@ -62,6 +64,13 @@ class NoteForm(BaseModel):
access_control: Optional[dict] = None
+class NoteUpdateForm(BaseModel):
+ title: Optional[str] = None
+ data: Optional[dict] = None
+ meta: Optional[dict] = None
+ access_control: Optional[dict] = None
+
+
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
@@ -89,37 +98,111 @@ class NoteTable:
db.commit()
return note
- def get_notes(self) -> list[NoteModel]:
+ def get_notes(
+ self, skip: Optional[int] = None, limit: Optional[int] = None
+ ) -> list[NoteModel]:
with get_db() as db:
- notes = db.query(Note).order_by(Note.updated_at.desc()).all()
+ query = db.query(Note).order_by(Note.updated_at.desc())
+ if skip is not None:
+ query = query.offset(skip)
+ if limit is not None:
+ query = query.limit(limit)
+ notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id(
- self, user_id: str, permission: str = "write"
+ self,
+ user_id: str,
+ skip: Optional[int] = None,
+ limit: Optional[int] = None,
) -> 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)
- ]
+ with get_db() as db:
+ query = db.query(Note).filter(Note.user_id == user_id)
+ query = query.order_by(Note.updated_at.desc())
+
+ if skip is not None:
+ query = query.offset(skip)
+ if limit is not None:
+ query = query.limit(limit)
+
+ notes = query.all()
+ return [NoteModel.model_validate(note) for note in notes]
+
+ def get_notes_by_permission(
+ self,
+ user_id: str,
+ permission: str = "write",
+ skip: Optional[int] = None,
+ limit: Optional[int] = None,
+ ) -> list[NoteModel]:
+ with get_db() as db:
+ user_groups = Groups.get_groups_by_member_id(user_id)
+ user_group_ids = {group.id for group in user_groups}
+
+ # Order newest-first. We stream to keep memory usage low.
+ query = (
+ db.query(Note)
+ .order_by(Note.updated_at.desc())
+ .execution_options(stream_results=True)
+ .yield_per(256)
+ )
+
+ results: list[NoteModel] = []
+ n_skipped = 0
+
+ for note in query:
+ # Fast-pass #1: owner
+ if note.user_id == user_id:
+ permitted = True
+ # Fast-pass #2: public/open
+ elif note.access_control is None:
+ # Technically this should mean public access for both read and write, but we'll only do read for now
+ # We might want to change this behavior later
+ permitted = permission == "read"
+ else:
+ permitted = has_access(
+ user_id, permission, note.access_control, user_group_ids
+ )
+
+ if not permitted:
+ continue
+
+ # Apply skip AFTER permission filtering so it counts only accessible notes
+ if skip and n_skipped < skip:
+ n_skipped += 1
+ continue
+
+ results.append(NoteModel.model_validate(note))
+ if limit is not None and len(results) >= limit:
+ break
+
+ return results
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]:
+ def update_note_by_id(
+ self, id: str, form_data: NoteUpdateForm
+ ) -> 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
+ form_data = form_data.model_dump(exclude_unset=True)
+
+ if "title" in form_data:
+ note.title = form_data["title"]
+ if "data" in form_data:
+ note.data = {**note.data, **form_data["data"]}
+ if "meta" in form_data:
+ note.meta = {**note.meta, **form_data["meta"]}
+
+ if "access_control" in form_data:
+ note.access_control = form_data["access_control"]
+
note.updated_at = int(time.time_ns())
db.commit()
diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py
new file mode 100644
index 0000000000..b0e465dbe7
--- /dev/null
+++ b/backend/open_webui/models/oauth_sessions.py
@@ -0,0 +1,277 @@
+import time
+import logging
+import uuid
+from typing import Optional, List
+import base64
+import hashlib
+import json
+
+from cryptography.fernet import Fernet
+
+from open_webui.internal.db import Base, get_db
+from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Column, String, Text, Index
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MODELS"])
+
+####################
+# DB MODEL
+####################
+
+
+class OAuthSession(Base):
+ __tablename__ = "oauth_session"
+
+ id = Column(Text, primary_key=True)
+ user_id = Column(Text, nullable=False)
+ provider = Column(Text, nullable=False)
+ token = Column(
+ Text, nullable=False
+ ) # JSON with access_token, id_token, refresh_token
+ expires_at = Column(BigInteger, nullable=False)
+ created_at = Column(BigInteger, nullable=False)
+ updated_at = Column(BigInteger, nullable=False)
+
+ # Add indexes for better performance
+ __table_args__ = (
+ Index("idx_oauth_session_user_id", "user_id"),
+ Index("idx_oauth_session_expires_at", "expires_at"),
+ Index("idx_oauth_session_user_provider", "user_id", "provider"),
+ )
+
+
+class OAuthSessionModel(BaseModel):
+ id: str
+ user_id: str
+ provider: str
+ token: dict
+ expires_at: int # timestamp in epoch
+ created_at: int # timestamp in epoch
+ updated_at: int # timestamp in epoch
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+####################
+# Forms
+####################
+
+
+class OAuthSessionResponse(BaseModel):
+ id: str
+ user_id: str
+ provider: str
+ expires_at: int
+
+
+class OAuthSessionTable:
+ def __init__(self):
+ self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
+ if not self.encryption_key:
+ raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
+
+ # check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
+ if len(self.encryption_key) != 44:
+ key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
+ self.encryption_key = base64.urlsafe_b64encode(key_bytes)
+ else:
+ self.encryption_key = self.encryption_key.encode()
+
+ try:
+ self.fernet = Fernet(self.encryption_key)
+ except Exception as e:
+ log.error(f"Error initializing Fernet with provided key: {e}")
+ raise
+
+ def _encrypt_token(self, token) -> str:
+ """Encrypt OAuth tokens for storage"""
+ try:
+ token_json = json.dumps(token)
+ encrypted = self.fernet.encrypt(token_json.encode()).decode()
+ return encrypted
+ except Exception as e:
+ log.error(f"Error encrypting tokens: {e}")
+ raise
+
+ def _decrypt_token(self, token: str):
+ """Decrypt OAuth tokens from storage"""
+ try:
+ decrypted = self.fernet.decrypt(token.encode()).decode()
+ return json.loads(decrypted)
+ except Exception as e:
+ log.error(f"Error decrypting tokens: {e}")
+ raise
+
+ def create_session(
+ self,
+ user_id: str,
+ provider: str,
+ token: dict,
+ ) -> Optional[OAuthSessionModel]:
+ """Create a new OAuth session"""
+ try:
+ with get_db() as db:
+ current_time = int(time.time())
+ id = str(uuid.uuid4())
+
+ result = OAuthSession(
+ **{
+ "id": id,
+ "user_id": user_id,
+ "provider": provider,
+ "token": self._encrypt_token(token),
+ "expires_at": token.get("expires_at"),
+ "created_at": current_time,
+ "updated_at": current_time,
+ }
+ )
+
+ db.add(result)
+ db.commit()
+ db.refresh(result)
+
+ if result:
+ result.token = token # Return decrypted token
+ return OAuthSessionModel.model_validate(result)
+ else:
+ return None
+ except Exception as e:
+ log.error(f"Error creating OAuth session: {e}")
+ return None
+
+ def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
+ """Get OAuth session by ID"""
+ try:
+ with get_db() as db:
+ session = db.query(OAuthSession).filter_by(id=session_id).first()
+ if session:
+ session.token = self._decrypt_token(session.token)
+ return OAuthSessionModel.model_validate(session)
+
+ return None
+ except Exception as e:
+ log.error(f"Error getting OAuth session by ID: {e}")
+ return None
+
+ def get_session_by_id_and_user_id(
+ self, session_id: str, user_id: str
+ ) -> Optional[OAuthSessionModel]:
+ """Get OAuth session by ID and user ID"""
+ try:
+ with get_db() as db:
+ session = (
+ db.query(OAuthSession)
+ .filter_by(id=session_id, user_id=user_id)
+ .first()
+ )
+ if session:
+ session.token = self._decrypt_token(session.token)
+ return OAuthSessionModel.model_validate(session)
+
+ return None
+ except Exception as e:
+ log.error(f"Error getting OAuth session by ID: {e}")
+ return None
+
+ def get_session_by_provider_and_user_id(
+ self, provider: str, user_id: str
+ ) -> Optional[OAuthSessionModel]:
+ """Get OAuth session by provider and user ID"""
+ try:
+ with get_db() as db:
+ session = (
+ db.query(OAuthSession)
+ .filter_by(provider=provider, user_id=user_id)
+ .first()
+ )
+ if session:
+ session.token = self._decrypt_token(session.token)
+ return OAuthSessionModel.model_validate(session)
+
+ return None
+ except Exception as e:
+ log.error(f"Error getting OAuth session by provider and user ID: {e}")
+ return None
+
+ def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
+ """Get all OAuth sessions for a user"""
+ try:
+ with get_db() as db:
+ sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
+
+ results = []
+ for session in sessions:
+ session.token = self._decrypt_token(session.token)
+ results.append(OAuthSessionModel.model_validate(session))
+
+ return results
+
+ except Exception as e:
+ log.error(f"Error getting OAuth sessions by user ID: {e}")
+ return []
+
+ def update_session_by_id(
+ self, session_id: str, token: dict
+ ) -> Optional[OAuthSessionModel]:
+ """Update OAuth session tokens"""
+ try:
+ with get_db() as db:
+ current_time = int(time.time())
+
+ db.query(OAuthSession).filter_by(id=session_id).update(
+ {
+ "token": self._encrypt_token(token),
+ "expires_at": token.get("expires_at"),
+ "updated_at": current_time,
+ }
+ )
+ db.commit()
+ session = db.query(OAuthSession).filter_by(id=session_id).first()
+
+ if session:
+ session.token = self._decrypt_token(session.token)
+ return OAuthSessionModel.model_validate(session)
+
+ return None
+ except Exception as e:
+ log.error(f"Error updating OAuth session tokens: {e}")
+ return None
+
+ def delete_session_by_id(self, session_id: str) -> bool:
+ """Delete an OAuth session"""
+ try:
+ with get_db() as db:
+ result = db.query(OAuthSession).filter_by(id=session_id).delete()
+ db.commit()
+ return result > 0
+ except Exception as e:
+ log.error(f"Error deleting OAuth session: {e}")
+ return False
+
+ def delete_sessions_by_user_id(self, user_id: str) -> bool:
+ """Delete all OAuth sessions for a user"""
+ try:
+ with get_db() as db:
+ result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
+ db.commit()
+ return True
+ except Exception as e:
+ log.error(f"Error deleting OAuth sessions by user ID: {e}")
+ return False
+
+ def delete_sessions_by_provider(self, provider: str) -> bool:
+ """Delete all OAuth sessions for a provider"""
+ try:
+ with get_db() as db:
+ db.query(OAuthSession).filter_by(provider=provider).delete()
+ db.commit()
+ return True
+ except Exception as e:
+ log.error(f"Error deleting OAuth sessions by provider {provider}: {e}")
+ return False
+
+
+OAuthSessions = OAuthSessionTable()
diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py
index 8ef4cd2bec..7502f34ccd 100644
--- a/backend/open_webui/models/prompts.py
+++ b/backend/open_webui/models/prompts.py
@@ -2,6 +2,7 @@ import time
from typing import Optional
from open_webui.internal.db import Base, get_db
+from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
@@ -103,10 +104,16 @@ class PromptsTable:
def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db:
- prompts = []
+ all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
- for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
- user = Users.get_user_by_id(prompt.user_id)
+ user_ids = list(set(prompt.user_id for prompt in all_prompts))
+
+ users = Users.get_users_by_user_ids(user_ids) if user_ids else []
+ users_dict = {user.id: user for user in users}
+
+ prompts = []
+ for prompt in all_prompts:
+ user = users_dict.get(prompt.user_id)
prompts.append(
PromptUserResponse.model_validate(
{
@@ -122,12 +129,13 @@ class PromptsTable:
self, user_id: str, permission: str = "write"
) -> list[PromptUserResponse]:
prompts = self.get_prompts()
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
- or has_access(user_id, permission, prompt.access_control)
+ or has_access(user_id, permission, prompt.access_control, user_group_ids)
]
def update_prompt_by_command(
diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py
index 279dc624d5..e1cbb68a0b 100644
--- a/backend/open_webui/models/tags.py
+++ b/backend/open_webui/models/tags.py
@@ -8,7 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint
+from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -24,6 +24,11 @@ class Tag(Base):
user_id = Column(String)
meta = Column(JSON, nullable=True)
+ __table_args__ = (
+ PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),
+ Index("user_id_idx", "user_id"),
+ )
+
# Unique constraint ensuring (id, user_id) is unique, not just the `id` column
__table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),)
diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py
index 68a83ea42c..48f84b3ac4 100644
--- a/backend/open_webui/models/tools.py
+++ b/backend/open_webui/models/tools.py
@@ -4,6 +4,8 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserResponse
+from open_webui.models.groups import Groups
+
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -93,6 +95,8 @@ class ToolResponse(BaseModel):
class ToolUserResponse(ToolResponse):
user: Optional[UserResponse] = None
+ model_config = ConfigDict(extra="allow")
+
class ToolForm(BaseModel):
id: str
@@ -144,9 +148,16 @@ class ToolsTable:
def get_tools(self) -> list[ToolUserModel]:
with get_db() as db:
+ all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
+
+ user_ids = list(set(tool.user_id for tool in all_tools))
+
+ users = Users.get_users_by_user_ids(user_ids) if user_ids else []
+ users_dict = {user.id: user for user in users}
+
tools = []
- for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all():
- user = Users.get_user_by_id(tool.user_id)
+ for tool in all_tools:
+ user = users_dict.get(tool.user_id)
tools.append(
ToolUserModel.model_validate(
{
@@ -161,12 +172,13 @@ class ToolsTable:
self, user_id: str, permission: str = "write"
) -> list[ToolUserModel]:
tools = self.get_tools()
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
tool
for tool in tools
if tool.user_id == user_id
- or has_access(user_id, permission, tool.access_control)
+ or has_access(user_id, permission, tool.access_control, user_group_ids)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
@@ -175,7 +187,7 @@ class ToolsTable:
tool = db.get(Tool, id)
return tool.valves if tool.valves else {}
except Exception as e:
- log.exception(f"Error getting tool valves by id {id}: {e}")
+ log.exception(f"Error getting tool valves by id {id}")
return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py
index 00d5040884..d93f7ddeb3 100644
--- a/backend/open_webui/models/users.py
+++ b/backend/open_webui/models/users.py
@@ -4,14 +4,17 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
+from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats
-from open_webui.models.groups import Groups
+from open_webui.models.groups import Groups, GroupMember
+from open_webui.utils.misc import throttle
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Column, String, Text
-from sqlalchemy import or_
+from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select
+from sqlalchemy import or_, case
+import datetime
####################
# User DB Schema
@@ -23,20 +26,28 @@ class User(Base):
id = Column(String, primary_key=True)
name = Column(String)
+
email = Column(String)
+ username = Column(String(50), nullable=True)
+
role = Column(String)
profile_image_url = Column(Text)
- last_active_at = Column(BigInteger)
- updated_at = Column(BigInteger)
- created_at = Column(BigInteger)
+ bio = Column(Text, nullable=True)
+ gender = Column(Text, nullable=True)
+ date_of_birth = Column(Date, nullable=True)
+
+ info = Column(JSONField, nullable=True)
+ settings = Column(JSONField, nullable=True)
api_key = Column(String, nullable=True, unique=True)
- settings = Column(JSONField, nullable=True)
- info = Column(JSONField, nullable=True)
-
oauth_sub = Column(Text, unique=True)
+ last_active_at = Column(BigInteger)
+
+ updated_at = Column(BigInteger)
+ created_at = Column(BigInteger)
+
class UserSettings(BaseModel):
ui: Optional[dict] = {}
@@ -47,20 +58,27 @@ class UserSettings(BaseModel):
class UserModel(BaseModel):
id: str
name: str
+
email: str
+ username: Optional[str] = None
+
role: str = "pending"
profile_image_url: str
+ bio: Optional[str] = None
+ gender: Optional[str] = None
+ date_of_birth: Optional[datetime.date] = None
+
+ info: Optional[dict] = None
+ settings: Optional[UserSettings] = None
+
+ api_key: Optional[str] = None
+ oauth_sub: Optional[str] = None
+
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
- api_key: Optional[str] = None
- settings: Optional[UserSettings] = None
- info: Optional[dict] = None
-
- oauth_sub: Optional[str] = None
-
model_config = ConfigDict(from_attributes=True)
@@ -69,8 +87,51 @@ class UserModel(BaseModel):
####################
+class UpdateProfileForm(BaseModel):
+ profile_image_url: str
+ name: str
+ bio: Optional[str] = None
+ gender: Optional[str] = None
+ date_of_birth: Optional[datetime.date] = None
+
+
+class UserGroupIdsModel(UserModel):
+ group_ids: list[str] = []
+
+
+class UserModelResponse(UserModel):
+ model_config = ConfigDict(extra="allow")
+
+
class UserListResponse(BaseModel):
- users: list[UserModel]
+ users: list[UserModelResponse]
+ total: int
+
+
+class UserGroupIdsListResponse(BaseModel):
+ users: list[UserGroupIdsModel]
+ total: int
+
+
+class UserInfoResponse(BaseModel):
+ id: str
+ name: str
+ email: str
+ role: str
+
+
+class UserIdNameResponse(BaseModel):
+ id: str
+ name: str
+
+
+class UserInfoListResponse(BaseModel):
+ users: list[UserInfoResponse]
+ total: int
+
+
+class UserIdNameListResponse(BaseModel):
+ users: list[UserIdNameResponse]
total: int
@@ -172,8 +233,9 @@ class UsersTable:
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
- ) -> UserListResponse:
+ ) -> dict:
with get_db() as db:
+ # Join GroupMember so we can order by group_id when requested
query = db.query(User)
if filter:
@@ -186,14 +248,65 @@ class UsersTable:
)
)
+ user_ids = filter.get("user_ids")
+ group_ids = filter.get("group_ids")
+
+ if isinstance(user_ids, list) and isinstance(group_ids, list):
+ # If both are empty lists, return no users
+ if not user_ids and not group_ids:
+ return {"users": [], "total": 0}
+
+ if user_ids:
+ query = query.filter(User.id.in_(user_ids))
+
+ if group_ids:
+ query = query.filter(
+ exists(
+ select(GroupMember.id).where(
+ GroupMember.user_id == User.id,
+ GroupMember.group_id.in_(group_ids),
+ )
+ )
+ )
+
+ roles = filter.get("roles")
+ if roles:
+ include_roles = [role for role in roles if not role.startswith("!")]
+ exclude_roles = [role[1:] for role in roles if role.startswith("!")]
+
+ if include_roles:
+ query = query.filter(User.role.in_(include_roles))
+ if exclude_roles:
+ query = query.filter(~User.role.in_(exclude_roles))
+
order_by = filter.get("order_by")
direction = filter.get("direction")
- if order_by == "name":
+ if order_by and order_by.startswith("group_id:"):
+ group_id = order_by.split(":", 1)[1]
+
+ # Subquery that checks if the user belongs to the group
+ membership_exists = exists(
+ select(GroupMember.id).where(
+ GroupMember.user_id == User.id,
+ GroupMember.group_id == group_id,
+ )
+ )
+
+ # CASE: user in group → 1, user not in group → 0
+ group_sort = case((membership_exists, 1), else_=0)
+
+ if direction == "asc":
+ query = query.order_by(group_sort.asc(), User.name.asc())
+ else:
+ query = query.order_by(group_sort.desc(), User.name.asc())
+
+ elif 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())
@@ -226,15 +339,19 @@ class UsersTable:
else:
query = query.order_by(User.created_at.desc())
- if skip:
+ # Count BEFORE pagination
+ total = query.count()
+
+ # correct pagination logic
+ if skip is not None:
query = query.offset(skip)
- if limit:
+ if limit is not None:
query = query.limit(limit)
users = query.all()
return {
"users": [UserModel.model_validate(user) for user in users],
- "total": db.query(User).count(),
+ "total": total,
}
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
@@ -246,6 +363,10 @@ class UsersTable:
with get_db() as db:
return db.query(User).count()
+ def has_users(self) -> bool:
+ with get_db() as db:
+ return db.query(db.query(User).exists()).scalar()
+
def get_first_user(self) -> UserModel:
try:
with get_db() as db:
@@ -270,6 +391,15 @@ class UsersTable:
except Exception:
return None
+ def get_num_users_active_today(self) -> Optional[int]:
+ with get_db() as db:
+ current_timestamp = int(datetime.datetime.now().timestamp())
+ today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
+ query = db.query(User).filter(
+ User.last_active_at > today_midnight_timestamp
+ )
+ return query.count()
+
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
try:
with get_db() as db:
@@ -295,6 +425,7 @@ class UsersTable:
except Exception:
return None
+ @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
try:
with get_db() as db:
@@ -330,7 +461,8 @@ class UsersTable:
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
# return UserModel(**user.dict())
- except Exception:
+ except Exception as e:
+ print(e)
return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
diff --git a/backend/open_webui/retrieval/loaders/datalab_marker.py b/backend/open_webui/retrieval/loaders/datalab_marker.py
index 104c2830df..8d14be0a40 100644
--- a/backend/open_webui/retrieval/loaders/datalab_marker.py
+++ b/backend/open_webui/retrieval/loaders/datalab_marker.py
@@ -15,24 +15,28 @@ class DatalabMarkerLoader:
self,
file_path: str,
api_key: str,
- langs: Optional[str] = None,
+ api_base_url: str,
+ additional_config: 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,
+ format_lines: bool = False,
output_format: str = None,
):
self.file_path = file_path
self.api_key = api_key
- self.langs = langs
+ self.api_base_url = api_base_url
+ self.additional_config = additional_config
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.format_lines = format_lines
self.output_format = output_format
def _get_mime_type(self, filename: str) -> str:
@@ -60,7 +64,7 @@ class DatalabMarkerLoader:
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}"
+ url = f"{self.api_base_url}/{request_id}"
headers = {"X-Api-Key": self.api_key}
try:
response = requests.get(url, headers=headers)
@@ -81,22 +85,24 @@ class DatalabMarkerLoader:
)
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(),
+ "format_lines": str(self.format_lines).lower(),
"output_format": self.output_format,
}
+ if self.additional_config and self.additional_config.strip():
+ form_data["additional_config"] = self.additional_config
+
log.info(
f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}"
)
@@ -105,7 +111,10 @@ class DatalabMarkerLoader:
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
+ f"{self.api_base_url}",
+ data=form_data,
+ files=files,
+ headers=headers,
)
response.raise_for_status()
result = response.json()
@@ -133,74 +142,92 @@ class DatalabMarkerLoader:
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",
+ # Check if this is a direct response (self-hosted) or polling response (DataLab)
+ if check_url:
+ # DataLab polling pattern
+ 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}"
)
- }
- 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"
+ 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"Marker processing failed: {error_msg}",
+ detail=f"Final processing failed: {error_msg}",
)
+
+ # DataLab format - content in format-specific fields
+ content_key = self.output_format.lower()
+ raw_content = poll_result.get(content_key)
+ final_result = poll_result
else:
- raise HTTPException(
- status.HTTP_504_GATEWAY_TIMEOUT, detail="Marker processing timed out"
- )
+ # Self-hosted direct response - content in "output" field
+ if "output" in result:
+ log.info("Self-hosted Marker returned direct response without polling")
+ raw_content = result.get("output")
+ final_result = result
+ else:
+ available_fields = (
+ list(result.keys())
+ if isinstance(result, dict)
+ else "non-dict response"
+ )
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.",
+ )
- 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":
+ if self.output_format.lower() == "json":
full_text = json.dumps(raw_content, indent=2)
- elif content_key in {"markdown", "html"}:
+ elif self.output_format.lower() in {"markdown", "html"}:
full_text = str(raw_content).strip()
else:
raise HTTPException(
@@ -211,14 +238,14 @@ class DatalabMarkerLoader:
if not full_text:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
- detail="Datalab Marker returned empty content",
+ detail="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")
+ file_ext = file_ext_map.get(self.output_format.lower(), "txt")
output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}"
output_path = os.path.join(marker_output_dir, output_filename)
@@ -231,13 +258,13 @@ class DatalabMarkerLoader:
metadata = {
"source": filename,
- "output_format": poll_result.get("output_format", self.output_format),
- "page_count": poll_result.get("page_count", 0),
+ "output_format": final_result.get("output_format", self.output_format),
+ "page_count": final_result.get("page_count", 0),
"processed_with_llm": self.use_llm,
"request_id": request_id or "",
}
- images = poll_result.get("images", {})
+ images = final_result.get("images", {})
if images:
metadata["image_count"] = len(images)
metadata["images"] = json.dumps(list(images.keys()))
diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py
index c0ccd72432..998afd36f6 100644
--- a/backend/open_webui/retrieval/loaders/external_document.py
+++ b/backend/open_webui/retrieval/loaders/external_document.py
@@ -1,9 +1,11 @@
import requests
import logging, os
from typing import Iterator, List, Union
+from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
+from open_webui.utils.headers import include_user_info_headers
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
@@ -17,6 +19,7 @@ class ExternalDocumentLoader(BaseLoader):
url: str,
api_key: str,
mime_type=None,
+ user=None,
**kwargs,
) -> None:
self.url = url
@@ -25,6 +28,8 @@ class ExternalDocumentLoader(BaseLoader):
self.file_path = file_path
self.mime_type = mime_type
+ self.user = user
+
def load(self) -> List[Document]:
with open(self.file_path, "rb") as f:
data = f.read()
@@ -37,10 +42,13 @@ class ExternalDocumentLoader(BaseLoader):
headers["Authorization"] = f"Bearer {self.api_key}"
try:
- headers["X-Filename"] = os.path.basename(self.file_path)
+ headers["X-Filename"] = quote(os.path.basename(self.file_path))
except:
pass
+ if self.user is not None:
+ headers = include_user_info_headers(headers, self.user)
+
url = self.url
if url.endswith("/"):
url = url[:-1]
diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py
index 8ac878fc22..fcc507e088 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
import json
+from azure.identity import DefaultAzureCredential
from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
BSHTMLLoader,
@@ -14,7 +15,7 @@ from langchain_community.document_loaders import (
TextLoader,
UnstructuredEPubLoader,
UnstructuredExcelLoader,
- UnstructuredMarkdownLoader,
+ UnstructuredODTLoader,
UnstructuredPowerPointLoader,
UnstructuredRSTLoader,
UnstructuredXMLLoader,
@@ -26,6 +27,7 @@ from open_webui.retrieval.loaders.external_document import ExternalDocumentLoade
from open_webui.retrieval.loaders.mistral import MistralLoader
from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
+from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
@@ -130,8 +132,9 @@ class TikaLoader:
class DoclingLoader:
- def __init__(self, url, file_path=None, mime_type=None, params=None):
+ def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=None):
self.url = url.rstrip("/")
+ self.api_key = api_key
self.file_path = file_path
self.mime_type = mime_type
@@ -139,6 +142,10 @@ class DoclingLoader:
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
+ headers = {}
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+
files = {
"files": (
self.file_path,
@@ -147,43 +154,15 @@ class DoclingLoader:
)
}
- params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
-
- if self.params:
- if self.params.get("do_picture_description"):
- params["do_picture_description"] = self.params.get(
- "do_picture_description"
- )
-
- picture_description_mode = self.params.get(
- "picture_description_mode", ""
- ).lower()
-
- if picture_description_mode == "local" and self.params.get(
- "picture_description_local", {}
- ):
- params["picture_description_local"] = json.dumps(
- self.params.get("picture_description_local", {})
- )
-
- elif picture_description_mode == "api" and self.params.get(
- "picture_description_api", {}
- ):
- params["picture_description_api"] = json.dumps(
- self.params.get("picture_description_api", {})
- )
-
- 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)
-
+ r = requests.post(
+ f"{self.url}/v1/convert/file",
+ files=files,
+ data={
+ "image_export_mode": "placeholder",
+ **self.params,
+ },
+ headers=headers,
+ )
if r.ok:
result = r.json()
document_data = result.get("document", {})
@@ -192,7 +171,6 @@ class DoclingLoader:
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}"
@@ -209,6 +187,7 @@ class DoclingLoader:
class Loader:
def __init__(self, engine: str = "", **kwargs):
self.engine = engine
+ self.user = kwargs.get("user", None)
self.kwargs = kwargs
def load(
@@ -226,7 +205,10 @@ class Loader:
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
+ file_content_type
+ and file_content_type.find("text/") >= 0
+ # Avoid text/html files being detected as text
+ and not file_content_type.find("html") >= 0
)
def _get_loader(self, filename: str, file_content_type: str, file_path: str):
@@ -242,6 +224,7 @@ class Loader:
url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"),
api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"),
mime_type=file_content_type,
+ user=self.user,
)
elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"):
if self._is_text_file(file_ext, file_content_type):
@@ -250,7 +233,6 @@ class Loader:
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 (
@@ -278,10 +260,15 @@ class Loader:
"tiff",
]
):
+ api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
+ if not api_base_url or api_base_url.strip() == "":
+ api_base_url = "https://www.datalab.to/api/v1/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349
+
loader = DatalabMarkerLoader(
file_path=file_path,
api_key=self.kwargs["DATALAB_MARKER_API_KEY"],
- langs=self.kwargs.get("DATALAB_MARKER_LANGS"),
+ api_base_url=api_base_url,
+ additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"),
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),
@@ -292,6 +279,7 @@ class Loader:
disable_image_extraction=self.kwargs.get(
"DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False
),
+ format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False),
output_format=self.kwargs.get(
"DATALAB_MARKER_OUTPUT_FORMAT", "markdown"
),
@@ -311,6 +299,7 @@ class Loader:
loader = DoclingLoader(
url=self.kwargs.get("DOCLING_SERVER_URL"),
+ api_key=self.kwargs.get("DOCLING_API_KEY", None),
file_path=file_path,
mime_type=file_content_type,
params=params,
@@ -318,23 +307,37 @@ class Loader:
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"]
+ file_ext in ["pdf", "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(
+ if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "":
+ loader = AzureAIDocumentIntelligenceLoader(
+ file_path=file_path,
+ api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
+ api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
+ )
+ else:
+ loader = AzureAIDocumentIntelligenceLoader(
+ file_path=file_path,
+ api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
+ azure_credential=DefaultAzureCredential(),
+ )
+ elif self.engine == "mineru" and file_ext in [
+ "pdf"
+ ]: # MinerU currently only supports PDF
+ loader = MinerULoader(
file_path=file_path,
- api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
- api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
+ api_mode=self.kwargs.get("MINERU_API_MODE", "local"),
+ api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"),
+ api_key=self.kwargs.get("MINERU_API_KEY", ""),
+ params=self.kwargs.get("MINERU_PARAMS", {}),
)
elif (
self.engine == "mistral_ocr"
@@ -343,16 +346,9 @@ class Loader:
in ["pdf"] # Mistral OCR currently only supports PDF and images
):
loader = MistralLoader(
- api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
- )
- elif (
- self.engine == "external"
- and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
- and file_ext
- in ["pdf"] # Mistral OCR currently only supports PDF and images
- ):
- loader = MistralLoader(
- api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), file_path=file_path
+ base_url=self.kwargs.get("MISTRAL_OCR_API_BASE_URL"),
+ api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"),
+ file_path=file_path,
)
else:
if file_ext == "pdf":
@@ -389,6 +385,8 @@ class Loader:
loader = UnstructuredPowerPointLoader(file_path)
elif file_ext == "msg":
loader = OutlookMessageLoader(file_path)
+ elif file_ext == "odt":
+ loader = UnstructuredODTLoader(file_path)
elif self._is_text_file(file_ext, file_content_type):
loader = TextLoader(file_path, autodetect_encoding=True)
else:
diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py
new file mode 100644
index 0000000000..360af804c7
--- /dev/null
+++ b/backend/open_webui/retrieval/loaders/mineru.py
@@ -0,0 +1,522 @@
+import os
+import time
+import requests
+import logging
+import tempfile
+import zipfile
+from typing import List, Optional
+from langchain_core.documents import Document
+from fastapi import HTTPException, status
+
+log = logging.getLogger(__name__)
+
+
+class MinerULoader:
+ """
+ MinerU document parser loader supporting both Cloud API and Local API modes.
+
+ Cloud API: Uses MinerU managed service with async task-based processing
+ Local API: Uses self-hosted MinerU API with synchronous processing
+ """
+
+ def __init__(
+ self,
+ file_path: str,
+ api_mode: str = "local",
+ api_url: str = "http://localhost:8000",
+ api_key: str = "",
+ params: dict = None,
+ ):
+ self.file_path = file_path
+ self.api_mode = api_mode.lower()
+ self.api_url = api_url.rstrip("/")
+ self.api_key = api_key
+
+ # Parse params dict with defaults
+ self.params = params or {}
+ self.enable_ocr = params.get("enable_ocr", False)
+ self.enable_formula = params.get("enable_formula", True)
+ self.enable_table = params.get("enable_table", True)
+ self.language = params.get("language", "en")
+ self.model_version = params.get("model_version", "pipeline")
+
+ self.page_ranges = self.params.pop("page_ranges", "")
+
+ # Validate API mode
+ if self.api_mode not in ["local", "cloud"]:
+ raise ValueError(
+ f"Invalid API mode: {self.api_mode}. Must be 'local' or 'cloud'"
+ )
+
+ # Validate Cloud API requirements
+ if self.api_mode == "cloud" and not self.api_key:
+ raise ValueError("API key is required for Cloud API mode")
+
+ def load(self) -> List[Document]:
+ """
+ Main entry point for loading and parsing the document.
+ Routes to Cloud or Local API based on api_mode.
+ """
+ try:
+ if self.api_mode == "cloud":
+ return self._load_cloud_api()
+ else:
+ return self._load_local_api()
+ except Exception as e:
+ log.error(f"Error loading document with MinerU: {e}")
+ raise
+
+ def _load_local_api(self) -> List[Document]:
+ """
+ Load document using Local API (synchronous).
+ Posts file to /file_parse endpoint and gets immediate response.
+ """
+ log.info(f"Using MinerU Local API at {self.api_url}")
+
+ filename = os.path.basename(self.file_path)
+
+ # Build form data for Local API
+ form_data = {
+ **self.params,
+ "return_md": "true",
+ }
+
+ # Page ranges (Local API uses start_page_id and end_page_id)
+ if self.page_ranges:
+ # For simplicity, if page_ranges is specified, log a warning
+ # Full page range parsing would require parsing the string
+ log.warning(
+ f"Page ranges '{self.page_ranges}' specified but Local API uses different format. "
+ "Consider using start_page_id/end_page_id parameters if needed."
+ )
+
+ try:
+ with open(self.file_path, "rb") as f:
+ files = {"files": (filename, f, "application/octet-stream")}
+
+ log.info(f"Sending file to MinerU Local API: {filename}")
+ log.debug(f"Local API parameters: {form_data}")
+
+ response = requests.post(
+ f"{self.api_url}/file_parse",
+ data=form_data,
+ files=files,
+ timeout=300, # 5 minute timeout for large documents
+ )
+ response.raise_for_status()
+
+ except FileNotFoundError:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
+ )
+ except requests.Timeout:
+ raise HTTPException(
+ status.HTTP_504_GATEWAY_TIMEOUT,
+ detail="MinerU Local API request timed out",
+ )
+ except requests.HTTPError as e:
+ error_detail = f"MinerU Local API request failed: {e}"
+ if e.response is not None:
+ try:
+ error_data = e.response.json()
+ error_detail += f" - {error_data}"
+ except:
+ error_detail += f" - {e.response.text}"
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error calling MinerU Local API: {str(e)}",
+ )
+
+ # Parse response
+ try:
+ result = response.json()
+ except ValueError as e:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"Invalid JSON response from MinerU Local API: {e}",
+ )
+
+ # Extract markdown content from response
+ if "results" not in result:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail="MinerU Local API response missing 'results' field",
+ )
+
+ results = result["results"]
+ if not results:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail="MinerU returned empty results",
+ )
+
+ # Get the first (and typically only) result
+ file_result = list(results.values())[0]
+ markdown_content = file_result.get("md_content", "")
+
+ if not markdown_content:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail="MinerU returned empty markdown content",
+ )
+
+ log.info(f"Successfully parsed document with MinerU Local API: {filename}")
+
+ # Create metadata
+ metadata = {
+ "source": filename,
+ "api_mode": "local",
+ "backend": result.get("backend", "unknown"),
+ "version": result.get("version", "unknown"),
+ }
+
+ return [Document(page_content=markdown_content, metadata=metadata)]
+
+ def _load_cloud_api(self) -> List[Document]:
+ """
+ Load document using Cloud API (asynchronous).
+ Uses batch upload endpoint to avoid need for public file URLs.
+ """
+ log.info(f"Using MinerU Cloud API at {self.api_url}")
+
+ filename = os.path.basename(self.file_path)
+
+ # Step 1: Request presigned upload URL
+ batch_id, upload_url = self._request_upload_url(filename)
+
+ # Step 2: Upload file to presigned URL
+ self._upload_to_presigned_url(upload_url)
+
+ # Step 3: Poll for results
+ result = self._poll_batch_status(batch_id, filename)
+
+ # Step 4: Download and extract markdown from ZIP
+ markdown_content = self._download_and_extract_zip(
+ result["full_zip_url"], filename
+ )
+
+ log.info(f"Successfully parsed document with MinerU Cloud API: {filename}")
+
+ # Create metadata
+ metadata = {
+ "source": filename,
+ "api_mode": "cloud",
+ "batch_id": batch_id,
+ }
+
+ return [Document(page_content=markdown_content, metadata=metadata)]
+
+ def _request_upload_url(self, filename: str) -> tuple:
+ """
+ Request presigned upload URL from Cloud API.
+ Returns (batch_id, upload_url).
+ """
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ # Build request body
+ request_body = {
+ **self.params,
+ "files": [
+ {
+ "name": filename,
+ "is_ocr": self.enable_ocr,
+ }
+ ],
+ }
+
+ # Add page ranges if specified
+ if self.page_ranges:
+ request_body["files"][0]["page_ranges"] = self.page_ranges
+
+ log.info(f"Requesting upload URL for: {filename}")
+ log.debug(f"Cloud API request body: {request_body}")
+
+ try:
+ response = requests.post(
+ f"{self.api_url}/file-urls/batch",
+ headers=headers,
+ json=request_body,
+ timeout=30,
+ )
+ response.raise_for_status()
+ except requests.HTTPError as e:
+ error_detail = f"Failed to request upload URL: {e}"
+ if e.response is not None:
+ try:
+ error_data = e.response.json()
+ error_detail += f" - {error_data.get('msg', error_data)}"
+ except:
+ error_detail += f" - {e.response.text}"
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error requesting upload URL: {str(e)}",
+ )
+
+ try:
+ result = response.json()
+ except ValueError as e:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"Invalid JSON response: {e}",
+ )
+
+ # Check for API error response
+ if result.get("code") != 0:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
+ )
+
+ data = result.get("data", {})
+ batch_id = data.get("batch_id")
+ file_urls = data.get("file_urls", [])
+
+ if not batch_id or not file_urls:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail="MinerU Cloud API response missing batch_id or file_urls",
+ )
+
+ upload_url = file_urls[0]
+ log.info(f"Received upload URL for batch: {batch_id}")
+
+ return batch_id, upload_url
+
+ def _upload_to_presigned_url(self, upload_url: str) -> None:
+ """
+ Upload file to presigned URL (no authentication needed).
+ """
+ log.info(f"Uploading file to presigned URL")
+
+ try:
+ with open(self.file_path, "rb") as f:
+ response = requests.put(
+ upload_url,
+ data=f,
+ timeout=300, # 5 minute timeout for large files
+ )
+ response.raise_for_status()
+ except FileNotFoundError:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}"
+ )
+ except requests.Timeout:
+ raise HTTPException(
+ status.HTTP_504_GATEWAY_TIMEOUT,
+ detail="File upload to presigned URL timed out",
+ )
+ except requests.HTTPError as e:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"Failed to upload file to presigned URL: {e}",
+ )
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error uploading file: {str(e)}",
+ )
+
+ log.info("File uploaded successfully")
+
+ def _poll_batch_status(self, batch_id: str, filename: str) -> dict:
+ """
+ Poll batch status until completion.
+ Returns the result dict for the file.
+ """
+ headers = {
+ "Authorization": f"Bearer {self.api_key}",
+ }
+
+ max_iterations = 300 # 10 minutes max (2 seconds per iteration)
+ poll_interval = 2 # seconds
+
+ log.info(f"Polling batch status: {batch_id}")
+
+ for iteration in range(max_iterations):
+ try:
+ response = requests.get(
+ f"{self.api_url}/extract-results/batch/{batch_id}",
+ headers=headers,
+ timeout=30,
+ )
+ response.raise_for_status()
+ except requests.HTTPError as e:
+ error_detail = f"Failed to poll batch status: {e}"
+ if e.response is not None:
+ try:
+ error_data = e.response.json()
+ error_detail += f" - {error_data.get('msg', error_data)}"
+ except:
+ error_detail += f" - {e.response.text}"
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail)
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error polling batch status: {str(e)}",
+ )
+
+ try:
+ result = response.json()
+ except ValueError as e:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"Invalid JSON response while polling: {e}",
+ )
+
+ # Check for API error response
+ if result.get("code") != 0:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}",
+ )
+
+ data = result.get("data", {})
+ extract_result = data.get("extract_result", [])
+
+ # Find our file in the batch results
+ file_result = None
+ for item in extract_result:
+ if item.get("file_name") == filename:
+ file_result = item
+ break
+
+ if not file_result:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"File {filename} not found in batch results",
+ )
+
+ state = file_result.get("state")
+
+ if state == "done":
+ log.info(f"Processing complete for {filename}")
+ return file_result
+ elif state == "failed":
+ error_msg = file_result.get("err_msg", "Unknown error")
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"MinerU processing failed: {error_msg}",
+ )
+ elif state in ["waiting-file", "pending", "running", "converting"]:
+ # Still processing
+ if iteration % 10 == 0: # Log every 20 seconds
+ log.info(
+ f"Processing status: {state} (iteration {iteration + 1}/{max_iterations})"
+ )
+ time.sleep(poll_interval)
+ else:
+ log.warning(f"Unknown state: {state}")
+ time.sleep(poll_interval)
+
+ # Timeout
+ raise HTTPException(
+ status.HTTP_504_GATEWAY_TIMEOUT,
+ detail="MinerU processing timed out after 10 minutes",
+ )
+
+ def _download_and_extract_zip(self, zip_url: str, filename: str) -> str:
+ """
+ Download ZIP file from CDN and extract markdown content.
+ Returns the markdown content as a string.
+ """
+ log.info(f"Downloading results from: {zip_url}")
+
+ try:
+ response = requests.get(zip_url, timeout=60)
+ response.raise_for_status()
+ except requests.HTTPError as e:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"Failed to download results ZIP: {e}",
+ )
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error downloading results: {str(e)}",
+ )
+
+ # Save ZIP to temporary file and extract
+ try:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
+ tmp_zip.write(response.content)
+ tmp_zip_path = tmp_zip.name
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ # Extract ZIP
+ with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref:
+ zip_ref.extractall(tmp_dir)
+
+ # Find markdown file - search recursively for any .md file
+ markdown_content = None
+ found_md_path = None
+
+ # First, list all files in the ZIP for debugging
+ all_files = []
+ for root, dirs, files in os.walk(tmp_dir):
+ for file in files:
+ full_path = os.path.join(root, file)
+ all_files.append(full_path)
+ # Look for any .md file
+ if file.endswith(".md"):
+ found_md_path = full_path
+ log.info(f"Found markdown file at: {full_path}")
+ try:
+ with open(full_path, "r", encoding="utf-8") as f:
+ markdown_content = f.read()
+ if (
+ markdown_content
+ ): # Use the first non-empty markdown file
+ break
+ except Exception as e:
+ log.warning(f"Failed to read {full_path}: {e}")
+ if markdown_content:
+ break
+
+ if markdown_content is None:
+ log.error(f"Available files in ZIP: {all_files}")
+ # Try to provide more helpful error message
+ md_files = [f for f in all_files if f.endswith(".md")]
+ if md_files:
+ error_msg = (
+ f"Found .md files but couldn't read them: {md_files}"
+ )
+ else:
+ error_msg = (
+ f"No .md files found in ZIP. Available files: {all_files}"
+ )
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=error_msg,
+ )
+
+ # Clean up temporary ZIP file
+ os.unlink(tmp_zip_path)
+
+ except zipfile.BadZipFile as e:
+ raise HTTPException(
+ status.HTTP_502_BAD_GATEWAY,
+ detail=f"Invalid ZIP file received: {e}",
+ )
+ except Exception as e:
+ raise HTTPException(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Error extracting ZIP: {str(e)}",
+ )
+
+ if not markdown_content:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail="Extracted markdown content is empty",
+ )
+
+ log.info(
+ f"Successfully extracted markdown content ({len(markdown_content)} characters)"
+ )
+ return markdown_content
diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py
index b00e9d7ce5..6a2d235559 100644
--- a/backend/open_webui/retrieval/loaders/mistral.py
+++ b/backend/open_webui/retrieval/loaders/mistral.py
@@ -30,10 +30,9 @@ class MistralLoader:
- Enhanced error handling with retryable error classification
"""
- BASE_API_URL = "https://api.mistral.ai/v1"
-
def __init__(
self,
+ base_url: str,
api_key: str,
file_path: str,
timeout: int = 300, # 5 minutes default
@@ -55,6 +54,9 @@ class MistralLoader:
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found at {file_path}")
+ self.base_url = (
+ base_url.rstrip("/") if base_url else "https://api.mistral.ai/v1"
+ )
self.api_key = api_key
self.file_path = file_path
self.timeout = timeout
@@ -240,7 +242,7 @@ class MistralLoader:
in a context manager to minimize memory usage duration.
"""
log.info("Uploading file to Mistral API")
- url = f"{self.BASE_API_URL}/files"
+ url = f"{self.base_url}/files"
def upload_request():
# MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime
@@ -275,7 +277,7 @@ class MistralLoader:
async def _upload_file_async(self, session: aiohttp.ClientSession) -> str:
"""Async file upload with streaming for better memory efficiency."""
- url = f"{self.BASE_API_URL}/files"
+ url = f"{self.base_url}/files"
async def upload_request():
# Create multipart writer for streaming upload
@@ -321,7 +323,7 @@ class MistralLoader:
def _get_signed_url(self, file_id: str) -> str:
"""Retrieves a temporary signed URL for the uploaded file (sync version)."""
log.info(f"Getting signed URL for file ID: {file_id}")
- url = f"{self.BASE_API_URL}/files/{file_id}/url"
+ url = f"{self.base_url}/files/{file_id}/url"
params = {"expiry": 1}
signed_url_headers = {**self.headers, "Accept": "application/json"}
@@ -346,7 +348,7 @@ class MistralLoader:
self, session: aiohttp.ClientSession, file_id: str
) -> str:
"""Async signed URL retrieval."""
- url = f"{self.BASE_API_URL}/files/{file_id}/url"
+ url = f"{self.base_url}/files/{file_id}/url"
params = {"expiry": 1}
headers = {**self.headers, "Accept": "application/json"}
@@ -373,7 +375,7 @@ class MistralLoader:
def _process_ocr(self, signed_url: str) -> Dict[str, Any]:
"""Sends the signed URL to the OCR endpoint for processing (sync version)."""
log.info("Processing OCR via Mistral API")
- url = f"{self.BASE_API_URL}/ocr"
+ url = f"{self.base_url}/ocr"
ocr_headers = {
**self.headers,
"Content-Type": "application/json",
@@ -407,7 +409,7 @@ class MistralLoader:
self, session: aiohttp.ClientSession, signed_url: str
) -> Dict[str, Any]:
"""Async OCR processing with timing metrics."""
- url = f"{self.BASE_API_URL}/ocr"
+ url = f"{self.base_url}/ocr"
headers = {
**self.headers,
@@ -446,7 +448,7 @@ class MistralLoader:
def _delete_file(self, file_id: str) -> None:
"""Deletes the file from Mistral storage (sync version)."""
log.info(f"Deleting uploaded file ID: {file_id}")
- url = f"{self.BASE_API_URL}/files/{file_id}"
+ url = f"{self.base_url}/files/{file_id}"
try:
response = requests.delete(
@@ -467,7 +469,7 @@ class MistralLoader:
async def delete_request():
self._debug_log(f"Deleting file ID: {file_id}")
async with session.delete(
- url=f"{self.BASE_API_URL}/files/{file_id}",
+ url=f"{self.base_url}/files/{file_id}",
headers=self.headers,
timeout=aiohttp.ClientTimeout(
total=self.cleanup_timeout
@@ -507,6 +509,7 @@ class MistralLoader:
timeout=timeout,
headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"},
raise_for_status=False, # We handle status codes manually
+ trust_env=True,
) as session:
yield session
diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py
index be5e533588..cba602ed87 100644
--- a/backend/open_webui/retrieval/loaders/youtube.py
+++ b/backend/open_webui/retrieval/loaders/youtube.py
@@ -83,6 +83,7 @@ class YoutubeLoader:
TranscriptsDisabled,
YouTubeTranscriptApi,
)
+ from youtube_transcript_api.proxies import GenericProxyConfig
except ImportError:
raise ImportError(
'Could not import "youtube_transcript_api" Python package. '
@@ -90,18 +91,16 @@ class YoutubeLoader:
)
if self.proxy_url:
- youtube_proxies = {
- "http": self.proxy_url,
- "https": self.proxy_url,
- }
+ youtube_proxies = GenericProxyConfig(
+ http_url=self.proxy_url, https_url=self.proxy_url
+ )
log.debug(f"Using proxy URL: {self.proxy_url[:14]}...")
else:
youtube_proxies = None
+ transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
try:
- transcript_list = YouTubeTranscriptApi.list_transcripts(
- self.video_id, proxies=youtube_proxies
- )
+ transcript_list = transcript_api.list(self.video_id)
except Exception as e:
log.exception("Loading YouTube transcript failed")
return []
@@ -158,3 +157,10 @@ class YoutubeLoader:
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(self.video_id, self.language, list(transcript_list))
+
+ async def aload(self) -> Generator[Document, None, None]:
+ """Asynchronously load YouTube transcripts into `Document` objects."""
+ import asyncio
+
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, self.load)
diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py
index 5ebc3e52ea..822cb3e3dd 100644
--- a/backend/open_webui/retrieval/models/external.py
+++ b/backend/open_webui/retrieval/models/external.py
@@ -1,9 +1,12 @@
import logging
import requests
from typing import Optional, List, Tuple
+from urllib.parse import quote
-from open_webui.env import SRC_LOG_LEVELS
+
+from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
+from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
@@ -21,7 +24,9 @@ class ExternalReranker(BaseReranker):
self.url = url
self.model = model
- def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
+ def predict(
+ self, sentences: List[Tuple[str, str]], user=None
+ ) -> Optional[List[float]]:
query = sentences[0][0]
docs = [i[1] for i in sentences]
@@ -36,12 +41,17 @@ class ExternalReranker(BaseReranker):
log.info(f"ExternalReranker:predict:model {self.model}")
log.info(f"ExternalReranker:predict:query {query}")
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {self.api_key}",
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.post(
f"{self.url}",
- headers={
- "Content-Type": "application/json",
- "Authorization": f"Bearer {self.api_key}",
- },
+ headers=headers,
json=payload,
)
diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py
index 00dd683063..b041a00471 100644
--- a/backend/open_webui/retrieval/utils.py
+++ b/backend/open_webui/retrieval/utils.py
@@ -1,11 +1,14 @@
import logging
import os
-from typing import Optional, Union
+from typing import Awaitable, Optional, Union
import requests
+import aiohttp
+import asyncio
import hashlib
from concurrent.futures import ThreadPoolExecutor
import time
+import re
from urllib.parse import quote
from huggingface_hub import snapshot_download
@@ -16,10 +19,21 @@ from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
+
from open_webui.models.users import UserModel
from open_webui.models.files import Files
+from open_webui.models.knowledge import Knowledges
+
+from open_webui.models.chats import Chats
+from open_webui.models.notes import Notes
from open_webui.retrieval.vector.main import GetResult
+from open_webui.utils.access_control import has_access
+from open_webui.utils.headers import include_user_info_headers
+from open_webui.utils.misc import get_message_list
+
+from open_webui.retrieval.web.utils import get_web_loader
+from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import (
@@ -43,20 +57,63 @@ from langchain_core.callbacks import CallbackManagerForRetrieverRun
from langchain_core.retrievers import BaseRetriever
+def is_youtube_url(url: str) -> bool:
+ youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$"
+ return re.match(youtube_regex, url) is not None
+
+
+def get_loader(request, url: str):
+ if is_youtube_url(url):
+ return YoutubeLoader(
+ url,
+ language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
+ proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
+ )
+ else:
+ return get_web_loader(
+ url,
+ verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
+ requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
+ trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
+ )
+
+
+def get_content_from_url(request, url: str) -> str:
+ loader = get_loader(request, url)
+ docs = loader.load()
+ content = " ".join([doc.page_content for doc in docs])
+ return content, docs
+
+
class VectorSearchRetriever(BaseRetriever):
collection_name: Any
embedding_function: Any
top_k: int
def _get_relevant_documents(
+ self, query: str, *, run_manager: CallbackManagerForRetrieverRun
+ ) -> list[Document]:
+ """Get documents relevant to a query.
+
+ Args:
+ query: String to find relevant documents for.
+ run_manager: The callback handler to use.
+
+ Returns:
+ List of relevant documents.
+ """
+ return []
+
+ async def _aget_relevant_documents(
self,
query: str,
*,
run_manager: CallbackManagerForRetrieverRun,
) -> list[Document]:
+ embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
result = VECTOR_DB_CLIENT.search(
collection_name=self.collection_name,
- vectors=[self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)],
+ vectors=[embedding],
limit=self.top_k,
)
@@ -109,7 +166,45 @@ def get_doc(collection_name: str, user: UserModel = None):
raise e
-def query_doc_with_hybrid_search(
+def get_enriched_texts(collection_result: GetResult) -> list[str]:
+ enriched_texts = []
+ for idx, text in enumerate(collection_result.documents[0]):
+ metadata = collection_result.metadatas[0][idx]
+ metadata_parts = [text]
+
+ # Add filename (repeat twice for extra weight in BM25 scoring)
+ if metadata.get("name"):
+ filename = metadata["name"]
+ filename_tokens = (
+ filename.replace("_", " ").replace("-", " ").replace(".", " ")
+ )
+ metadata_parts.append(
+ f"Filename: {filename} {filename_tokens} {filename_tokens}"
+ )
+
+ # Add title if available
+ if metadata.get("title"):
+ metadata_parts.append(f"Title: {metadata['title']}")
+
+ # Add document section headings if available (from markdown splitter)
+ if metadata.get("headings") and isinstance(metadata["headings"], list):
+ headings = " > ".join(str(h) for h in metadata["headings"])
+ metadata_parts.append(f"Section: {headings}")
+
+ # Add source URL/path if available
+ if metadata.get("source"):
+ metadata_parts.append(f"Source: {metadata['source']}")
+
+ # Add snippet for web search results
+ if metadata.get("snippet"):
+ metadata_parts.append(f"Snippet: {metadata['snippet']}")
+
+ enriched_texts.append(" ".join(metadata_parts))
+
+ return enriched_texts
+
+
+async def query_doc_with_hybrid_search(
collection_name: str,
collection_result: GetResult,
query: str,
@@ -119,11 +214,37 @@ def query_doc_with_hybrid_search(
k_reranker: int,
r: float,
hybrid_bm25_weight: float,
+ enable_enriched_texts: bool = False,
) -> dict:
try:
+ # First check if collection_result has the required attributes
+ if (
+ not collection_result
+ or not hasattr(collection_result, "documents")
+ or not hasattr(collection_result, "metadatas")
+ ):
+ log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
+ return {"documents": [], "metadatas": [], "distances": []}
+
+ # Now safely check the documents content after confirming attributes exist
+ if (
+ not collection_result.documents
+ or len(collection_result.documents) == 0
+ or not collection_result.documents[0]
+ ):
+ log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
+ return {"documents": [], "metadatas": [], "distances": []}
+
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
+
+ bm25_texts = (
+ get_enriched_texts(collection_result)
+ if enable_enriched_texts
+ else collection_result.documents[0]
+ )
+
bm25_retriever = BM25Retriever.from_texts(
- texts=collection_result.documents[0],
+ texts=bm25_texts,
metadatas=collection_result.metadatas[0],
)
bm25_retriever.k = k
@@ -159,7 +280,7 @@ def query_doc_with_hybrid_search(
base_compressor=compressor, base_retriever=ensemble_retriever
)
- result = compression_retriever.invoke(query)
+ result = await compression_retriever.ainvoke(query)
distances = [d.metadata.get("score") for d in result]
documents = [d.page_content for d in result]
@@ -171,7 +292,11 @@ def query_doc_with_hybrid_search(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
- distances, documents, metadatas = map(list, zip(*sorted_items))
+
+ if sorted_items:
+ distances, documents, metadatas = map(list, zip(*sorted_items))
+ else:
+ distances, documents, metadatas = [], [], []
result = {
"distances": [distances],
@@ -215,6 +340,13 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
combined = dict() # To store documents with unique document hashes
for data in query_results:
+ if (
+ len(data.get("distances", [])) == 0
+ or len(data.get("documents", [])) == 0
+ or len(data.get("metadatas", [])) == 0
+ ):
+ continue
+
distances = data["distances"][0]
documents = data["documents"][0]
metadatas = data["metadatas"][0]
@@ -267,7 +399,7 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict:
return merge_get_results(results)
-def query_collection(
+async def query_collection(
collection_names: list[str],
queries: list[str],
embedding_function,
@@ -292,7 +424,9 @@ def query_collection(
return None, e
# Generate all query embeddings (in one call)
- query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
+ query_embeddings = await embedding_function(
+ queries, prefix=RAG_EMBEDDING_QUERY_PREFIX
+ )
log.debug(
f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections"
)
@@ -319,7 +453,7 @@ def query_collection(
return merge_and_sort_query_results(results, k=k)
-def query_collection_with_hybrid_search(
+async def query_collection_with_hybrid_search(
collection_names: list[str],
queries: list[str],
embedding_function,
@@ -328,6 +462,7 @@ def query_collection_with_hybrid_search(
k_reranker: int,
r: float,
hybrid_bm25_weight: float,
+ enable_enriched_texts: bool = False,
) -> dict:
results = []
error = False
@@ -350,9 +485,9 @@ def query_collection_with_hybrid_search(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
)
- def process_query(collection_name, query):
+ async def process_query(collection_name, query):
try:
- result = query_doc_with_hybrid_search(
+ result = await query_doc_with_hybrid_search(
collection_name=collection_name,
collection_result=collection_results[collection_name],
query=query,
@@ -362,6 +497,7 @@ def query_collection_with_hybrid_search(
k_reranker=k_reranker,
r=r,
hybrid_bm25_weight=hybrid_bm25_weight,
+ enable_enriched_texts=enable_enriched_texts,
)
return result, None
except Exception as e:
@@ -371,15 +507,16 @@ def query_collection_with_hybrid_search(
# 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
+ (collection_name, query)
+ for collection_name in collection_names
+ if collection_results[collection_name] is not None
+ for query 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]
+ # Run all queries in parallel using asyncio.gather
+ task_results = await asyncio.gather(
+ *[process_query(collection_name, query) for collection_name, query in tasks]
+ )
for result, err in task_results:
if err is not None:
@@ -395,6 +532,248 @@ def query_collection_with_hybrid_search(
return merge_and_sort_query_results(results, k=k)
+def generate_openai_batch_embeddings(
+ model: str,
+ 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
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {key}",
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ r = requests.post(
+ f"{url}/embeddings",
+ headers=headers,
+ json=json_data,
+ )
+ r.raise_for_status()
+ data = r.json()
+ if "data" in data:
+ return [elem["embedding"] for elem in data["data"]]
+ else:
+ raise "Something went wrong :/"
+ except Exception as e:
+ log.exception(f"Error generating openai batch embeddings: {e}")
+ return None
+
+
+async def agenerate_openai_batch_embeddings(
+ model: str,
+ 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"agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}"
+ )
+ form_data = {"input": texts, "model": model}
+ if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
+ form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {key}",
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ f"{url}/embeddings", headers=headers, json=form_data
+ ) as r:
+ r.raise_for_status()
+ data = await r.json()
+ if "data" in data:
+ return [item["embedding"] for item in data["data"]]
+ else:
+ raise Exception("Something went wrong :/")
+ except Exception as 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):
+ headers = {
+ "Content-Type": "application/json",
+ "api-key": key,
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ r = requests.post(
+ url,
+ headers=headers,
+ 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
+
+
+async def agenerate_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"agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}"
+ )
+ form_data = {"input": texts}
+ if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
+ form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
+
+ full_url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}"
+
+ headers = {
+ "Content-Type": "application/json",
+ "api-key": key,
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(full_url, headers=headers, json=form_data) as r:
+ r.raise_for_status()
+ data = await r.json()
+ if "data" in data:
+ return [item["embedding"] for item in data["data"]]
+ else:
+ raise Exception("Something went wrong :/")
+ 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 = "",
+ 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
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {key}",
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ r = requests.post(
+ f"{url}/api/embed",
+ headers=headers,
+ json=json_data,
+ )
+ r.raise_for_status()
+ data = r.json()
+
+ if "embeddings" in data:
+ return data["embeddings"]
+ else:
+ raise "Something went wrong :/"
+ except Exception as e:
+ log.exception(f"Error generating ollama batch embeddings: {e}")
+ return None
+
+
+async def agenerate_ollama_batch_embeddings(
+ model: str,
+ texts: list[str],
+ url: str,
+ key: str = "",
+ prefix: str = None,
+ user: UserModel = None,
+) -> Optional[list[list[float]]]:
+ try:
+ log.debug(
+ f"agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}"
+ )
+ form_data = {"input": texts, "model": model}
+ if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
+ form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {key}",
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ f"{url}/api/embed", headers=headers, json=form_data
+ ) as r:
+ r.raise_for_status()
+ data = await r.json()
+ if "embeddings" in data:
+ return data["embeddings"]
+ else:
+ raise Exception("Something went wrong :/")
+ except Exception as e:
+ log.exception(f"Error generating ollama batch embeddings: {e}")
+ return None
+
+
def get_embedding_function(
embedding_engine,
embedding_model,
@@ -403,13 +782,24 @@ def get_embedding_function(
key,
embedding_batch_size,
azure_api_version=None,
-):
+ enable_async=True,
+) -> Awaitable:
if embedding_engine == "":
- return lambda query, prefix=None, user=None: embedding_function.encode(
- query, **({"prompt": prefix} if prefix else {})
- ).tolist()
+ # Sentence transformers: CPU-bound sync operation
+ async def async_embedding_function(query, prefix=None, user=None):
+ return await asyncio.to_thread(
+ (
+ lambda query, prefix=None: embedding_function.encode(
+ query, **({"prompt": prefix} if prefix else {})
+ ).tolist()
+ ),
+ query,
+ prefix,
+ )
+
+ return async_embedding_function
elif embedding_engine in ["ollama", "openai", "azure_openai"]:
- func = lambda query, prefix=None, user=None: generate_embeddings(
+ embedding_function = lambda query, prefix=None, user=None: generate_embeddings(
engine=embedding_engine,
model=embedding_model,
text=query,
@@ -420,31 +810,116 @@ def get_embedding_function(
azure_api_version=azure_api_version,
)
- def generate_multiple(query, prefix, user, func):
+ async def async_embedding_function(query, prefix=None, user=None):
if isinstance(query, list):
- embeddings = []
- for i in range(0, len(query), embedding_batch_size):
- embeddings.extend(
- func(
- query[i : i + embedding_batch_size],
- prefix=prefix,
- user=user,
- )
+ # Create batches
+ batches = [
+ query[i : i + embedding_batch_size]
+ for i in range(0, len(query), embedding_batch_size)
+ ]
+
+ if enable_async:
+ log.debug(
+ f"generate_multiple_async: Processing {len(batches)} batches in parallel"
)
+ # Execute all batches in parallel
+ tasks = [
+ embedding_function(batch, prefix=prefix, user=user)
+ for batch in batches
+ ]
+ batch_results = await asyncio.gather(*tasks)
+ else:
+ log.debug(
+ f"generate_multiple_async: Processing {len(batches)} batches sequentially"
+ )
+ batch_results = []
+ for batch in batches:
+ batch_results.append(
+ await embedding_function(batch, prefix=prefix, user=user)
+ )
+
+ # Flatten results
+ embeddings = []
+ for batch_embeddings in batch_results:
+ if isinstance(batch_embeddings, list):
+ embeddings.extend(batch_embeddings)
+
+ log.debug(
+ f"generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches"
+ )
return embeddings
else:
- return func(query, prefix, user)
+ return await embedding_function(query, prefix, user)
- return lambda query, prefix=None, user=None: generate_multiple(
- query, prefix, user, func
- )
+ return async_embedding_function
else:
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
-def get_sources_from_files(
+async 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 prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None:
+ if isinstance(text, list):
+ text = [f"{prefix}{text_element}" for text_element in text]
+ else:
+ text = f"{prefix}{text}"
+
+ if engine == "ollama":
+ embeddings = await agenerate_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":
+ embeddings = await agenerate_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 = await agenerate_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
+
+
+def get_reranking_function(reranking_engine, reranking_model, reranking_function):
+ if reranking_function is None:
+ return None
+ if reranking_engine == "external":
+ return lambda query, documents, user=None: reranking_function.predict(
+ [(query, doc.page_content) for doc in documents], user=user
+ )
+ else:
+ return lambda query, documents, user=None: reranking_function.predict(
+ [(query, doc.page_content) for doc in documents]
+ )
+
+
+async def get_sources_from_items(
request,
- files,
+ items,
queries,
embedding_function,
k,
@@ -454,164 +929,272 @@ def get_sources_from_files(
hybrid_bm25_weight,
hybrid_search,
full_context=False,
+ user: Optional[UserModel] = None,
):
log.debug(
- f"files: {files} {queries} {embedding_function} {reranking_function} {full_context}"
+ f"items: {items} {queries} {embedding_function} {reranking_function} {full_context}"
)
extracted_collections = []
- relevant_contexts = []
+ query_results = []
- for file in files:
+ for item in items:
+ query_result = None
+ collection_names = []
- 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")}]],
- }
- 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", [])
+ if item.get("type") == "text":
+ # Raw Text
+ # Used during temporary chat file uploads or web page & youtube attachements
- documents = []
- metadatas = []
- for file_id in file_ids:
- file_object = Files.get_file_by_id(file_id)
+ if item.get("context") == "full":
+ if item.get("file"):
+ # if item has file data, use it
+ query_result = {
+ "documents": [
+ [item.get("file", {}).get("data", {}).get("content")]
+ ],
+ "metadatas": [[item.get("file", {}).get("meta", {})]],
+ }
- if file_object:
- documents.append(file_object.data.get("content", ""))
- metadatas.append(
- {
- "file_id": file_id,
- "name": file_object.filename,
- "source": file_object.filename,
- }
- )
+ if query_result is None:
+ # Fallback
+ if item.get("collection_name"):
+ # If item has a collection name, use it
+ collection_names.append(item.get("collection_name"))
+ elif item.get("file"):
+ # If item has file data, use it
+ query_result = {
+ "documents": [
+ [item.get("file", {}).get("data", {}).get("content")]
+ ],
+ "metadatas": [[item.get("file", {}).get("meta", {})]],
+ }
+ else:
+ # Fallback to item content
+ query_result = {
+ "documents": [[item.get("content")]],
+ "metadatas": [
+ [{"file_id": item.get("id"), "name": item.get("name")}]
+ ],
+ }
- context = {
- "documents": [documents],
- "metadatas": [metadatas],
+ elif item.get("type") == "note":
+ # Note Attached
+ note = Notes.get_note_by_id(item.get("id"))
+
+ if note and (
+ user.role == "admin"
+ or note.user_id == user.id
+ or has_access(user.id, "read", note.access_control)
+ ):
+ # User has access to the note
+ query_result = {
+ "documents": [[note.data.get("content", {}).get("md", "")]],
+ "metadatas": [[{"file_id": note.id, "name": note.title}]],
}
- elif file.get("id"):
- file_object = Files.get_file_by_id(file.get("id"))
- if file_object:
- context = {
- "documents": [[file_object.data.get("content", "")]],
+ elif item.get("type") == "chat":
+ # Chat Attached
+ chat = Chats.get_chat_by_id(item.get("id"))
+
+ if chat and (user.role == "admin" or chat.user_id == user.id):
+ messages_map = chat.chat.get("history", {}).get("messages", {})
+ message_id = chat.chat.get("history", {}).get("currentId")
+
+ if messages_map and message_id:
+ # Reconstruct the message list in order
+ message_list = get_message_list(messages_map, message_id)
+ message_history = "\n".join(
+ [
+ f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n"
+ for m in message_list
+ ]
+ )
+
+ # User has access to the chat
+ query_result = {
+ "documents": [[message_history]],
+ "metadatas": [[{"file_id": chat.id, "name": chat.title}]],
+ }
+
+ elif item.get("type") == "url":
+ content, docs = get_content_from_url(request, item.get("url"))
+ if docs:
+ query_result = {
+ "documents": [[content]],
+ "metadatas": [[{"url": item.get("url"), "name": item.get("url")}]],
+ }
+ elif item.get("type") == "file":
+ if (
+ item.get("context") == "full"
+ or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
+ ):
+ if item.get("file", {}).get("data", {}).get("content", ""):
+ # Manual Full Mode Toggle
+ # Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
+ query_result = {
+ "documents": [
+ [item.get("file", {}).get("data", {}).get("content", "")]
+ ],
"metadatas": [
[
{
- "file_id": file.get("id"),
- "name": file_object.filename,
- "source": file_object.filename,
+ "file_id": item.get("id"),
+ "name": item.get("name"),
+ **item.get("file")
+ .get("data", {})
+ .get("metadata", {}),
}
]
],
}
- 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"):
- collection_names = file.get("collection_names", [])
+ elif item.get("id"):
+ file_object = Files.get_file_by_id(item.get("id"))
+ if file_object:
+ query_result = {
+ "documents": [[file_object.data.get("content", "")]],
+ "metadatas": [
+ [
+ {
+ "file_id": item.get("id"),
+ "name": file_object.filename,
+ "source": file_object.filename,
+ }
+ ]
+ ],
+ }
+ else:
+ # Fallback to collection names
+ if item.get("legacy"):
+ collection_names.append(f"{item['id']}")
else:
- collection_names.append(file["id"])
- elif file.get("collection_name"):
- collection_names.append(file["collection_name"])
- elif file.get("id"):
- if file.get("legacy"):
- collection_names.append(f"{file['id']}")
- else:
- collection_names.append(f"file-{file['id']}")
+ collection_names.append(f"file-{item['id']}")
+ elif item.get("type") == "collection":
+ # Manual Full Mode Toggle for Collection
+ knowledge_base = Knowledges.get_knowledge_by_id(item.get("id"))
+
+ if knowledge_base and (
+ user.role == "admin"
+ or knowledge_base.user_id == user.id
+ or has_access(user.id, "read", knowledge_base.access_control)
+ ):
+ if (
+ item.get("context") == "full"
+ or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
+ ):
+ if knowledge_base and (
+ user.role == "admin"
+ or knowledge_base.user_id == user.id
+ or has_access(user.id, "read", knowledge_base.access_control)
+ ):
+
+ file_ids = knowledge_base.data.get("file_ids", [])
+
+ documents = []
+ metadatas = []
+ for file_id in file_ids:
+ file_object = Files.get_file_by_id(file_id)
+
+ if file_object:
+ documents.append(file_object.data.get("content", ""))
+ metadatas.append(
+ {
+ "file_id": file_id,
+ "name": file_object.filename,
+ "source": file_object.filename,
+ }
+ )
+
+ query_result = {
+ "documents": [documents],
+ "metadatas": [metadatas],
+ }
+ else:
+ # Fallback to collection names
+ if item.get("legacy"):
+ collection_names = item.get("collection_names", [])
+ else:
+ collection_names.append(item["id"])
+
+ elif item.get("docs"):
+ # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
+ query_result = {
+ "documents": [[doc.get("content") for doc in item.get("docs")]],
+ "metadatas": [[doc.get("metadata") for doc in item.get("docs")]],
+ }
+ elif item.get("collection_name"):
+ # Direct Collection Name
+ collection_names.append(item["collection_name"])
+ elif item.get("collection_names"):
+ # Collection Names List
+ collection_names.extend(item["collection_names"])
+
+ # If query_result is None
+ # Fallback to collection names and vector search the collections
+ if query_result is None and collection_names:
collection_names = set(collection_names).difference(extracted_collections)
if not collection_names:
- log.debug(f"skipping {file} as it has already been extracted")
+ log.debug(f"skipping {item} as it has already been extracted")
continue
- 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(
+ try:
+ if full_context:
+ query_result = get_all_items_from_collections(collection_names)
+ else:
+ query_result = None # Initialize to None
+ if hybrid_search:
+ try:
+ query_result = await 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,
+ enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
)
- except Exception as e:
- log.exception(e)
+ except Exception as e:
+ log.debug(
+ "Error when using hybrid search, using non hybrid search as fallback."
+ )
+
+ # fallback to non-hybrid search
+ if not hybrid_search and query_result is None:
+ query_result = await query_collection(
+ collection_names=collection_names,
+ queries=queries,
+ embedding_function=embedding_function,
+ k=k,
+ )
+ 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})
+ if query_result:
+ if "data" in item:
+ del item["data"]
+ query_results.append({**query_result, "file": item})
sources = []
- for context in relevant_contexts:
+ for query_result in query_results:
try:
- if "documents" in context:
- if "metadatas" in context:
+ if "documents" in query_result:
+ if "metadatas" in query_result:
source = {
- "source": context["file"],
- "document": context["documents"][0],
- "metadata": context["metadatas"][0],
+ "source": query_result["file"],
+ "document": query_result["documents"][0],
+ "metadata": query_result["metadatas"][0],
}
- if "distances" in context and context["distances"]:
- source["distances"] = context["distances"][0]
+ if "distances" in query_result and query_result["distances"]:
+ source["distances"] = query_result["distances"][0]
sources.append(source)
except Exception as e:
log.exception(e)
-
return sources
@@ -656,199 +1239,6 @@ def get_model_path(model: str, update_model: bool = False):
return model
-def generate_openai_batch_embeddings(
- model: str,
- 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={
- "Content-Type": "application/json",
- "Authorization": f"Bearer {key}",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
- json=json_data,
- )
- r.raise_for_status()
- data = r.json()
- if "data" in data:
- return [elem["embedding"] for elem in data["data"]]
- else:
- raise "Something went wrong :/"
- except Exception as 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": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(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 = "",
- 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={
- "Content-Type": "application/json",
- "Authorization": f"Bearer {key}",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- },
- json=json_data,
- )
- r.raise_for_status()
- data = r.json()
-
- if "embeddings" in data:
- return data["embeddings"]
- else:
- raise "Something went wrong :/"
- except Exception as e:
- log.exception(f"Error generating ollama batch embeddings: {e}")
- return None
-
-
-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 prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None:
- if isinstance(text, list):
- text = [f"{prefix}{text_element}" for text_element in text]
- else:
- 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":
- 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
-
-
import operator
from typing import Optional, Sequence
@@ -871,38 +1261,67 @@ class RerankCompressor(BaseDocumentCompressor):
documents: Sequence[Document],
query: str,
callbacks: Optional[Callbacks] = None,
+ ) -> Sequence[Document]:
+ """Compress retrieved documents given the query context.
+
+ Args:
+ documents: The retrieved documents.
+ query: The query context.
+ callbacks: Optional callbacks to run during compression.
+
+ Returns:
+ The compressed documents.
+
+ """
+ return []
+
+ async def acompress_documents(
+ self,
+ documents: Sequence[Document],
+ query: str,
+ callbacks: Optional[Callbacks] = None,
) -> Sequence[Document]:
reranking = self.reranking_function is not None
+ scores = None
if reranking:
- scores = self.reranking_function.predict(
- [(query, doc.page_content) for doc in documents]
- )
+ scores = self.reranking_function(query, documents)
else:
from sentence_transformers import util
- query_embedding = self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
- document_embedding = self.embedding_function(
+ query_embedding = await self.embedding_function(
+ query, RAG_EMBEDDING_QUERY_PREFIX
+ )
+ document_embedding = await self.embedding_function(
[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() 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
- ]
-
- result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
- final_results = []
- for doc, doc_score in result[: self.top_n]:
- metadata = doc.metadata
- metadata["score"] = doc_score
- doc = Document(
- page_content=doc.page_content,
- metadata=metadata,
+ if scores is not None:
+ docs_with_scores = list(
+ zip(
+ documents,
+ scores.tolist() if not isinstance(scores, list) else scores,
+ )
)
- final_results.append(doc)
- return final_results
+ if self.r_score:
+ docs_with_scores = [
+ (d, s) for d, s in docs_with_scores if s >= self.r_score
+ ]
+
+ result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True)
+ final_results = []
+ for doc, doc_score in result[: self.top_n]:
+ metadata = doc.metadata
+ metadata["score"] = doc_score
+ doc = Document(
+ page_content=doc.page_content,
+ metadata=metadata,
+ )
+ final_results.append(doc)
+ return final_results
+ else:
+ log.warning(
+ "No valid scores found, check your reranking function. Returning original documents."
+ )
+ return documents
diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py
index f9adc9c95f..1fdb064c51 100755
--- a/backend/open_webui/retrieval/vector/dbs/chroma.py
+++ b/backend/open_webui/retrieval/vector/dbs/chroma.py
@@ -11,6 +11,8 @@ from open_webui.retrieval.vector.main import (
SearchResult,
GetResult,
)
+from open_webui.retrieval.vector.utils import process_metadata
+
from open_webui.config import (
CHROMA_DATA_PATH,
CHROMA_HTTP_HOST,
@@ -144,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
- metadatas = [item["metadata"] for item in items]
+ metadatas = [process_metadata(item["metadata"]) for item in items]
for batch in create_batches(
api=self.client,
@@ -164,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items]
documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items]
- metadatas = [item["metadata"] for item in items]
+ metadatas = [process_metadata(item["metadata"]) for item in items]
collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
index 18a915e381..6de0d859f8 100644
--- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
+++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py
@@ -2,6 +2,8 @@ from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
+
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -243,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
},
}
for item in batch
@@ -264,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name,
"vector": item["vector"],
"text": item["text"],
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py
index a4bad13d00..98f8e335f2 100644
--- a/backend/open_webui/retrieval/vector/dbs/milvus.py
+++ b/backend/open_webui/retrieval/vector/dbs/milvus.py
@@ -1,8 +1,12 @@
from pymilvus import MilvusClient as Client
from pymilvus import FieldSchema, DataType
+from pymilvus import connections, Collection
+
import json
import logging
from typing import Optional
+
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -18,6 +22,8 @@ from open_webui.config import (
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
+ MILVUS_DISKANN_MAX_DEGREE,
+ MILVUS_DISKANN_SEARCH_LIST_SIZE,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -127,12 +133,18 @@ class MilvusClient(VectorDBBase):
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 == "DISKANN":
+ index_creation_params = {
+ "max_degree": MILVUS_DISKANN_MAX_DEGREE,
+ "search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
+ }
+ log.info(f"DISKANN 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"Supported types: HNSW, IVF_FLAT, DISKANN, 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.
@@ -185,7 +197,9 @@ class MilvusClient(VectorDBBase):
)
return self._result_to_search_result(result)
- def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
+ def query(self, collection_name: str, filter: dict, limit: int = -1):
+ connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
+
# Construct the filter string for querying
collection_name = collection_name.replace("-", "_")
if not self.has_collection(collection_name):
@@ -199,72 +213,36 @@ class MilvusClient(VectorDBBase):
for key, value in filter.items()
]
)
- max_limit = 16383 # The maximum number of records per request
- all_results = []
- if limit is None:
- # 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
+ collection = Collection(f"{self.collection_prefix}_{collection_name}")
+ collection.load()
+ all_results = []
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:
- current_fetch = min(
- 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=[
- "id",
- "data",
- "metadata",
- ], # Explicitly list needed fields. Vector not usually needed in query.
- limit=current_fetch,
- offset=offset,
- )
+ iterator = collection.query_iterator(
+ filter=filter_string,
+ output_fields=[
+ "id",
+ "data",
+ "metadata",
+ ],
+ limit=limit, # Pass the limit directly; -1 means no limit.
+ )
- if not results:
- log.debug("No more results from query.")
- break
-
- all_results.extend(results)
- results_count = len(results)
- 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 (means end of data)
- if results_count < current_fetch:
- log.debug(
- "Fetched less than requested, assuming end of results for this query."
- )
+ while True:
+ result = iterator.next()
+ if not result:
+ iterator.close()
break
+ all_results += result
log.info(f"Total results from query: {len(all_results)}")
return self._result_to_get_result([all_results])
+
except Exception as e:
log.exception(
f"Error querying collection {self.collection_prefix}_{collection_name} with filter '{filter_string}' and limit {limit}: {e}"
@@ -279,7 +257,7 @@ class MilvusClient(VectorDBBase):
)
# 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)
+ return self.query(collection_name=collection_name, filter={}, limit=-1)
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.
@@ -311,7 +289,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
}
for item in items
],
@@ -347,7 +325,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"],
"vector": item["vector"],
"data": {"text": item["text"]},
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
}
for item in items
],
diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py
new file mode 100644
index 0000000000..5c80d155d3
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py
@@ -0,0 +1,282 @@
+import logging
+from typing import Optional, Tuple, List, Dict, Any
+
+from open_webui.config import (
+ MILVUS_URI,
+ MILVUS_TOKEN,
+ MILVUS_DB,
+ MILVUS_COLLECTION_PREFIX,
+ MILVUS_INDEX_TYPE,
+ MILVUS_METRIC_TYPE,
+ MILVUS_HNSW_M,
+ MILVUS_HNSW_EFCONSTRUCTION,
+ MILVUS_IVF_FLAT_NLIST,
+)
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.vector.main import (
+ GetResult,
+ SearchResult,
+ VectorDBBase,
+ VectorItem,
+)
+from pymilvus import (
+ connections,
+ utility,
+ Collection,
+ CollectionSchema,
+ FieldSchema,
+ DataType,
+)
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+RESOURCE_ID_FIELD = "resource_id"
+
+
+class MilvusClient(VectorDBBase):
+ def __init__(self):
+ # Milvus collection names can only contain numbers, letters, and underscores.
+ self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
+ connections.connect(
+ alias="default",
+ uri=MILVUS_URI,
+ token=MILVUS_TOKEN,
+ db_name=MILVUS_DB,
+ )
+
+ # 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"
+ self.shared_collections = [
+ self.MEMORY_COLLECTION,
+ self.KNOWLEDGE_COLLECTION,
+ self.FILE_COLLECTION,
+ self.WEB_SEARCH_COLLECTION,
+ self.HASH_BASED_COLLECTION,
+ ]
+
+ def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
+ """
+ Maps the traditional collection name to multi-tenant collection and resource ID.
+
+ WARNING: This mapping relies on current Open WebUI naming conventions for
+ collection names. If Open WebUI changes how it generates collection names
+ (e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
+ formats), this mapping will break and route data to incorrect collections.
+ POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
+ DATA MAPPING INSIDE THE DATABASE.
+ """
+ resource_id = collection_name
+
+ if collection_name.startswith("user-memory-"):
+ return self.MEMORY_COLLECTION, resource_id
+ elif collection_name.startswith("file-"):
+ return self.FILE_COLLECTION, resource_id
+ elif collection_name.startswith("web-search-"):
+ return self.WEB_SEARCH_COLLECTION, resource_id
+ elif len(collection_name) == 63 and all(
+ c in "0123456789abcdef" for c in collection_name
+ ):
+ return self.HASH_BASED_COLLECTION, resource_id
+ else:
+ return self.KNOWLEDGE_COLLECTION, resource_id
+
+ def _create_shared_collection(self, mt_collection_name: str, dimension: int):
+ fields = [
+ FieldSchema(
+ name="id",
+ dtype=DataType.VARCHAR,
+ is_primary=True,
+ auto_id=False,
+ max_length=36,
+ ),
+ FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
+ FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
+ FieldSchema(name="metadata", dtype=DataType.JSON),
+ FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
+ ]
+ schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
+ collection = Collection(mt_collection_name, schema)
+
+ index_params = {
+ "metric_type": MILVUS_METRIC_TYPE,
+ "index_type": MILVUS_INDEX_TYPE,
+ "params": {},
+ }
+ if MILVUS_INDEX_TYPE == "HNSW":
+ index_params["params"] = {
+ "M": MILVUS_HNSW_M,
+ "efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
+ }
+ elif MILVUS_INDEX_TYPE == "IVF_FLAT":
+ index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
+
+ collection.create_index("vector", index_params)
+ collection.create_index(RESOURCE_ID_FIELD)
+ log.info(f"Created shared collection: {mt_collection_name}")
+ return collection
+
+ def _ensure_collection(self, mt_collection_name: str, dimension: int):
+ if not utility.has_collection(mt_collection_name):
+ self._create_shared_collection(mt_collection_name, dimension)
+
+ def has_collection(self, collection_name: str) -> bool:
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return False
+
+ collection = Collection(mt_collection)
+ collection.load()
+ res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
+ return len(res) > 0
+
+ def upsert(self, collection_name: str, items: List[VectorItem]):
+ if not items:
+ return
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ dimension = len(items[0]["vector"])
+ self._ensure_collection(mt_collection, dimension)
+ collection = Collection(mt_collection)
+
+ entities = [
+ {
+ "id": item["id"],
+ "vector": item["vector"],
+ "text": item["text"],
+ "metadata": item["metadata"],
+ RESOURCE_ID_FIELD: resource_id,
+ }
+ for item in items
+ ]
+ collection.insert(entities)
+ collection.flush()
+
+ def search(
+ self, collection_name: str, vectors: List[List[float]], limit: int
+ ) -> Optional[SearchResult]:
+ if not vectors:
+ return None
+
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return None
+
+ collection = Collection(mt_collection)
+ collection.load()
+
+ search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
+ results = collection.search(
+ data=vectors,
+ anns_field="vector",
+ param=search_params,
+ limit=limit,
+ expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
+ output_fields=["id", "text", "metadata"],
+ )
+
+ ids, documents, metadatas, distances = [], [], [], []
+ for hits in results:
+ batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
+ for hit in hits:
+ batch_ids.append(hit.entity.get("id"))
+ batch_docs.append(hit.entity.get("text"))
+ batch_metadatas.append(hit.entity.get("metadata"))
+ batch_dists.append(hit.distance)
+ ids.append(batch_ids)
+ documents.append(batch_docs)
+ metadatas.append(batch_metadatas)
+ distances.append(batch_dists)
+
+ return SearchResult(
+ ids=ids, documents=documents, metadatas=metadatas, distances=distances
+ )
+
+ def delete(
+ self,
+ collection_name: str,
+ ids: Optional[List[str]] = None,
+ filter: Optional[Dict[str, Any]] = None,
+ ):
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return
+
+ collection = Collection(mt_collection)
+
+ # Build expression
+ expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
+ if ids:
+ # Milvus expects a string list for 'in' operator
+ id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
+ expr.append(f"id in [{id_list_str}]")
+
+ if filter:
+ for key, value in filter.items():
+ expr.append(f"metadata['{key}'] == '{value}'")
+
+ collection.delete(" and ".join(expr))
+
+ def reset(self):
+ for collection_name in self.shared_collections:
+ if utility.has_collection(collection_name):
+ utility.drop_collection(collection_name)
+
+ def delete_collection(self, collection_name: str):
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return
+
+ collection = Collection(mt_collection)
+ collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
+
+ def query(
+ self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
+ ) -> Optional[GetResult]:
+ mt_collection, resource_id = self._get_collection_and_resource_id(
+ collection_name
+ )
+ if not utility.has_collection(mt_collection):
+ return None
+
+ collection = Collection(mt_collection)
+ collection.load()
+
+ expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
+ if filter:
+ for key, value in filter.items():
+ if isinstance(value, str):
+ expr.append(f"metadata['{key}'] == '{value}'")
+ else:
+ expr.append(f"metadata['{key}'] == {value}")
+
+ results = collection.query(
+ expr=" and ".join(expr),
+ output_fields=["id", "text", "metadata"],
+ limit=limit,
+ )
+
+ ids = [res["id"] for res in results]
+ documents = [res["text"] for res in results]
+ metadatas = [res["metadata"] for res in results]
+
+ return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
+
+ def get(self, collection_name: str) -> Optional[GetResult]:
+ return self.query(collection_name, filter={}, limit=None)
+
+ def insert(self, collection_name: str, items: List[VectorItem]):
+ return self.upsert(collection_name, items)
diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py
index 60ef2d906c..2e946710e2 100644
--- a/backend/open_webui/retrieval/vector/dbs/opensearch.py
+++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py
@@ -2,6 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -157,10 +158,10 @@ class OpenSearchClient(VectorDBBase):
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
- {"match": {"metadata." + str(field): value}}
+ {"term": {"metadata." + str(field) + ".keyword": value}}
)
- size = limit if limit else 10
+ size = limit if limit else 10000
try:
result = self.client.search(
@@ -200,12 +201,13 @@ class OpenSearchClient(VectorDBBase):
"_source": {
"vector": item["vector"],
"text": item["text"],
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
},
}
for item in batch
]
bulk(self.client, actions)
+ self.client.indices.refresh(self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@@ -221,13 +223,14 @@ class OpenSearchClient(VectorDBBase):
"doc": {
"vector": item["vector"],
"text": item["text"],
- "metadata": item["metadata"],
+ "metadata": process_metadata(item["metadata"]),
},
"doc_as_upsert": True,
}
for item in batch
]
bulk(self.client, actions)
+ self.client.indices.refresh(self._get_index_name(collection_name))
def delete(
self,
@@ -251,11 +254,12 @@ class OpenSearchClient(VectorDBBase):
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append(
- {"match": {"metadata." + str(field): value}}
+ {"term": {"metadata." + str(field) + ".keyword": value}}
)
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
+ self.client.indices.refresh(self._get_index_name(collection_name))
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py
new file mode 100644
index 0000000000..b714588bdc
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py
@@ -0,0 +1,943 @@
+"""
+Oracle 23ai Vector Database Client - Fixed Version
+
+# .env
+VECTOR_DB = "oracle23ai"
+
+## DBCS or oracle 23ai free
+ORACLE_DB_USE_WALLET = false
+ORACLE_DB_USER = "DEMOUSER"
+ORACLE_DB_PASSWORD = "Welcome123456"
+ORACLE_DB_DSN = "localhost:1521/FREEPDB1"
+
+## ADW or ATP
+# ORACLE_DB_USE_WALLET = true
+# ORACLE_DB_USER = "DEMOUSER"
+# ORACLE_DB_PASSWORD = "Welcome123456"
+# ORACLE_DB_DSN = "medium"
+# ORACLE_DB_DSN = "(description= (retry_count=3)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=xx.oraclecloud.com))(connect_data=(service_name=yy.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
+# ORACLE_WALLET_DIR = "/home/opc/adb_wallet"
+# ORACLE_WALLET_PASSWORD = "Welcome1"
+
+ORACLE_VECTOR_LENGTH = 768
+
+ORACLE_DB_POOL_MIN = 2
+ORACLE_DB_POOL_MAX = 10
+ORACLE_DB_POOL_INCREMENT = 1
+"""
+
+from typing import Optional, List, Dict, Any, Union
+from decimal import Decimal
+import logging
+import os
+import threading
+import time
+import json
+import array
+import oracledb
+
+from open_webui.retrieval.vector.main import (
+ VectorDBBase,
+ VectorItem,
+ SearchResult,
+ GetResult,
+)
+
+from open_webui.config import (
+ ORACLE_DB_USE_WALLET,
+ ORACLE_DB_USER,
+ ORACLE_DB_PASSWORD,
+ ORACLE_DB_DSN,
+ ORACLE_WALLET_DIR,
+ ORACLE_WALLET_PASSWORD,
+ ORACLE_VECTOR_LENGTH,
+ ORACLE_DB_POOL_MIN,
+ ORACLE_DB_POOL_MAX,
+ ORACLE_DB_POOL_INCREMENT,
+)
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+class Oracle23aiClient(VectorDBBase):
+ """
+ Oracle Vector Database Client for vector similarity search using Oracle Database 23ai.
+
+ This client provides an interface to store, retrieve, and search vector embeddings
+ in an Oracle database. It uses connection pooling for efficient database access
+ and supports vector similarity search operations.
+
+ Attributes:
+ pool: Connection pool for Oracle database connections
+ """
+
+ def __init__(self) -> None:
+ """
+ Initialize the Oracle23aiClient with a connection pool.
+
+ Creates a connection pool with configurable min/max connections, initializes
+ the database schema if needed, and sets up necessary tables and indexes.
+
+ Raises:
+ ValueError: If required configuration parameters are missing
+ Exception: If database initialization fails
+ """
+ self.pool = None
+
+ try:
+ # Create the appropriate connection pool based on DB type
+ if ORACLE_DB_USE_WALLET:
+ self._create_adb_pool()
+ else: # DBCS
+ self._create_dbcs_pool()
+
+ dsn = ORACLE_DB_DSN
+ log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]")
+
+ with self.get_connection() as connection:
+ log.info(f"Connection version: {connection.version}")
+ self._initialize_database(connection)
+
+ log.info("Oracle Vector Search initialization complete.")
+ except Exception as e:
+ log.exception(f"Error during Oracle Vector Search initialization: {e}")
+ raise
+
+ def _create_adb_pool(self) -> None:
+ """
+ Create connection pool for Oracle Autonomous Database.
+
+ Uses wallet-based authentication.
+ """
+ self.pool = oracledb.create_pool(
+ user=ORACLE_DB_USER,
+ password=ORACLE_DB_PASSWORD,
+ dsn=ORACLE_DB_DSN,
+ min=ORACLE_DB_POOL_MIN,
+ max=ORACLE_DB_POOL_MAX,
+ increment=ORACLE_DB_POOL_INCREMENT,
+ config_dir=ORACLE_WALLET_DIR,
+ wallet_location=ORACLE_WALLET_DIR,
+ wallet_password=ORACLE_WALLET_PASSWORD,
+ )
+ log.info("Created ADB connection pool with wallet authentication.")
+
+ def _create_dbcs_pool(self) -> None:
+ """
+ Create connection pool for Oracle Database Cloud Service.
+
+ Uses basic authentication without wallet.
+ """
+ self.pool = oracledb.create_pool(
+ user=ORACLE_DB_USER,
+ password=ORACLE_DB_PASSWORD,
+ dsn=ORACLE_DB_DSN,
+ min=ORACLE_DB_POOL_MIN,
+ max=ORACLE_DB_POOL_MAX,
+ increment=ORACLE_DB_POOL_INCREMENT,
+ )
+ log.info("Created DB connection pool with basic authentication.")
+
+ def get_connection(self):
+ """
+ Acquire a connection from the connection pool with retry logic.
+
+ Returns:
+ connection: A database connection with output type handler configured
+ """
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ connection = self.pool.acquire()
+ connection.outputtypehandler = self._output_type_handler
+ return connection
+ except oracledb.DatabaseError as e:
+ (error_obj,) = e.args
+ log.exception(
+ f"Connection attempt {attempt + 1} failed: {error_obj.message}"
+ )
+
+ if attempt < max_retries - 1:
+ wait_time = 2**attempt
+ log.info(f"Retrying in {wait_time} seconds...")
+ time.sleep(wait_time)
+ else:
+ raise
+
+ def start_health_monitor(self, interval_seconds: int = 60):
+ """
+ Start a background thread to periodically check the health of the connection pool.
+
+ Args:
+ interval_seconds (int): Number of seconds between health checks
+ """
+
+ def _monitor():
+ while True:
+ try:
+ log.info("[HealthCheck] Running periodic DB health check...")
+ self.ensure_connection()
+ log.info("[HealthCheck] Connection is healthy.")
+ except Exception as e:
+ log.exception(f"[HealthCheck] Connection health check failed: {e}")
+ time.sleep(interval_seconds)
+
+ thread = threading.Thread(target=_monitor, daemon=True)
+ thread.start()
+ log.info(f"Started DB health monitor every {interval_seconds} seconds.")
+
+ def _reconnect_pool(self):
+ """
+ Attempt to reinitialize the connection pool if it's been closed or broken.
+ """
+ try:
+ log.info("Attempting to reinitialize the Oracle connection pool...")
+
+ # Close existing pool if it exists
+ if self.pool:
+ try:
+ self.pool.close()
+ except Exception as close_error:
+ log.warning(f"Error closing existing pool: {close_error}")
+
+ # Re-create the appropriate connection pool based on DB type
+ if ORACLE_DB_USE_WALLET:
+ self._create_adb_pool()
+ else: # DBCS
+ self._create_dbcs_pool()
+
+ log.info("Connection pool reinitialized.")
+ except Exception as e:
+ log.exception(f"Failed to reinitialize the connection pool: {e}")
+ raise
+
+ def ensure_connection(self):
+ """
+ Ensure the database connection is alive, reconnecting pool if needed.
+ """
+ try:
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT 1 FROM dual")
+ except Exception as e:
+ log.exception(
+ f"Connection check failed: {e}, attempting to reconnect pool..."
+ )
+ self._reconnect_pool()
+
+ def _output_type_handler(self, cursor, metadata):
+ """
+ Handle Oracle vector type conversion.
+
+ Args:
+ cursor: Oracle database cursor
+ metadata: Metadata for the column
+
+ Returns:
+ A variable with appropriate conversion for vector types
+ """
+ if metadata.type_code is oracledb.DB_TYPE_VECTOR:
+ return cursor.var(
+ metadata.type_code, arraysize=cursor.arraysize, outconverter=list
+ )
+
+ def _initialize_database(self, connection) -> None:
+ """
+ Initialize database schema, tables and indexes.
+
+ Creates the document_chunk table and necessary indexes if they don't exist.
+
+ Args:
+ connection: Oracle database connection
+
+ Raises:
+ Exception: If schema initialization fails
+ """
+ with connection.cursor() as cursor:
+ try:
+ log.info("Creating Table document_chunk")
+ cursor.execute(
+ """
+ BEGIN
+ EXECUTE IMMEDIATE '
+ CREATE TABLE IF NOT EXISTS document_chunk (
+ id VARCHAR2(255) PRIMARY KEY,
+ collection_name VARCHAR2(255) NOT NULL,
+ text CLOB,
+ vmetadata JSON,
+ vector vector(*, float32)
+ )
+ ';
+ EXCEPTION
+ WHEN OTHERS THEN
+ IF SQLCODE != -955 THEN
+ RAISE;
+ END IF;
+ END;
+ """
+ )
+
+ log.info("Creating Index document_chunk_collection_name_idx")
+ cursor.execute(
+ """
+ BEGIN
+ EXECUTE IMMEDIATE '
+ CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
+ ON document_chunk (collection_name)
+ ';
+ EXCEPTION
+ WHEN OTHERS THEN
+ IF SQLCODE != -955 THEN
+ RAISE;
+ END IF;
+ END;
+ """
+ )
+
+ log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
+ cursor.execute(
+ """
+ BEGIN
+ EXECUTE IMMEDIATE '
+ CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
+ ON document_chunk(vector)
+ ORGANIZATION NEIGHBOR PARTITIONS
+ DISTANCE COSINE
+ WITH TARGET ACCURACY 95
+ PARAMETERS (TYPE IVF, NEIGHBOR PARTITIONS 100)
+ ';
+ EXCEPTION
+ WHEN OTHERS THEN
+ IF SQLCODE != -955 THEN
+ RAISE;
+ END IF;
+ END;
+ """
+ )
+
+ connection.commit()
+ log.info("Database initialization completed successfully.")
+
+ except Exception as e:
+ connection.rollback()
+ log.exception(f"Error during database initialization: {e}")
+ raise
+
+ def check_vector_length(self) -> None:
+ """
+ Check vector length compatibility (placeholder).
+
+ This method would check if the configured vector length matches the database schema.
+ Currently implemented as a placeholder.
+ """
+ pass
+
+ def _vector_to_blob(self, vector: List[float]) -> bytes:
+ """
+ Convert a vector to Oracle BLOB format.
+
+ Args:
+ vector (List[float]): The vector to convert
+
+ Returns:
+ bytes: The vector in Oracle BLOB format
+ """
+ return array.array("f", vector)
+
+ def adjust_vector_length(self, vector: List[float]) -> List[float]:
+ """
+ Adjust vector to the expected length if needed.
+
+ Args:
+ vector (List[float]): The vector to adjust
+
+ Returns:
+ List[float]: The adjusted vector
+ """
+ return vector
+
+ def _decimal_handler(self, obj):
+ """
+ Handle Decimal objects for JSON serialization.
+
+ Args:
+ obj: Object to serialize
+
+ Returns:
+ float: Converted decimal value
+
+ Raises:
+ TypeError: If object is not JSON serializable
+ """
+ if isinstance(obj, Decimal):
+ return float(obj)
+ raise TypeError(f"{obj} is not JSON serializable")
+
+ def _metadata_to_json(self, metadata: Dict) -> str:
+ """
+ Convert metadata dictionary to JSON string.
+
+ Args:
+ metadata (Dict): Metadata dictionary
+
+ Returns:
+ str: JSON representation of metadata
+ """
+ return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}"
+
+ def _json_to_metadata(self, json_str: str) -> Dict:
+ """
+ Convert JSON string to metadata dictionary.
+
+ Args:
+ json_str (str): JSON string
+
+ Returns:
+ Dict: Metadata dictionary
+ """
+ return json.loads(json_str) if json_str else {}
+
+ def insert(self, collection_name: str, items: List[VectorItem]) -> None:
+ """
+ Insert vector items into the database.
+
+ Args:
+ collection_name (str): Name of the collection
+ items (List[VectorItem]): List of vector items to insert
+
+ Raises:
+ Exception: If insertion fails
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> items = [
+ ... {"id": "1", "text": "Sample text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
+ ... {"id": "2", "text": "Another text", "vector": [0.3, 0.4, ...], "metadata": {"source": "doc2"}}
+ ... ]
+ >>> client.insert("my_collection", items)
+ """
+ log.info(f"Inserting {len(items)} items into collection '{collection_name}'.")
+
+ with self.get_connection() as connection:
+ try:
+ with connection.cursor() as cursor:
+ for item in items:
+ vector_blob = self._vector_to_blob(item["vector"])
+ metadata_json = self._metadata_to_json(item["metadata"])
+
+ cursor.execute(
+ """
+ INSERT INTO document_chunk
+ (id, collection_name, text, vmetadata, vector)
+ VALUES (:id, :collection_name, :text, :metadata, :vector)
+ """,
+ {
+ "id": item["id"],
+ "collection_name": collection_name,
+ "text": item["text"],
+ "metadata": metadata_json,
+ "vector": vector_blob,
+ },
+ )
+
+ connection.commit()
+ log.info(
+ f"Successfully inserted {len(items)} items into collection '{collection_name}'."
+ )
+
+ except Exception as e:
+ connection.rollback()
+ log.exception(f"Error during insert: {e}")
+ raise
+
+ def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
+ """
+ Update or insert vector items into the database.
+
+ If an item with the same ID exists, it will be updated;
+ otherwise, it will be inserted.
+
+ Args:
+ collection_name (str): Name of the collection
+ items (List[VectorItem]): List of vector items to upsert
+
+ Raises:
+ Exception: If upsert operation fails
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> items = [
+ ... {"id": "1", "text": "Updated text", "vector": [0.1, 0.2, ...], "metadata": {"source": "doc1"}},
+ ... {"id": "3", "text": "New item", "vector": [0.5, 0.6, ...], "metadata": {"source": "doc3"}}
+ ... ]
+ >>> client.upsert("my_collection", items)
+ """
+ log.info(f"Upserting {len(items)} items into collection '{collection_name}'.")
+
+ with self.get_connection() as connection:
+ try:
+ with connection.cursor() as cursor:
+ for item in items:
+ vector_blob = self._vector_to_blob(item["vector"])
+ metadata_json = self._metadata_to_json(item["metadata"])
+
+ cursor.execute(
+ """
+ MERGE INTO document_chunk d
+ USING (SELECT :merge_id as id FROM dual) s
+ ON (d.id = s.id)
+ WHEN MATCHED THEN
+ UPDATE SET
+ collection_name = :upd_collection_name,
+ text = :upd_text,
+ vmetadata = :upd_metadata,
+ vector = :upd_vector
+ WHEN NOT MATCHED THEN
+ INSERT (id, collection_name, text, vmetadata, vector)
+ VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector)
+ """,
+ {
+ "merge_id": item["id"],
+ "upd_collection_name": collection_name,
+ "upd_text": item["text"],
+ "upd_metadata": metadata_json,
+ "upd_vector": vector_blob,
+ "ins_id": item["id"],
+ "ins_collection_name": collection_name,
+ "ins_text": item["text"],
+ "ins_metadata": metadata_json,
+ "ins_vector": vector_blob,
+ },
+ )
+
+ connection.commit()
+ log.info(
+ f"Successfully upserted {len(items)} items into collection '{collection_name}'."
+ )
+
+ except Exception as e:
+ connection.rollback()
+ log.exception(f"Error during upsert: {e}")
+ raise
+
+ def search(
+ self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
+ ) -> Optional[SearchResult]:
+ """
+ Search for similar vectors in the database.
+
+ Performs vector similarity search using cosine distance.
+
+ Args:
+ collection_name (str): Name of the collection to search
+ vectors (List[List[Union[float, int]]]): Query vectors to find similar items for
+ limit (int): Maximum number of results to return per query
+
+ Returns:
+ Optional[SearchResult]: Search results containing ids, distances, documents, and metadata
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> query_vector = [0.1, 0.2, 0.3, ...] # Must match VECTOR_LENGTH
+ >>> results = client.search("my_collection", [query_vector], limit=5)
+ >>> if results:
+ ... log.info(f"Found {len(results.ids[0])} matches")
+ ... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])):
+ ... log.info(f"Match {i+1}: id={id}, distance={dist}")
+ """
+ log.info(
+ f"Searching items from collection '{collection_name}' with limit {limit}."
+ )
+
+ try:
+ if not vectors:
+ log.warning("No vectors provided for search.")
+ return None
+
+ num_queries = len(vectors)
+
+ ids = [[] for _ in range(num_queries)]
+ distances = [[] for _ in range(num_queries)]
+ documents = [[] for _ in range(num_queries)]
+ metadatas = [[] for _ in range(num_queries)]
+
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ for qid, vector in enumerate(vectors):
+ vector_blob = self._vector_to_blob(vector)
+
+ cursor.execute(
+ """
+ SELECT dc.id, dc.text,
+ JSON_SERIALIZE(dc.vmetadata RETURNING VARCHAR2(4096)) as vmetadata,
+ VECTOR_DISTANCE(dc.vector, :query_vector, COSINE) as distance
+ FROM document_chunk dc
+ WHERE dc.collection_name = :collection_name
+ ORDER BY VECTOR_DISTANCE(dc.vector, :query_vector, COSINE)
+ FETCH APPROX FIRST :limit ROWS ONLY
+ """,
+ {
+ "query_vector": vector_blob,
+ "collection_name": collection_name,
+ "limit": limit,
+ },
+ )
+
+ results = cursor.fetchall()
+
+ for row in results:
+ ids[qid].append(row[0])
+ documents[qid].append(
+ row[1].read()
+ if isinstance(row[1], oracledb.LOB)
+ else str(row[1])
+ )
+ # 🔧 FIXED: Parse JSON metadata properly
+ metadata_str = (
+ row[2].read()
+ if isinstance(row[2], oracledb.LOB)
+ else row[2]
+ )
+ metadatas[qid].append(self._json_to_metadata(metadata_str))
+ distances[qid].append(float(row[3]))
+
+ log.info(
+ f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results."
+ )
+
+ return SearchResult(
+ ids=ids, distances=distances, documents=documents, metadatas=metadatas
+ )
+
+ except Exception as e:
+ log.exception(f"Error during search: {e}")
+ return None
+
+ def query(
+ self, collection_name: str, filter: Dict, limit: Optional[int] = None
+ ) -> Optional[GetResult]:
+ """
+ Query items based on metadata filters.
+
+ Retrieves items that match specified metadata criteria.
+
+ Args:
+ collection_name (str): Name of the collection to query
+ filter (Dict[str, Any]): Metadata filters to apply
+ limit (Optional[int]): Maximum number of results to return
+
+ Returns:
+ Optional[GetResult]: Query results containing ids, documents, and metadata
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> filter = {"source": "doc1", "category": "finance"}
+ >>> results = client.query("my_collection", filter, limit=20)
+ >>> if results:
+ ... print(f"Found {len(results.ids[0])} matching documents")
+ """
+ log.info(f"Querying items from collection '{collection_name}' with filters.")
+
+ try:
+ limit = limit or 100
+
+ query = """
+ SELECT id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
+ FROM document_chunk
+ WHERE collection_name = :collection_name
+ """
+
+ params = {"collection_name": collection_name}
+
+ for i, (key, value) in enumerate(filter.items()):
+ param_name = f"value_{i}"
+ query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
+ params[param_name] = str(value)
+
+ query += " FETCH FIRST :limit ROWS ONLY"
+ params["limit"] = limit
+
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(query, params)
+ results = cursor.fetchall()
+
+ if not results:
+ log.info("No results found for query.")
+ return None
+
+ ids = [[row[0] for row in results]]
+ documents = [
+ [
+ row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
+ for row in results
+ ]
+ ]
+ # 🔧 FIXED: Parse JSON metadata properly
+ metadatas = [
+ [
+ self._json_to_metadata(
+ row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
+ )
+ for row in results
+ ]
+ ]
+
+ log.info(f"Query completed. Found {len(results)} results.")
+
+ return GetResult(ids=ids, documents=documents, metadatas=metadatas)
+
+ except Exception as e:
+ log.exception(f"Error during query: {e}")
+ return None
+
+ def get(self, collection_name: str) -> Optional[GetResult]:
+ """
+ Get all items in a collection.
+
+ Retrieves items from a specified collection up to the limit.
+
+ Args:
+ collection_name (str): Name of the collection to retrieve
+ limit (Optional[int]): Maximum number of items to retrieve
+
+ Returns:
+ Optional[GetResult]: Result containing ids, documents, and metadata
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> results = client.get("my_collection", limit=50)
+ >>> if results:
+ ... print(f"Retrieved {len(results.ids[0])} documents from collection")
+ """
+ log.info(
+ f"Getting items from collection '{collection_name}' with limit {limit}."
+ )
+
+ try:
+ limit = 1000 # Hardcoded limit for get operation
+
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT /*+ MONITOR */ id, text, JSON_SERIALIZE(vmetadata RETURNING VARCHAR2(4096)) as vmetadata
+ FROM document_chunk
+ WHERE collection_name = :collection_name
+ FETCH FIRST :limit ROWS ONLY
+ """,
+ {"collection_name": collection_name, "limit": limit},
+ )
+
+ results = cursor.fetchall()
+
+ if not results:
+ log.info("No results found.")
+ return None
+
+ ids = [[row[0] for row in results]]
+ documents = [
+ [
+ row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])
+ for row in results
+ ]
+ ]
+ # 🔧 FIXED: Parse JSON metadata properly
+ metadatas = [
+ [
+ self._json_to_metadata(
+ row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]
+ )
+ for row in results
+ ]
+ ]
+
+ return GetResult(ids=ids, documents=documents, metadatas=metadatas)
+
+ except Exception as e:
+ log.exception(f"Error during get: {e}")
+ return None
+
+ def delete(
+ self,
+ collection_name: str,
+ ids: Optional[List[str]] = None,
+ filter: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ """
+ Delete items from the database.
+
+ Deletes items from a collection based on IDs or metadata filters.
+
+ Args:
+ collection_name (str): Name of the collection to delete from
+ ids (Optional[List[str]]): Specific item IDs to delete
+ filter (Optional[Dict[str, Any]]): Metadata filters for deletion
+
+ Raises:
+ Exception: If deletion fails
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> # Delete specific items by ID
+ >>> client.delete("my_collection", ids=["1", "3", "5"])
+ >>> # Or delete by metadata filter
+ >>> client.delete("my_collection", filter={"source": "deprecated_source"})
+ """
+ log.info(f"Deleting items from collection '{collection_name}'.")
+
+ try:
+ query = (
+ "DELETE FROM document_chunk WHERE collection_name = :collection_name"
+ )
+ params = {"collection_name": collection_name}
+
+ if ids:
+ # 🔧 FIXED: Use proper parameterized query to prevent SQL injection
+ placeholders = ",".join([f":id_{i}" for i in range(len(ids))])
+ query += f" AND id IN ({placeholders})"
+ for i, id_val in enumerate(ids):
+ params[f"id_{i}"] = id_val
+
+ if filter:
+ for i, (key, value) in enumerate(filter.items()):
+ param_name = f"value_{i}"
+ query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}"
+ params[param_name] = str(value)
+
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(query, params)
+ deleted = cursor.rowcount
+ connection.commit()
+
+ log.info(f"Deleted {deleted} items from collection '{collection_name}'.")
+
+ except Exception as e:
+ log.exception(f"Error during delete: {e}")
+ raise
+
+ def reset(self) -> None:
+ """
+ Reset the database by deleting all items.
+
+ Deletes all items from the document_chunk table.
+
+ Raises:
+ Exception: If reset fails
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> client.reset() # Warning: Removes all data!
+ """
+ log.info("Resetting database - deleting all items.")
+
+ try:
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("DELETE FROM document_chunk")
+ deleted = cursor.rowcount
+ connection.commit()
+
+ log.info(
+ f"Reset complete. Deleted {deleted} items from 'document_chunk' table."
+ )
+
+ except Exception as e:
+ log.exception(f"Error during reset: {e}")
+ raise
+
+ def close(self) -> None:
+ """
+ Close the database connection pool.
+
+ Properly closes the connection pool and releases all resources.
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> # After finishing all operations
+ >>> client.close()
+ """
+ try:
+ if hasattr(self, "pool") and self.pool:
+ self.pool.close()
+ log.info("Oracle Vector Search connection pool closed.")
+ except Exception as e:
+ log.exception(f"Error closing connection pool: {e}")
+
+ def has_collection(self, collection_name: str) -> bool:
+ """
+ Check if a collection exists.
+
+ Args:
+ collection_name (str): Name of the collection to check
+
+ Returns:
+ bool: True if the collection exists, False otherwise
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> if client.has_collection("my_collection"):
+ ... print("Collection exists!")
+ ... else:
+ ... print("Collection does not exist.")
+ """
+ try:
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT COUNT(*)
+ FROM document_chunk
+ WHERE collection_name = :collection_name
+ FETCH FIRST 1 ROWS ONLY
+ """,
+ {"collection_name": collection_name},
+ )
+
+ count = cursor.fetchone()[0]
+
+ return count > 0
+
+ except Exception as e:
+ log.exception(f"Error checking collection existence: {e}")
+ return False
+
+ def delete_collection(self, collection_name: str) -> None:
+ """
+ Delete an entire collection.
+
+ Removes all items belonging to the specified collection.
+
+ Args:
+ collection_name (str): Name of the collection to delete
+
+ Example:
+ >>> client = Oracle23aiClient()
+ >>> client.delete_collection("obsolete_collection")
+ """
+ log.info(f"Deleting collection '{collection_name}'.")
+
+ try:
+ with self.get_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ DELETE FROM document_chunk
+ WHERE collection_name = :collection_name
+ """,
+ {"collection_name": collection_name},
+ )
+
+ deleted = cursor.rowcount
+ connection.commit()
+
+ log.info(
+ f"Collection '{collection_name}' deleted. Removed {deleted} items."
+ )
+
+ except Exception as e:
+ log.exception(f"Error deleting collection '{collection_name}': {e}")
+ raise
diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py
index 632937ef5b..85c2ef009d 100644
--- a/backend/open_webui/retrieval/vector/dbs/pgvector.py
+++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py
@@ -1,4 +1,4 @@
-from typing import Optional, List, Dict, Any
+from typing import Optional, List, Dict, Any, Tuple
import logging
import json
from sqlalchemy import (
@@ -18,14 +18,16 @@ from sqlalchemy import (
values,
)
from sqlalchemy.sql import true
-from sqlalchemy.pool import NullPool
+from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
-from pgvector.sqlalchemy import Vector
+from pgvector.sqlalchemy import Vector, HALFVEC
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
+
+from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
@@ -35,13 +37,27 @@ from open_webui.retrieval.vector.main import (
from open_webui.config import (
PGVECTOR_DB_URL,
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
+ PGVECTOR_CREATE_EXTENSION,
PGVECTOR_PGCRYPTO,
PGVECTOR_PGCRYPTO_KEY,
+ PGVECTOR_POOL_SIZE,
+ PGVECTOR_POOL_MAX_OVERFLOW,
+ PGVECTOR_POOL_TIMEOUT,
+ PGVECTOR_POOL_RECYCLE,
+ PGVECTOR_INDEX_METHOD,
+ PGVECTOR_HNSW_M,
+ PGVECTOR_HNSW_EF_CONSTRUCTION,
+ PGVECTOR_IVFFLAT_LISTS,
+ PGVECTOR_USE_HALFVEC,
)
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
+USE_HALFVEC = PGVECTOR_USE_HALFVEC
+
+VECTOR_TYPE_FACTORY = HALFVEC if USE_HALFVEC else Vector
+VECTOR_OPCLASS = "halfvec_cosine_ops" if USE_HALFVEC else "vector_cosine_ops"
Base = declarative_base()
log = logging.getLogger(__name__)
@@ -60,7 +76,7 @@ class DocumentChunk(Base):
__tablename__ = "document_chunk"
id = Column(Text, primary_key=True)
- vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
+ vector = Column(VECTOR_TYPE_FACTORY(dim=VECTOR_LENGTH), nullable=True)
collection_name = Column(Text, nullable=False)
if PGVECTOR_PGCRYPTO:
@@ -80,9 +96,24 @@ class PgvectorClient(VectorDBBase):
self.session = Session
else:
- engine = create_engine(
- PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
- )
+ if isinstance(PGVECTOR_POOL_SIZE, int):
+ if PGVECTOR_POOL_SIZE > 0:
+ engine = create_engine(
+ PGVECTOR_DB_URL,
+ pool_size=PGVECTOR_POOL_SIZE,
+ max_overflow=PGVECTOR_POOL_MAX_OVERFLOW,
+ pool_timeout=PGVECTOR_POOL_TIMEOUT,
+ pool_recycle=PGVECTOR_POOL_RECYCLE,
+ pool_pre_ping=True,
+ poolclass=QueuePool,
+ )
+ else:
+ engine = create_engine(
+ PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
+ )
+ else:
+ engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
+
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
@@ -90,11 +121,36 @@ class PgvectorClient(VectorDBBase):
try:
# Ensure the pgvector extension is available
- self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
+ # Use a conditional check to avoid permission issues on Azure PostgreSQL
+ if PGVECTOR_CREATE_EXTENSION:
+ self.session.execute(
+ text(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
+ CREATE EXTENSION IF NOT EXISTS vector;
+ END IF;
+ END $$;
+ """
+ )
+ )
if PGVECTOR_PGCRYPTO:
# Ensure the pgcrypto extension is available for encryption
- self.session.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto;"))
+ # Use a conditional check to avoid permission issues on Azure PostgreSQL
+ self.session.execute(
+ text(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
+ END IF;
+ END $$;
+ """
+ )
+ )
if not PGVECTOR_PGCRYPTO_KEY:
raise ValueError(
@@ -110,13 +166,9 @@ class PgvectorClient(VectorDBBase):
connection = self.session.connection()
Base.metadata.create_all(bind=connection)
- # Create an index on the vector column if it doesn't exist
- self.session.execute(
- text(
- "CREATE INDEX IF NOT EXISTS idx_document_chunk_vector "
- "ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);"
- )
- )
+ index_method, index_options = self._vector_index_configuration()
+ self._ensure_vector_index(index_method, index_options)
+
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
@@ -130,6 +182,78 @@ class PgvectorClient(VectorDBBase):
log.exception(f"Error during initialization: {e}")
raise
+ @staticmethod
+ def _extract_index_method(index_def: Optional[str]) -> Optional[str]:
+ if not index_def:
+ return None
+ try:
+ after_using = index_def.lower().split("using ", 1)[1]
+ return after_using.split()[0]
+ except (IndexError, AttributeError):
+ return None
+
+ def _vector_index_configuration(self) -> Tuple[str, str]:
+ if PGVECTOR_INDEX_METHOD:
+ index_method = PGVECTOR_INDEX_METHOD
+ log.info(
+ "Using vector index method '%s' from PGVECTOR_INDEX_METHOD.",
+ index_method,
+ )
+ elif USE_HALFVEC:
+ index_method = "hnsw"
+ log.info(
+ "VECTOR_LENGTH=%s exceeds 2000; using halfvec column type with hnsw index.",
+ VECTOR_LENGTH,
+ )
+ else:
+ index_method = "ivfflat"
+
+ if index_method == "hnsw":
+ index_options = f"WITH (m = {PGVECTOR_HNSW_M}, ef_construction = {PGVECTOR_HNSW_EF_CONSTRUCTION})"
+ else:
+ index_options = f"WITH (lists = {PGVECTOR_IVFFLAT_LISTS})"
+
+ return index_method, index_options
+
+ def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
+ index_name = "idx_document_chunk_vector"
+ existing_index_def = self.session.execute(
+ text(
+ """
+ SELECT indexdef
+ FROM pg_indexes
+ WHERE schemaname = current_schema()
+ AND tablename = 'document_chunk'
+ AND indexname = :index_name
+ """
+ ),
+ {"index_name": index_name},
+ ).scalar()
+
+ existing_method = self._extract_index_method(existing_index_def)
+ if existing_method and existing_method != index_method:
+ raise RuntimeError(
+ f"Existing pgvector index '{index_name}' uses method '{existing_method}' but configuration now "
+ f"requires '{index_method}'. Automatic rebuild is disabled to prevent long-running maintenance. "
+ "Drop the index manually (optionally after tuning maintenance_work_mem/max_parallel_maintenance_workers) "
+ "and recreate it with the new method before restarting Open WebUI."
+ )
+
+ if not existing_index_def:
+ index_sql = (
+ f"CREATE INDEX IF NOT EXISTS {index_name} "
+ f"ON document_chunk USING {index_method} (vector {VECTOR_OPCLASS})"
+ )
+ if index_options:
+ index_sql = f"{index_sql} {index_options}"
+ self.session.execute(text(index_sql))
+ log.info(
+ "Ensured vector index '%s' using %s%s.",
+ index_name,
+ index_method,
+ f" {index_options}" if index_options else "",
+ )
+
def check_vector_length(self) -> None:
"""
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
@@ -149,16 +273,19 @@ class PgvectorClient(VectorDBBase):
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
- if isinstance(vector_type, Vector):
- db_vector_length = vector_type.dim
- if db_vector_length != VECTOR_LENGTH:
- raise Exception(
- f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
- "Cannot change vector size after initialization without migrating the data."
- )
- else:
+ expected_type = HALFVEC if USE_HALFVEC else Vector
+
+ if not isinstance(vector_type, expected_type):
raise Exception(
- "The 'vector' column exists but is not of type 'Vector'."
+ "The 'vector' column type does not match the expected type "
+ f"('{expected_type.__name__}') for VECTOR_LENGTH {VECTOR_LENGTH}."
+ )
+
+ db_vector_length = getattr(vector_type, "dim", None)
+ if db_vector_length is not None and db_vector_length != VECTOR_LENGTH:
+ raise Exception(
+ f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
+ "Cannot change vector size after initialization without migrating the data."
)
else:
raise Exception(
@@ -182,6 +309,8 @@ class PgvectorClient(VectorDBBase):
for item in items:
vector = self.adjust_vector_length(item["vector"])
# Use raw SQL for BYTEA/pgcrypto
+ # Ensure metadata is converted to its JSON text representation
+ json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -190,7 +319,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
- pgp_sym_encrypt(:metadata::text, :key)
+ pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
@@ -200,7 +329,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
- "metadata": json.dumps(item["metadata"]),
+ "metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -216,7 +345,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
- vmetadata=item["metadata"],
+ vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
@@ -234,6 +363,7 @@ class PgvectorClient(VectorDBBase):
if PGVECTOR_PGCRYPTO:
for item in items:
vector = self.adjust_vector_length(item["vector"])
+ json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
@@ -242,7 +372,7 @@ class PgvectorClient(VectorDBBase):
VALUES (
:id, :vector, :collection_name,
pgp_sym_encrypt(:text, :key),
- pgp_sym_encrypt(:metadata::text, :key)
+ pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO UPDATE SET
vector = EXCLUDED.vector,
@@ -256,7 +386,7 @@ class PgvectorClient(VectorDBBase):
"vector": vector,
"collection_name": collection_name,
"text": item["text"],
- "metadata": json.dumps(item["metadata"]),
+ "metadata_text": json_metadata,
"key": PGVECTOR_PGCRYPTO_KEY,
},
)
@@ -273,7 +403,7 @@ class PgvectorClient(VectorDBBase):
if existing:
existing.vector = vector
existing.text = item["text"]
- existing.vmetadata = item["metadata"]
+ existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = (
collection_name # Update collection_name if necessary
)
@@ -283,7 +413,7 @@ class PgvectorClient(VectorDBBase):
vector=vector,
collection_name=collection_name,
text=item["text"],
- vmetadata=item["metadata"],
+ vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
@@ -310,11 +440,11 @@ class PgvectorClient(VectorDBBase):
num_queries = len(vectors)
def vector_expr(vector):
- return cast(array(vector), Vector(VECTOR_LENGTH))
+ return cast(array(vector), VECTOR_TYPE_FACTORY(VECTOR_LENGTH))
# Create the values for query vectors
qid_col = column("qid", Integer)
- q_vector_col = column("q_vector", Vector(VECTOR_LENGTH))
+ q_vector_col = column("q_vector", VECTOR_TYPE_FACTORY(VECTOR_LENGTH))
query_vectors = (
values(qid_col, q_vector_col)
.data(
@@ -397,10 +527,12 @@ class PgvectorClient(VectorDBBase):
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
+ self.session.rollback() # read-only transaction
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
+ self.session.rollback()
log.exception(f"Error during search: {e}")
return None
@@ -453,12 +585,14 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
+ self.session.rollback() # read-only transaction
return GetResult(
ids=ids,
documents=documents,
metadatas=metadatas,
)
except Exception as e:
+ self.session.rollback()
log.exception(f"Error during query: {e}")
return None
@@ -499,8 +633,10 @@ class PgvectorClient(VectorDBBase):
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
+ self.session.rollback() # read-only transaction
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
+ self.session.rollback()
log.exception(f"Error during get: {e}")
return None
@@ -568,8 +704,10 @@ class PgvectorClient(VectorDBBase):
.first()
is not None
)
+ self.session.rollback() # read-only transaction
return exists
except Exception as e:
+ self.session.rollback()
log.exception(f"Error checking collection existence: {e}")
return False
diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py
index 8291332c0f..5bef0d9ea7 100644
--- a/backend/open_webui/retrieval/vector/dbs/pinecone.py
+++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py
@@ -32,6 +32,8 @@ from open_webui.config import (
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.vector.utils import process_metadata
+
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
@@ -183,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = {
"id": item["id"],
"values": item["vector"],
- "metadata": metadata,
+ "metadata": process_metadata(metadata),
}
points.append(point)
return points
diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py
index dfe2979076..ea43297499 100644
--- a/backend/open_webui/retrieval/vector/dbs/qdrant.py
+++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py
@@ -18,6 +18,9 @@ from open_webui.config import (
QDRANT_ON_DISK,
QDRANT_GRPC_PORT,
QDRANT_PREFER_GRPC,
+ QDRANT_COLLECTION_PREFIX,
+ QDRANT_TIMEOUT,
+ QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -29,12 +32,14 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase):
def __init__(self):
- self.collection_prefix = "open-webui"
+ self.collection_prefix = QDRANT_COLLECTION_PREFIX
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
+ self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
+ self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
self.client = None
@@ -52,9 +57,14 @@ class QdrantClient(VectorDBBase):
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
+ timeout=self.QDRANT_TIMEOUT,
)
else:
- self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
+ self.client = Qclient(
+ url=self.QDRANT_URI,
+ api_key=self.QDRANT_API_KEY,
+ timeout=QDRANT_TIMEOUT,
+ )
def _result_to_get_result(self, points) -> GetResult:
ids = []
@@ -84,8 +94,30 @@ class QdrantClient(VectorDBBase):
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
+ hnsw_config=models.HnswConfigDiff(
+ m=self.QDRANT_HNSW_M,
+ ),
)
+ # Create payload indexes for efficient filtering
+ self.client.create_payload_index(
+ collection_name=collection_name_with_prefix,
+ field_name="metadata.hash",
+ field_schema=models.KeywordIndexParams(
+ type=models.KeywordIndexType.KEYWORD,
+ is_tenant=False,
+ on_disk=self.QDRANT_ON_DISK,
+ ),
+ )
+ self.client.create_payload_index(
+ collection_name=collection_name_with_prefix,
+ field_name="metadata.file_id",
+ field_schema=models.KeywordIndexParams(
+ type=models.KeywordIndexType.KEYWORD,
+ is_tenant=False,
+ on_disk=self.QDRANT_ON_DISK,
+ ),
+ )
log.info(f"collection {collection_name_with_prefix} successfully created!")
def _create_collection_if_not_exists(self, collection_name, dimension):
@@ -151,23 +183,23 @@ class QdrantClient(VectorDBBase):
)
)
- points = self.client.query_points(
+ points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
- query_filter=models.Filter(should=field_conditions),
+ scroll_filter=models.Filter(should=field_conditions),
limit=limit,
)
- return self._result_to_get_result(points.points)
+ return self._result_to_get_result(points[0])
except Exception as e:
log.exception(f"Error querying a collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
# Get all the items in the collection.
- points = self.client.query_points(
+ points = self.client.scroll(
collection_name=f"{self.collection_prefix}_{collection_name}",
limit=NO_LIMIT, # otherwise qdrant would set limit to 10!
)
- return self._result_to_get_result(points.points)
+ return self._result_to_get_result(points[0])
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.
diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
index e83c437ef7..e9fa03d459 100644
--- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
+++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py
@@ -1,5 +1,5 @@
import logging
-from typing import Optional, Tuple
+from typing import Optional, Tuple, List, Dict, Any
from urllib.parse import urlparse
import grpc
@@ -9,6 +9,9 @@ from open_webui.config import (
QDRANT_ON_DISK,
QDRANT_PREFER_GRPC,
QDRANT_URI,
+ QDRANT_COLLECTION_PREFIX,
+ QDRANT_TIMEOUT,
+ QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
@@ -23,39 +26,62 @@ from qdrant_client.http.models import PointStruct
from qdrant_client.models import models
NO_LIMIT = 999999999
+TENANT_ID_FIELD = "tenant_id"
+DEFAULT_DIMENSION = 384
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
+def _tenant_filter(tenant_id: str) -> models.FieldCondition:
+ return models.FieldCondition(
+ key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)
+ )
+
+
+def _metadata_filter(key: str, value: Any) -> models.FieldCondition:
+ return models.FieldCondition(
+ key=f"metadata.{key}", match=models.MatchValue(value=value)
+ )
+
+
class QdrantClient(VectorDBBase):
def __init__(self):
- self.collection_prefix = "open-webui"
+ self.collection_prefix = QDRANT_COLLECTION_PREFIX
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
+ self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
+ self.QDRANT_HNSW_M = QDRANT_HNSW_M
if not self.QDRANT_URI:
- self.client = None
- return
+ raise ValueError(
+ "QDRANT_URI is not set. Please configure it in the environment variables."
+ )
# 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(
+ self.client = (
+ Qclient(
host=host,
port=http_port,
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
+ timeout=self.QDRANT_TIMEOUT,
)
- else:
- self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
+ if self.PREFER_GRPC
+ else Qclient(
+ url=self.QDRANT_URI,
+ api_key=self.QDRANT_API_KEY,
+ timeout=self.QDRANT_TIMEOUT,
+ )
+ )
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
@@ -65,23 +91,13 @@ class QdrantClient(VectorDBBase):
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based"
def _result_to_get_result(self, points) -> GetResult:
- ids = []
- documents = []
- metadatas = []
-
+ 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],
- }
- )
+ return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]:
"""
@@ -89,6 +105,13 @@ class QdrantClient(VectorDBBase):
Returns:
tuple: (collection_name, tenant_id)
+
+ WARNING: This mapping relies on current Open WebUI naming conventions for
+ collection names. If Open WebUI changes how it generates collection names
+ (e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
+ formats), this mapping will break and route data to incorrect collections.
+ POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
+ DATA MAPPING INSIDE THE DATABASE.
"""
# Check for user memory collections
tenant_id = collection_name
@@ -113,143 +136,53 @@ class QdrantClient(VectorDBBase):
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
+ def _create_multi_tenant_collection(
+ self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
):
"""
- 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.
+ Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields.
"""
- 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,
- ),
- )
+ 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,
+ ),
+ # Disable global index building due to multitenancy
+ # For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
+ hnsw_config=models.HnswConfigDiff(
+ payload_m=self.QDRANT_HNSW_M,
+ m=0,
+ ),
+ )
+ log.info(
+ f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!"
+ )
- # Create tenant ID payload index
+ self.client.create_payload_index(
+ collection_name=mt_collection_name,
+ field_name=TENANT_ID_FIELD,
+ field_schema=models.KeywordIndexParams(
+ type=models.KeywordIndexType.KEYWORD,
+ is_tenant=True,
+ on_disk=self.QDRANT_ON_DISK,
+ ),
+ )
+
+ for field in ("metadata.hash", "metadata.file_id"):
self.client.create_payload_index(
collection_name=mt_collection_name,
- field_name="tenant_id",
+ field_name=field,
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):
+ def _create_points(
+ self, items: List[VectorItem], tenant_id: str
+ ) -> List[PointStruct]:
"""
Create point structs from vector items with tenant ID.
"""
@@ -260,56 +193,42 @@ class QdrantClient(VectorDBBase):
payload={
"text": item["text"],
"metadata": item["metadata"],
- "tenant_id": tenant_id,
+ TENANT_ID_FIELD: tenant_id,
},
)
for item in items
]
+ def _ensure_collection(
+ self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION
+ ):
+ """
+ Ensure the collection exists and payload indexes are created for tenant_id and metadata fields.
+ """
+ if not self.client.collection_exists(collection_name=mt_collection_name):
+ self._create_multi_tenant_collection(mt_collection_name, dimension)
+
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}")
+ if not self.client.collection_exists(collection_name=mt_collection):
return False
+ tenant_filter = _tenant_filter(tenant_id)
+ count_result = self.client.count(
+ collection_name=mt_collection,
+ count_filter=models.Filter(must=[tenant_filter]),
+ )
+ return count_result.count > 0
def delete(
self,
collection_name: str,
- ids: Optional[list[str]] = None,
- filter: Optional[dict] = None,
+ ids: Optional[List[str]] = None,
+ filter: Optional[Dict[str, Any]] = None,
):
"""
Delete vectors by ID or filter from a collection with tenant isolation.
@@ -317,189 +236,76 @@ class QdrantClient(VectorDBBase):
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)
+ if not self.client.collection_exists(collection_name=mt_collection):
+ log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
+ return None
- # Create tenant filter
- tenant_filter = models.FieldCondition(
- key="tenant_id", match=models.MatchValue(value=tenant_id)
+ must_conditions = [_tenant_filter(tenant_id)]
+ should_conditions = []
+ if ids:
+ should_conditions = [_metadata_filter("id", id_value) for id_value in ids]
+ elif filter:
+ must_conditions += [_metadata_filter(k, v) for k, v in filter.items()]
+
+ return self.client.delete(
+ collection_name=mt_collection,
+ points_selector=models.FilterSelector(
+ filter=models.Filter(must=must_conditions, should=should_conditions)
+ ),
)
- 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
+ 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:
+ if not self.client or not vectors:
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}")
+ if not self.client.collection_exists(collection_name=mt_collection):
+ log.debug(f"Collection {mt_collection} doesn't exist, search returns None")
return None
- def query(self, collection_name: str, filter: dict, limit: Optional[int] = None):
+ tenant_filter = _tenant_filter(tenant_id)
+ query_response = self.client.query_points(
+ collection_name=mt_collection,
+ query=vectors[0],
+ limit=limit,
+ query_filter=models.Filter(must=[tenant_filter]),
+ )
+ get_result = self._result_to_get_result(query_response.points)
+ return SearchResult(
+ ids=get_result.ids,
+ documents=get_result.documents,
+ metadatas=get_result.metadatas,
+ distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]],
+ )
+
+ def query(
+ self, collection_name: str, filter: Dict[str, Any], 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 not self.client.collection_exists(collection_name=mt_collection):
+ log.debug(f"Collection {mt_collection} doesn't exist, query returns None")
+ return None
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
+ tenant_filter = _tenant_filter(tenant_id)
+ field_conditions = [_metadata_filter(k, v) for k, v in filter.items()]
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
+ points = self.client.scroll(
+ collection_name=mt_collection,
+ scroll_filter=combined_filter,
+ limit=limit,
+ )
+ return self._result_to_get_result(points[0])
def get(self, collection_name: str) -> Optional[GetResult]:
"""
@@ -507,169 +313,36 @@ class QdrantClient(VectorDBBase):
"""
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}")
+ if not self.client.collection_exists(collection_name=mt_collection):
+ log.debug(f"Collection {mt_collection} doesn't exist, get returns None")
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
+ tenant_filter = _tenant_filter(tenant_id)
+ points = self.client.scroll(
+ collection_name=mt_collection,
+ scroll_filter=models.Filter(must=[tenant_filter]),
+ limit=NO_LIMIT,
)
+ return self._result_to_get_result(points[0])
- def upsert(self, collection_name: str, items: list[VectorItem]):
+ 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
+ dimension = len(items[0]["vector"])
+ self._ensure_collection(mt_collection, dimension)
points = self._create_points(items, tenant_id)
+ self.client.upload_points(mt_collection, points)
+ return None
- # Handle the operation with error retry
- return self._handle_operation_with_error_retry(
- "upsert", mt_collection, points, dimension
- )
+ def insert(self, collection_name: str, items: List[VectorItem]):
+ """
+ Insert items with tenant ID.
+ """
+ return self.upsert(collection_name, items)
def reset(self):
"""
@@ -677,11 +350,9 @@ class QdrantClient(VectorDBBase):
"""
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)
+ for collection in self.client.get_collections().collections:
+ if collection.name.startswith(self.collection_prefix):
+ self.client.delete_collection(collection_name=collection.name)
def delete_collection(self, collection_name: str):
"""
@@ -689,24 +360,13 @@ class QdrantClient(VectorDBBase):
"""
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(
+ if not self.client.collection_exists(collection_name=mt_collection):
+ log.debug(f"Collection {mt_collection} doesn't exist, nothing to delete")
+ return None
+ self.client.delete(
collection_name=mt_collection,
points_selector=models.FilterSelector(
- filter=models.Filter(must=field_conditions)
+ filter=models.Filter(must=[_tenant_filter(tenant_id)])
),
)
-
- 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/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py
new file mode 100644
index 0000000000..e2a7adfd8b
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py
@@ -0,0 +1,776 @@
+from open_webui.retrieval.vector.utils import process_metadata
+from open_webui.retrieval.vector.main import (
+ VectorDBBase,
+ VectorItem,
+ GetResult,
+ SearchResult,
+)
+from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION
+from open_webui.env import SRC_LOG_LEVELS
+from typing import List, Optional, Dict, Any, Union
+import logging
+import boto3
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+class S3VectorClient(VectorDBBase):
+ """
+ AWS S3 Vector integration for Open WebUI Knowledge.
+ """
+
+ def __init__(self):
+ self.bucket_name = S3_VECTOR_BUCKET_NAME
+ self.region = S3_VECTOR_REGION
+
+ # Simple validation - log warnings instead of raising exceptions
+ if not self.bucket_name:
+ log.warning("S3_VECTOR_BUCKET_NAME not set - S3Vector will not work")
+ if not self.region:
+ log.warning("S3_VECTOR_REGION not set - S3Vector will not work")
+
+ if self.bucket_name and self.region:
+ try:
+ self.client = boto3.client("s3vectors", region_name=self.region)
+ log.info(
+ f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'"
+ )
+ except Exception as e:
+ log.error(f"Failed to initialize S3Vector client: {e}")
+ self.client = None
+ else:
+ self.client = None
+
+ def _create_index(
+ self,
+ index_name: str,
+ dimension: int,
+ data_type: str = "float32",
+ distance_metric: str = "cosine",
+ ) -> None:
+ """
+ Create a new index in the S3 vector bucket for the given collection if it does not exist.
+ """
+ if self.has_collection(index_name):
+ log.debug(f"Index '{index_name}' already exists, skipping creation")
+ return
+
+ try:
+ self.client.create_index(
+ vectorBucketName=self.bucket_name,
+ indexName=index_name,
+ dataType=data_type,
+ dimension=dimension,
+ distanceMetric=distance_metric,
+ )
+ log.info(
+ f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})"
+ )
+ except Exception as e:
+ log.error(f"Error creating S3 index '{index_name}': {e}")
+ raise
+
+ def _filter_metadata(
+ self, metadata: Dict[str, Any], item_id: str
+ ) -> Dict[str, Any]:
+ """
+ Filter vector metadata keys to comply with S3 Vector API limit of 10 keys maximum.
+ """
+ if not isinstance(metadata, dict) or len(metadata) <= 10:
+ return metadata
+
+ # Keep only the first 10 keys, prioritizing important ones based on actual Open WebUI metadata
+ important_keys = [
+ "text", # The actual document content
+ "file_id", # File ID
+ "source", # Document source file
+ "title", # Document title
+ "page", # Page number
+ "total_pages", # Total pages in document
+ "embedding_config", # Embedding configuration
+ "created_by", # User who created it
+ "name", # Document name
+ "hash", # Content hash
+ ]
+ filtered_metadata = {}
+
+ # First, add important keys if they exist
+ for key in important_keys:
+ if key in metadata:
+ filtered_metadata[key] = metadata[key]
+ if len(filtered_metadata) >= 10:
+ break
+
+ # If we still have room, add other keys
+ if len(filtered_metadata) < 10:
+ for key, value in metadata.items():
+ if key not in filtered_metadata:
+ filtered_metadata[key] = value
+ if len(filtered_metadata) >= 10:
+ break
+
+ log.warning(
+ f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys"
+ )
+ return filtered_metadata
+
+ def has_collection(self, collection_name: str) -> bool:
+ """
+ Check if a vector index exists using direct lookup.
+ This avoids pagination issues with list_indexes() and is significantly faster.
+ """
+ try:
+ self.client.get_index(
+ vectorBucketName=self.bucket_name, indexName=collection_name
+ )
+ return True
+ except Exception as e:
+ log.error(f"Error checking if index '{collection_name}' exists: {e}")
+ return False
+
+ def delete_collection(self, collection_name: str) -> None:
+ """
+ Delete an entire S3 Vector index/collection.
+ """
+
+ if not self.has_collection(collection_name):
+ log.warning(
+ f"Collection '{collection_name}' does not exist, nothing to delete"
+ )
+ return
+
+ try:
+ log.info(f"Deleting collection '{collection_name}'")
+ self.client.delete_index(
+ vectorBucketName=self.bucket_name, indexName=collection_name
+ )
+ log.info(f"Successfully deleted collection '{collection_name}'")
+ except Exception as e:
+ log.error(f"Error deleting collection '{collection_name}': {e}")
+ raise
+
+ def insert(self, collection_name: str, items: List[VectorItem]) -> None:
+ """
+ Insert vector items into the S3 Vector index. Create index if it does not exist.
+ """
+ if not items:
+ log.warning("No items to insert")
+ return
+
+ dimension = len(items[0]["vector"])
+
+ try:
+ if not self.has_collection(collection_name):
+ log.info(f"Index '{collection_name}' does not exist. Creating index.")
+ self._create_index(
+ index_name=collection_name,
+ dimension=dimension,
+ data_type="float32",
+ distance_metric="cosine",
+ )
+
+ # Prepare vectors for insertion
+ vectors = []
+ for item in items:
+ # Ensure vector data is in the correct format for S3 Vector API
+ vector_data = item["vector"]
+ if isinstance(vector_data, list):
+ # Convert list to float32 values as required by S3 Vector API
+ vector_data = [float(x) for x in vector_data]
+
+ # Prepare metadata, ensuring the text field is preserved
+ metadata = item.get("metadata", {}).copy()
+
+ # Add the text field to metadata so it's available for retrieval
+ metadata["text"] = item["text"]
+
+ # Convert metadata to string format for consistency
+ metadata = process_metadata(metadata)
+
+ # Filter metadata to comply with S3 Vector API limit of 10 keys
+ metadata = self._filter_metadata(metadata, item["id"])
+
+ vectors.append(
+ {
+ "key": item["id"],
+ "data": {"float32": vector_data},
+ "metadata": metadata,
+ }
+ )
+
+ # Insert vectors in batches of 500 (S3 Vector API limit)
+ batch_size = 500
+ for i in range(0, len(vectors), batch_size):
+ batch = vectors[i : i + batch_size]
+ self.client.put_vectors(
+ vectorBucketName=self.bucket_name,
+ indexName=collection_name,
+ vectors=batch,
+ )
+ log.info(
+ f"Inserted batch {i//batch_size + 1}: {len(batch)} vectors into index '{collection_name}'."
+ )
+
+ log.info(
+ f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'."
+ )
+ except Exception as e:
+ log.error(f"Error inserting vectors: {e}")
+ raise
+
+ def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
+ """
+ Insert or update vector items in the S3 Vector index. Create index if it does not exist.
+ """
+ if not items:
+ log.warning("No items to upsert")
+ return
+
+ dimension = len(items[0]["vector"])
+ log.info(f"Upsert dimension: {dimension}")
+
+ try:
+ if not self.has_collection(collection_name):
+ log.info(
+ f"Index '{collection_name}' does not exist. Creating index for upsert."
+ )
+ self._create_index(
+ index_name=collection_name,
+ dimension=dimension,
+ data_type="float32",
+ distance_metric="cosine",
+ )
+
+ # Prepare vectors for upsert
+ vectors = []
+ for item in items:
+ # Ensure vector data is in the correct format for S3 Vector API
+ vector_data = item["vector"]
+ if isinstance(vector_data, list):
+ # Convert list to float32 values as required by S3 Vector API
+ vector_data = [float(x) for x in vector_data]
+
+ # Prepare metadata, ensuring the text field is preserved
+ metadata = item.get("metadata", {}).copy()
+ # Add the text field to metadata so it's available for retrieval
+ metadata["text"] = item["text"]
+
+ # Convert metadata to string format for consistency
+ metadata = process_metadata(metadata)
+
+ # Filter metadata to comply with S3 Vector API limit of 10 keys
+ metadata = self._filter_metadata(metadata, item["id"])
+
+ vectors.append(
+ {
+ "key": item["id"],
+ "data": {"float32": vector_data},
+ "metadata": metadata,
+ }
+ )
+
+ # Upsert vectors in batches of 500 (S3 Vector API limit)
+ batch_size = 500
+ for i in range(0, len(vectors), batch_size):
+ batch = vectors[i : i + batch_size]
+ if i == 0: # Log sample info for first batch only
+ log.info(
+ f"Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]['key']}, data_type={type(batch[0]['data']['float32'])}, data_len={len(batch[0]['data']['float32'])}"
+ )
+ else:
+ log.info(
+ f"Upserting batch {i//batch_size + 1}: {len(batch)} vectors."
+ )
+
+ self.client.put_vectors(
+ vectorBucketName=self.bucket_name,
+ indexName=collection_name,
+ vectors=batch,
+ )
+
+ log.info(
+ f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'."
+ )
+ except Exception as e:
+ log.error(f"Error upserting vectors: {e}")
+ raise
+
+ def search(
+ self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
+ ) -> Optional[SearchResult]:
+ """
+ Search for similar vectors in a collection using multiple query vectors.
+ """
+
+ if not self.has_collection(collection_name):
+ log.warning(f"Collection '{collection_name}' does not exist")
+ return None
+
+ if not vectors:
+ log.warning("No query vectors provided")
+ return None
+
+ try:
+ log.info(
+ f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}"
+ )
+
+ # Initialize result lists
+ all_ids = []
+ all_documents = []
+ all_metadatas = []
+ all_distances = []
+
+ # Process each query vector
+ for i, query_vector in enumerate(vectors):
+ log.debug(f"Processing query vector {i+1}/{len(vectors)}")
+
+ # Prepare the query vector in S3 Vector format
+ query_vector_dict = {"float32": [float(x) for x in query_vector]}
+
+ # Call S3 Vector query API
+ response = self.client.query_vectors(
+ vectorBucketName=self.bucket_name,
+ indexName=collection_name,
+ topK=limit,
+ queryVector=query_vector_dict,
+ returnMetadata=True,
+ returnDistance=True,
+ )
+
+ # Process results for this query
+ query_ids = []
+ query_documents = []
+ query_metadatas = []
+ query_distances = []
+
+ result_vectors = response.get("vectors", [])
+
+ for vector in result_vectors:
+ vector_id = vector.get("key")
+ vector_metadata = vector.get("metadata", {})
+ vector_distance = vector.get("distance", 0.0)
+
+ # Extract document text from metadata
+ document_text = ""
+ if isinstance(vector_metadata, dict):
+ # Get the text field first (highest priority)
+ document_text = vector_metadata.get("text")
+ if not document_text:
+ # Fallback to other possible text fields
+ document_text = (
+ vector_metadata.get("content")
+ or vector_metadata.get("document")
+ or vector_id
+ )
+ else:
+ document_text = vector_id
+
+ query_ids.append(vector_id)
+ query_documents.append(document_text)
+ query_metadatas.append(vector_metadata)
+ query_distances.append(vector_distance)
+
+ # Add this query's results to the overall results
+ all_ids.append(query_ids)
+ all_documents.append(query_documents)
+ all_metadatas.append(query_metadatas)
+ all_distances.append(query_distances)
+
+ log.info(f"Search completed. Found results for {len(all_ids)} queries")
+
+ # Return SearchResult format
+ return SearchResult(
+ ids=all_ids if all_ids else None,
+ documents=all_documents if all_documents else None,
+ metadatas=all_metadatas if all_metadatas else None,
+ distances=all_distances if all_distances else None,
+ )
+
+ except Exception as e:
+ log.error(f"Error searching collection '{collection_name}': {str(e)}")
+ # Handle specific AWS exceptions
+ if hasattr(e, "response") and "Error" in e.response:
+ error_code = e.response["Error"]["Code"]
+ if error_code == "NotFoundException":
+ log.warning(f"Collection '{collection_name}' not found")
+ return None
+ elif error_code == "ValidationException":
+ log.error(f"Invalid query vector dimensions or parameters")
+ return None
+ elif error_code == "AccessDeniedException":
+ log.error(
+ f"Access denied for collection '{collection_name}'. Check permissions."
+ )
+ return None
+ raise
+
+ def query(
+ self, collection_name: str, filter: Dict, limit: Optional[int] = None
+ ) -> Optional[GetResult]:
+ """
+ Query vectors from a collection using metadata filter.
+ """
+
+ if not self.has_collection(collection_name):
+ log.warning(f"Collection '{collection_name}' does not exist")
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+
+ if not filter:
+ log.warning("No filter provided, returning all vectors")
+ return self.get(collection_name)
+
+ try:
+ log.info(f"Querying collection '{collection_name}' with filter: {filter}")
+
+ # For S3 Vector, we need to use list_vectors and then filter results
+ # Since S3 Vector may not support complex server-side filtering,
+ # we'll retrieve all vectors and filter client-side
+
+ # Get all vectors first
+ all_vectors_result = self.get(collection_name)
+
+ if not all_vectors_result or not all_vectors_result.ids:
+ log.warning("No vectors found in collection")
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+
+ # Extract the lists from the result
+ all_ids = all_vectors_result.ids[0] if all_vectors_result.ids else []
+ all_documents = (
+ all_vectors_result.documents[0] if all_vectors_result.documents else []
+ )
+ all_metadatas = (
+ all_vectors_result.metadatas[0] if all_vectors_result.metadatas else []
+ )
+
+ # Apply client-side filtering
+ filtered_ids = []
+ filtered_documents = []
+ filtered_metadatas = []
+
+ for i, metadata in enumerate(all_metadatas):
+ if self._matches_filter(metadata, filter):
+ if i < len(all_ids):
+ filtered_ids.append(all_ids[i])
+ if i < len(all_documents):
+ filtered_documents.append(all_documents[i])
+ filtered_metadatas.append(metadata)
+
+ # Apply limit if specified
+ if limit and len(filtered_ids) >= limit:
+ break
+
+ log.info(
+ f"Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total"
+ )
+
+ # Return GetResult format
+ if filtered_ids:
+ return GetResult(
+ ids=[filtered_ids],
+ documents=[filtered_documents],
+ metadatas=[filtered_metadatas],
+ )
+ else:
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+
+ except Exception as e:
+ log.error(f"Error querying collection '{collection_name}': {str(e)}")
+ # Handle specific AWS exceptions
+ if hasattr(e, "response") and "Error" in e.response:
+ error_code = e.response["Error"]["Code"]
+ if error_code == "NotFoundException":
+ log.warning(f"Collection '{collection_name}' not found")
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+ elif error_code == "AccessDeniedException":
+ log.error(
+ f"Access denied for collection '{collection_name}'. Check permissions."
+ )
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+ raise
+
+ def get(self, collection_name: str) -> Optional[GetResult]:
+ """
+ Retrieve all vectors from a collection.
+ """
+
+ if not self.has_collection(collection_name):
+ log.warning(f"Collection '{collection_name}' does not exist")
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+
+ try:
+ log.info(f"Retrieving all vectors from collection '{collection_name}'")
+
+ # Initialize result lists
+ all_ids = []
+ all_documents = []
+ all_metadatas = []
+
+ # Handle pagination
+ next_token = None
+
+ while True:
+ # Prepare request parameters
+ request_params = {
+ "vectorBucketName": self.bucket_name,
+ "indexName": collection_name,
+ "returnData": False, # Don't include vector data (not needed for get)
+ "returnMetadata": True, # Include metadata
+ "maxResults": 500, # Use reasonable page size
+ }
+
+ if next_token:
+ request_params["nextToken"] = next_token
+
+ # Call S3 Vector API
+ response = self.client.list_vectors(**request_params)
+
+ # Process vectors in this page
+ vectors = response.get("vectors", [])
+
+ for vector in vectors:
+ vector_id = vector.get("key")
+ vector_data = vector.get("data", {})
+ vector_metadata = vector.get("metadata", {})
+
+ # Extract the actual vector array
+ vector_array = vector_data.get("float32", [])
+
+ # For documents, we try to extract text from metadata or use the vector ID
+ document_text = ""
+ if isinstance(vector_metadata, dict):
+ # Get the text field first (highest priority)
+ document_text = vector_metadata.get("text")
+ if not document_text:
+ # Fallback to other possible text fields
+ document_text = (
+ vector_metadata.get("content")
+ or vector_metadata.get("document")
+ or vector_id
+ )
+
+ # Log the actual content for debugging
+ log.debug(
+ f"Document text preview (first 200 chars): {str(document_text)[:200]}"
+ )
+ else:
+ document_text = vector_id
+
+ all_ids.append(vector_id)
+ all_documents.append(document_text)
+ all_metadatas.append(vector_metadata)
+
+ # Check if there are more pages
+ next_token = response.get("nextToken")
+ if not next_token:
+ break
+
+ log.info(
+ f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'"
+ )
+
+ # Return in GetResult format
+ # The Open WebUI GetResult expects lists of lists, so we wrap each list
+ if all_ids:
+ return GetResult(
+ ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas]
+ )
+ else:
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+
+ except Exception as e:
+ log.error(
+ f"Error retrieving vectors from collection '{collection_name}': {str(e)}"
+ )
+ # Handle specific AWS exceptions
+ if hasattr(e, "response") and "Error" in e.response:
+ error_code = e.response["Error"]["Code"]
+ if error_code == "NotFoundException":
+ log.warning(f"Collection '{collection_name}' not found")
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+ elif error_code == "AccessDeniedException":
+ log.error(
+ f"Access denied for collection '{collection_name}'. Check permissions."
+ )
+ return GetResult(ids=[[]], documents=[[]], metadatas=[[]])
+ raise
+
+ 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.
+ """
+
+ if not self.has_collection(collection_name):
+ log.warning(
+ f"Collection '{collection_name}' does not exist, nothing to delete"
+ )
+ return
+
+ # Check if this is a knowledge collection (not file-specific)
+ is_knowledge_collection = not collection_name.startswith("file-")
+
+ try:
+ if ids:
+ # Delete by specific vector IDs/keys
+ log.info(
+ f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'"
+ )
+ self.client.delete_vectors(
+ vectorBucketName=self.bucket_name,
+ indexName=collection_name,
+ keys=ids,
+ )
+ log.info(f"Deleted {len(ids)} vectors from index '{collection_name}'")
+
+ elif filter:
+ # Handle filter-based deletion
+ log.info(
+ f"Deleting vectors by filter from collection '{collection_name}': {filter}"
+ )
+
+ # If this is a knowledge collection and we have a file_id filter,
+ # also clean up the corresponding file-specific collection
+ if is_knowledge_collection and "file_id" in filter:
+ file_id = filter["file_id"]
+ file_collection_name = f"file-{file_id}"
+ if self.has_collection(file_collection_name):
+ log.info(
+ f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates"
+ )
+ self.delete_collection(file_collection_name)
+
+ # For the main collection, implement query-then-delete
+ # First, query to get IDs matching the filter
+ query_result = self.query(collection_name, filter)
+ if query_result and query_result.ids and query_result.ids[0]:
+ matching_ids = query_result.ids[0]
+ log.info(
+ f"Found {len(matching_ids)} vectors matching filter, deleting them"
+ )
+
+ # Delete the matching vectors by ID
+ self.client.delete_vectors(
+ vectorBucketName=self.bucket_name,
+ indexName=collection_name,
+ keys=matching_ids,
+ )
+ log.info(
+ f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter"
+ )
+ else:
+ log.warning("No vectors found matching the filter criteria")
+ else:
+ log.warning("No IDs or filter provided for deletion")
+ except Exception as e:
+ log.error(
+ f"Error deleting vectors from collection '{collection_name}': {e}"
+ )
+ raise
+
+ def reset(self) -> None:
+ """
+ Reset/clear all vector data. For S3 Vector, this deletes all indexes.
+ """
+
+ try:
+ log.warning(
+ "Reset called - this will delete all vector indexes in the S3 bucket"
+ )
+
+ # List all indexes
+ response = self.client.list_indexes(vectorBucketName=self.bucket_name)
+ indexes = response.get("indexes", [])
+
+ if not indexes:
+ log.warning("No indexes found to delete")
+ return
+
+ # Delete all indexes
+ deleted_count = 0
+ for index in indexes:
+ index_name = index.get("indexName")
+ if index_name:
+ try:
+ self.client.delete_index(
+ vectorBucketName=self.bucket_name, indexName=index_name
+ )
+ deleted_count += 1
+ log.info(f"Deleted index: {index_name}")
+ except Exception as e:
+ log.error(f"Error deleting index '{index_name}': {e}")
+
+ log.info(f"Reset completed: deleted {deleted_count} indexes")
+
+ except Exception as e:
+ log.error(f"Error during reset: {e}")
+ raise
+
+ def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> bool:
+ """
+ Check if metadata matches the given filter conditions.
+ """
+ if not isinstance(metadata, dict) or not isinstance(filter, dict):
+ return False
+
+ # Check each filter condition
+ for key, expected_value in filter.items():
+ # Handle special operators
+ if key.startswith("$"):
+ if key == "$and":
+ # All conditions must match
+ if not isinstance(expected_value, list):
+ continue
+ for condition in expected_value:
+ if not self._matches_filter(metadata, condition):
+ return False
+ elif key == "$or":
+ # At least one condition must match
+ if not isinstance(expected_value, list):
+ continue
+ any_match = False
+ for condition in expected_value:
+ if self._matches_filter(metadata, condition):
+ any_match = True
+ break
+ if not any_match:
+ return False
+ continue
+
+ # Get the actual value from metadata
+ actual_value = metadata.get(key)
+
+ # Handle different types of expected values
+ if isinstance(expected_value, dict):
+ # Handle comparison operators
+ for op, op_value in expected_value.items():
+ if op == "$eq":
+ if actual_value != op_value:
+ return False
+ elif op == "$ne":
+ if actual_value == op_value:
+ return False
+ elif op == "$in":
+ if (
+ not isinstance(op_value, list)
+ or actual_value not in op_value
+ ):
+ return False
+ elif op == "$nin":
+ if isinstance(op_value, list) and actual_value in op_value:
+ return False
+ elif op == "$exists":
+ if bool(op_value) != (key in metadata):
+ return False
+ # Add more operators as needed
+ else:
+ # Simple equality check
+ if actual_value != expected_value:
+ return False
+
+ return True
diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py
new file mode 100644
index 0000000000..6bb8a1ecb4
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py
@@ -0,0 +1,340 @@
+import weaviate
+import re
+import uuid
+from typing import Any, Dict, List, Optional, Union
+
+from open_webui.retrieval.vector.main import (
+ VectorDBBase,
+ VectorItem,
+ SearchResult,
+ GetResult,
+)
+from open_webui.retrieval.vector.utils import process_metadata
+from open_webui.config import (
+ WEAVIATE_HTTP_HOST,
+ WEAVIATE_HTTP_PORT,
+ WEAVIATE_GRPC_PORT,
+ WEAVIATE_API_KEY,
+)
+
+
+def _convert_uuids_to_strings(obj: Any) -> Any:
+ """
+ Recursively convert UUID objects to strings in nested data structures.
+
+ This function handles:
+ - UUID objects -> string
+ - Dictionaries with UUID values
+ - Lists/Tuples with UUID values
+ - Nested combinations of the above
+
+ Args:
+ obj: Any object that might contain UUIDs
+
+ Returns:
+ The same object structure with UUIDs converted to strings
+ """
+ if isinstance(obj, uuid.UUID):
+ return str(obj)
+ elif isinstance(obj, dict):
+ return {key: _convert_uuids_to_strings(value) for key, value in obj.items()}
+ elif isinstance(obj, (list, tuple)):
+ return type(obj)(_convert_uuids_to_strings(item) for item in obj)
+ elif isinstance(obj, (str, int, float, bool, type(None))):
+ return obj
+ else:
+ return obj
+
+
+class WeaviateClient(VectorDBBase):
+ def __init__(self):
+ self.url = WEAVIATE_HTTP_HOST
+ try:
+ # Build connection parameters
+ connection_params = {
+ "host": WEAVIATE_HTTP_HOST,
+ "port": WEAVIATE_HTTP_PORT,
+ "grpc_port": WEAVIATE_GRPC_PORT,
+ }
+
+ # Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
+ if WEAVIATE_API_KEY:
+ connection_params["auth_credentials"] = (
+ weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
+ )
+
+ self.client = weaviate.connect_to_local(**connection_params)
+ self.client.connect()
+ except Exception as e:
+ raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
+
+ def _sanitize_collection_name(self, collection_name: str) -> str:
+ """Sanitize collection name to be a valid Weaviate class name."""
+ if not isinstance(collection_name, str) or not collection_name.strip():
+ raise ValueError("Collection name must be a non-empty string")
+
+ # Requirements for a valid Weaviate class name:
+ # The collection name must begin with a capital letter.
+ # The name can only contain letters, numbers, and the underscore (_) character. Spaces are not allowed.
+
+ # Replace hyphens with underscores and keep only alphanumeric characters
+ name = re.sub(r"[^a-zA-Z0-9_]", "", collection_name.replace("-", "_"))
+ name = name.strip("_")
+
+ if not name:
+ raise ValueError(
+ "Could not sanitize collection name to be a valid Weaviate class name"
+ )
+
+ # Ensure it starts with a letter and is capitalized
+ if not name[0].isalpha():
+ name = "C" + name
+
+ return name[0].upper() + name[1:]
+
+ def has_collection(self, collection_name: str) -> bool:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ return self.client.collections.exists(sane_collection_name)
+
+ def delete_collection(self, collection_name: str) -> None:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if self.client.collections.exists(sane_collection_name):
+ self.client.collections.delete(sane_collection_name)
+
+ def _create_collection(self, collection_name: str) -> None:
+ self.client.collections.create(
+ name=collection_name,
+ vector_config=weaviate.classes.config.Configure.Vectors.self_provided(),
+ properties=[
+ weaviate.classes.config.Property(
+ name="text", data_type=weaviate.classes.config.DataType.TEXT
+ ),
+ ],
+ )
+
+ def insert(self, collection_name: str, items: List[VectorItem]) -> None:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ self._create_collection(sane_collection_name)
+
+ collection = self.client.collections.get(sane_collection_name)
+
+ with collection.batch.fixed_size(batch_size=100) as batch:
+ for item in items:
+ item_uuid = str(uuid.uuid4()) if not item["id"] else str(item["id"])
+
+ properties = {"text": item["text"]}
+ if item["metadata"]:
+ clean_metadata = _convert_uuids_to_strings(
+ process_metadata(item["metadata"])
+ )
+ clean_metadata.pop("text", None)
+ properties.update(clean_metadata)
+
+ batch.add_object(
+ properties=properties, uuid=item_uuid, vector=item["vector"]
+ )
+
+ def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ self._create_collection(sane_collection_name)
+
+ collection = self.client.collections.get(sane_collection_name)
+
+ with collection.batch.fixed_size(batch_size=100) as batch:
+ for item in items:
+ item_uuid = str(item["id"]) if item["id"] else None
+
+ properties = {"text": item["text"]}
+ if item["metadata"]:
+ clean_metadata = _convert_uuids_to_strings(
+ process_metadata(item["metadata"])
+ )
+ clean_metadata.pop("text", None)
+ properties.update(clean_metadata)
+
+ batch.add_object(
+ properties=properties, uuid=item_uuid, vector=item["vector"]
+ )
+
+ def search(
+ self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
+ ) -> Optional[SearchResult]:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ return None
+
+ collection = self.client.collections.get(sane_collection_name)
+
+ result_ids, result_documents, result_metadatas, result_distances = (
+ [],
+ [],
+ [],
+ [],
+ )
+
+ for vector_embedding in vectors:
+ try:
+ response = collection.query.near_vector(
+ near_vector=vector_embedding,
+ limit=limit,
+ return_metadata=weaviate.classes.query.MetadataQuery(distance=True),
+ )
+
+ ids = [str(obj.uuid) for obj in response.objects]
+ documents = []
+ metadatas = []
+ distances = []
+
+ for obj in response.objects:
+ properties = dict(obj.properties) if obj.properties else {}
+ documents.append(properties.pop("text", ""))
+ metadatas.append(_convert_uuids_to_strings(properties))
+
+ # Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1
+ raw_distances = [
+ (
+ obj.metadata.distance
+ if obj.metadata and obj.metadata.distance
+ else 2.0
+ )
+ for obj in response.objects
+ ]
+ distances = [(2 - dist) / 2 for dist in raw_distances]
+
+ result_ids.append(ids)
+ result_documents.append(documents)
+ result_metadatas.append(metadatas)
+ result_distances.append(distances)
+ except Exception:
+ result_ids.append([])
+ result_documents.append([])
+ result_metadatas.append([])
+ result_distances.append([])
+
+ return SearchResult(
+ **{
+ "ids": result_ids,
+ "documents": result_documents,
+ "metadatas": result_metadatas,
+ "distances": result_distances,
+ }
+ )
+
+ def query(
+ self, collection_name: str, filter: Dict, limit: Optional[int] = None
+ ) -> Optional[GetResult]:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ return None
+
+ collection = self.client.collections.get(sane_collection_name)
+
+ weaviate_filter = None
+ if filter:
+ for key, value in filter.items():
+ prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(
+ value
+ )
+ weaviate_filter = (
+ prop_filter
+ if weaviate_filter is None
+ else weaviate.classes.query.Filter.all_of(
+ [weaviate_filter, prop_filter]
+ )
+ )
+
+ try:
+ response = collection.query.fetch_objects(
+ filters=weaviate_filter, limit=limit
+ )
+
+ ids = [str(obj.uuid) for obj in response.objects]
+ documents = []
+ metadatas = []
+
+ for obj in response.objects:
+ properties = dict(obj.properties) if obj.properties else {}
+ documents.append(properties.pop("text", ""))
+ metadatas.append(_convert_uuids_to_strings(properties))
+
+ return GetResult(
+ **{
+ "ids": [ids],
+ "documents": [documents],
+ "metadatas": [metadatas],
+ }
+ )
+ except Exception:
+ return None
+
+ def get(self, collection_name: str) -> Optional[GetResult]:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ return None
+
+ collection = self.client.collections.get(sane_collection_name)
+ ids, documents, metadatas = [], [], []
+
+ try:
+ for item in collection.iterator():
+ ids.append(str(item.uuid))
+ properties = dict(item.properties) if item.properties else {}
+ documents.append(properties.pop("text", ""))
+ metadatas.append(_convert_uuids_to_strings(properties))
+
+ if not ids:
+ return None
+
+ return GetResult(
+ **{
+ "ids": [ids],
+ "documents": [documents],
+ "metadatas": [metadatas],
+ }
+ )
+ except Exception:
+ return None
+
+ def delete(
+ self,
+ collection_name: str,
+ ids: Optional[List[str]] = None,
+ filter: Optional[Dict] = None,
+ ) -> None:
+ sane_collection_name = self._sanitize_collection_name(collection_name)
+ if not self.client.collections.exists(sane_collection_name):
+ return
+
+ collection = self.client.collections.get(sane_collection_name)
+
+ try:
+ if ids:
+ for item_id in ids:
+ collection.data.delete_by_id(uuid=item_id)
+ elif filter:
+ weaviate_filter = None
+ for key, value in filter.items():
+ prop_filter = weaviate.classes.query.Filter.by_property(
+ name=key
+ ).equal(value)
+ weaviate_filter = (
+ prop_filter
+ if weaviate_filter is None
+ else weaviate.classes.query.Filter.all_of(
+ [weaviate_filter, prop_filter]
+ )
+ )
+
+ if weaviate_filter:
+ collection.data.delete_many(where=weaviate_filter)
+ except Exception:
+ pass
+
+ def reset(self) -> None:
+ try:
+ for collection_name in self.client.collections.list_all().keys():
+ self.client.collections.delete(collection_name)
+ except Exception:
+ pass
diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py
index 72a3f6cebe..b843e0926d 100644
--- a/backend/open_webui/retrieval/vector/factory.py
+++ b/backend/open_webui/retrieval/vector/factory.py
@@ -1,6 +1,10 @@
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
+from open_webui.config import (
+ VECTOR_DB,
+ ENABLE_QDRANT_MULTITENANCY_MODE,
+ ENABLE_MILVUS_MULTITENANCY_MODE,
+)
class Vector:
@@ -12,9 +16,16 @@ class Vector:
"""
match vector_type:
case VectorType.MILVUS:
- from open_webui.retrieval.vector.dbs.milvus import MilvusClient
+ if ENABLE_MILVUS_MULTITENANCY_MODE:
+ from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
+ MilvusClient,
+ )
- return MilvusClient()
+ return MilvusClient()
+ else:
+ 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 (
@@ -30,6 +41,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
return PineconeClient()
+ case VectorType.S3VECTOR:
+ from open_webui.retrieval.vector.dbs.s3vector import S3VectorClient
+
+ return S3VectorClient()
case VectorType.OPENSEARCH:
from open_webui.retrieval.vector.dbs.opensearch import OpenSearchClient
@@ -48,6 +63,14 @@ class Vector:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
return ChromaClient()
+ case VectorType.ORACLE23AI:
+ from open_webui.retrieval.vector.dbs.oracle23ai import Oracle23aiClient
+
+ return Oracle23aiClient()
+ case VectorType.WEAVIATE:
+ from open_webui.retrieval.vector.dbs.weaviate import WeaviateClient
+
+ return WeaviateClient()
case _:
raise ValueError(f"Unsupported vector type: {vector_type}")
diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py
index b03bcb4828..292cad1e78 100644
--- a/backend/open_webui/retrieval/vector/type.py
+++ b/backend/open_webui/retrieval/vector/type.py
@@ -9,3 +9,6 @@ class VectorType(StrEnum):
ELASTICSEARCH = "elasticsearch"
OPENSEARCH = "opensearch"
PGVECTOR = "pgvector"
+ ORACLE23AI = "oracle23ai"
+ S3VECTOR = "s3vector"
+ WEAVIATE = "weaviate"
diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py
new file mode 100644
index 0000000000..a597390b92
--- /dev/null
+++ b/backend/open_webui/retrieval/vector/utils.py
@@ -0,0 +1,28 @@
+from datetime import datetime
+
+KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
+
+
+def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
+ metadata = {
+ key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
+ }
+ return metadata
+
+
+def process_metadata(
+ metadata: dict[str, any],
+) -> dict[str, any]:
+ for key, value in metadata.items():
+ # Remove large fields
+ if key in KEYS_TO_EXCLUDE:
+ del metadata[key]
+
+ # Convert non-serializable fields to strings
+ if (
+ isinstance(value, datetime)
+ or isinstance(value, list)
+ or isinstance(value, dict)
+ ):
+ metadata[key] = str(value)
+ return metadata
diff --git a/backend/open_webui/retrieval/web/azure.py b/backend/open_webui/retrieval/web/azure.py
new file mode 100644
index 0000000000..814cf4b63c
--- /dev/null
+++ b/backend/open_webui/retrieval/web/azure.py
@@ -0,0 +1,128 @@
+import logging
+from typing import Optional
+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"])
+
+"""
+Azure AI Search integration for Open WebUI.
+Documentation: https://learn.microsoft.com/en-us/python/api/overview/azure/search-documents-readme?view=azure-python
+
+Required package: azure-search-documents
+Install: pip install azure-search-documents
+"""
+
+
+def search_azure(
+ api_key: str,
+ endpoint: str,
+ index_name: str,
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+) -> list[SearchResult]:
+ """
+ Search using Azure AI Search.
+
+ Args:
+ api_key: Azure Search API key (query key or admin key)
+ endpoint: Azure Search service endpoint (e.g., https://myservice.search.windows.net)
+ index_name: Name of the search index to query
+ query: Search query string
+ count: Number of results to return
+ filter_list: Optional list of domains to filter results
+
+ Returns:
+ List of SearchResult objects with link, title, and snippet
+ """
+ try:
+ from azure.core.credentials import AzureKeyCredential
+ from azure.search.documents import SearchClient
+ except ImportError:
+ log.error(
+ "azure-search-documents package is not installed. "
+ "Install it with: pip install azure-search-documents"
+ )
+ raise ImportError(
+ "azure-search-documents is required for Azure AI Search. "
+ "Install it with: pip install azure-search-documents"
+ )
+
+ try:
+ # Create search client with API key authentication
+ credential = AzureKeyCredential(api_key)
+ search_client = SearchClient(
+ endpoint=endpoint, index_name=index_name, credential=credential
+ )
+
+ # Perform the search
+ results = search_client.search(search_text=query, top=count)
+
+ # Convert results to list and extract fields
+ search_results = []
+ for result in results:
+ # Azure AI Search returns documents with custom schemas
+ # We need to extract common fields that might represent URL, title, and content
+ # Common field names to look for:
+ result_dict = dict(result)
+
+ # Try to find URL field (common names)
+ link = (
+ result_dict.get("url")
+ or result_dict.get("link")
+ or result_dict.get("uri")
+ or result_dict.get("metadata_storage_path")
+ or ""
+ )
+
+ # Try to find title field (common names)
+ title = (
+ result_dict.get("title")
+ or result_dict.get("name")
+ or result_dict.get("metadata_title")
+ or result_dict.get("metadata_storage_name")
+ or None
+ )
+
+ # Try to find content/snippet field (common names)
+ snippet = (
+ result_dict.get("content")
+ or result_dict.get("snippet")
+ or result_dict.get("description")
+ or result_dict.get("summary")
+ or result_dict.get("text")
+ or None
+ )
+
+ # Truncate snippet if too long
+ if snippet and len(snippet) > 500:
+ snippet = snippet[:497] + "..."
+
+ if link: # Only add if we found a valid link
+ search_results.append(
+ {
+ "link": link,
+ "title": title,
+ "snippet": snippet,
+ }
+ )
+
+ # Apply domain filtering if specified
+ if filter_list:
+ search_results = get_filtered_results(search_results, filter_list)
+
+ # Convert to SearchResult objects
+ return [
+ SearchResult(
+ link=result["link"],
+ title=result.get("title"),
+ snippet=result.get("snippet"),
+ )
+ for result in search_results
+ ]
+
+ except Exception as ex:
+ log.error(f"Azure AI Search error: {ex}")
+ raise ex
diff --git a/backend/open_webui/retrieval/web/brave.py b/backend/open_webui/retrieval/web/brave.py
index 3075db990f..7bea575620 100644
--- a/backend/open_webui/retrieval/web/brave.py
+++ b/backend/open_webui/retrieval/web/brave.py
@@ -36,7 +36,9 @@ def search_brave(
return [
SearchResult(
- link=result["url"], title=result.get("title"), snippet=result.get("snippet")
+ link=result["url"],
+ title=result.get("title"),
+ snippet=result.get("description"),
)
for result in results[:count]
]
diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py
index bf8ae6880b..e4cf9d00ec 100644
--- a/backend/open_webui/retrieval/web/duckduckgo.py
+++ b/backend/open_webui/retrieval/web/duckduckgo.py
@@ -2,8 +2,8 @@ import logging
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 ddgs import DDGS
+from ddgs.exceptions import RatelimitException
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
@@ -11,7 +11,10 @@ log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_duckduckgo(
- query: str, count: int, filter_list: Optional[list[str]] = None
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+ concurrent_requests: Optional[int] = None,
) -> list[SearchResult]:
"""
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
@@ -25,6 +28,9 @@ def search_duckduckgo(
# Use the DDGS context manager to create a DDGS object
search_results = []
with DDGS() as ddgs:
+ if concurrent_requests:
+ ddgs.threads = concurrent_requests
+
# Use the ddgs.text() method to perform the search
try:
search_results = ddgs.text(
diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py
index a5c8003e47..13f6a5aa68 100644
--- a/backend/open_webui/retrieval/web/external.py
+++ b/backend/open_webui/retrieval/web/external.py
@@ -2,27 +2,42 @@ import logging
from typing import Optional, List
import requests
-from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+
+from fastapi import Request
+
from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.utils.headers import include_user_info_headers
+
+
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_external(
+ request: Request,
external_url: str,
external_api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
+ user=None,
) -> List[SearchResult]:
try:
+ headers = {
+ "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
+ "Authorization": f"Bearer {external_api_key}",
+ }
+ headers = include_user_info_headers(headers, user)
+
+ chat_id = getattr(request.state, "chat_id", None)
+ if chat_id:
+ headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
+
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}",
- },
+ headers=headers,
json={
"query": query,
"count": count,
diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py
index a85fc51fbd..2d9b104bca 100644
--- a/backend/open_webui/retrieval/web/firecrawl.py
+++ b/backend/open_webui/retrieval/web/firecrawl.py
@@ -1,11 +1,10 @@
import logging
from typing import Optional, List
-from urllib.parse import urljoin
-import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
+
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -18,27 +17,20 @@ def search_firecrawl(
filter_list: Optional[List[str]] = None,
) -> List[SearchResult]:
try:
- firecrawl_search_url = urljoin(firecrawl_url, "/v1/search")
- response = requests.post(
- firecrawl_search_url,
- headers={
- "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
- "Authorization": f"Bearer {firecrawl_api_key}",
- },
- json={
- "query": query,
- "limit": count,
- },
+ from firecrawl import FirecrawlApp
+
+ firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url)
+ response = firecrawl.search(
+ query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3
)
- response.raise_for_status()
- results = response.json().get("data", [])
+ results = response.web
if filter_list:
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
- link=result.get("url"),
- title=result.get("title"),
- snippet=result.get("description"),
+ link=result.url,
+ title=result.title,
+ snippet=result.description,
)
for result in results[:count]
]
diff --git a/backend/open_webui/retrieval/web/google_pse.py b/backend/open_webui/retrieval/web/google_pse.py
index 2d2b863b42..69de24711a 100644
--- a/backend/open_webui/retrieval/web/google_pse.py
+++ b/backend/open_webui/retrieval/web/google_pse.py
@@ -15,6 +15,7 @@ def search_google_pse(
query: str,
count: int,
filter_list: Optional[list[str]] = None,
+ referer: Optional[str] = None,
) -> list[SearchResult]:
"""Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects.
Handles pagination for counts greater than 10.
@@ -30,7 +31,11 @@ def search_google_pse(
list[SearchResult]: A list of SearchResult objects.
"""
url = "https://www.googleapis.com/customsearch/v1"
+
headers = {"Content-Type": "application/json"}
+ if referer:
+ headers["Referer"] = referer
+
all_results = []
start_index = 1 # Google PSE start parameter is 1-based
diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py
index 28a749e7d2..6d2fd1bc5a 100644
--- a/backend/open_webui/retrieval/web/main.py
+++ b/backend/open_webui/retrieval/web/main.py
@@ -5,18 +5,38 @@ from urllib.parse import urlparse
from pydantic import BaseModel
+from open_webui.retrieval.web.utils import resolve_hostname
+from open_webui.utils.misc import is_string_allowed
+
def get_filtered_results(results, filter_list):
if not filter_list:
return results
+
filtered_results = []
+
for result in results:
- url = result.get("url") or result.get("link", "")
+ url = result.get("url") or result.get("link", "") or result.get("href", "")
if not validators.url(url):
continue
+
domain = urlparse(url).netloc
- if any(domain.endswith(filtered_domain) for filtered_domain in filter_list):
+ if not domain:
+ continue
+
+ hostnames = [domain]
+
+ try:
+ ipv4_addresses, ipv6_addresses = resolve_hostname(domain)
+ hostnames.extend(ipv4_addresses)
+ hostnames.extend(ipv6_addresses)
+ except Exception:
+ pass
+
+ if any(is_string_allowed(hostname, filter_list) for hostname in hostnames):
filtered_results.append(result)
+ continue
+
return filtered_results
diff --git a/backend/open_webui/retrieval/web/ollama.py b/backend/open_webui/retrieval/web/ollama.py
new file mode 100644
index 0000000000..a199a14389
--- /dev/null
+++ b/backend/open_webui/retrieval/web/ollama.py
@@ -0,0 +1,51 @@
+import logging
+from dataclasses import dataclass
+from typing import Optional
+
+import requests
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.web.main import SearchResult
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_ollama_cloud(
+ url: str,
+ api_key: str,
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+) -> list[SearchResult]:
+ """Search using Ollama Search API and return the results as a list of SearchResult objects.
+
+ Args:
+ api_key (str): A Ollama Search API key
+ query (str): The query to search for
+ count (int): Number of results to return
+ filter_list (Optional[list[str]]): List of domains to filter results by
+ """
+ log.info(f"Searching with Ollama for query: {query}")
+
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ payload = {"query": query, "max_results": count}
+
+ try:
+ response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
+ response.raise_for_status()
+ data = response.json()
+
+ results = data.get("results", [])
+ log.info(f"Found {len(results)} results")
+
+ return [
+ SearchResult(
+ link=result.get("url", ""),
+ title=result.get("title", ""),
+ snippet=result.get("content", ""),
+ )
+ for result in results
+ ]
+ except Exception as e:
+ log.error(f"Error searching Ollama: {e}")
+ return []
diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py
new file mode 100644
index 0000000000..97961f478b
--- /dev/null
+++ b/backend/open_webui/retrieval/web/perplexity_search.py
@@ -0,0 +1,76 @@
+import logging
+from typing import Optional, Literal
+import requests
+
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.utils.headers import include_user_info_headers
+from open_webui.env import SRC_LOG_LEVELS
+
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_perplexity_search(
+ api_key: str,
+ query: str,
+ count: int,
+ filter_list: Optional[list[str]] = None,
+ api_url: str = "https://api.perplexity.ai/search",
+ user=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
+ filter_list (Optional[list[str]]): List of domains to filter results
+ api_url (str): Custom API URL (defaults to https://api.perplexity.ai/search)
+ user: Optional user object for forwarding user info headers
+
+ """
+
+ # Handle PersistentConfig object
+ if hasattr(api_key, "__str__"):
+ api_key = str(api_key)
+
+ if hasattr(api_url, "__str__"):
+ api_url = str(api_url)
+
+ try:
+ url = api_url
+
+ # Create payload for the API call
+ payload = {
+ "query": query,
+ "max_results": count,
+ }
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+
+ # Forward user info headers if user is provided
+ if user is not None:
+ headers = include_user_info_headers(headers, user)
+
+ # 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
+ results = json_response.get("results", [])
+
+ return [
+ SearchResult(
+ link=result["url"], title=result["title"], snippet=result["snippet"]
+ )
+ for result in results
+ ]
+
+ except Exception as e:
+ log.error(f"Error searching with Perplexity Search API: {e}")
+ return []
diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py
index 5a90a86e0f..bdbde0b3a9 100644
--- a/backend/open_webui/retrieval/web/utils.py
+++ b/backend/open_webui/retrieval/web/utils.py
@@ -4,7 +4,6 @@ import socket
import ssl
import urllib.parse
import urllib.request
-from collections import defaultdict
from datetime import datetime, time, timedelta
from typing import (
Any,
@@ -17,13 +16,15 @@ from typing import (
Union,
Literal,
)
+
+from fastapi.concurrency import run_in_threadpool
import aiohttp
import certifi
import validators
from langchain_community.document_loaders import PlaywrightURLLoader, WebBaseLoader
-from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
+
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.retrieval.loaders.external_web import ExternalWebLoader
from open_webui.constants import ERROR_MESSAGES
@@ -38,17 +39,46 @@ from open_webui.config import (
TAVILY_EXTRACT_DEPTH,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
+ WEB_FETCH_FILTER_LIST,
)
-from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.utils.misc import is_string_allowed
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
+def resolve_hostname(hostname):
+ # Get address information
+ addr_info = socket.getaddrinfo(hostname, None)
+
+ # Extract IP addresses from address information
+ ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
+ ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
+
+ return ipv4_addresses, ipv6_addresses
+
+
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if isinstance(validators.url(url), validators.ValidationError):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
+
+ parsed_url = urllib.parse.urlparse(url)
+
+ # Protocol validation - only allow http/https
+ if parsed_url.scheme not in ["http", "https"]:
+ log.warning(
+ f"Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}"
+ )
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
+
+ # Blocklist check using unified filtering logic
+ if WEB_FETCH_FILTER_LIST:
+ if not is_string_allowed(url, WEB_FETCH_FILTER_LIST):
+ log.warning(f"URL blocked by filter list: {url}")
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
+
if not ENABLE_RAG_LOCAL_WEB_FETCH:
# Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
parsed_url = urllib.parse.urlparse(url)
@@ -75,22 +105,12 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
try:
if validate_url(u):
valid_urls.append(u)
- except ValueError:
+ except Exception as e:
+ log.debug(f"Invalid URL {u}: {str(e)}")
continue
return valid_urls
-def resolve_hostname(hostname):
- # Get address information
- addr_info = socket.getaddrinfo(hostname, None)
-
- # Extract IP addresses from address information
- ipv4_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET]
- ipv6_addresses = [info[4][0] for info in addr_info if info[0] == socket.AF_INET6]
-
- return ipv4_addresses, ipv6_addresses
-
-
def extract_metadata(soup, url):
metadata = {"source": url}
if title := soup.find("title"):
@@ -141,13 +161,13 @@ class RateLimitMixin:
class URLProcessingMixin:
- def _verify_ssl_cert(self, url: str) -> bool:
+ async def _verify_ssl_cert(self, url: str) -> bool:
"""Verify SSL certificate for a URL."""
- return verify_ssl_cert(url)
+ return await run_in_threadpool(verify_ssl_cert, url)
async def _safe_process_url(self, url: str) -> bool:
"""Perform safety checks before processing a URL."""
- if self.verify_ssl and not self._verify_ssl_cert(url):
+ if self.verify_ssl and not await self._verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
await self._wait_for_rate_limit()
return True
@@ -188,13 +208,12 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
(uses FIRE_CRAWL_API_KEY environment variable if not provided).
api_url: Base URL for FireCrawl API. Defaults to official API endpoint.
mode: Operation mode selection:
- - 'crawl': Website crawling mode (default)
- - 'scrape': Direct page scraping
+ - 'crawl': Website crawling mode
+ - 'scrape': Direct page scraping (default)
- 'map': Site map generation
proxy: Proxy override settings for the FireCrawl API.
params: The parameters to pass to the Firecrawl API.
- Examples include crawlerOptions.
- For more details, visit: https://github.com/mendableai/firecrawl-py
+ For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape
"""
proxy_server = proxy.get("server") if proxy else None
if trust_env and not proxy_server:
@@ -214,50 +233,88 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
self.api_key = api_key
self.api_url = api_url
self.mode = mode
- self.params = params
+ self.params = params or {}
def lazy_load(self) -> Iterator[Document]:
- """Load documents concurrently using FireCrawl."""
- for url in self.web_paths:
- try:
- self._safe_process_url_sync(url)
- loader = FireCrawlLoader(
- url=url,
- api_key=self.api_key,
- api_url=self.api_url,
- mode=self.mode,
- params=self.params,
+ """Load documents using FireCrawl batch_scrape."""
+ log.debug(
+ "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s",
+ len(self.web_paths),
+ self.mode,
+ self.params,
+ )
+ try:
+ from firecrawl import FirecrawlApp
+
+ firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
+ result = firecrawl.batch_scrape(
+ self.web_paths,
+ formats=["markdown"],
+ skip_tls_verification=not self.verify_ssl,
+ ignore_invalid_urls=True,
+ remove_base64_images=True,
+ max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
+ wait_timeout=len(self.web_paths) * 3,
+ **self.params,
+ )
+
+ if result.status != "completed":
+ raise RuntimeError(
+ f"FireCrawl batch scrape did not complete successfully. result: {result}"
)
- for document in loader.lazy_load():
- if not document.metadata.get("source"):
- document.metadata["source"] = document.metadata.get("sourceURL")
- yield document
- except Exception as e:
- if self.continue_on_failure:
- log.exception(f"Error loading {url}: {e}")
- continue
+
+ for data in result.data:
+ metadata = data.metadata or {}
+ yield Document(
+ page_content=data.markdown or "",
+ metadata={"source": metadata.url or metadata.source_url or ""},
+ )
+
+ except Exception as e:
+ if self.continue_on_failure:
+ log.exception(f"Error extracting content from URLs: {e}")
+ else:
raise e
async def alazy_load(self):
"""Async version of lazy_load."""
- for url in self.web_paths:
- try:
- await self._safe_process_url(url)
- loader = FireCrawlLoader(
- url=url,
- api_key=self.api_key,
- api_url=self.api_url,
- mode=self.mode,
- params=self.params,
+ log.debug(
+ "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s",
+ len(self.web_paths),
+ self.mode,
+ self.params,
+ )
+ try:
+ from firecrawl import FirecrawlApp
+
+ firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url)
+ result = firecrawl.batch_scrape(
+ self.web_paths,
+ formats=["markdown"],
+ skip_tls_verification=not self.verify_ssl,
+ ignore_invalid_urls=True,
+ remove_base64_images=True,
+ max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
+ wait_timeout=len(self.web_paths) * 3,
+ **self.params,
+ )
+
+ if result.status != "completed":
+ raise RuntimeError(
+ f"FireCrawl batch scrape did not complete successfully. result: {result}"
)
- async for document in loader.alazy_load():
- if not document.metadata.get("source"):
- document.metadata["source"] = document.metadata.get("sourceURL")
- yield document
- except Exception as e:
- if self.continue_on_failure:
- log.exception(f"Error loading {url}: {e}")
- continue
+
+ for data in result.data:
+ metadata = data.metadata or {}
+ yield Document(
+ page_content=data.markdown or "",
+ metadata={"source": metadata.url or metadata.source_url or ""},
+ )
+
+ except Exception as e:
+ if self.continue_on_failure:
+ log.exception(f"Error extracting content from URLs: {e}")
+ else:
raise e
@@ -517,6 +574,7 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get(
url,
**(self.requests_kwargs | kwargs),
+ allow_redirects=False,
) as response:
if self.raise_for_status:
response.raise_for_status()
@@ -602,6 +660,10 @@ def get_web_loader(
# Check if the URLs are valid
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
+ if not safe_urls:
+ log.warning(f"All provided URLs were blocked or invalid: {urls}")
+ raise ValueError(ERROR_MESSAGES.INVALID_URL)
+
web_loader_args = {
"web_paths": safe_urls,
"verify_ssl": verify_ssl,
@@ -614,7 +676,7 @@ def get_web_loader(
WebLoaderClass = SafeWebBaseLoader
if WEB_LOADER_ENGINE.value == "playwright":
WebLoaderClass = SafePlaywrightURLLoader
- web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
+ web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
if PLAYWRIGHT_WS_URL.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py
index 6baaa589df..9c84f9c704 100644
--- a/backend/open_webui/routers/audio.py
+++ b/backend/open_webui/routers/audio.py
@@ -3,8 +3,9 @@ import json
import logging
import os
import uuid
+import html
+import base64
from functools import lru_cache
-from pathlib import Path
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
@@ -15,7 +16,6 @@ import aiohttp
import aiofiles
import requests
import mimetypes
-from urllib.parse import quote
from fastapi import (
Depends,
@@ -34,18 +34,20 @@ from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_verified_user
+from open_webui.utils.headers import include_user_info_headers
from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_MODEL_DIR,
CACHE_DIR,
WHISPER_LANGUAGE,
+ ELEVENLABS_API_BASE_URL,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
+ ENV,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
- ENV,
SRC_LOG_LEVELS,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
@@ -154,6 +156,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
class TTSConfigForm(BaseModel):
OPENAI_API_BASE_URL: str
OPENAI_API_KEY: str
+ OPENAI_PARAMS: Optional[dict] = None
API_KEY: str
ENGINE: str
MODEL: str
@@ -177,6 +180,9 @@ class STTConfigForm(BaseModel):
AZURE_LOCALES: str
AZURE_BASE_URL: str
AZURE_MAX_SPEAKERS: str
+ MISTRAL_API_KEY: str
+ MISTRAL_API_BASE_URL: str
+ MISTRAL_USE_CHAT_COMPLETIONS: bool
class AudioConfigUpdateForm(BaseModel):
@@ -190,6 +196,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"tts": {
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
+ "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
"API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL,
@@ -212,6 +219,9 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
+ "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
+ "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
+ "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
},
}
@@ -222,6 +232,7 @@ async def update_audio_config(
):
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
+ request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
request.app.state.config.TTS_MODEL = form_data.tts.MODEL
@@ -252,6 +263,13 @@ async def update_audio_config(
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
form_data.stt.AZURE_MAX_SPEAKERS
)
+ request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY
+ request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = (
+ form_data.stt.MISTRAL_API_BASE_URL
+ )
+ request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = (
+ form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS
+ )
if request.app.state.config.STT_ENGINE == "":
request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -262,12 +280,13 @@ async def update_audio_config(
return {
"tts": {
- "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
- "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
- "API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL,
"VOICE": request.app.state.config.TTS_VOICE,
+ "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
+ "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
+ "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
+ "API_KEY": request.app.state.config.TTS_API_KEY,
"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,
@@ -286,6 +305,9 @@ async def update_audio_config(
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
+ "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY,
+ "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL,
+ "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS,
},
}
@@ -328,6 +350,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
log.exception(e)
raise HTTPException(status_code=400, detail="Invalid JSON payload")
+ r = None
if request.app.state.config.TTS_ENGINE == "openai":
payload["model"] = request.app.state.config.TTS_MODEL
@@ -336,32 +359,32 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async with aiohttp.ClientSession(
timeout=timeout, trust_env=True
) as session:
- async with session.post(
+ payload = {
+ **payload,
+ **(request.app.state.config.TTS_OPENAI_PARAMS or {}),
+ }
+
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
+ }
+ if ENABLE_FORWARD_USER_INFO_HEADERS:
+ headers = include_user_info_headers(headers, user)
+
+ r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
json=payload,
- headers={
- "Content-Type": "application/json",
- "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- },
+ headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
- ) as r:
- r.raise_for_status()
+ )
- async with aiofiles.open(file_path, "wb") as f:
- await f.write(await r.read())
+ r.raise_for_status()
- async with aiofiles.open(file_body_path, "w") as f:
- await f.write(json.dumps(payload))
+ async with aiofiles.open(file_path, "wb") as f:
+ await f.write(await r.read())
+
+ async with aiofiles.open(file_body_path, "w") as f:
+ await f.write(json.dumps(payload))
return FileResponse(file_path)
@@ -369,18 +392,22 @@ async def speech(request: Request, user=Depends(get_verified_user)):
log.exception(e)
detail = None
- try:
- if r.status != 200:
- res = await r.json()
+ status_code = 500
+ detail = f"Open WebUI: Server Connection Error"
+ if r is not None:
+ status_code = r.status
+
+ try:
+ res = await r.json()
if "error" in res:
- detail = f"External: {res['error'].get('message', '')}"
- except Exception:
- detail = f"External: {e}"
+ detail = f"External: {res['error']}"
+ except Exception:
+ detail = f"External: {e}"
raise HTTPException(
- status_code=getattr(r, "status", 500) if r else 500,
- detail=detail if detail else "Open WebUI: Server Connection Error",
+ status_code=status_code,
+ detail=detail,
)
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
@@ -398,7 +425,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
timeout=timeout, trust_env=True
) as session:
async with session.post(
- f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
+ f"{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}",
json={
"text": payload["input"],
"model_id": request.app.state.config.TTS_MODEL,
@@ -453,7 +480,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
try:
data = f"""
- {payload["input"]}
+ {html.escape(payload["input"])}
"""
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession(
@@ -537,13 +564,18 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
-def transcription_handler(request, file_path, metadata):
+def transcription_handler(request, file_path, metadata, user=None):
filename = os.path.basename(file_path)
file_dir = os.path.dirname(file_path)
id = filename.split(".")[0]
metadata = metadata or {}
+ languages = [
+ metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
+ None, # Always fallback to None in case transcription fails
+ ]
+
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(
@@ -555,7 +587,7 @@ def transcription_handler(request, file_path, metadata):
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
- language=metadata.get("language") or WHISPER_LANGUAGE,
+ language=languages[0],
)
log.info(
"Detected language '%s' with probability %f"
@@ -575,21 +607,30 @@ def transcription_handler(request, file_path, metadata):
elif request.app.state.config.STT_ENGINE == "openai":
r = None
try:
- r = requests.post(
- url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
- headers={
- "Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
- },
- files={"file": (filename, open(file_path, "rb"))},
- data={
+ for language in languages:
+ payload = {
"model": request.app.state.config.STT_MODEL,
- **(
- {"language": metadata.get("language")}
- if metadata.get("language")
- else {}
- ),
- },
- )
+ }
+
+ if language:
+ payload["language"] = language
+
+ headers = {
+ "Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}"
+ }
+ if user and ENABLE_FORWARD_USER_INFO_HEADERS:
+ headers = include_user_info_headers(headers, user)
+
+ r = requests.post(
+ url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
+ headers=headers,
+ files={"file": (filename, open(file_path, "rb"))},
+ data=payload,
+ )
+
+ if r.status_code == 200:
+ # Successful transcription
+ break
r.raise_for_status()
data = r.json()
@@ -631,18 +672,26 @@ def transcription_handler(request, file_path, metadata):
"Content-Type": mime,
}
- # Add model if specified
- params = {}
- if request.app.state.config.STT_MODEL:
- params["model"] = request.app.state.config.STT_MODEL
+ for language in languages:
+ params = {}
+ if request.app.state.config.STT_MODEL:
+ params["model"] = request.app.state.config.STT_MODEL
+
+ if language:
+ params["language"] = language
+
+ # Make request to Deepgram API
+ r = requests.post(
+ "https://api.deepgram.com/v1/listen?smart_format=true",
+ headers=headers,
+ params=params,
+ data=file_data,
+ )
+
+ if r.status_code == 200:
+ # Successful transcription
+ break
- # Make request to Deepgram API
- r = requests.post(
- "https://api.deepgram.com/v1/listen?smart_format=true",
- headers=headers,
- params=params,
- data=file_data,
- )
r.raise_for_status()
response_data = r.json()
@@ -795,8 +844,190 @@ def transcription_handler(request, file_path, metadata):
detail=detail if detail else "Open WebUI: Server Connection Error",
)
+ elif request.app.state.config.STT_ENGINE == "mistral":
+ # Check file exists
+ if not os.path.exists(file_path):
+ raise HTTPException(status_code=400, detail="Audio file not found")
-def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None):
+ # Check file size
+ file_size = os.path.getsize(file_path)
+ if file_size > MAX_FILE_SIZE:
+ raise HTTPException(
+ status_code=400,
+ detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB}MB",
+ )
+
+ api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY
+ api_base_url = (
+ request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL
+ or "https://api.mistral.ai/v1"
+ )
+ use_chat_completions = (
+ request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS
+ )
+
+ if not api_key:
+ raise HTTPException(
+ status_code=400,
+ detail="Mistral API key is required for Mistral STT",
+ )
+
+ r = None
+ try:
+ # Use voxtral-mini-latest as the default model for transcription
+ model = request.app.state.config.STT_MODEL or "voxtral-mini-latest"
+
+ log.info(
+ f"Mistral STT - model: {model}, "
+ f"method: {'chat_completions' if use_chat_completions else 'transcriptions'}"
+ )
+
+ if use_chat_completions:
+ # Use chat completions API with audio input
+ # This method requires mp3 or wav format
+ audio_file_to_use = file_path
+
+ if is_audio_conversion_required(file_path):
+ log.debug("Converting audio to mp3 for chat completions API")
+ converted_path = convert_audio_to_mp3(file_path)
+ if converted_path:
+ audio_file_to_use = converted_path
+ else:
+ log.error("Audio conversion failed")
+ raise HTTPException(
+ status_code=500,
+ detail="Audio conversion failed. Chat completions API requires mp3 or wav format.",
+ )
+
+ # Read and encode audio file as base64
+ with open(audio_file_to_use, "rb") as audio_file:
+ audio_base64 = base64.b64encode(audio_file.read()).decode("utf-8")
+
+ # Prepare chat completions request
+ url = f"{api_base_url}/chat/completions"
+
+ # Add language instruction if specified
+ language = metadata.get("language", None) if metadata else None
+ if language:
+ text_instruction = f"Transcribe this audio exactly as spoken in {language}. Do not translate it."
+ else:
+ text_instruction = "Transcribe this audio exactly as spoken in its original language. Do not translate it to another language."
+
+ payload = {
+ "model": model,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "input_audio",
+ "input_audio": audio_base64,
+ },
+ {"type": "text", "text": text_instruction},
+ ],
+ }
+ ],
+ }
+
+ r = requests.post(
+ url=url,
+ json=payload,
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ )
+
+ r.raise_for_status()
+ response = r.json()
+
+ # Extract transcript from chat completion response
+ transcript = (
+ response.get("choices", [{}])[0]
+ .get("message", {})
+ .get("content", "")
+ .strip()
+ )
+ if not transcript:
+ raise ValueError("Empty transcript in response")
+
+ data = {"text": transcript}
+
+ else:
+ # Use dedicated transcriptions API
+ url = f"{api_base_url}/audio/transcriptions"
+
+ # Determine the MIME type
+ mime_type, _ = mimetypes.guess_type(file_path)
+ if not mime_type:
+ mime_type = "audio/webm"
+
+ # Use context manager to ensure file is properly closed
+ with open(file_path, "rb") as audio_file:
+ files = {"file": (filename, audio_file, mime_type)}
+ data_form = {"model": model}
+
+ # Add language if specified in metadata
+ language = metadata.get("language", None) if metadata else None
+ if language:
+ data_form["language"] = language
+
+ r = requests.post(
+ url=url,
+ files=files,
+ data=data_form,
+ headers={
+ "Authorization": f"Bearer {api_key}",
+ },
+ )
+
+ r.raise_for_status()
+ response = r.json()
+
+ # Extract transcript from response
+ transcript = response.get("text", "").strip()
+ if not transcript:
+ raise ValueError("Empty transcript in response")
+
+ data = {"text": transcript}
+
+ # Save transcript to json file (consistent with other providers)
+ transcript_file = f"{file_dir}/{id}.json"
+ with open(transcript_file, "w") as f:
+ json.dump(data, f)
+
+ log.debug(data)
+ return data
+
+ except ValueError as e:
+ log.exception("Error parsing Mistral response")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Failed to parse Mistral response: {str(e)}",
+ )
+ except requests.exceptions.RequestException as e:
+ log.exception(e)
+ detail = None
+
+ try:
+ if r is not None and r.status_code != 200:
+ res = r.json()
+ if "error" in res:
+ detail = f"External: {res['error'].get('message', '')}"
+ else:
+ detail = f"External: {r.text}"
+ except Exception:
+ detail = f"External: {e}"
+
+ raise HTTPException(
+ status_code=getattr(r, "status_code", 500) if r else 500,
+ detail=detail if detail else "Open WebUI: Server Connection Error",
+ )
+
+
+def transcribe(
+ request: Request, file_path: str, metadata: Optional[dict] = None, user=None
+):
log.info(f"transcribe: {file_path} {metadata}")
if is_audio_conversion_required(file_path):
@@ -823,7 +1054,9 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
with ThreadPoolExecutor() as executor:
# Submit tasks for each chunk_path
futures = [
- executor.submit(transcription_handler, request, chunk_path, metadata)
+ executor.submit(
+ transcription_handler, request, chunk_path, metadata, user
+ )
for chunk_path in chunk_paths
]
# Gather results as they complete
@@ -920,14 +1153,18 @@ def transcription(
):
log.info(f"file.content_type: {file.content_type}")
- supported_content_types = request.app.state.config.STT_SUPPORTED_CONTENT_TYPES or [
- "audio/*",
- "video/webm",
- ]
+ stt_supported_content_types = getattr(
+ request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
+ )
if not any(
fnmatch(file.content_type, content_type)
- for content_type in supported_content_types
+ for content_type in (
+ stt_supported_content_types
+ if stt_supported_content_types
+ and any(t.strip() for t in stt_supported_content_types)
+ else ["audio/*", "video/webm"]
+ )
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -954,7 +1191,7 @@ def transcription(
if language:
metadata = {"language": language}
- result = transcribe(request, file_path, metadata)
+ result = transcribe(request, file_path, metadata, user)
return {
**result,
@@ -1000,7 +1237,7 @@ def get_available_models(request: Request) -> list[dict]:
elif request.app.state.config.TTS_ENGINE == "elevenlabs":
try:
response = requests.get(
- "https://api.elevenlabs.io/v1/models",
+ f"{ELEVENLABS_API_BASE_URL}/v1/models",
headers={
"xi-api-key": request.app.state.config.TTS_API_KEY,
"Content-Type": "application/json",
@@ -1104,7 +1341,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
try:
# TODO: Add retries
response = requests.get(
- "https://api.elevenlabs.io/v1/voices",
+ f"{ELEVENLABS_API_BASE_URL}/v1/voices",
headers={
"xi-api-key": api_key,
"Content-Type": "application/json",
diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py
index 60a12db4b3..764196c5f1 100644
--- a/backend/open_webui/routers/auths.py
+++ b/backend/open_webui/routers/auths.py
@@ -4,6 +4,7 @@ import time
import datetime
import logging
from aiohttp import ClientSession
+import urllib
from open_webui.models.auths import (
AddUserForm,
@@ -15,11 +16,11 @@ from open_webui.models.auths import (
SigninResponse,
SignupForm,
UpdatePasswordForm,
- UpdateProfileForm,
UserResponse,
)
-from open_webui.models.users import Users
+from open_webui.models.users import Users, UpdateProfileForm
from open_webui.models.groups import Groups
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
@@ -30,16 +31,25 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
+ ENABLE_INITIAL_ADMIN_SIGNUP,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse
-from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
+from open_webui.config import (
+ OPENID_PROVIDER_URL,
+ ENABLE_OAUTH_SIGNUP,
+ ENABLE_LDAP,
+ ENABLE_PASSWORD_AUTH,
+)
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.auth import (
+ validate_password,
+ verify_password,
decode_token,
+ invalidate_token,
create_api_key,
create_token,
get_admin_user,
@@ -49,7 +59,7 @@ from open_webui.utils.auth import (
get_http_authorization_cred,
)
from open_webui.utils.webhook import post_webhook
-from open_webui.utils.access_control import get_permissions
+from open_webui.utils.access_control import get_permissions, has_permission
from typing import Optional, List
@@ -73,7 +83,13 @@ class SessionUserResponse(Token, UserResponse):
permissions: Optional[dict] = None
-@router.get("/", response_model=SessionUserResponse)
+class SessionUserInfoResponse(SessionUserResponse):
+ bio: Optional[str] = None
+ gender: Optional[str] = None
+ date_of_birth: Optional[datetime.date] = None
+
+
+@router.get("/", response_model=SessionUserInfoResponse)
async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user)
):
@@ -121,6 +137,9 @@ async def get_session_user(
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
+ "bio": user.bio,
+ "gender": user.gender,
+ "date_of_birth": user.date_of_birth,
"permissions": user_permissions,
}
@@ -137,7 +156,7 @@ async def update_profile(
if session_user:
user = Users.update_user_by_id(
session_user.id,
- {"profile_image_url": form_data.profile_image_url, "name": form_data.name},
+ form_data.model_dump(),
)
if user:
return user
@@ -159,13 +178,19 @@ async def update_password(
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if session_user:
- user = Auths.authenticate_user(session_user.email, form_data.password)
+ user = Auths.authenticate_user(
+ session_user.email, lambda pw: verify_password(form_data.password, pw)
+ )
if user:
+ try:
+ validate_password(form_data.password)
+ except Exception as e:
+ raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed)
else:
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_PASSWORD)
+ raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -175,7 +200,17 @@ async def update_password(
############################
@router.post("/ldap", response_model=SessionUserResponse)
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
- ENABLE_LDAP = request.app.state.config.ENABLE_LDAP
+ # Security checks FIRST - before loading any config
+ if not request.app.state.config.ENABLE_LDAP:
+ raise HTTPException(400, detail="LDAP authentication is not enabled")
+
+ if not ENABLE_PASSWORD_AUTH:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+ )
+
+ # NOW load LDAP config variables
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
@@ -196,9 +231,6 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
else "ALL"
)
- if not ENABLE_LDAP:
- raise HTTPException(400, detail="LDAP authentication is not enabled")
-
try:
tls = Tls(
validate=LDAP_VALIDATE_CERT,
@@ -351,11 +383,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
user = Users.get_user_by_email(email)
if not user:
try:
- user_count = Users.get_num_users()
-
role = (
"admin"
- if user_count == 0
+ if not Users.has_users()
else request.app.state.config.DEFAULT_USER_ROLE
)
@@ -455,6 +485,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
@router.post("/signin", response_model=SessionUserResponse)
async def signin(request: Request, response: Response, form_data: SigninForm):
+ if not ENABLE_PASSWORD_AUTH:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+ )
+
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
@@ -464,6 +500,10 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
+ try:
+ name = urllib.parse.unquote(name, encoding="utf-8")
+ except Exception as e:
+ pass
if not Users.get_user_by_email(email.lower()):
await signup(
@@ -487,9 +527,11 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
admin_password = "admin"
if Users.get_user_by_email(admin_email.lower()):
- user = Auths.authenticate_user(admin_email.lower(), admin_password)
+ user = Auths.authenticate_user(
+ admin_email.lower(), lambda pw: verify_password(admin_password, pw)
+ )
else:
- if Users.get_num_users() != 0:
+ if Users.has_users():
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
@@ -498,9 +540,22 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
SignupForm(email=admin_email, password=admin_password, name="User"),
)
- user = Auths.authenticate_user(admin_email.lower(), admin_password)
+ user = Auths.authenticate_user(
+ admin_email.lower(), lambda pw: verify_password(admin_password, pw)
+ )
else:
- user = Auths.authenticate_user(form_data.email.lower(), form_data.password)
+ password_bytes = form_data.password.encode("utf-8")
+ if len(password_bytes) > 72:
+ # TODO: Implement other hashing algorithms that support longer passwords
+ log.info("Password too long, truncating to 72 bytes for bcrypt")
+ password_bytes = password_bytes[:72]
+
+ # decode safely — ignore incomplete UTF-8 sequences
+ form_data.password = password_bytes.decode("utf-8", errors="ignore")
+
+ user = Auths.authenticate_user(
+ form_data.email.lower(), lambda pw: verify_password(form_data.password, pw)
+ )
if user:
@@ -556,22 +611,23 @@ 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):
+ has_users = Users.has_users()
if WEBUI_AUTH:
if (
not request.app.state.config.ENABLE_SIGNUP
or not request.app.state.config.ENABLE_LOGIN_FORM
):
- raise HTTPException(
- status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
- )
+ if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
+ )
else:
- if Users.get_num_users() != 0:
+ if has_users:
raise HTTPException(
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
@@ -581,18 +637,14 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
- role = (
- "admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
- )
-
- # 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,
- )
+ try:
+ validate_password(form_data.password)
+ except Exception as e:
+ raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
+
+ role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
user = Auths.insert_new_auth(
form_data.email.lower(),
hashed,
@@ -629,7 +681,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
)
if request.app.state.config.WEBHOOK_URL:
- post_webhook(
+ await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@@ -644,10 +696,14 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
user.id, request.app.state.config.USER_PERMISSIONS
)
- if user_count == 0:
+ if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
+ default_group_id = getattr(request.app.state.config, "DEFAULT_GROUP_ID", "")
+ if default_group_id and default_group_id:
+ Groups.add_users_to_group(default_group_id, [user.id])
+
return {
"token": token,
"token_type": "Bearer",
@@ -668,38 +724,66 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
@router.get("/signout")
async def signout(request: Request, response: Response):
+
+ # get auth token from headers or cookies
+ token = None
+ auth_header = request.headers.get("Authorization")
+ if auth_header:
+ auth_cred = get_http_authorization_cred(auth_header)
+ token = auth_cred.credentials
+ else:
+ token = request.cookies.get("token")
+
+ if token:
+ await invalidate_token(request, token)
+
response.delete_cookie("token")
+ response.delete_cookie("oui-session")
+ response.delete_cookie("oauth_id_token")
- if ENABLE_OAUTH_SIGNUP.value:
- oauth_id_token = request.cookies.get("oauth_id_token")
- if oauth_id_token:
+ oauth_session_id = request.cookies.get("oauth_session_id")
+ if oauth_session_id:
+ response.delete_cookie("oauth_session_id")
+
+ session = OAuthSessions.get_session_by_id(oauth_session_id)
+ oauth_server_metadata_url = (
+ request.app.state.oauth_manager.get_server_metadata_url(session.provider)
+ if session
+ else None
+ ) or OPENID_PROVIDER_URL.value
+
+ if session and oauth_server_metadata_url:
+ oauth_id_token = session.token.get("id_token")
try:
- async with ClientSession() as session:
- async with session.get(OPENID_PROVIDER_URL.value) as resp:
- if resp.status == 200:
- openid_data = await resp.json()
+ async with ClientSession(trust_env=True) as session:
+ async with session.get(oauth_server_metadata_url) as r:
+ if r.status == 200:
+ openid_data = await r.json()
logout_url = openid_data.get("end_session_endpoint")
- if logout_url:
- response.delete_cookie("oauth_id_token")
+ if logout_url:
return JSONResponse(
status_code=200,
content={
"status": True,
- "redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}",
+ "redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}"
+ + (
+ f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}"
+ if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
+ else ""
+ ),
},
headers=response.headers,
)
else:
- raise HTTPException(
- status_code=resp.status,
- detail="Failed to fetch OpenID configuration",
- )
+ raise Exception("Failed to fetch OpenID configuration")
+
except Exception as e:
log.error(f"OpenID signout error: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to sign out from the OpenID provider.",
+ headers=response.headers,
)
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
@@ -733,6 +817,11 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
+ try:
+ validate_password(form_data.password)
+ except Exception as e:
+ raise HTTPException(400, detail=str(e))
+
hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth(
form_data.email.lower(),
@@ -804,10 +893,11 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
- "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_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
+ "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
+ "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
+ "DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
"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,
@@ -824,10 +914,11 @@ class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool
WEBUI_URL: str
ENABLE_SIGNUP: bool
- ENABLE_API_KEY: bool
- ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
- API_KEY_ALLOWED_ENDPOINTS: str
+ ENABLE_API_KEYS: bool
+ ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
+ API_KEYS_ALLOWED_ENDPOINTS: str
DEFAULT_USER_ROLE: str
+ DEFAULT_GROUP_ID: str
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
@@ -847,12 +938,12 @@ async def update_admin_config(
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
- request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
- request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
- form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
+ request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
+ request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = (
+ form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
)
- request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = (
- form_data.API_KEY_ALLOWED_ENDPOINTS
+ request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = (
+ form_data.API_KEYS_ALLOWED_ENDPOINTS
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
@@ -861,6 +952,8 @@ async def update_admin_config(
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
+ request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
+
pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$"
# Check if the input string matches the pattern
@@ -887,10 +980,11 @@ async def update_admin_config(
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
- "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_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
+ "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
+ "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
+ "DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID,
"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,
@@ -1014,9 +1108,11 @@ async def update_ldap_config(
# create api key
@router.post("/api_key", response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user)):
- if not request.app.state.config.ENABLE_API_KEY:
+ if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
+ user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
+ ):
raise HTTPException(
- status.HTTP_403_FORBIDDEN,
+ status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
)
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index 6da3f04cee..e47c98554e 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -7,10 +7,25 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status, Backgrou
from pydantic import BaseModel
-from open_webui.socket.main import sio, get_user_ids_from_room
-from open_webui.models.users import Users, UserNameResponse
+from open_webui.socket.main import (
+ sio,
+ get_user_ids_from_room,
+ get_active_status_by_user_id,
+)
+from open_webui.models.users import (
+ UserListResponse,
+ UserModelResponse,
+ Users,
+ UserNameResponse,
+)
-from open_webui.models.channels import Channels, ChannelModel, ChannelForm
+from open_webui.models.groups import Groups
+from open_webui.models.channels import (
+ Channels,
+ ChannelModel,
+ ChannelForm,
+ ChannelResponse,
+)
from open_webui.models.messages import (
Messages,
MessageModel,
@@ -24,9 +39,21 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
+from open_webui.utils.models import (
+ get_all_models,
+ get_filtered_models,
+)
+from open_webui.utils.chat import generate_chat_completion
+
+
from open_webui.utils.auth import get_admin_user, get_verified_user
-from open_webui.utils.access_control import has_access, get_users_with_access
+from open_webui.utils.access_control import (
+ has_access,
+ get_users_with_access,
+ get_permitted_group_and_user_ids,
+)
from open_webui.utils.webhook import post_webhook
+from open_webui.utils.channels import extract_mentions, replace_mentions
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -40,10 +67,14 @@ router = APIRouter()
@router.get("/", response_model=list[ChannelModel])
async def get_channels(user=Depends(get_verified_user)):
+ return Channels.get_channels_by_user_id(user.id)
+
+
+@router.get("/list", response_model=list[ChannelModel])
+async def get_all_channels(user=Depends(get_verified_user)):
if user.role == "admin":
return Channels.get_channels()
- else:
- return Channels.get_channels_by_user_id(user.id)
+ return Channels.get_channels_by_user_id(user.id)
############################
@@ -68,7 +99,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
############################
-@router.get("/{id}", response_model=Optional[ChannelModel])
+@router.get("/{id}", response_model=Optional[ChannelResponse])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
channel = Channels.get_channel_by_id(id)
if not channel:
@@ -83,7 +114,75 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
- return ChannelModel(**channel.model_dump())
+ write_access = has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ )
+
+ user_count = len(get_users_with_access("read", channel.access_control))
+
+ return ChannelResponse(
+ **{
+ **channel.model_dump(),
+ "write_access": write_access or user.role == "admin",
+ "user_count": user_count,
+ }
+ )
+
+
+PAGE_ITEM_COUNT = 30
+
+
+@router.get("/{id}/users", response_model=UserListResponse)
+async def get_channel_users_by_id(
+ id: str,
+ query: Optional[str] = None,
+ order_by: Optional[str] = None,
+ direction: Optional[str] = None,
+ page: Optional[int] = 1,
+ user=Depends(get_verified_user),
+):
+
+ channel = Channels.get_channel_by_id(id)
+ if not channel:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
+
+ limit = PAGE_ITEM_COUNT
+
+ page = max(1, page)
+ skip = (page - 1) * limit
+
+ filter = {
+ "roles": ["!pending"],
+ }
+
+ if query:
+ filter["query"] = query
+ if order_by:
+ filter["order_by"] = order_by
+ if direction:
+ filter["direction"] = direction
+
+ permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control)
+ if permitted_ids:
+ filter["user_ids"] = permitted_ids.get("user_ids")
+ filter["group_ids"] = permitted_ids.get("group_ids")
+
+ result = Users.get_users(filter=filter, skip=skip, limit=limit)
+
+ users = result["users"]
+ total = result["total"]
+
+ return {
+ "users": [
+ UserModelResponse(
+ **user.model_dump(), is_active=get_active_status_by_user_id(user.id)
+ )
+ for user in users
+ ],
+ "total": total,
+ }
############################
@@ -140,7 +239,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
class MessageUserResponse(MessageResponse):
- user: UserNameResponse
+ pass
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
@@ -169,15 +268,17 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
- replies = Messages.get_replies_by_message_id(message.id)
- latest_reply_at = replies[0].created_at if replies else None
+ thread_replies = Messages.get_thread_replies_by_message_id(message.id)
+ latest_thread_reply_at = (
+ thread_replies[0].created_at if thread_replies else None
+ )
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
- "reply_count": len(replies),
- "latest_reply_at": latest_reply_at,
+ "reply_count": len(thread_replies),
+ "latest_reply_at": latest_thread_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
@@ -196,16 +297,13 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
users = get_users_with_access("read", channel.access_control)
for user in users:
- if user.id in active_user_ids:
- continue
- else:
+ if user.id not in active_user_ids:
if user.settings:
webhook_url = user.settings.ui.get("notifications", {}).get(
"webhook_url", None
)
-
if webhook_url:
- post_webhook(
+ await post_webhook(
name,
webhook_url,
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
@@ -217,14 +315,185 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
},
)
+ return True
-@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
-async def post_new_message(
- request: Request,
- id: str,
- form_data: MessageForm,
- background_tasks: BackgroundTasks,
- user=Depends(get_verified_user),
+
+async def model_response_handler(request, channel, message, user):
+ MODELS = {
+ model["id"]: model
+ for model in get_filtered_models(await get_all_models(request, user=user), user)
+ }
+
+ mentions = extract_mentions(message.content)
+ message_content = replace_mentions(message.content)
+
+ model_mentions = {}
+
+ # check if the message is a reply to a message sent by a model
+ if (
+ message.reply_to_message
+ and message.reply_to_message.meta
+ and message.reply_to_message.meta.get("model_id", None)
+ ):
+ model_id = message.reply_to_message.meta.get("model_id", None)
+ model_mentions[model_id] = {"id": model_id, "id_type": "M"}
+
+ # check if any of the mentions are models
+ for mention in mentions:
+ if mention["id_type"] == "M" and mention["id"] not in model_mentions:
+ model_mentions[mention["id"]] = mention
+
+ if not model_mentions:
+ return False
+
+ for mention in model_mentions.values():
+ model_id = mention["id"]
+ model = MODELS.get(model_id, None)
+
+ if model:
+ try:
+ # reverse to get in chronological order
+ thread_messages = Messages.get_messages_by_parent_id(
+ channel.id,
+ message.parent_id if message.parent_id else message.id,
+ )[::-1]
+
+ response_message, channel = await new_message_handler(
+ request,
+ channel.id,
+ MessageForm(
+ **{
+ "parent_id": (
+ message.parent_id if message.parent_id else message.id
+ ),
+ "content": f"",
+ "data": {},
+ "meta": {
+ "model_id": model_id,
+ "model_name": model.get("name", model_id),
+ },
+ }
+ ),
+ user,
+ )
+
+ thread_history = []
+ images = []
+ message_users = {}
+
+ for thread_message in thread_messages:
+ message_user = None
+ if thread_message.user_id not in message_users:
+ message_user = Users.get_user_by_id(thread_message.user_id)
+ message_users[thread_message.user_id] = message_user
+ else:
+ message_user = message_users[thread_message.user_id]
+
+ if thread_message.meta and thread_message.meta.get(
+ "model_id", None
+ ):
+ # If the message was sent by a model, use the model name
+ message_model_id = thread_message.meta.get("model_id", None)
+ message_model = MODELS.get(message_model_id, None)
+ username = (
+ message_model.get("name", message_model_id)
+ if message_model
+ else message_model_id
+ )
+ else:
+ username = message_user.name if message_user else "Unknown"
+
+ thread_history.append(
+ f"{username}: {replace_mentions(thread_message.content)}"
+ )
+
+ thread_message_files = thread_message.data.get("files", [])
+ for file in thread_message_files:
+ if file.get("type", "") == "image":
+ images.append(file.get("url", ""))
+
+ thread_history_string = "\n\n".join(thread_history)
+ system_message = {
+ "role": "system",
+ "content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
+ + (
+ f"Here's the thread history:\n\n\n{thread_history_string}\n\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
+ if thread_history
+ else ""
+ ),
+ }
+
+ content = f"{user.name if user else 'User'}: {message_content}"
+ if images:
+ content = [
+ {
+ "type": "text",
+ "text": content,
+ },
+ *[
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": image,
+ },
+ }
+ for image in images
+ ],
+ ]
+
+ form_data = {
+ "model": model_id,
+ "messages": [
+ system_message,
+ {"role": "user", "content": content},
+ ],
+ "stream": False,
+ }
+
+ res = await generate_chat_completion(
+ request,
+ form_data=form_data,
+ user=user,
+ )
+
+ if res:
+ if res.get("choices", []) and len(res["choices"]) > 0:
+ await update_message_by_id(
+ channel.id,
+ response_message.id,
+ MessageForm(
+ **{
+ "content": res["choices"][0]["message"]["content"],
+ "meta": {
+ "done": True,
+ },
+ }
+ ),
+ user,
+ )
+ elif res.get("error", None):
+ await update_message_by_id(
+ channel.id,
+ response_message.id,
+ MessageForm(
+ **{
+ "content": f"Error: {res['error']}",
+ "meta": {
+ "done": True,
+ },
+ }
+ ),
+ user,
+ )
+ except Exception as e:
+ log.info(e)
+ pass
+
+ return True
+
+
+async def new_message_handler(
+ request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
@@ -233,7 +502,7 @@ async def post_new_message(
)
if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
+ user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -241,31 +510,21 @@ async def post_new_message(
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
-
if message:
+ message = Messages.get_message_by_id(message.id)
event_data = {
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message",
- "data": MessageUserResponse(
- **{
- **message.model_dump(),
- "reply_count": 0,
- "latest_reply_at": None,
- "reactions": Messages.get_reactions_by_message_id(
- message.id
- ),
- "user": UserNameResponse(**user.model_dump()),
- }
- ).model_dump(),
+ "data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
}
await sio.emit(
- "channel-events",
+ "events:channel",
event_data,
to=f"channel:{channel.id}",
)
@@ -276,33 +535,45 @@ async def post_new_message(
if parent_message:
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
- "data": MessageUserResponse(
- **{
- **parent_message.model_dump(),
- "user": UserNameResponse(
- **Users.get_user_by_id(
- parent_message.user_id
- ).model_dump()
- ),
- }
- ).model_dump(),
+ "data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
+ return message, channel
+ else:
+ raise Exception("Error creating message")
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
- active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
- background_tasks.add_task(
- send_notification,
+@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
+async def post_new_message(
+ request: Request,
+ id: str,
+ form_data: MessageForm,
+ background_tasks: BackgroundTasks,
+ user=Depends(get_verified_user),
+):
+
+ try:
+ message, channel = await new_message_handler(request, id, form_data, user)
+ active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
+
+ async def background_handler():
+ await model_response_handler(request, channel, message, user)
+ await send_notification(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBUI_URL,
channel,
@@ -310,7 +581,12 @@ async def post_new_message(
active_user_ids,
)
- return MessageModel(**message.model_dump())
+ background_tasks.add_task(background_handler)
+
+ return message
+
+ except HTTPException as e:
+ raise e
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -430,13 +706,6 @@ async def update_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
-
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -448,26 +717,28 @@ async def update_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
+ if (
+ user.role != "admin"
+ and message.user_id != user.id
+ and not has_access(user.id, type="read", access_control=channel.access_control)
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
if message:
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:update",
- "data": MessageUserResponse(
- **{
- **message.model_dump(),
- "user": UserNameResponse(
- **user.model_dump()
- ).model_dump(),
- }
- ).model_dump(),
+ "data": message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -503,7 +774,7 @@ async def add_reaction_to_message(
)
if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
+ user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -525,7 +796,7 @@ async def add_reaction_to_message(
message = Messages.get_message_by_id(message_id)
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": message.id,
@@ -533,9 +804,6 @@ async def add_reaction_to_message(
"type": "message:reaction:add",
"data": {
**message.model_dump(),
- "user": UserNameResponse(
- **Users.get_user_by_id(message.user_id).model_dump()
- ).model_dump(),
"name": form_data.name,
},
},
@@ -569,7 +837,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
)
if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
+ user.id, type="write", access_control=channel.access_control, strict=False
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -594,7 +862,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
message = Messages.get_message_by_id(message_id)
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": message.id,
@@ -602,9 +870,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
- "user": UserNameResponse(
- **Users.get_user_by_id(message.user_id).model_dump()
- ).model_dump(),
"name": form_data.name,
},
},
@@ -637,13 +902,6 @@ async def delete_message_by_id(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
-
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
@@ -655,10 +913,21 @@ async def delete_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
+ if (
+ user.role != "admin"
+ and message.user_id != user.id
+ and not has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ )
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
try:
Messages.delete_message_by_id(message_id)
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": message.id,
@@ -681,22 +950,13 @@ async def delete_message_by_id(
if parent_message:
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
- "data": MessageUserResponse(
- **{
- **parent_message.model_dump(),
- "user": UserNameResponse(
- **Users.get_user_by_id(
- parent_message.user_id
- ).model_dump()
- ),
- }
- ).model_dump(),
+ "data": parent_message.model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py
index 29b12ed676..78cd8bdb1a 100644
--- a/backend/open_webui/routers/chats.py
+++ b/backend/open_webui/routers/chats.py
@@ -7,6 +7,7 @@ from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
ChatImportForm,
+ ChatsImportForm,
ChatResponse,
Chats,
ChatTitleIdResponse,
@@ -36,16 +37,33 @@ router = APIRouter()
@router.get("/", response_model=list[ChatTitleIdResponse])
@router.get("/list", response_model=list[ChatTitleIdResponse])
-async def get_session_user_chat_list(
- user=Depends(get_verified_user), page: Optional[int] = None
+def get_session_user_chat_list(
+ user=Depends(get_verified_user),
+ page: Optional[int] = None,
+ include_pinned: Optional[bool] = False,
+ include_folders: Optional[bool] = False,
):
- if page is not None:
- limit = 60
- skip = (page - 1) * limit
+ try:
+ if page is not None:
+ limit = 60
+ skip = (page - 1) * limit
- return Chats.get_chat_title_id_list_by_user_id(user.id, skip=skip, limit=limit)
- else:
- return Chats.get_chat_title_id_list_by_user_id(user.id)
+ return Chats.get_chat_title_id_list_by_user_id(
+ user.id,
+ include_folders=include_folders,
+ include_pinned=include_pinned,
+ skip=skip,
+ limit=limit,
+ )
+ else:
+ return Chats.get_chat_title_id_list_by_user_id(
+ user.id, include_folders=include_folders, include_pinned=include_pinned
+ )
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
############################
@@ -125,26 +143,15 @@ async def create_new_chat(form_data: ChatForm, user=Depends(get_verified_user)):
############################
-# ImportChat
+# ImportChats
############################
-@router.post("/import", response_model=Optional[ChatResponse])
-async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)):
+@router.post("/import", response_model=list[ChatResponse])
+async def import_chats(form_data: ChatsImportForm, user=Depends(get_verified_user)):
try:
- chat = Chats.import_chat(user.id, form_data)
- if chat:
- tags = chat.meta.get("tags", [])
- for tag_id in tags:
- tag_id = tag_id.replace(" ", "_").lower()
- tag_name = " ".join([word.capitalize() for word in tag_id.split("_")])
- if (
- tag_id != "none"
- and Tags.get_tag_by_name_and_user_id(tag_name, user.id) is None
- ):
- Tags.insert_new_tag(tag_name, user.id)
-
- return ChatResponse(**chat.model_dump())
+ chats = Chats.import_chats(user.id, form_data.chats)
+ return chats
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -158,7 +165,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)
@router.get("/search", response_model=list[ChatTitleIdResponse])
-async def search_user_chats(
+def search_user_chats(
text: str, page: Optional[int] = None, user=Depends(get_verified_user)
):
if page is None:
@@ -206,6 +213,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
]
+@router.get("/folder/{folder_id}/list")
+async def get_chat_list_by_folder_id(
+ folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
+):
+ try:
+ limit = 10
+ skip = (page - 1) * limit
+
+ return [
+ {"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
+ for chat in Chats.get_chats_by_folder_id_and_user_id(
+ folder_id, user.id, skip=skip, limit=limit
+ )
+ ]
+
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+
############################
# GetPinnedChats
############################
@@ -327,6 +356,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
return Chats.archive_all_chats_by_user_id(user.id)
+############################
+# UnarchiveAllChats
+############################
+
+
+@router.post("/unarchive/all", response_model=bool)
+async def unarchive_all_chats(user=Depends(get_verified_user)):
+ return Chats.unarchive_all_chats_by_user_id(user.id)
+
+
############################
# GetSharedChatById
############################
@@ -609,8 +648,28 @@ async def clone_chat_by_id(
"title": form_data.title if form_data.title else f"Clone of {chat.title}",
}
- chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
- return ChatResponse(**chat.model_dump())
+ chats = Chats.import_chats(
+ user.id,
+ [
+ ChatImportForm(
+ **{
+ "chat": updated_chat,
+ "meta": chat.meta,
+ "pinned": chat.pinned,
+ "folder_id": chat.folder_id,
+ }
+ )
+ ],
+ )
+
+ if chats:
+ chat = chats[0]
+ return ChatResponse(**chat.model_dump())
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=ERROR_MESSAGES.DEFAULT(),
+ )
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
@@ -638,8 +697,28 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
"title": f"Clone of {chat.title}",
}
- chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
- return ChatResponse(**chat.model_dump())
+ chats = Chats.import_chats(
+ user.id,
+ [
+ ChatImportForm(
+ **{
+ "chat": updated_chat,
+ "meta": chat.meta,
+ "pinned": chat.pinned,
+ "folder_id": chat.folder_id,
+ }
+ )
+ ],
+ )
+
+ if chats:
+ chat = chats[0]
+ return ChatResponse(**chat.model_dump())
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=ERROR_MESSAGES.DEFAULT(),
+ )
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
@@ -684,8 +763,10 @@ 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(request: Request, id: str, user=Depends(get_verified_user)):
- if not has_permission(
- user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
+ if (user.role != "admin") and (
+ not has_permission(
+ user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
+ )
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py
index 44b2ef40cf..0eb88e767e 100644
--- a/backend/open_webui/routers/configs.py
+++ b/backend/open_webui/routers/configs.py
@@ -1,5 +1,8 @@
+import logging
+import copy
from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel, ConfigDict
+import aiohttp
from typing import Optional
@@ -7,11 +10,30 @@ 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
+from open_webui.utils.tools import (
+ get_tool_server_data,
+ get_tool_server_url,
+ set_tool_servers,
+)
+from open_webui.utils.mcp.client import MCPClient
+from open_webui.models.oauth_sessions import OAuthSessions
+from open_webui.env import SRC_LOG_LEVELS
+
+from open_webui.utils.oauth import (
+ get_discovery_urls,
+ get_oauth_client_info_with_dynamic_client_registration,
+ encrypt_data,
+ decrypt_data,
+ OAuthClientInformationFull,
+)
+from mcp.shared.auth import OAuthMetadata
router = APIRouter()
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MAIN"])
+
############################
# ImportConfig
@@ -39,35 +61,79 @@ async def export_config(user=Depends(get_admin_user)):
############################
-# Direct Connections Config
+# Connections Config
############################
-class DirectConnectionsConfigForm(BaseModel):
+class ConnectionsConfigForm(BaseModel):
ENABLE_DIRECT_CONNECTIONS: bool
+ ENABLE_BASE_MODELS_CACHE: bool
-@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
-async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
+@router.get("/connections", response_model=ConnectionsConfigForm)
+async def get_connections_config(request: Request, user=Depends(get_admin_user)):
return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
+ "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
}
-@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
-async def set_direct_connections_config(
+@router.post("/connections", response_model=ConnectionsConfigForm)
+async def set_connections_config(
request: Request,
- form_data: DirectConnectionsConfigForm,
+ form_data: ConnectionsConfigForm,
user=Depends(get_admin_user),
):
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
form_data.ENABLE_DIRECT_CONNECTIONS
)
+ request.app.state.config.ENABLE_BASE_MODELS_CACHE = (
+ form_data.ENABLE_BASE_MODELS_CACHE
+ )
+
return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
+ "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE,
}
+class OAuthClientRegistrationForm(BaseModel):
+ url: str
+ client_id: str
+ client_name: Optional[str] = None
+
+
+@router.post("/oauth/clients/register")
+async def register_oauth_client(
+ request: Request,
+ form_data: OAuthClientRegistrationForm,
+ type: Optional[str] = None,
+ user=Depends(get_admin_user),
+):
+ try:
+ oauth_client_id = form_data.client_id
+ if type:
+ oauth_client_id = f"{type}:{form_data.client_id}"
+
+ oauth_client_info = (
+ await get_oauth_client_info_with_dynamic_client_registration(
+ request, oauth_client_id, form_data.url
+ )
+ )
+ return {
+ "status": True,
+ "oauth_client_info": encrypt_data(
+ oauth_client_info.model_dump(mode="json")
+ ),
+ }
+ except Exception as e:
+ log.debug(f"Failed to register OAuth client: {e}")
+ raise HTTPException(
+ status_code=400,
+ detail=f"Failed to register OAuth client",
+ )
+
+
############################
# ToolServers Config
############################
@@ -76,7 +142,9 @@ async def set_direct_connections_config(
class ToolServerConnection(BaseModel):
url: str
path: str
+ type: Optional[str] = "openapi" # openapi, mcp
auth_type: Optional[str]
+ headers: Optional[dict | str] = None
key: Optional[str]
config: Optional[dict]
@@ -100,13 +168,47 @@ async def set_tool_servers_config(
form_data: ToolServersConfigForm,
user=Depends(get_admin_user),
):
+ for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+ server_type = connection.get("type", "openapi")
+ auth_type = connection.get("auth_type", "none")
+
+ if auth_type == "oauth_2.1":
+ # Remove existing OAuth clients for tool servers
+ server_id = connection.get("info", {}).get("id")
+ client_key = f"{server_type}:{server_id}"
+
+ try:
+ request.app.state.oauth_client_manager.remove_client(client_key)
+ except:
+ pass
+
+ # Set new tool server connections
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
]
- request.app.state.TOOL_SERVERS = await get_tool_servers_data(
- request.app.state.config.TOOL_SERVER_CONNECTIONS
- )
+ await set_tool_servers(request)
+
+ for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+ server_type = connection.get("type", "openapi")
+ if server_type == "mcp":
+ server_id = connection.get("info", {}).get("id")
+ auth_type = connection.get("auth_type", "none")
+
+ if auth_type == "oauth_2.1" and server_id:
+ try:
+ oauth_client_info = connection.get("info", {}).get(
+ "oauth_client_info", ""
+ )
+ oauth_client_info = decrypt_data(oauth_client_info)
+
+ request.app.state.oauth_client_manager.add_client(
+ f"{server_type}:{server_id}",
+ OAuthClientInformationFull(**oauth_client_info),
+ )
+ except Exception as e:
+ log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
+ continue
return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
@@ -121,19 +223,129 @@ async def verify_tool_servers_config(
Verify the connection to the tool server.
"""
try:
+ if form_data.type == "mcp":
+ if form_data.auth_type == "oauth_2.1":
+ discovery_urls = get_discovery_urls(form_data.url)
+ for discovery_url in discovery_urls:
+ log.debug(
+ f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
+ )
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.get(
+ discovery_url
+ ) as oauth_server_metadata_response:
+ if oauth_server_metadata_response.status == 200:
+ try:
+ oauth_server_metadata = (
+ OAuthMetadata.model_validate(
+ await oauth_server_metadata_response.json()
+ )
+ )
+ return {
+ "status": True,
+ "oauth_server_metadata": oauth_server_metadata.model_dump(
+ mode="json"
+ ),
+ }
+ except Exception as e:
+ log.info(
+ f"Failed to parse OAuth 2.1 discovery document: {e}"
+ )
+ raise HTTPException(
+ status_code=400,
+ detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_url}",
+ )
- token = None
- if form_data.auth_type == "bearer":
- token = form_data.key
- elif form_data.auth_type == "session":
- token = request.state.token.credentials
+ raise HTTPException(
+ status_code=400,
+ detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
+ )
+ else:
+ try:
+ client = MCPClient()
+ headers = None
- url = f"{form_data.url}/{form_data.path}"
- return await get_tool_server_data(token, url)
+ token = None
+ if form_data.auth_type == "bearer":
+ token = form_data.key
+ elif form_data.auth_type == "session":
+ token = request.state.token.credentials
+ elif form_data.auth_type == "system_oauth":
+ oauth_token = None
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+
+ if oauth_token:
+ token = oauth_token.get("access_token", "")
+ except Exception as e:
+ pass
+ if token:
+ headers = {"Authorization": f"Bearer {token}"}
+
+ if form_data.headers and isinstance(form_data.headers, dict):
+ if headers is None:
+ headers = {}
+ headers.update(form_data.headers)
+
+ await client.connect(form_data.url, headers=headers)
+ specs = await client.list_tool_specs()
+ return {
+ "status": True,
+ "specs": specs,
+ }
+ except Exception as e:
+ log.debug(f"Failed to create MCP client: {e}")
+ raise HTTPException(
+ status_code=400,
+ detail=f"Failed to create MCP client",
+ )
+ finally:
+ if client:
+ await client.disconnect()
+ else: # openapi
+ token = None
+ headers = None
+ if form_data.auth_type == "bearer":
+ token = form_data.key
+ elif form_data.auth_type == "session":
+ token = request.state.token.credentials
+ elif form_data.auth_type == "system_oauth":
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = (
+ await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+ )
+
+ if oauth_token:
+ token = oauth_token.get("access_token", "")
+
+ except Exception as e:
+ pass
+
+ if token:
+ headers = {"Authorization": f"Bearer {token}"}
+
+ if form_data.headers and isinstance(form_data.headers, dict):
+ if headers is None:
+ headers = {}
+ headers.update(form_data.headers)
+
+ url = get_tool_server_url(form_data.url, form_data.path)
+ return await get_tool_server_data(url, headers=headers)
+ except HTTPException as e:
+ raise e
except Exception as e:
+ log.debug(f"Failed to connect to the tool server: {e}")
raise HTTPException(
status_code=400,
- detail=f"Failed to connect to the tool server: {str(e)}",
+ detail=f"Failed to connect to the tool server",
)
@@ -251,6 +463,7 @@ async def set_code_execution_config(
############################
class ModelsConfigForm(BaseModel):
DEFAULT_MODELS: Optional[str]
+ DEFAULT_PINNED_MODELS: Optional[str]
MODEL_ORDER_LIST: Optional[list[str]]
@@ -258,6 +471,7 @@ class ModelsConfigForm(BaseModel):
async def get_models_config(request: Request, user=Depends(get_admin_user)):
return {
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
+ "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
}
@@ -267,9 +481,11 @@ async def set_models_config(
request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)
):
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
+ request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
return {
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
+ "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
}
diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py
index 164f3c40b4..3e5e14801c 100644
--- a/backend/open_webui/routers/evaluations.py
+++ b/backend/open_webui/routers/evaluations.py
@@ -7,6 +7,8 @@ from open_webui.models.feedbacks import (
FeedbackModel,
FeedbackResponse,
FeedbackForm,
+ FeedbackUserResponse,
+ FeedbackListResponse,
Feedbacks,
)
@@ -56,35 +58,10 @@ 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[UserResponse] = None
-
-
-@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
+@router.get("/feedbacks/all", response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
-
- 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,
- )
- )
- return feedback_list
+ return feedbacks
@router.delete("/feedbacks/all")
@@ -111,6 +88,31 @@ async def delete_feedbacks(user=Depends(get_verified_user)):
return success
+PAGE_ITEM_COUNT = 30
+
+
+@router.get("/feedbacks/list", response_model=FeedbackListResponse)
+async def get_feedbacks(
+ order_by: Optional[str] = None,
+ direction: Optional[str] = None,
+ page: Optional[int] = 1,
+ user=Depends(get_admin_user),
+):
+ limit = PAGE_ITEM_COUNT
+
+ page = max(1, page)
+ skip = (page - 1) * limit
+
+ filter = {}
+ if order_by:
+ filter["order_by"] = order_by
+ if direction:
+ filter["direction"] = direction
+
+ result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit)
+ return result
+
+
@router.post("/feedback", response_model=FeedbackModel)
async def create_feedback(
request: Request,
@@ -129,7 +131,10 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
- feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
+ if user.role == "admin":
+ feedback = Feedbacks.get_feedback_by_id(id=id)
+ else:
+ feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
if not feedback:
raise HTTPException(
@@ -143,9 +148,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
):
- feedback = Feedbacks.update_feedback_by_id_and_user_id(
- id=id, user_id=user.id, form_data=form_data
- )
+ if user.role == "admin":
+ feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
+ else:
+ feedback = Feedbacks.update_feedback_by_id_and_user_id(
+ id=id, user_id=user.id, form_data=form_data
+ )
if not feedback:
raise HTTPException(
diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py
index b9bb15c7b4..54084941fe 100644
--- a/backend/open_webui/routers/files.py
+++ b/backend/open_webui/routers/files.py
@@ -6,8 +6,10 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
from urllib.parse import quote
+import asyncio
from fastapi import (
+ BackgroundTasks,
APIRouter,
Depends,
File,
@@ -18,9 +20,11 @@ from fastapi import (
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.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.users import Users
from open_webui.models.files import (
@@ -41,7 +45,6 @@ from pydantic import BaseModel
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
-
router = APIRouter()
@@ -82,14 +85,85 @@ def has_access_to_file(
############################
+def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
+ try:
+ if file.content_type:
+ stt_supported_content_types = getattr(
+ request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
+ )
+
+ if any(
+ fnmatch(file.content_type, content_type)
+ for content_type in (
+ stt_supported_content_types
+ if stt_supported_content_types
+ and any(t.strip() for t in stt_supported_content_types)
+ else ["audio/*", "video/webm"]
+ )
+ ):
+ file_path = Storage.get_file(file_path)
+ result = transcribe(request, file_path, file_metadata, user)
+
+ process_file(
+ request,
+ ProcessFileForm(
+ file_id=file_item.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=file_item.id), user=user)
+ else:
+ raise Exception(
+ f"File type {file.content_type} is not supported for processing"
+ )
+ else:
+ log.info(
+ f"File type {file.content_type} is not provided, but trying to process anyway"
+ )
+ process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
+ except Exception as e:
+ log.error(f"Error processing file: {file_item.id}")
+ Files.update_file_data_by_id(
+ file_item.id,
+ {
+ "status": "failed",
+ "error": str(e.detail) if hasattr(e, "detail") else str(e),
+ },
+ )
+
+
@router.post("/", response_model=FileModelResponse)
def upload_file(
+ request: Request,
+ background_tasks: BackgroundTasks,
+ file: UploadFile = File(...),
+ metadata: Optional[dict | str] = Form(None),
+ process: bool = Query(True),
+ process_in_background: bool = Query(True),
+ user=Depends(get_verified_user),
+):
+ return upload_file_handler(
+ request,
+ file=file,
+ metadata=metadata,
+ process=process,
+ process_in_background=process_in_background,
+ user=user,
+ background_tasks=background_tasks,
+ )
+
+
+def upload_file_handler(
request: Request,
file: UploadFile = File(...),
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
- internal: bool = False,
+ process_in_background: bool = Query(True),
user=Depends(get_verified_user),
+ background_tasks: Optional[BackgroundTasks] = None,
):
log.info(f"file.content_type: {file.content_type}")
@@ -111,7 +185,7 @@ def upload_file(
# 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:
+ if process 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
]
@@ -128,13 +202,16 @@ def upload_file(
id = str(uuid.uuid4())
name = filename
filename = f"{id}_{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)
+ contents, file_path = Storage.upload_file(
+ file.file,
+ filename,
+ {
+ "OpenWebUI-User-Email": user.email,
+ "OpenWebUI-User-Id": user.id,
+ "OpenWebUI-User-Name": user.name,
+ "OpenWebUI-File-Id": id,
+ },
+ )
file_item = Files.insert_new_file(
user.id,
@@ -143,6 +220,9 @@ def upload_file(
"id": id,
"filename": name,
"path": file_path,
+ "data": {
+ **({"status": "pending"} if process else {}),
+ },
"meta": {
"name": name,
"content_type": file.content_type,
@@ -152,57 +232,37 @@ def upload_file(
}
),
)
+
if process:
- try:
- if file.content_type:
- stt_supported_content_types = (
- request.app.state.config.STT_SUPPORTED_CONTENT_TYPES
- or [
- "audio/*",
- "video/webm",
- ]
- )
-
- if any(
- fnmatch(file.content_type, content_type)
- for content_type in stt_supported_content_types
- ):
- file_path = Storage.get_file(file_path)
- result = transcribe(request, file_path, file_metadata)
-
- 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 background_tasks and process_in_background:
+ background_tasks.add_task(
+ process_uploaded_file,
+ request,
+ file,
+ file_path,
+ file_item,
+ file_metadata,
+ user,
)
-
- if file_item:
- return file_item
+ return {"status": True, **file_item.model_dump()}
+ else:
+ process_uploaded_file(
+ request,
+ file,
+ file_path,
+ file_item,
+ file_metadata,
+ user,
+ )
+ return {"status": True, **file_item.model_dump()}
else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
- )
+ if file_item:
+ return file_item
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
+ )
except Exception as e:
log.exception(e)
@@ -285,6 +345,7 @@ async def delete_all_files(user=Depends(get_admin_user)):
if result:
try:
Storage.delete_all_files()
+ VECTOR_DB_CLIENT.reset()
except Exception as e:
log.exception(e)
log.error("Error deleting files")
@@ -328,6 +389,63 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
)
+@router.get("/{id}/process/status")
+async def get_file_process_status(
+ id: str, stream: bool = Query(False), user=Depends(get_verified_user)
+):
+ file = Files.get_file_by_id(id)
+
+ 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)
+ ):
+ if stream:
+ MAX_FILE_PROCESSING_DURATION = 3600 * 2
+
+ async def event_stream(file_item):
+ if file_item:
+ for _ in range(MAX_FILE_PROCESSING_DURATION):
+ file_item = Files.get_file_by_id(file_item.id)
+ if file_item:
+ data = file_item.model_dump().get("data", {})
+ status = data.get("status")
+
+ if status:
+ event = {"status": status}
+ if status == "failed":
+ event["error"] = data.get("error")
+
+ yield f"data: {json.dumps(event)}\n\n"
+ if status in ("completed", "failed"):
+ break
+ else:
+ # Legacy
+ break
+
+ await asyncio.sleep(0.5)
+ else:
+ yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
+
+ return StreamingResponse(
+ event_stream(file),
+ media_type="text/event-stream",
+ )
+ else:
+ return {"status": file.data.get("status", "pending")}
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=ERROR_MESSAGES.NOT_FOUND,
+ )
+
+
############################
# Get File Data Content By Id
############################
@@ -602,12 +720,12 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
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)
if result:
try:
Storage.delete_file(file.path)
+ VECTOR_DB_CLIENT.delete(collection_name=f"file-{id}")
except Exception as e:
log.exception(e)
log.error("Error deleting files")
diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py
index 2c41c92854..03212bdb7c 100644
--- a/backend/open_webui/routers/folders.py
+++ b/backend/open_webui/routers/folders.py
@@ -10,10 +10,15 @@ import mimetypes
from open_webui.models.folders import (
FolderForm,
+ FolderUpdateForm,
FolderModel,
+ FolderNameIdResponse,
Folders,
)
from open_webui.models.chats import Chats
+from open_webui.models.files import Files
+from open_webui.models.knowledge import Knowledges
+
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
@@ -40,24 +45,46 @@ router = APIRouter()
############################
-@router.get("/", response_model=list[FolderModel])
+@router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
- return [
- {
- **folder.model_dump(),
- "items": {
- "chats": [
- {"title": chat.title, "id": chat.id}
- for chat in Chats.get_chats_by_folder_id_and_user_id(
- folder.id, user.id
- )
- ]
- },
- }
- for folder in folders
- ]
+ # Verify folder data integrity
+ folder_list = []
+ for folder in folders:
+ if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
+ folder.parent_id, user.id
+ ):
+ folder = Folders.update_folder_parent_id_by_id_and_user_id(
+ folder.id, user.id, None
+ )
+
+ if folder.data:
+ if "files" in folder.data:
+ valid_files = []
+ for file in folder.data["files"]:
+
+ if file.get("type") == "file":
+ if Files.check_access_by_user_id(
+ file.get("id"), user.id, "read"
+ ):
+ valid_files.append(file)
+ elif file.get("type") == "collection":
+ if Knowledges.check_access_by_user_id(
+ file.get("id"), user.id, "read"
+ ):
+ valid_files.append(file)
+ else:
+ valid_files.append(file)
+
+ folder.data["files"] = valid_files
+ Folders.update_folder_by_id_and_user_id(
+ folder.id, user.id, FolderUpdateForm(data=folder.data)
+ )
+
+ folder_list.append(FolderNameIdResponse(**folder.model_dump()))
+
+ return folder_list
############################
@@ -78,7 +105,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
)
try:
- folder = Folders.insert_new_folder(user.id, form_data.name)
+ folder = Folders.insert_new_folder(user.id, form_data)
return folder
except Exception as e:
log.exception(e)
@@ -113,24 +140,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update")
async def update_folder_name_by_id(
- id: str, form_data: FolderForm, user=Depends(get_verified_user)
+ id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
- existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
- folder.parent_id, user.id, form_data.name
- )
- if existing_folder:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
+
+ if form_data.name is not None:
+ # Check if folder with same name exists
+ existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
+ folder.parent_id, user.id, form_data.name
)
+ if existing_folder and existing_folder.id != id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
+ )
try:
- folder = Folders.update_folder_name_by_id_and_user_id(
- id, user.id, form_data.name
- )
-
+ folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
return folder
except Exception as e:
log.exception(e)
@@ -231,33 +258,52 @@ async def update_folder_is_expanded_by_id(
@router.delete("/{id}")
async def delete_folder_by_id(
- request: Request, id: str, user=Depends(get_verified_user)
+ request: Request,
+ id: str,
+ delete_contents: Optional[bool] = True,
+ 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,
+ if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
+ chat_delete_permission = has_permission(
+ user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
)
-
- folder = Folders.get_folder_by_id_and_user_id(id, user.id)
- if folder:
- try:
- result = Folders.delete_folder_by_id_and_user_id(id, user.id)
- if result:
- return result
- else:
- raise Exception("Error deleting folder")
- except Exception as e:
- log.exception(e)
- log.error(f"Error deleting folder: {id}")
+ if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
+
+ folders = []
+ folders.append(Folders.get_folder_by_id_and_user_id(id, user.id))
+ while folders:
+ folder = folders.pop()
+ if folder:
+ try:
+ folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
+
+ for folder_id in folder_ids:
+ if delete_contents:
+ Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
+ else:
+ Chats.move_chats_by_user_id_and_folder_id(
+ user.id, folder_id, None
+ )
+
+ return True
+ except Exception as e:
+ log.exception(e)
+ log.error(f"Error deleting folder: {id}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
+ )
+ finally:
+ # Get all subfolders
+ subfolders = Folders.get_folders_by_parent_id_and_user_id(
+ folder.id, user.id
+ )
+ folders.extend(subfolders)
+
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py
index 355093335a..c8f131553c 100644
--- a/backend/open_webui/routers/functions.py
+++ b/backend/open_webui/routers/functions.py
@@ -10,6 +10,8 @@ from open_webui.models.functions import (
FunctionForm,
FunctionModel,
FunctionResponse,
+ FunctionUserResponse,
+ FunctionWithValvesModel,
Functions,
)
from open_webui.utils.plugin import (
@@ -41,14 +43,19 @@ async def get_functions(user=Depends(get_verified_user)):
return Functions.get_functions()
+@router.get("/list", response_model=list[FunctionUserResponse])
+async def get_function_list(user=Depends(get_admin_user)):
+ return Functions.get_function_list()
+
+
############################
# ExportFunctions
############################
-@router.get("/export", response_model=list[FunctionModel])
-async def get_functions(user=Depends(get_admin_user)):
- return Functions.get_functions()
+@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
+async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
+ return Functions.get_functions(include_valves=include_valves)
############################
@@ -105,7 +112,7 @@ async def load_function_from_url(
)
try:
- async with aiohttp.ClientSession() as session:
+ async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
@@ -131,15 +138,41 @@ async def load_function_from_url(
############################
-class SyncFunctionsForm(FunctionForm):
- functions: list[FunctionModel] = []
+class SyncFunctionsForm(BaseModel):
+ functions: list[FunctionWithValvesModel] = []
-@router.post("/sync", response_model=Optional[FunctionModel])
+@router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
- return Functions.sync_functions(user.id, form_data.functions)
+ try:
+ for function in form_data.functions:
+ function.content = replace_imports(function.content)
+ function_module, function_type, frontmatter = load_function_module_by_id(
+ function.id,
+ content=function.content,
+ )
+
+ if hasattr(function_module, "Valves") and function.valves:
+ Valves = function_module.Valves
+ try:
+ Valves(
+ **{k: v for k, v in function.valves.items() if v is not None}
+ )
+ except Exception as e:
+ log.exception(
+ f"Error validating valves for function {function.id}: {e}"
+ )
+ raise e
+
+ return Functions.sync_functions(user.id, form_data.functions)
+ except Exception as e:
+ log.exception(f"Failed to load a function: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT(e),
+ )
############################
@@ -177,6 +210,9 @@ async def create_new_function(
function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True)
+ if function_type == "filter" and getattr(function_module, "toggle", None):
+ Functions.update_function_metadata_by_id(id, {"toggle": True})
+
if function:
return function
else:
@@ -293,6 +329,9 @@ async def update_function_by_id(
function = Functions.update_function_by_id(id, updated)
+ if function_type == "filter" and getattr(function_module, "toggle", None):
+ Functions.update_function_metadata_by_id(id, {"toggle": True})
+
if function:
return function
else:
@@ -398,8 +437,10 @@ async def update_function_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
- Functions.update_function_valves_by_id(id, valves.model_dump())
- return valves.model_dump()
+
+ valves_dict = valves.model_dump(exclude_unset=True)
+ Functions.update_function_valves_by_id(id, valves_dict)
+ return valves_dict
except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}")
raise HTTPException(
@@ -481,10 +522,11 @@ async def update_function_user_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
+ user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id(
- id, user.id, user_valves.model_dump()
+ id, user.id, user_valves_dict
)
- return user_valves.model_dump()
+ return user_valves_dict
except Exception as e:
log.exception(f"Error updating function user valves by id {id}: {e}")
raise HTTPException(
diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py
index ae822c0d00..2b531b462b 100755
--- a/backend/open_webui/routers/groups.py
+++ b/backend/open_webui/routers/groups.py
@@ -9,6 +9,7 @@ from open_webui.models.groups import (
GroupForm,
GroupUpdateForm,
GroupResponse,
+ UserIdsForm,
)
from open_webui.config import CACHE_DIR
@@ -30,11 +31,32 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse])
-async def get_groups(user=Depends(get_verified_user)):
+async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)):
if user.role == "admin":
- return Groups.get_groups()
+ groups = Groups.get_groups()
else:
- return Groups.get_groups_by_member_id(user.id)
+ groups = Groups.get_groups_by_member_id(user.id)
+
+ group_list = []
+
+ for group in groups:
+ if share is not None:
+ # Check if the group has data and a config with share key
+ if (
+ group.data
+ and "share" in group.data.get("config", {})
+ and group.data["config"]["share"] != share
+ ):
+ continue
+
+ group_list.append(
+ GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
+ )
+
+ return group_list
############################
@@ -47,7 +69,10 @@ 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:
- return group
+ return GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -70,7 +95,10 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
async def get_group_by_id(id: str, user=Depends(get_admin_user)):
group = Groups.get_group_by_id(id)
if group:
- return group
+ return GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -88,12 +116,12 @@ async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
):
try:
- if form_data.user_ids:
- form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
-
group = Groups.update_group_by_id(id, form_data)
if group:
- return group
+ return GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -107,6 +135,62 @@ async def update_group_by_id(
)
+############################
+# AddUserToGroupByUserIdAndGroupId
+############################
+
+
+@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse])
+async def add_user_to_group(
+ id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
+):
+ try:
+ if form_data.user_ids:
+ form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
+
+ group = Groups.add_users_to_group(id, form_data.user_ids)
+ if group:
+ return GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT("Error adding users to group"),
+ )
+ except Exception as e:
+ log.exception(f"Error adding users to group {id}: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT(e),
+ )
+
+
+@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse])
+async def remove_users_from_group(
+ id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
+):
+ try:
+ group = Groups.remove_users_from_group(id, form_data.user_ids)
+ if group:
+ return GroupResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT("Error removing users from group"),
+ )
+ except Exception as e:
+ log.exception(f"Error removing users from group {id}: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.DEFAULT(e),
+ )
+
+
############################
# DeleteGroupById
############################
diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py
index ca949fd936..8aabf0f73b 100644
--- a/backend/open_webui/routers/images.py
+++ b/backend/open_webui/routers/images.py
@@ -1,5 +1,6 @@
import asyncio
import base64
+import uuid
import io
import json
import logging
@@ -11,15 +12,21 @@ from typing import Optional
from urllib.parse import quote
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse
+
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
-from open_webui.routers.files import upload_file
+from open_webui.routers.files import upload_file_handler, get_file_content_by_id
from open_webui.utils.auth import get_admin_user, get_verified_user
+from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.images.comfyui import (
- ComfyUIGenerateImageForm,
+ ComfyUICreateImageForm,
+ ComfyUIEditImageForm,
ComfyUIWorkflow,
- comfyui_generate_image,
+ comfyui_upload_image,
+ comfyui_create_image,
+ comfyui_edit_image,
)
from pydantic import BaseModel
@@ -29,221 +36,31 @@ log.setLevel(SRC_LOG_LEVELS["IMAGES"])
IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations"
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
-
router = APIRouter()
-@router.get("/config")
-async def get_config(request: Request, user=Depends(get_admin_user)):
- return {
- "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
- "engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
- "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
- "openai": {
- "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
- "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
- },
- "automatic1111": {
- "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL,
- "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH,
- "AUTOMATIC1111_CFG_SCALE": request.app.state.config.AUTOMATIC1111_CFG_SCALE,
- "AUTOMATIC1111_SAMPLER": request.app.state.config.AUTOMATIC1111_SAMPLER,
- "AUTOMATIC1111_SCHEDULER": request.app.state.config.AUTOMATIC1111_SCHEDULER,
- },
- "comfyui": {
- "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
- "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
- "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
- "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
- },
- "gemini": {
- "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
- "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
- },
- }
-
-
-class OpenAIConfigForm(BaseModel):
- OPENAI_API_BASE_URL: str
- OPENAI_API_KEY: str
-
-
-class Automatic1111ConfigForm(BaseModel):
- AUTOMATIC1111_BASE_URL: str
- AUTOMATIC1111_API_AUTH: str
- AUTOMATIC1111_CFG_SCALE: Optional[str | float | int]
- AUTOMATIC1111_SAMPLER: Optional[str]
- AUTOMATIC1111_SCHEDULER: Optional[str]
-
-
-class ComfyUIConfigForm(BaseModel):
- COMFYUI_BASE_URL: str
- COMFYUI_API_KEY: str
- COMFYUI_WORKFLOW: str
- COMFYUI_WORKFLOW_NODES: list[dict]
-
-
-class GeminiConfigForm(BaseModel):
- GEMINI_API_BASE_URL: str
- GEMINI_API_KEY: str
-
-
-class ConfigForm(BaseModel):
- enabled: bool
- engine: str
- prompt_generation: bool
- openai: OpenAIConfigForm
- automatic1111: Automatic1111ConfigForm
- comfyui: ComfyUIConfigForm
- gemini: GeminiConfigForm
-
-
-@router.post("/config/update")
-async def update_config(
- request: Request, form_data: ConfigForm, user=Depends(get_admin_user)
-):
- request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine
- request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled
-
- request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
- form_data.prompt_generation
- )
-
- request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
- form_data.openai.OPENAI_API_BASE_URL
- )
- request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
-
- request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
- form_data.gemini.GEMINI_API_BASE_URL
- )
- request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.gemini.GEMINI_API_KEY
-
- request.app.state.config.AUTOMATIC1111_BASE_URL = (
- form_data.automatic1111.AUTOMATIC1111_BASE_URL
- )
- request.app.state.config.AUTOMATIC1111_API_AUTH = (
- form_data.automatic1111.AUTOMATIC1111_API_AUTH
- )
-
- request.app.state.config.AUTOMATIC1111_CFG_SCALE = (
- float(form_data.automatic1111.AUTOMATIC1111_CFG_SCALE)
- if form_data.automatic1111.AUTOMATIC1111_CFG_SCALE
- else None
- )
- request.app.state.config.AUTOMATIC1111_SAMPLER = (
- form_data.automatic1111.AUTOMATIC1111_SAMPLER
- if form_data.automatic1111.AUTOMATIC1111_SAMPLER
- else None
- )
- request.app.state.config.AUTOMATIC1111_SCHEDULER = (
- form_data.automatic1111.AUTOMATIC1111_SCHEDULER
- if form_data.automatic1111.AUTOMATIC1111_SCHEDULER
- else None
- )
-
- request.app.state.config.COMFYUI_BASE_URL = (
- form_data.comfyui.COMFYUI_BASE_URL.strip("/")
- )
- request.app.state.config.COMFYUI_API_KEY = form_data.comfyui.COMFYUI_API_KEY
-
- request.app.state.config.COMFYUI_WORKFLOW = form_data.comfyui.COMFYUI_WORKFLOW
- request.app.state.config.COMFYUI_WORKFLOW_NODES = (
- form_data.comfyui.COMFYUI_WORKFLOW_NODES
- )
-
- return {
- "enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
- "engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
- "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
- "openai": {
- "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
- "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
- },
- "automatic1111": {
- "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL,
- "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH,
- "AUTOMATIC1111_CFG_SCALE": request.app.state.config.AUTOMATIC1111_CFG_SCALE,
- "AUTOMATIC1111_SAMPLER": request.app.state.config.AUTOMATIC1111_SAMPLER,
- "AUTOMATIC1111_SCHEDULER": request.app.state.config.AUTOMATIC1111_SCHEDULER,
- },
- "comfyui": {
- "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
- "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
- "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
- "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
- },
- "gemini": {
- "GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
- "GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
- },
- }
-
-
-def get_automatic1111_api_auth(request: Request):
- if request.app.state.config.AUTOMATIC1111_API_AUTH is None:
- return ""
- else:
- auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode(
- "utf-8"
- )
- auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string)
- auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode("utf-8")
- return f"Basic {auth1111_base64_encoded_string}"
-
-
-@router.get("/config/url/verify")
-async def verify_url(request: Request, user=Depends(get_admin_user)):
- if request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111":
- try:
- r = requests.get(
- url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
- headers={"authorization": get_automatic1111_api_auth(request)},
- )
- r.raise_for_status()
- return True
- except Exception:
- request.app.state.config.ENABLE_IMAGE_GENERATION = False
- raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
- elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
-
- headers = None
- if request.app.state.config.COMFYUI_API_KEY:
- headers = {
- "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
- }
-
- try:
- r = requests.get(
- url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
- headers=headers,
- )
- r.raise_for_status()
- return True
- except Exception:
- request.app.state.config.ENABLE_IMAGE_GENERATION = False
- raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
- else:
- return True
-
-
def set_image_model(request: Request, model: str):
log.info(f"Setting image model to {model}")
request.app.state.config.IMAGE_GENERATION_MODEL = model
if request.app.state.config.IMAGE_GENERATION_ENGINE in ["", "automatic1111"]:
api_auth = get_automatic1111_api_auth(request)
- r = requests.get(
- url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
- headers={"authorization": api_auth},
- )
- options = r.json()
- if model != options["sd_model_checkpoint"]:
- options["sd_model_checkpoint"] = model
- r = requests.post(
+
+ try:
+ r = requests.get(
url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
- json=options,
headers={"authorization": api_auth},
)
+ options = r.json()
+ if model != options["sd_model_checkpoint"]:
+ options["sd_model_checkpoint"] = model
+ r = requests.post(
+ url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
+ json=options,
+ headers={"authorization": api_auth},
+ )
+ except Exception as e:
+ log.debug(f"{e}")
+
return request.app.state.config.IMAGE_GENERATION_MODEL
@@ -282,29 +99,118 @@ def get_image_model(request):
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
-class ImageConfigForm(BaseModel):
- MODEL: str
- IMAGE_SIZE: str
- IMAGE_STEPS: int
+class ImagesConfig(BaseModel):
+ ENABLE_IMAGE_GENERATION: bool
+ ENABLE_IMAGE_PROMPT_GENERATION: bool
+
+ IMAGE_GENERATION_ENGINE: str
+ IMAGE_GENERATION_MODEL: str
+ IMAGE_SIZE: Optional[str]
+ IMAGE_STEPS: Optional[int]
+
+ IMAGES_OPENAI_API_BASE_URL: str
+ IMAGES_OPENAI_API_KEY: str
+ IMAGES_OPENAI_API_VERSION: str
+ IMAGES_OPENAI_API_PARAMS: Optional[dict | str]
+
+ AUTOMATIC1111_BASE_URL: str
+ AUTOMATIC1111_API_AUTH: Optional[dict | str]
+ AUTOMATIC1111_PARAMS: Optional[dict | str]
+
+ COMFYUI_BASE_URL: str
+ COMFYUI_API_KEY: str
+ COMFYUI_WORKFLOW: str
+ COMFYUI_WORKFLOW_NODES: list[dict]
+
+ IMAGES_GEMINI_API_BASE_URL: str
+ IMAGES_GEMINI_API_KEY: str
+ IMAGES_GEMINI_ENDPOINT_METHOD: str
+
+ ENABLE_IMAGE_EDIT: bool
+ IMAGE_EDIT_ENGINE: str
+ IMAGE_EDIT_MODEL: str
+ IMAGE_EDIT_SIZE: Optional[str]
+
+ IMAGES_EDIT_OPENAI_API_BASE_URL: str
+ IMAGES_EDIT_OPENAI_API_KEY: str
+ IMAGES_EDIT_OPENAI_API_VERSION: str
+ IMAGES_EDIT_GEMINI_API_BASE_URL: str
+ IMAGES_EDIT_GEMINI_API_KEY: str
+ IMAGES_EDIT_COMFYUI_BASE_URL: str
+ IMAGES_EDIT_COMFYUI_API_KEY: str
+ IMAGES_EDIT_COMFYUI_WORKFLOW: str
+ IMAGES_EDIT_COMFYUI_WORKFLOW_NODES: list[dict]
-@router.get("/image/config")
-async def get_image_config(request: Request, user=Depends(get_admin_user)):
+@router.get("/config", response_model=ImagesConfig)
+async def get_config(request: Request, user=Depends(get_admin_user)):
return {
- "MODEL": request.app.state.config.IMAGE_GENERATION_MODEL,
+ "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION,
+ "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
+ "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE,
+ "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL,
"IMAGE_SIZE": request.app.state.config.IMAGE_SIZE,
"IMAGE_STEPS": request.app.state.config.IMAGE_STEPS,
+ "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
+ "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
+ "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
+ "IMAGES_OPENAI_API_PARAMS": request.app.state.config.IMAGES_OPENAI_API_PARAMS,
+ "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL,
+ "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH,
+ "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS,
+ "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
+ "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
+ "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
+ "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
+ "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
+ "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
+ "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
+ "ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT,
+ "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE,
+ "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL,
+ "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE,
+ "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
+ "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
+ "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
+ "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
+ "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
+ "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
+ "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
+ "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
+ "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
}
-@router.post("/image/config/update")
-async def update_image_config(
- request: Request, form_data: ImageConfigForm, user=Depends(get_admin_user)
+@router.post("/config/update")
+async def update_config(
+ request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)
):
- set_image_model(request, form_data.MODEL)
+ request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION
+
+ # Create Image
+ request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
+ form_data.ENABLE_IMAGE_PROMPT_GENERATION
+ )
+
+ request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE
+ set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
+ if (
+ form_data.IMAGE_SIZE == "auto"
+ and form_data.IMAGE_GENERATION_MODEL != "gpt-image-1"
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail=ERROR_MESSAGES.INCORRECT_FORMAT(
+ " (auto is only allowed with gpt-image-1)."
+ ),
+ )
pattern = r"^\d+x\d+$"
- if re.match(pattern, form_data.IMAGE_SIZE):
+ if (
+ form_data.IMAGE_SIZE == "auto"
+ or form_data.IMAGE_SIZE == ""
+ or re.match(pattern, form_data.IMAGE_SIZE)
+ ):
request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE
else:
raise HTTPException(
@@ -320,13 +226,152 @@ async def update_image_config(
detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 50)."),
)
+ request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
+ form_data.IMAGES_OPENAI_API_BASE_URL
+ )
+ request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY
+ request.app.state.config.IMAGES_OPENAI_API_VERSION = (
+ form_data.IMAGES_OPENAI_API_VERSION
+ )
+ request.app.state.config.IMAGES_OPENAI_API_PARAMS = (
+ form_data.IMAGES_OPENAI_API_PARAMS
+ )
+
+ request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL
+ request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH
+ request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS
+
+ request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip("/")
+ request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY
+ request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW
+ request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES
+
+ request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
+ form_data.IMAGES_GEMINI_API_BASE_URL
+ )
+ request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY
+ request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = (
+ form_data.IMAGES_GEMINI_ENDPOINT_METHOD
+ )
+
+ # Edit Image
+ request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT
+ request.app.state.config.IMAGE_EDIT_ENGINE = form_data.IMAGE_EDIT_ENGINE
+ request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL
+ request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE
+
+ request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = (
+ form_data.IMAGES_EDIT_OPENAI_API_BASE_URL
+ )
+ request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = (
+ form_data.IMAGES_EDIT_OPENAI_API_KEY
+ )
+ request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = (
+ form_data.IMAGES_EDIT_OPENAI_API_VERSION
+ )
+
+ request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = (
+ form_data.IMAGES_EDIT_GEMINI_API_BASE_URL
+ )
+ request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = (
+ form_data.IMAGES_EDIT_GEMINI_API_KEY
+ )
+
+ request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = (
+ form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip("/")
+ )
+ request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = (
+ form_data.IMAGES_EDIT_COMFYUI_API_KEY
+ )
+ request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = (
+ form_data.IMAGES_EDIT_COMFYUI_WORKFLOW
+ )
+ request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = (
+ form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES
+ )
+
return {
- "MODEL": request.app.state.config.IMAGE_GENERATION_MODEL,
+ "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION,
+ "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
+ "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE,
+ "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL,
"IMAGE_SIZE": request.app.state.config.IMAGE_SIZE,
"IMAGE_STEPS": request.app.state.config.IMAGE_STEPS,
+ "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
+ "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
+ "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
+ "IMAGES_OPENAI_API_PARAMS": request.app.state.config.IMAGES_OPENAI_API_PARAMS,
+ "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL,
+ "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH,
+ "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS,
+ "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
+ "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
+ "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
+ "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
+ "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL,
+ "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY,
+ "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD,
+ "ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT,
+ "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE,
+ "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL,
+ "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE,
+ "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL,
+ "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY,
+ "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION,
+ "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL,
+ "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
+ "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
+ "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
+ "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
+ "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
}
+def get_automatic1111_api_auth(request: Request):
+ if request.app.state.config.AUTOMATIC1111_API_AUTH is None:
+ return ""
+ else:
+ auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode(
+ "utf-8"
+ )
+ auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string)
+ auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode("utf-8")
+ return f"Basic {auth1111_base64_encoded_string}"
+
+
+@router.get("/config/url/verify")
+async def verify_url(request: Request, user=Depends(get_admin_user)):
+ if request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111":
+ try:
+ r = requests.get(
+ url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
+ headers={"authorization": get_automatic1111_api_auth(request)},
+ )
+ r.raise_for_status()
+ return True
+ except Exception:
+ request.app.state.config.ENABLE_IMAGE_GENERATION = False
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
+ elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
+ headers = None
+ if request.app.state.config.COMFYUI_API_KEY:
+ headers = {
+ "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
+ }
+ try:
+ r = requests.get(
+ url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
+ headers=headers,
+ )
+ r.raise_for_status()
+ return True
+ except Exception:
+ request.app.state.config.ENABLE_IMAGE_GENERATION = False
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
+ else:
+ return True
+
+
@router.get("/models")
def get_models(request: Request, user=Depends(get_verified_user)):
try:
@@ -409,7 +454,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
-class GenerateImageForm(BaseModel):
+class CreateImageForm(BaseModel):
model: Optional[str] = None
prompt: str
size: Optional[str] = None
@@ -417,41 +462,38 @@ class GenerateImageForm(BaseModel):
negative_prompt: Optional[str] = None
-def load_b64_image_data(b64_str):
+GenerateImageForm = CreateImageForm # Alias for backward compatibility
+
+
+def get_image_data(data: str, headers=None):
try:
- if "," in b64_str:
- header, encoded = b64_str.split(",", 1)
- mime_type = header.split(";")[0].lstrip("data:")
- img_data = base64.b64decode(encoded)
+ if data.startswith("http://") or data.startswith("https://"):
+ if headers:
+ r = requests.get(data, headers=headers)
+ else:
+ r = requests.get(data)
+
+ r.raise_for_status()
+ if r.headers["content-type"].split("/")[0] == "image":
+ mime_type = r.headers["content-type"]
+ return r.content, mime_type
+ else:
+ log.error("Url does not point to an image.")
+ return None
else:
- mime_type = "image/png"
- img_data = base64.b64decode(b64_str)
- return img_data, mime_type
+ if "," in data:
+ header, encoded = data.split(",", 1)
+ mime_type = header.split(";")[0].lstrip("data:")
+ img_data = base64.b64decode(encoded)
+ else:
+ mime_type = "image/png"
+ img_data = base64.b64decode(data)
+ return img_data, mime_type
except Exception as e:
log.exception(f"Error loading image data: {e}")
return None, None
-def load_url_image_data(url, headers=None):
- try:
- if headers:
- r = requests.get(url, headers=headers)
- else:
- r = requests.get(url)
-
- r.raise_for_status()
- if r.headers["content-type"].split("/")[0] == "image":
- mime_type = r.headers["content-type"]
- return r.content, mime_type
- else:
- log.error("Url does not point to an image.")
- return None
-
- except Exception as e:
- log.exception(f"Error saving image: {e}")
- return None
-
-
def upload_image(request, image_data, content_type, metadata, user):
image_format = mimetypes.guess_extension(content_type)
file = UploadFile(
@@ -461,7 +503,13 @@ def upload_image(request, image_data, content_type, metadata, user):
"content-type": content_type,
},
)
- file_item = upload_file(request, file, metadata=metadata, internal=True, user=user)
+ file_item = upload_file_handler(
+ request,
+ file=file,
+ metadata=metadata,
+ process=False,
+ user=user,
+ )
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
@@ -469,32 +517,44 @@ def upload_image(request, image_data, content_type, metadata, user):
@router.post("/generations")
async def image_generations(
request: Request,
- form_data: GenerateImageForm,
+ form_data: CreateImageForm,
user=Depends(get_verified_user),
):
- width, height = tuple(map(int, request.app.state.config.IMAGE_SIZE.split("x")))
+ # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
+ # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
+ # image model other than gpt-image-1, which is warned about on settings save
+
+ size = "512x512"
+ if (
+ request.app.state.config.IMAGE_SIZE
+ and "x" in request.app.state.config.IMAGE_SIZE
+ ):
+ size = request.app.state.config.IMAGE_SIZE
+
+ if form_data.size and "x" in form_data.size:
+ size = form_data.size
+
+ width, height = tuple(map(int, size.split("x")))
+ model = get_image_model(request)
r = None
try:
if request.app.state.config.IMAGE_GENERATION_ENGINE == "openai":
- headers = {}
- headers["Authorization"] = (
- f"Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}"
- )
- headers["Content-Type"] = "application/json"
+
+ headers = {
+ "Authorization": f"Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}",
+ "Content-Type": "application/json",
+ }
if ENABLE_FORWARD_USER_INFO_HEADERS:
- headers["X-OpenWebUI-User-Name"] = quote(user.name)
- headers["X-OpenWebUI-User-Id"] = quote(user.id)
- headers["X-OpenWebUI-User-Email"] = quote(user.email)
- headers["X-OpenWebUI-User-Role"] = quote(user.role)
+ headers = include_user_info_headers(headers, user)
+
+ url = f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations"
+ if request.app.state.config.IMAGES_OPENAI_API_VERSION:
+ url = f"{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}"
data = {
- "model": (
- request.app.state.config.IMAGE_GENERATION_MODEL
- if request.app.state.config.IMAGE_GENERATION_MODEL != ""
- else "dall-e-2"
- ),
+ "model": model,
"prompt": form_data.prompt,
"n": form_data.n,
"size": (
@@ -507,12 +567,17 @@ async def image_generations(
if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL
else {"response_format": "b64_json"}
),
+ **(
+ {}
+ if not request.app.state.config.IMAGES_OPENAI_API_PARAMS
+ else request.app.state.config.IMAGES_OPENAI_API_PARAMS
+ ),
}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
- url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations",
+ url=url,
json=data,
headers=headers,
)
@@ -524,32 +589,46 @@ async def image_generations(
for image in res["data"]:
if image_url := image.get("url", None):
- image_data, content_type = load_url_image_data(image_url, headers)
+ image_data, content_type = get_image_data(image_url, headers)
else:
- image_data, content_type = load_b64_image_data(image["b64_json"])
+ image_data, content_type = get_image_data(image["b64_json"])
url = upload_image(request, image_data, content_type, data, user)
images.append({"url": url})
return images
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
- headers = {}
- headers["Content-Type"] = "application/json"
- headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
-
- model = get_image_model(request)
- data = {
- "instances": {"prompt": form_data.prompt},
- "parameters": {
- "sampleCount": form_data.n,
- "outputOptions": {"mimeType": "image/png"},
- },
+ headers = {
+ "Content-Type": "application/json",
+ "x-goog-api-key": request.app.state.config.IMAGES_GEMINI_API_KEY,
}
+ data = {}
+
+ if (
+ request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == ""
+ or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == "predict"
+ ):
+ model = f"{model}:predict"
+ data = {
+ "instances": {"prompt": form_data.prompt},
+ "parameters": {
+ "sampleCount": form_data.n,
+ "outputOptions": {"mimeType": "image/png"},
+ },
+ }
+
+ elif (
+ request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD
+ == "generateContent"
+ ):
+ model = f"{model}:generateContent"
+ data = {"contents": [{"parts": [{"text": form_data.prompt}]}]}
+
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
- url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}:predict",
+ url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}",
json=data,
headers=headers,
)
@@ -558,12 +637,25 @@ async def image_generations(
res = r.json()
images = []
- for image in res["predictions"]:
- image_data, content_type = load_b64_image_data(
- image["bytesBase64Encoded"]
- )
- url = upload_image(request, image_data, content_type, data, user)
- images.append({"url": url})
+
+ if model.endswith(":predict"):
+ for image in res["predictions"]:
+ image_data, content_type = get_image_data(
+ image["bytesBase64Encoded"]
+ )
+ url = upload_image(request, image_data, content_type, data, user)
+ images.append({"url": url})
+ elif model.endswith(":generateContent"):
+ for image in res["candidates"]:
+ for part in image["content"]["parts"]:
+ if part.get("inlineData", {}).get("data"):
+ image_data, content_type = get_image_data(
+ part["inlineData"]["data"]
+ )
+ url = upload_image(
+ request, image_data, content_type, data, user
+ )
+ images.append({"url": url})
return images
@@ -581,7 +673,7 @@ async def image_generations(
if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt
- form_data = ComfyUIGenerateImageForm(
+ form_data = ComfyUICreateImageForm(
**{
"workflow": ComfyUIWorkflow(
**{
@@ -592,8 +684,8 @@ async def image_generations(
**data,
}
)
- res = await comfyui_generate_image(
- request.app.state.config.IMAGE_GENERATION_MODEL,
+ res = await comfyui_create_image(
+ model,
form_data,
user.id,
request.app.state.config.COMFYUI_BASE_URL,
@@ -610,7 +702,7 @@ async def image_generations(
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
- image_data, content_type = load_url_image_data(image["url"], headers)
+ image_data, content_type = get_image_data(image["url"], headers)
url = upload_image(
request,
image_data,
@@ -640,14 +732,8 @@ async def image_generations(
if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt
- if request.app.state.config.AUTOMATIC1111_CFG_SCALE:
- data["cfg_scale"] = request.app.state.config.AUTOMATIC1111_CFG_SCALE
-
- if request.app.state.config.AUTOMATIC1111_SAMPLER:
- data["sampler_name"] = request.app.state.config.AUTOMATIC1111_SAMPLER
-
- if request.app.state.config.AUTOMATIC1111_SCHEDULER:
- data["scheduler"] = request.app.state.config.AUTOMATIC1111_SCHEDULER
+ if request.app.state.config.AUTOMATIC1111_PARAMS:
+ data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS}
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
@@ -663,7 +749,7 @@ async def image_generations(
images = []
for image in res["images"]:
- image_data, content_type = load_b64_image_data(image)
+ image_data, content_type = get_image_data(image)
url = upload_image(
request,
image_data,
@@ -680,3 +766,292 @@ async def image_generations(
if "error" in data:
error = data["error"]["message"]
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
+
+
+class EditImageForm(BaseModel):
+ image: str | list[str] # base64-encoded image(s) or URL(s)
+ prompt: str
+ model: Optional[str] = None
+ size: Optional[str] = None
+ n: Optional[int] = None
+ negative_prompt: Optional[str] = None
+
+
+@router.post("/edit")
+async def image_edits(
+ request: Request,
+ form_data: EditImageForm,
+ user=Depends(get_verified_user),
+):
+ size = None
+ width, height = None, None
+ if (
+ request.app.state.config.IMAGE_EDIT_SIZE
+ and "x" in request.app.state.config.IMAGE_EDIT_SIZE
+ ) or (form_data.size and "x" in form_data.size):
+ size = (
+ form_data.size
+ if form_data.size
+ else request.app.state.config.IMAGE_EDIT_SIZE
+ )
+ width, height = tuple(map(int, size.split("x")))
+
+ model = (
+ request.app.state.config.IMAGE_EDIT_MODEL
+ if form_data.model is None
+ else form_data.model
+ )
+
+ try:
+
+ async def load_url_image(data):
+ if data.startswith("http://") or data.startswith("https://"):
+ r = await asyncio.to_thread(requests.get, data)
+ r.raise_for_status()
+
+ image_data = base64.b64encode(r.content).decode("utf-8")
+ return f"data:{r.headers['content-type']};base64,{image_data}"
+
+ elif data.startswith("/api/v1/files"):
+ file_id = data.split("/api/v1/files/")[1].split("/content")[0]
+ file_response = await get_file_content_by_id(file_id, user)
+
+ if isinstance(file_response, FileResponse):
+ file_path = file_response.path
+
+ with open(file_path, "rb") as f:
+ file_bytes = f.read()
+ image_data = base64.b64encode(file_bytes).decode("utf-8")
+ mime_type, _ = mimetypes.guess_type(file_path)
+
+ return f"data:{mime_type};base64,{image_data}"
+
+ return data
+
+ # Load image(s) from URL(s) if necessary
+ if isinstance(form_data.image, str):
+ form_data.image = await load_url_image(form_data.image)
+ elif isinstance(form_data.image, list):
+ form_data.image = [await load_url_image(img) for img in form_data.image]
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
+
+ def get_image_file_item(base64_string, param_name="image"):
+ data = base64_string
+ header, encoded = data.split(",", 1)
+ mime_type = header.split(";")[0].lstrip("data:")
+ image_data = base64.b64decode(encoded)
+ return (
+ param_name,
+ (
+ f"{uuid.uuid4()}.png",
+ io.BytesIO(image_data),
+ mime_type if mime_type else "image/png",
+ ),
+ )
+
+ r = None
+ try:
+ if request.app.state.config.IMAGE_EDIT_ENGINE == "openai":
+ headers = {
+ "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}",
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS:
+ headers = include_user_info_headers(headers, user)
+
+ data = {
+ "model": model,
+ "prompt": form_data.prompt,
+ **({"n": form_data.n} if form_data.n else {}),
+ **({"size": size} if size else {}),
+ **(
+ {}
+ if "gpt-image-1" in request.app.state.config.IMAGE_EDIT_MODEL
+ else {"response_format": "b64_json"}
+ ),
+ }
+
+ files = []
+ if isinstance(form_data.image, str):
+ files = [get_image_file_item(form_data.image)]
+ elif isinstance(form_data.image, list):
+ for img in form_data.image:
+ files.append(get_image_file_item(img, "image[]"))
+
+ url_search_params = ""
+ if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION:
+ url_search_params += f"?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}"
+
+ # Use asyncio.to_thread for the requests.post call
+ r = await asyncio.to_thread(
+ requests.post,
+ url=f"{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}",
+ headers=headers,
+ files=files,
+ data=data,
+ )
+
+ r.raise_for_status()
+ res = r.json()
+
+ images = []
+ for image in res["data"]:
+ if image_url := image.get("url", None):
+ image_data, content_type = get_image_data(image_url, headers)
+ else:
+ image_data, content_type = get_image_data(image["b64_json"])
+
+ url = upload_image(request, image_data, content_type, data, user)
+ images.append({"url": url})
+ return images
+
+ elif request.app.state.config.IMAGE_EDIT_ENGINE == "gemini":
+ headers = {
+ "Content-Type": "application/json",
+ "x-goog-api-key": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY,
+ }
+
+ model = f"{model}:generateContent"
+ data = {"contents": [{"parts": [{"text": form_data.prompt}]}]}
+
+ if isinstance(form_data.image, str):
+ data["contents"][0]["parts"].append(
+ {
+ "inline_data": {
+ "mime_type": "image/png",
+ "data": form_data.image.split(",", 1)[1],
+ }
+ }
+ )
+ elif isinstance(form_data.image, list):
+ data["contents"][0]["parts"].extend(
+ [
+ {
+ "inline_data": {
+ "mime_type": "image/png",
+ "data": image.split(",", 1)[1],
+ }
+ }
+ for image in form_data.image
+ ]
+ )
+
+ # Use asyncio.to_thread for the requests.post call
+ r = await asyncio.to_thread(
+ requests.post,
+ url=f"{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}",
+ json=data,
+ headers=headers,
+ )
+
+ r.raise_for_status()
+ res = r.json()
+
+ images = []
+ for image in res["candidates"]:
+ for part in image["content"]["parts"]:
+ if part.get("inlineData", {}).get("data"):
+ image_data, content_type = get_image_data(
+ part["inlineData"]["data"]
+ )
+ url = upload_image(
+ request, image_data, content_type, data, user
+ )
+ images.append({"url": url})
+
+ return images
+
+ elif request.app.state.config.IMAGE_EDIT_ENGINE == "comfyui":
+ try:
+ files = []
+ if isinstance(form_data.image, str):
+ files = [get_image_file_item(form_data.image)]
+ elif isinstance(form_data.image, list):
+ for img in form_data.image:
+ files.append(get_image_file_item(img))
+
+ # Upload images to ComfyUI and get their names
+ comfyui_images = []
+ for file_item in files:
+ res = await comfyui_upload_image(
+ file_item,
+ request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
+ request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
+ )
+ comfyui_images.append(res.get("name", file_item[1][0]))
+ except Exception as e:
+ log.debug(f"Error uploading images to ComfyUI: {e}")
+ raise Exception("Failed to upload images to ComfyUI.")
+
+ data = {
+ "image": comfyui_images,
+ "prompt": form_data.prompt,
+ **({"width": width} if width is not None else {}),
+ **({"height": height} if height is not None else {}),
+ **({"n": form_data.n} if form_data.n else {}),
+ }
+
+ form_data = ComfyUIEditImageForm(
+ **{
+ "workflow": ComfyUIWorkflow(
+ **{
+ "workflow": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW,
+ "nodes": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
+ }
+ ),
+ **data,
+ }
+ )
+ res = await comfyui_edit_image(
+ model,
+ form_data,
+ user.id,
+ request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL,
+ request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY,
+ )
+ log.debug(f"res: {res}")
+
+ image_urls = set()
+ for image in res["data"]:
+ image_urls.add(image["url"])
+ image_urls = list(image_urls)
+
+ # Prioritize output type URLs if available
+ output_type_urls = [url for url in image_urls if "type=output" in url]
+ if output_type_urls:
+ image_urls = output_type_urls
+
+ log.debug(f"Image URLs: {image_urls}")
+ images = []
+
+ for image_url in image_urls:
+ headers = None
+ if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY:
+ headers = {
+ "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}"
+ }
+
+ image_data, content_type = get_image_data(image_url, headers)
+ url = upload_image(
+ request,
+ image_data,
+ content_type,
+ form_data.model_dump(exclude_none=True),
+ user,
+ )
+ images.append({"url": url})
+
+ return images
+ except Exception as e:
+ error = e
+ if r != None:
+ data = r.text
+ try:
+ data = json.loads(data)
+ if "error" in data:
+ error = data["error"]["message"]
+ except Exception:
+ error = data
+
+ raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py
index e6e55f4d38..ad47fc1686 100644
--- a/backend/open_webui/routers/knowledge.py
+++ b/backend/open_webui/routers/knowledge.py
@@ -1,6 +1,7 @@
from typing import List, Optional
from pydantic import BaseModel
-from fastapi import APIRouter, Depends, HTTPException, status, Request
+from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
+from fastapi.concurrency import run_in_threadpool
import logging
from open_webui.models.knowledge import (
@@ -25,6 +26,7 @@ from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
+from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.models.models import Models, ModelForm
@@ -42,7 +44,7 @@ router = APIRouter()
async def get_knowledge(user=Depends(get_verified_user)):
knowledge_bases = []
- if user.role == "admin":
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
@@ -90,7 +92,7 @@ async def get_knowledge(user=Depends(get_verified_user)):
async def get_knowledge_list(user=Depends(get_verified_user)):
knowledge_bases = []
- if user.role == "admin":
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
@@ -150,6 +152,18 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
+ # Check if user can share publicly
+ if (
+ user.role != "admin"
+ and form_data.access_control == None
+ and not has_permission(
+ user.id,
+ "sharing.public_knowledge",
+ request.app.state.config.USER_PERMISSIONS,
+ )
+ ):
+ form_data.access_control = {}
+
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge:
@@ -210,7 +224,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
failed_files = []
for file in files:
try:
- process_file(
+ await run_in_threadpool(
+ process_file,
request,
ProcessFileForm(
file_id=file.id, collection_name=knowledge_base.id
@@ -284,6 +299,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_by_id(
+ request: Request,
id: str,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
@@ -305,10 +321,22 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
+ # Check if user can share publicly
+ if (
+ user.role != "admin"
+ and form_data.access_control == None
+ and not has_permission(
+ user.id,
+ "sharing.public_knowledge",
+ request.app.state.config.USER_PERMISSIONS,
+ )
+ ):
+ form_data.access_control = {}
+
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
- files = Files.get_files_by_ids(file_ids)
+ files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -491,6 +519,7 @@ def update_file_from_knowledge_by_id(
def remove_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
+ delete_file: bool = Query(True),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
@@ -527,18 +556,19 @@ def remove_file_from_knowledge_by_id(
log.debug(e)
pass
- try:
- # Remove the file's collection from vector database
- file_collection = f"file-{form_data.file_id}"
- if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
- VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
- except Exception as e:
- log.debug("This was most likely caused by bypassing embedding processing")
- log.debug(e)
- pass
+ if delete_file:
+ try:
+ # Remove the file's collection from vector database
+ file_collection = f"file-{form_data.file_id}"
+ if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
+ VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
+ except Exception as e:
+ log.debug("This was most likely caused by bypassing embedding processing")
+ log.debug(e)
+ pass
- # Delete file from database
- Files.delete_file_by_id(form_data.file_id)
+ # Delete file from database
+ Files.delete_file_by_id(form_data.file_id)
if knowledge:
data = knowledge.data or {}
diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py
index 333e9ecc6a..8e45a14dfb 100644
--- a/backend/open_webui/routers/memories.py
+++ b/backend/open_webui/routers/memories.py
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
import logging
+import asyncio
from typing import Optional
from open_webui.models.memories import Memories, MemoryModel
@@ -17,7 +18,7 @@ router = APIRouter()
@router.get("/ef")
async def get_embeddings(request: Request):
- return {"result": request.app.state.EMBEDDING_FUNCTION("hello world")}
+ return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")}
############################
@@ -51,15 +52,15 @@ async def add_memory(
):
memory = Memories.insert_new_memory(user.id, form_data.content)
+ vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
+
VECTOR_DB_CLIENT.upsert(
collection_name=f"user-memory-{user.id}",
items=[
{
"id": memory.id,
"text": memory.content,
- "vector": request.app.state.EMBEDDING_FUNCTION(
- memory.content, user=user
- ),
+ "vector": vector,
"metadata": {"created_at": memory.created_at},
}
],
@@ -82,9 +83,15 @@ class QueryMemoryForm(BaseModel):
async def query_memory(
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
):
+ memories = Memories.get_memories_by_user_id(user.id)
+ if not memories:
+ raise HTTPException(status_code=404, detail="No memories found for user")
+
+ vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)
+
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
- vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],
+ vectors=[vector],
limit=form_data.k,
)
@@ -101,21 +108,28 @@ async def reset_memory_from_vector_db(
VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}")
memories = Memories.get_memories_by_user_id(user.id)
+
+ # Generate vectors in parallel
+ vectors = await asyncio.gather(
+ *[
+ request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
+ for memory in memories
+ ]
+ )
+
VECTOR_DB_CLIENT.upsert(
collection_name=f"user-memory-{user.id}",
items=[
{
"id": memory.id,
"text": memory.content,
- "vector": request.app.state.EMBEDDING_FUNCTION(
- memory.content, user=user
- ),
+ "vector": vectors[idx],
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
},
}
- for memory in memories
+ for idx, memory in enumerate(memories)
],
)
@@ -160,15 +174,15 @@ async def update_memory_by_id(
raise HTTPException(status_code=404, detail="Memory not found")
if form_data.content is not None:
+ vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
+
VECTOR_DB_CLIENT.upsert(
collection_name=f"user-memory-{user.id}",
items=[
{
"id": memory.id,
"text": memory.content,
- "vector": request.app.state.EMBEDDING_FUNCTION(
- memory.content, user=user
- ),
+ "vector": vector,
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py
index 0cf3308f19..93d8cb8bf7 100644
--- a/backend/open_webui/routers/models.py
+++ b/backend/open_webui/routers/models.py
@@ -1,34 +1,86 @@
from typing import Optional
+import io
+import base64
+import json
+import asyncio
+import logging
from open_webui.models.models import (
ModelForm,
ModelModel,
ModelResponse,
- ModelUserResponse,
+ ModelListResponse,
Models,
)
+
+from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import (
+ APIRouter,
+ Depends,
+ HTTPException,
+ Request,
+ status,
+ Response,
+)
+from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
+from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
+log = logging.getLogger(__name__)
router = APIRouter()
+def is_valid_model_id(model_id: str) -> bool:
+ return model_id and len(model_id) <= 256
+
+
###########################
# GetModels
###########################
-@router.get("/", response_model=list[ModelUserResponse])
-async def get_models(id: Optional[str] = None, user=Depends(get_verified_user)):
- if user.role == "admin":
- return Models.get_models()
- else:
- return Models.get_models_by_user_id(user.id)
+PAGE_ITEM_COUNT = 30
+
+
+@router.get(
+ "/list", response_model=ModelListResponse
+) # do NOT use "/" as path, conflicts with main.py
+async def get_models(
+ query: Optional[str] = None,
+ view_option: Optional[str] = None,
+ tag: Optional[str] = None,
+ order_by: Optional[str] = None,
+ direction: Optional[str] = None,
+ page: Optional[int] = 1,
+ user=Depends(get_verified_user),
+):
+
+ limit = PAGE_ITEM_COUNT
+
+ page = max(1, page)
+ skip = (page - 1) * limit
+
+ filter = {}
+ if query:
+ filter["query"] = query
+ if view_option:
+ filter["view_option"] = view_option
+ if tag:
+ filter["tag"] = tag
+ if order_by:
+ filter["order_by"] = order_by
+ if direction:
+ filter["direction"] = direction
+
+ if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
+ filter["user_id"] = user.id
+
+ return Models.search_models(user.id, filter=filter, skip=skip, limit=limit)
###########################
@@ -41,6 +93,30 @@ async def get_base_models(user=Depends(get_admin_user)):
return Models.get_base_models()
+###########################
+# GetModelTags
+###########################
+
+
+@router.get("/tags", response_model=list[str])
+async def get_model_tags(user=Depends(get_verified_user)):
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
+ models = Models.get_models()
+ else:
+ models = Models.get_models_by_user_id(user.id)
+
+ tags_set = set()
+ for model in models:
+ if model.meta:
+ meta = model.meta.model_dump()
+ for tag in meta.get("tags", []):
+ tags_set.add((tag.get("name")))
+
+ tags = [tag for tag in tags_set]
+ tags.sort()
+ return tags
+
+
############################
# CreateNewModel
############################
@@ -67,6 +143,12 @@ async def create_new_model(
detail=ERROR_MESSAGES.MODEL_ID_TAKEN,
)
+ if not is_valid_model_id(form_data.id):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.MODEL_ID_TOO_LONG,
+ )
+
else:
model = Models.insert_new_model(form_data, user.id)
if model:
@@ -78,18 +160,113 @@ async def create_new_model(
)
+############################
+# ExportModels
+############################
+
+
+@router.get("/export", response_model=list[ModelModel])
+async def export_models(request: Request, user=Depends(get_verified_user)):
+ if user.role != "admin" and not has_permission(
+ user.id, "workspace.models_export", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
+ return Models.get_models()
+ else:
+ return Models.get_models_by_user_id(user.id)
+
+
+############################
+# ImportModels
+############################
+
+
+class ModelsImportForm(BaseModel):
+ models: list[dict]
+
+
+@router.post("/import", response_model=bool)
+async def import_models(
+ request: Request,
+ user=Depends(get_verified_user),
+ form_data: ModelsImportForm = (...),
+):
+ if user.role != "admin" and not has_permission(
+ user.id, "workspace.models_import", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+ try:
+ data = form_data.models
+ if isinstance(data, list):
+ for model_data in data:
+ # Here, you can add logic to validate model_data if needed
+ model_id = model_data.get("id")
+
+ if model_id and is_valid_model_id(model_id):
+ existing_model = Models.get_model_by_id(model_id)
+ if existing_model:
+ # Update existing model
+ model_data["meta"] = model_data.get("meta", {})
+ model_data["params"] = model_data.get("params", {})
+
+ updated_model = ModelForm(
+ **{**existing_model.model_dump(), **model_data}
+ )
+ Models.update_model_by_id(model_id, updated_model)
+ else:
+ # Insert new model
+ model_data["meta"] = model_data.get("meta", {})
+ model_data["params"] = model_data.get("params", {})
+ new_model = ModelForm(**model_data)
+ Models.insert_new_model(user_id=user.id, form_data=new_model)
+ return True
+ else:
+ raise HTTPException(status_code=400, detail="Invalid JSON format")
+ except Exception as e:
+ log.exception(e)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+############################
+# SyncModels
+############################
+
+
+class SyncModelsForm(BaseModel):
+ models: list[ModelModel] = []
+
+
+@router.post("/sync", response_model=list[ModelModel])
+async def sync_models(
+ request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user)
+):
+ return Models.sync_models(user.id, form_data.models)
+
+
###########################
# GetModelById
###########################
+class ModelIdForm(BaseModel):
+ id: str
+
+
# Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id
@router.get("/model", response_model=Optional[ModelResponse])
async def get_model_by_id(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
if (
- user.role == "admin"
+ (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or has_access(user.id, "read", model.access_control)
):
@@ -101,8 +278,42 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
)
+###########################
+# GetModelById
+###########################
+
+
+@router.get("/model/profile/image")
+async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
+ model = Models.get_model_by_id(id)
+ if model:
+ if model.meta.profile_image_url:
+ if model.meta.profile_image_url.startswith("http"):
+ return Response(
+ status_code=status.HTTP_302_FOUND,
+ headers={"Location": model.meta.profile_image_url},
+ )
+ elif model.meta.profile_image_url.startswith("data:image"):
+ try:
+ header, base64_data = model.meta.profile_image_url.split(",", 1)
+ image_data = base64.b64decode(base64_data)
+ image_buffer = io.BytesIO(image_data)
+
+ return StreamingResponse(
+ image_buffer,
+ media_type="image/png",
+ headers={"Content-Disposition": "inline; filename=image.png"},
+ )
+ except Exception as e:
+ pass
+
+ return FileResponse(f"{STATIC_DIR}/favicon.png")
+ else:
+ return FileResponse(f"{STATIC_DIR}/favicon.png")
+
+
############################
-# ToggelModelById
+# ToggleModelById
############################
@@ -143,12 +354,10 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/model/update", response_model=Optional[ModelModel])
async def update_model_by_id(
- id: str,
form_data: ModelForm,
user=Depends(get_verified_user),
):
- model = Models.get_model_by_id(id)
-
+ model = Models.get_model_by_id(form_data.id)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -165,7 +374,7 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
- model = Models.update_model_by_id(id, form_data)
+ model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()))
return model
@@ -174,9 +383,9 @@ async def update_model_by_id(
############################
-@router.delete("/model/delete", response_model=bool)
-async def delete_model_by_id(id: str, user=Depends(get_verified_user)):
- model = Models.get_model_by_id(id)
+@router.post("/model/delete", response_model=bool)
+async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_user)):
+ model = Models.get_model_by_id(form_data.id)
if not model:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -193,7 +402,7 @@ async def delete_model_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
- result = Models.delete_model_by_id(id)
+ result = Models.delete_model_by_id(form_data.id)
return result
diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py
index 2cbbd331b5..3858c4670f 100644
--- a/backend/open_webui/routers/notes.py
+++ b/backend/open_webui/routers/notes.py
@@ -6,6 +6,9 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
+from open_webui.socket.main import sio
+
+
from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
@@ -45,15 +48,23 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
- for note in Notes.get_notes_by_user_id(user.id, "write")
+ for note in Notes.get_notes_by_permission(user.id, "write")
]
return notes
-@router.get("/list", response_model=list[NoteUserResponse])
-async def get_note_list(request: Request, user=Depends(get_verified_user)):
+class NoteTitleIdResponse(BaseModel):
+ id: str
+ title: str
+ updated_at: int
+ created_at: int
+
+@router.get("/list", response_model=list[NoteTitleIdResponse])
+async def get_note_list(
+ request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
+):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
@@ -62,14 +73,17 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
+ limit = None
+ skip = None
+ if page is not None:
+ limit = 60
+ skip = (page - 1) * limit
+
notes = [
- NoteUserResponse(
- **{
- **note.model_dump(),
- "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
- }
+ NoteTitleIdResponse(**note.model_dump())
+ for note in Notes.get_notes_by_permission(
+ user.id, "write", skip=skip, limit=limit
)
- for note in Notes.get_notes_by_user_id(user.id, "read")
]
return notes
@@ -166,8 +180,26 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
+ # Check if user can share publicly
+ if (
+ user.role != "admin"
+ and form_data.access_control == None
+ and not has_permission(
+ user.id,
+ "sharing.public_notes",
+ request.app.state.config.USER_PERMISSIONS,
+ )
+ ):
+ form_data.access_control = {}
+
try:
note = Notes.update_note_by_id(id, form_data)
+ await sio.emit(
+ "note-events",
+ note.model_dump(),
+ to=f"note:{note.id}",
+ )
+
return note
except Exception as e:
log.exception(e)
diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py
index 9c1e1fdb00..9606763b00 100644
--- a/backend/open_webui/routers/ollama.py
+++ b/backend/open_webui/routers/ollama.py
@@ -16,8 +16,8 @@ from urllib.parse import urlparse
import aiohttp
from aiocache import cached
import requests
-from urllib.parse import quote
+from open_webui.utils.headers import include_user_info_headers
from open_webui.models.chats import Chats
from open_webui.models.users import UserModel
@@ -47,7 +47,7 @@ from open_webui.utils.misc import (
from open_webui.utils.payload import (
apply_model_params_to_body_ollama,
apply_model_params_to_body_openai,
- apply_model_system_prompt_to_body,
+ apply_system_prompt_to_body,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
@@ -59,6 +59,7 @@ from open_webui.config import (
from open_webui.env import (
ENV,
SRC_LOG_LEVELS,
+ MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@@ -81,22 +82,17 @@ 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:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
async with session.get(
url,
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
@@ -123,6 +119,7 @@ async def send_post_request(
key: Optional[str] = None,
content_type: Optional[str] = None,
user: UserModel = None,
+ metadata: Optional[dict] = None,
):
r = None
@@ -131,23 +128,20 @@ async def send_post_request(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
)
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+ if metadata and metadata.get("chat_id"):
+ headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
+
r = await session.post(
url,
data=payload,
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -183,7 +177,6 @@ async def send_post_request(
)
else:
res = await r.json()
- await cleanup_response(r, session)
return res
except HTTPException as e:
@@ -195,6 +188,9 @@ async def send_post_request(
status_code=r.status if r else 500,
detail=detail if e else "Open WebUI: Server Connection Error",
)
+ finally:
+ if not stream:
+ await cleanup_response(r, session)
def get_api_key(idx, url, configs):
@@ -237,21 +233,16 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
+ headers = {
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
async with session.get(
f"{url}/api/version",
- headers={
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
@@ -320,17 +311,21 @@ def merge_ollama_models_lists(model_lists):
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)
+ id = model.get("model")
+ if id is not None:
+ 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)
+@cached(
+ ttl=MODELS_CACHE_TTL,
+ key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
+)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
@@ -406,15 +401,15 @@ async def get_all_models(request: Request, user: UserModel = None):
try:
loaded_models = await get_ollama_loaded_models(request, user=user)
expires_map = {
- m["name"]: m["expires_at"]
+ m["model"]: 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:
+ if m["model"] in expires_map:
# Parse ISO8601 datetime with offset, get unix timestamp as int
- dt = datetime.fromisoformat(expires_map[m["name"]])
+ dt = datetime.fromisoformat(expires_map[m["model"]])
m["expires_at"] = int(dt.timestamp())
except Exception as e:
log.debug(f"Failed to get loaded models: {e}")
@@ -456,22 +451,17 @@ async def get_ollama_tags(
r = None
try:
+ headers = {
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="GET",
url=f"{url}/api/tags",
- headers={
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
)
r.raise_for_status()
@@ -635,7 +625,10 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
class ModelNameForm(BaseModel):
- name: str
+ model: Optional[str] = None
+ model_config = ConfigDict(
+ extra="allow",
+ )
@router.post("/api/unload")
@@ -644,10 +637,12 @@ async def unload_model(
form_data: ModelNameForm,
user=Depends(get_admin_user),
):
- model_name = form_data.name
+ form_data = form_data.model_dump(exclude_none=True)
+ model_name = form_data.get("model", form_data.get("name"))
+
if not model_name:
raise HTTPException(
- status_code=400, detail="Missing 'name' of model to unload."
+ status_code=400, detail="Missing name of the model to unload."
)
# Refresh/load models if needed, get mapping from name to URLs
@@ -710,11 +705,14 @@ async def pull_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
+ form_data = form_data.model_dump(exclude_none=True)
+ form_data["model"] = form_data.get("model", form_data.get("name"))
+
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
log.info(f"url: {url}")
# Admin should be able to pull models from any source
- payload = {**form_data.model_dump(exclude_none=True), "insecure": True}
+ payload = {**form_data, "insecure": True}
return await send_post_request(
url=f"{url}/api/pull",
@@ -725,7 +723,7 @@ async def pull_model(
class PushModelForm(BaseModel):
- name: str
+ model: str
insecure: Optional[bool] = None
stream: Optional[bool] = None
@@ -742,12 +740,12 @@ async def push_model(
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
- if form_data.name in models:
- url_idx = models[form_data.name]["urls"][0]
+ if form_data.model in models:
+ url_idx = models[form_data.model]["urls"][0]
else:
raise HTTPException(
status_code=400,
- detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model),
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
@@ -817,23 +815,18 @@ async def copy_model(
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="POST",
url=f"{url}/api/copy",
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
@@ -866,40 +859,40 @@ async def delete_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
+ form_data = form_data.model_dump(exclude_none=True)
+ form_data["model"] = form_data.get("model", form_data.get("name"))
+
+ model = form_data.get("model")
+
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
- if form_data.name in models:
- url_idx = models[form_data.name]["urls"][0]
+ if model in models:
+ url_idx = models[model]["urls"][0]
else:
raise HTTPException(
status_code=400,
- detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="DELETE",
url=f"{url}/api/delete",
+ headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
)
r.raise_for_status()
@@ -927,38 +920,38 @@ async def delete_model(
async def show_model_info(
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
):
+ form_data = form_data.model_dump(exclude_none=True)
+ form_data["model"] = form_data.get("model", form_data.get("name"))
+
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
- if form_data.name not in models:
+ model = form_data.get("model")
+
+ if model not in models:
raise HTTPException(
status_code=400,
- detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.name),
+ detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model),
)
- url_idx = random.choice(models[form_data.name]["urls"])
+ url_idx = random.choice(models[model]["urls"])
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="POST",
url=f"{url}/api/show",
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
@@ -989,6 +982,10 @@ class GenerateEmbedForm(BaseModel):
options: Optional[dict] = None
keep_alive: Optional[Union[int, str]] = None
+ model_config = ConfigDict(
+ extra="allow",
+ )
+
@router.post("/api/embed")
@router.post("/api/embed/{url_idx}")
@@ -1029,23 +1026,18 @@ async def embed(
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
try:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="POST",
url=f"{url}/api/embed",
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
@@ -1116,23 +1108,18 @@ async def embeddings(
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
try:
+ headers = {
+ "Content-Type": "application/json",
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
r = requests.request(
method="POST",
url=f"{url}/api/embeddings",
- headers={
- "Content-Type": "application/json",
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
data=form_data.model_dump_json(exclude_none=True).encode(),
)
r.raise_for_status()
@@ -1303,7 +1290,7 @@ async def generate_chat_completion(
system = params.pop("system", None)
payload = apply_model_params_to_body_ollama(params, payload)
- payload = apply_model_system_prompt_to_body(system, payload, metadata, user)
+ payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -1344,6 +1331,7 @@ async def generate_chat_completion(
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson",
user=user,
+ metadata=metadata,
)
@@ -1382,6 +1370,8 @@ async def generate_openai_completion(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
+ metadata = form_data.pop("metadata", None)
+
try:
form_data = OpenAICompletionForm(**form_data)
except Exception as e:
@@ -1447,6 +1437,7 @@ async def generate_openai_completion(
stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
+ metadata=metadata,
)
@@ -1488,7 +1479,7 @@ async def generate_openai_chat_completion(
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)
+ payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if user.role == "user":
@@ -1528,6 +1519,7 @@ async def generate_openai_chat_completion(
stream=payload.get("stream", False),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
user=user,
+ metadata=metadata,
)
@@ -1658,25 +1650,27 @@ async def download_file_stream(
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done:
- file.seek(0)
- chunk_size = 1024 * 1024 * 2
- hashed = calculate_sha256(file, chunk_size)
- file.seek(0)
+ file.close()
- url = f"{ollama_url}/api/blobs/sha256:{hashed}"
- response = requests.post(url, data=file)
+ with open(file_path, "rb") as file:
+ chunk_size = 1024 * 1024 * 2
+ hashed = calculate_sha256(file, chunk_size)
- if response.ok:
- res = {
- "done": done,
- "blob": f"sha256:{hashed}",
- "name": file_name,
- }
- os.remove(file_path)
+ url = f"{ollama_url}/api/blobs/sha256:{hashed}"
+ with requests.Session() as session:
+ response = session.post(url, data=file, timeout=30)
- yield f"data: {json.dumps(res)}\n\n"
- else:
- raise "Ollama: Could not create blob, Please try again."
+ if response.ok:
+ res = {
+ "done": done,
+ "blob": f"sha256:{hashed}",
+ "name": file_name,
+ }
+ os.remove(file_path)
+
+ yield f"data: {json.dumps(res)}\n\n"
+ else:
+ raise "Ollama: Could not create blob, Please try again."
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py
index e3210ae5f3..a74a59ca1f 100644
--- a/backend/open_webui/routers/openai.py
+++ b/backend/open_webui/routers/openai.py
@@ -2,17 +2,21 @@ import asyncio
import hashlib
import json
import logging
-from pathlib import Path
-from typing import Literal, Optional, overload
+from typing import Optional
import aiohttp
from aiocache import cached
import requests
-from urllib.parse import quote
-from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, StreamingResponse
+from azure.identity import DefaultAzureCredential, get_bearer_token_provider
+
+from fastapi import Depends, HTTPException, Request, APIRouter
+from fastapi.responses import (
+ FileResponse,
+ StreamingResponse,
+ JSONResponse,
+ PlainTextResponse,
+)
from pydantic import BaseModel
from starlette.background import BackgroundTask
@@ -21,6 +25,7 @@ from open_webui.config import (
CACHE_DIR,
)
from open_webui.env import (
+ MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
@@ -30,19 +35,21 @@ from open_webui.env import (
from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES
-from open_webui.env import ENV, SRC_LOG_LEVELS
+from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.payload import (
apply_model_params_to_body_openai,
- apply_model_system_prompt_to_body,
+ apply_system_prompt_to_body,
)
from open_webui.utils.misc import (
convert_logit_bias_input_to_json,
+ stream_chunks_handler,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
+from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
@@ -60,21 +67,16 @@ 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:
+ headers = {
+ **({"Authorization": f"Bearer {key}"} if key else {}),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+
async with session.get(
url,
- headers={
- **({"Authorization": f"Bearer {key}"} if key else {}),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
@@ -94,12 +96,12 @@ async def cleanup_response(
await session.close()
-def openai_o_series_handler(payload):
+def openai_reasoning_model_handler(payload):
"""
- Handle "o" series specific parameters
+ Handle reasoning model specific parameters
"""
if "max_tokens" in payload:
- # Convert "max_tokens" to "max_completion_tokens" for all o-series models
+ # Convert "max_tokens" to "max_completion_tokens" for all reasoning models
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
@@ -115,6 +117,86 @@ def openai_o_series_handler(payload):
return payload
+async def get_headers_and_cookies(
+ request: Request,
+ url,
+ key=None,
+ config=None,
+ metadata: Optional[dict] = None,
+ user: UserModel = None,
+):
+ cookies = {}
+ headers = {
+ "Content-Type": "application/json",
+ **(
+ {
+ "HTTP-Referer": "https://openwebui.com/",
+ "X-Title": "Open WebUI",
+ }
+ if "openrouter.ai" in url
+ else {}
+ ),
+ }
+
+ if ENABLE_FORWARD_USER_INFO_HEADERS and user:
+ headers = include_user_info_headers(headers, user)
+ if metadata and metadata.get("chat_id"):
+ headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
+
+ token = None
+ auth_type = config.get("auth_type")
+
+ if auth_type == "bearer" or auth_type is None:
+ # Default to bearer if not specified
+ token = f"{key}"
+ elif auth_type == "none":
+ token = None
+ elif auth_type == "session":
+ cookies = request.cookies
+ token = request.state.token.credentials
+ elif auth_type == "system_oauth":
+ cookies = request.cookies
+
+ oauth_token = None
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+ except Exception as e:
+ log.error(f"Error getting OAuth token: {e}")
+
+ if oauth_token:
+ token = f"{oauth_token.get('access_token', '')}"
+
+ elif auth_type in ("azure_ad", "microsoft_entra_id"):
+ token = get_microsoft_entra_id_access_token()
+
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+
+ if config.get("headers") and isinstance(config.get("headers"), dict):
+ headers = {**headers, **config.get("headers")}
+
+ return headers, cookies
+
+
+def get_microsoft_entra_id_access_token():
+ """
+ Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
+ Returns the token string or None if authentication fails.
+ """
+ try:
+ token_provider = get_bearer_token_provider(
+ DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
+ )
+ return token_provider()
+ except Exception as e:
+ log.error(f"Error getting Microsoft Entra ID access token: {e}")
+ return None
+
+
##########################################
#
# API routes
@@ -206,34 +288,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
+ key = request.app.state.config.OPENAI_API_KEYS[idx]
+ api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
+ str(idx),
+ request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
+ )
+
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, user=user
+ )
r = None
try:
r = requests.post(
url=f"{url}/audio/speech",
data=body,
- headers={
- "Content-Type": "application/json",
- "Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
- **(
- {
- "HTTP-Referer": "https://openwebui.com/",
- "X-Title": "Open WebUI",
- }
- if "openrouter.ai" in url
- else {}
- ),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- },
+ headers=headers,
+ cookies=cookies,
stream=True,
)
@@ -357,11 +428,22 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
prefix_id = api_config.get("prefix_id", None)
tags = api_config.get("tags", [])
- for model in (
+ model_list = (
response if isinstance(response, list) else response.get("data", [])
- ):
+ )
+ if not isinstance(model_list, list):
+ # Catch non-list responses
+ model_list = []
+
+ for model in model_list:
+ # Remove name key if its value is None #16689
+ if "name" in model and model["name"] is None:
+ del model["name"]
+
if prefix_id:
- model["id"] = f"{prefix_id}.{model['id']}"
+ model["id"] = (
+ f"{prefix_id}.{model.get('id', model.get('name', ''))}"
+ )
if tags:
model["tags"] = tags
@@ -386,7 +468,10 @@ async def get_filtered_models(models, user):
return filtered_models
-@cached(ttl=1)
+@cached(
+ ttl=MODELS_CACHE_TTL,
+ key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
+)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
@@ -402,50 +487,55 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
return response
return None
- def merge_models_lists(model_lists):
+ def is_supported_openai_models(model_id):
+ if any(
+ name in model_id
+ for name in [
+ "babbage",
+ "dall-e",
+ "davinci",
+ "embedding",
+ "tts",
+ "whisper",
+ ]
+ ):
+ return False
+ return True
+
+ def get_merged_models(model_lists):
log.debug(f"merge_models_lists {model_lists}")
- merged_list = []
+ models = {}
- for idx, models in enumerate(model_lists):
- if models is not None and "error" not in models:
+ for idx, model_list in enumerate(model_lists):
+ if model_list is not None and "error" not in model_list:
+ for model in model_list:
+ model_id = model.get("id") or model.get("name")
- merged_list.extend(
- [
- {
+ if (
+ "api.openai.com"
+ in request.app.state.config.OPENAI_API_BASE_URLS[idx]
+ and not is_supported_openai_models(model_id)
+ ):
+ # Skip unwanted OpenAI models
+ continue
+
+ if model_id and model_id not in models:
+ models[model_id] = {
**model,
- "name": model.get("name", model["id"]),
+ "name": model.get("name", model_id),
"owned_by": "openai",
"openai": model,
"connection_type": model.get("connection_type", "external"),
"urlIdx": idx,
}
- for model in models
- if (model.get("id") or model.get("name"))
- and (
- "api.openai.com"
- not in request.app.state.config.OPENAI_API_BASE_URLS[idx]
- or not any(
- name in model["id"]
- for name in [
- "babbage",
- "dall-e",
- "davinci",
- "embedding",
- "tts",
- "whisper",
- ]
- )
- )
- ]
- )
- return merged_list
+ return models
- models = {"data": merge_models_lists(map(extract_data, responses))}
+ models = get_merged_models(map(extract_data, responses))
log.debug(f"models: {models}")
- request.app.state.OPENAI_MODELS = {model["id"]: model for model in models["data"]}
- return models
+ request.app.state.OPENAI_MODELS = models
+ return {"data": list(models.values())}
@router.get("/models")
@@ -474,19 +564,9 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
- headers = {
- "Content-Type": "application/json",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- }
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, user=user
+ )
if api_config.get("azure", False):
models = {
@@ -494,11 +574,10 @@ async def get_models(
"object": "list",
}
else:
- headers["Authorization"] = f"Bearer {key}"
-
async with session.get(
f"{url}/models",
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
@@ -557,7 +636,9 @@ class ConnectionVerificationForm(BaseModel):
@router.post("/verify")
async def verify_connection(
- form_data: ConnectionVerificationForm, user=Depends(get_admin_user)
+ request: Request,
+ form_data: ConnectionVerificationForm,
+ user=Depends(get_admin_user),
):
url = form_data.url
key = form_data.key
@@ -569,56 +650,61 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
- headers = {
- "Content-Type": "application/json",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- }
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, user=user
+ )
if api_config.get("azure", False):
- headers["api-key"] = key
- api_version = api_config.get("api_version", "") or "2023-03-15-preview"
+ # Only set api-key header if not using Azure Entra ID authentication
+ auth_type = api_config.get("auth_type", "bearer")
+ if auth_type not in ("azure_ad", "microsoft_entra_id"):
+ headers["api-key"] = key
+ api_version = api_config.get("api_version", "") or "2023-03-15-preview"
async with session.get(
url=f"{url}/openai/models?api-version={api_version}",
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
- 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)
+ try:
+ response_data = await r.json()
+ except Exception:
+ response_data = await r.text()
+
+ if r.status != 200:
+ if isinstance(response_data, (dict, list)):
+ return JSONResponse(
+ status_code=r.status, content=response_data
+ )
+ else:
+ return PlainTextResponse(
+ status_code=r.status, content=response_data
+ )
- response_data = await r.json()
return response_data
else:
- headers["Authorization"] = f"Bearer {key}"
-
async with session.get(
f"{url}/models",
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
- 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)
+ try:
+ response_data = await r.json()
+ except Exception:
+ response_data = await r.text()
+
+ if r.status != 200:
+ if isinstance(response_data, (dict, list)):
+ return JSONResponse(
+ status_code=r.status, content=response_data
+ )
+ else:
+ return PlainTextResponse(
+ status_code=r.status, content=response_data
+ )
- response_data = await r.json()
return response_data
except aiohttp.ClientError as e:
@@ -629,17 +715,12 @@ async def verify_connection(
)
except Exception as e:
log.exception(f"Unexpected error: {e}")
- error_detail = f"Unexpected error: {str(e)}"
- raise HTTPException(status_code=500, detail=error_detail)
+ raise HTTPException(
+ status_code=500, detail="Open WebUI: Server Connection Error"
+ )
-def convert_to_azure_payload(
- url,
- payload: dict,
-):
- model = payload.get("model", "")
-
- # Filter allowed parameters based on Azure OpenAI API
+def get_azure_allowed_params(api_version: str) -> set[str]:
allowed_params = {
"messages",
"temperature",
@@ -667,10 +748,32 @@ def convert_to_azure_payload(
"response_format",
"seed",
"max_completion_tokens",
+ "reasoning_effort",
}
+ try:
+ if api_version >= "2024-09-01-preview":
+ allowed_params.add("stream_options")
+ except ValueError:
+ log.debug(
+ f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters."
+ )
+
+ return allowed_params
+
+
+def is_openai_reasoning_model(model: str) -> bool:
+ return model.lower().startswith(("o1", "o3", "o4", "gpt-5"))
+
+
+def convert_to_azure_payload(url, payload: dict, api_version: str):
+ model = payload.get("model", "")
+
+ # Filter allowed parameters based on Azure OpenAI API
+ allowed_params = get_azure_allowed_params(api_version)
+
# Special handling for o-series models
- if model.startswith("o") and model.endswith("-mini"):
+ if is_openai_reasoning_model(model):
# Convert max_tokens to max_completion_tokens for o-series models
if "max_tokens" in payload:
payload["max_completion_tokens"] = payload["max_tokens"]
@@ -720,7 +823,7 @@ async def generate_chat_completion(
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)
+ payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -775,10 +878,9 @@ async def generate_chat_completion(
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
- # 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)
+ # Check if model is a reasoning model that needs special handling
+ if is_openai_reasoning_model(payload["model"]):
+ payload = openai_reasoning_model_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:
@@ -794,37 +896,23 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
- headers = {
- "Content-Type": "application/json",
- **(
- {
- "HTTP-Referer": "https://openwebui.com/",
- "X-Title": "Open WebUI",
- }
- if "openrouter.ai" in url
- else {}
- ),
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- }
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, metadata, user=user
+ )
if api_config.get("azure", False):
- 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
+ api_version = api_config.get("api_version", "2023-03-15-preview")
+ request_url, payload = convert_to_azure_payload(url, payload, api_version)
+
+ # Only set api-key header if not using Azure Entra ID authentication
+ auth_type = api_config.get("auth_type", "bearer")
+ if auth_type not in ("azure_ad", "microsoft_entra_id"):
+ headers["api-key"] = key
+
headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
- headers["Authorization"] = f"Bearer {key}"
payload = json.dumps(payload)
@@ -843,6 +931,7 @@ async def generate_chat_completion(
url=request_url,
data=payload,
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -850,7 +939,7 @@ async def generate_chat_completion(
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
- r.content,
+ stream_chunks_handler(r.content),
status_code=r.status,
headers=dict(r.headers),
background=BackgroundTask(
@@ -864,27 +953,23 @@ async def generate_chat_completion(
log.error(e)
response = await r.text()
- r.raise_for_status()
+ if r.status >= 400:
+ if isinstance(response, (dict, list)):
+ return JSONResponse(status_code=r.status, content=response)
+ else:
+ return PlainTextResponse(status_code=r.status, content=response)
+
return response
except Exception as e:
log.exception(e)
- detail = None
- if isinstance(response, dict):
- if "error" in response:
- detail = f"{response['error']['message'] if 'message' in response['error'] else response['error']}"
- elif isinstance(response, str):
- detail = response
-
raise HTTPException(
status_code=r.status if r else 500,
- detail=detail if detail else "Open WebUI: Server Connection Error",
+ detail="Open WebUI: Server Connection Error",
)
finally:
- if not streaming and session:
- if r:
- r.close()
- await session.close()
+ if not streaming:
+ await cleanup_response(r, session)
async def embeddings(request: Request, form_data: dict, user):
@@ -908,33 +993,31 @@ async def embeddings(request: Request, form_data: dict, user):
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
+
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
+ api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
+ str(idx),
+ request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
+ )
+
r = None
session = None
streaming = False
+
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, user=user
+ )
try:
session = aiohttp.ClientSession(trust_env=True)
r = await session.request(
method="POST",
url=f"{url}/embeddings",
data=body,
- headers={
- "Authorization": f"Bearer {key}",
- "Content-Type": "application/json",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS and user
- else {}
- ),
- },
+ headers=headers,
+ cookies=cookies,
)
- r.raise_for_status()
+
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
@@ -946,27 +1029,29 @@ async def embeddings(request: Request, form_data: dict, user):
),
)
else:
- response_data = await r.json()
+ try:
+ response_data = await r.json()
+ except Exception:
+ response_data = await r.text()
+
+ if r.status >= 400:
+ if isinstance(response_data, (dict, list)):
+ return JSONResponse(status_code=r.status, content=response_data)
+ else:
+ return PlainTextResponse(
+ status_code=r.status, content=response_data
+ )
+
return response_data
except Exception as e:
log.exception(e)
- detail = None
- if r is not None:
- try:
- res = await r.json()
- if "error" in res:
- detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
- except Exception:
- detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
- detail=detail if detail else "Open WebUI: Server Connection Error",
+ detail="Open WebUI: Server Connection Error",
)
finally:
- if not streaming and session:
- if r:
- r.close()
- await session.close()
+ if not streaming:
+ await cleanup_response(r, session)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
@@ -992,33 +1077,26 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False
try:
- headers = {
- "Content-Type": "application/json",
- **(
- {
- "X-OpenWebUI-User-Name": quote(user.name),
- "X-OpenWebUI-User-Id": quote(user.id),
- "X-OpenWebUI-User-Email": quote(user.email),
- "X-OpenWebUI-User-Role": quote(user.role),
- }
- if ENABLE_FORWARD_USER_INFO_HEADERS
- else {}
- ),
- }
+ headers, cookies = await get_headers_and_cookies(
+ request, url, key, api_config, user=user
+ )
if api_config.get("azure", False):
- headers["api-key"] = key
- headers["api-version"] = (
- api_config.get("api_version", "") or "2023-03-15-preview"
- )
+ api_version = api_config.get("api_version", "2023-03-15-preview")
+
+ # Only set api-key header if not using Azure Entra ID authentication
+ auth_type = api_config.get("auth_type", "bearer")
+ if auth_type not in ("azure_ad", "microsoft_entra_id"):
+ headers["api-key"] = key
+
+ headers["api-version"] = api_version
payload = json.loads(body)
- url, payload = convert_to_azure_payload(url, payload)
+ url, payload = convert_to_azure_payload(url, payload, api_version)
body = json.dumps(payload).encode()
- request_url = f"{url}/{path}?api-version={api_config.get('api_version', '2023-03-15-preview')}"
+ request_url = f"{url}/{path}?api-version={api_version}"
else:
- headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
@@ -1027,9 +1105,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
url=request_url,
data=body,
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
- r.raise_for_status()
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
@@ -1043,27 +1121,27 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
),
)
else:
- response_data = await r.json()
+ try:
+ response_data = await r.json()
+ except Exception:
+ response_data = await r.text()
+
+ if r.status >= 400:
+ if isinstance(response_data, (dict, list)):
+ return JSONResponse(status_code=r.status, content=response_data)
+ else:
+ return PlainTextResponse(
+ status_code=r.status, content=response_data
+ )
+
return response_data
except Exception as e:
log.exception(e)
-
- detail = None
- if r is not None:
- try:
- res = await r.json()
- log.error(res)
- if "error" in res:
- detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}"
- except Exception:
- detail = f"External: {e}"
raise HTTPException(
status_code=r.status if r else 500,
- detail=detail if detail else "Open WebUI: Server Connection Error",
+ detail="Open WebUI: Server Connection Error",
)
finally:
- if not streaming and session:
- if r:
- r.close()
- await session.close()
+ if not streaming:
+ await cleanup_response(r, session)
diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py
index 9fb946c6e7..6a957f2547 100644
--- a/backend/open_webui/routers/prompts.py
+++ b/backend/open_webui/routers/prompts.py
@@ -1,4 +1,5 @@
from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.models.prompts import (
PromptForm,
@@ -7,9 +8,9 @@ from open_webui.models.prompts import (
Prompts,
)
from open_webui.constants import ERROR_MESSAGES
-from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
+from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
router = APIRouter()
@@ -20,7 +21,7 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)):
- if user.role == "admin":
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read")
@@ -30,7 +31,7 @@ async def get_prompts(user=Depends(get_verified_user)):
@router.get("/list", response_model=list[PromptUserResponse])
async def get_prompt_list(user=Depends(get_verified_user)):
- if user.role == "admin":
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts()
else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write")
@@ -47,8 +48,15 @@ async def get_prompt_list(user=Depends(get_verified_user)):
async def create_new_prompt(
request: Request, form_data: PromptForm, user=Depends(get_verified_user)
):
- if user.role != "admin" and not has_permission(
- user.id, "workspace.prompts", request.app.state.config.USER_PERMISSIONS
+ if user.role != "admin" and not (
+ has_permission(
+ user.id, "workspace.prompts", request.app.state.config.USER_PERMISSIONS
+ )
+ or has_permission(
+ user.id,
+ "workspace.prompts_import",
+ request.app.state.config.USER_PERMISSIONS,
+ )
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py
index ee6f99fbb5..6080337250 100644
--- a/backend/open_webui/routers/retrieval.py
+++ b/backend/open_webui/routers/retrieval.py
@@ -5,7 +5,7 @@ import os
import shutil
import asyncio
-
+import re
import uuid
from datetime import datetime
from pathlib import Path
@@ -29,9 +29,10 @@ import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
+from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_core.documents import Document
-from open_webui.models.files import FileModel, Files
+from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage
@@ -45,6 +46,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
# Web search engines
from open_webui.retrieval.web.main import SearchResult
from open_webui.retrieval.web.utils import get_web_loader
+from open_webui.retrieval.web.ollama import search_ollama_cloud
+from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.brave import search_brave
from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.mojeek import search_mojeek
@@ -61,6 +64,7 @@ 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.azure import search_azure
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
@@ -68,13 +72,16 @@ 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_content_from_url,
get_embedding_function,
+ get_reranking_function,
get_model_path,
query_collection,
query_collection_with_hybrid_search,
query_doc,
query_doc_with_hybrid_search,
)
+from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.utils.misc import (
calculate_sha256_string,
)
@@ -185,6 +192,26 @@ def get_rf(
log.error(f"CrossEncoder: {e}")
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
+ # Safely adjust pad_token_id if missing as some models do not have this in config
+ try:
+ model_cfg = getattr(rf, "model", None)
+ if model_cfg and hasattr(model_cfg, "config"):
+ cfg = model_cfg.config
+ if getattr(cfg, "pad_token_id", None) is None:
+ # Fallback to eos_token_id when available
+ eos = getattr(cfg, "eos_token_id", None)
+ if eos is not None:
+ cfg.pad_token_id = eos
+ log.debug(
+ f"Missing pad_token_id detected; set to eos_token_id={eos}"
+ )
+ else:
+ log.warning(
+ "Neither pad_token_id nor eos_token_id present in model config"
+ )
+ except Exception as e2:
+ log.warning(f"Failed to adjust pad_token_id on CrossEncoder: {e2}")
+
return rf
@@ -214,13 +241,14 @@ class SearchForm(BaseModel):
async def get_status(request: Request):
return {
"status": True,
- "chunk_size": request.app.state.config.CHUNK_SIZE,
- "chunk_overlap": request.app.state.config.CHUNK_OVERLAP,
- "template": request.app.state.config.RAG_TEMPLATE,
- "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
- "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL,
- "reranking_model": request.app.state.config.RAG_RERANKING_MODEL,
- "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
+ "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP,
+ "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE,
+ "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE,
+ "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
+ "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
+ "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
}
@@ -228,9 +256,10 @@ async def get_status(request: Request):
async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
return {
"status": True,
- "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
- "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL,
- "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE,
+ "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
+ "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -267,9 +296,25 @@ 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
+ RAG_EMBEDDING_ENGINE: str
+ RAG_EMBEDDING_MODEL: str
+ RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1
+ ENABLE_ASYNC_EMBEDDING: Optional[bool] = True
+
+
+def unload_embedding_model(request: Request):
+ if request.app.state.config.RAG_EMBEDDING_ENGINE == "":
+ # unloads current internal embedding model and clears VRAM cache
+ request.app.state.ef = None
+ request.app.state.EMBEDDING_FUNCTION = None
+ import gc
+
+ gc.collect()
+ if DEVICE_TYPE == "cuda":
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
@router.post("/embedding/update")
@@ -277,11 +322,18 @@ async def update_embedding_config(
request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)
):
log.info(
- f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.embedding_model}"
+ f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}"
)
+ unload_embedding_model(request)
try:
- request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.embedding_engine
- request.app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model
+ request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE
+ request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL
+ request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = (
+ form_data.RAG_EMBEDDING_BATCH_SIZE
+ )
+ request.app.state.config.ENABLE_ASYNC_EMBEDDING = (
+ form_data.ENABLE_ASYNC_EMBEDDING
+ )
if request.app.state.config.RAG_EMBEDDING_ENGINE in [
"ollama",
@@ -315,10 +367,6 @@ async def update_embedding_config(
form_data.azure_openai_config.version
)
- request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = (
- form_data.embedding_batch_size
- )
-
request.app.state.ef = get_ef(
request.app.state.config.RAG_EMBEDDING_ENGINE,
request.app.state.config.RAG_EMBEDDING_MODEL,
@@ -352,13 +400,15 @@ async def update_embedding_config(
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None
),
+ enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
)
return {
"status": True,
- "embedding_engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
- "embedding_model": request.app.state.config.RAG_EMBEDDING_MODEL,
- "embedding_batch_size": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE,
+ "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
+ "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+ "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -392,6 +442,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"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,
+ "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS,
"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,
@@ -399,27 +450,31 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"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_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
+ "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
"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_FORMAT_LINES": request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
"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,
- "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
- "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
- "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
+ "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY,
+ "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
+ "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL,
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
+ # MinerU settings
+ "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
+ "MINERU_API_URL": request.app.state.config.MINERU_API_URL,
+ "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
+ "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
# Reranking settings
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
@@ -445,9 +500,11 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"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_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_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,
+ "OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"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,
@@ -474,6 +531,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY,
"PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL,
"PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE,
+ "PERPLEXITY_SEARCH_API_URL": request.app.state.config.PERPLEXITY_SEARCH_API_URL,
"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,
@@ -500,9 +558,11 @@ class WebConfig(BaseModel):
WEB_SEARCH_TRUST_ENV: Optional[bool] = None
WEB_SEARCH_RESULT_COUNT: Optional[int] = None
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
+ WEB_LOADER_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
+ OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None
@@ -529,6 +589,7 @@ class WebConfig(BaseModel):
PERPLEXITY_API_KEY: Optional[str] = None
PERPLEXITY_MODEL: Optional[str] = None
PERPLEXITY_SEARCH_CONTEXT_USAGE: Optional[str] = None
+ PERPLEXITY_SEARCH_API_URL: Optional[str] = None
SOUGOU_API_SID: Optional[str] = None
SOUGOU_API_SK: Optional[str] = None
WEB_LOADER_ENGINE: Optional[str] = None
@@ -556,6 +617,7 @@ class ConfigForm(BaseModel):
# Hybrid search settings
ENABLE_RAG_HYBRID_SEARCH: Optional[bool] = None
+ ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS: Optional[bool] = None
TOP_K_RERANKER: Optional[int] = None
RELEVANCE_THRESHOLD: Optional[float] = None
HYBRID_BM25_WEIGHT: Optional[float] = None
@@ -563,30 +625,37 @@ class ConfigForm(BaseModel):
# 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_API_BASE_URL: Optional[str] = None
+ DATALAB_MARKER_ADDITIONAL_CONFIG: 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_FORMAT_LINES: 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
- DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
- DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
- DOCLING_PICTURE_DESCRIPTION_API: Optional[dict] = None
+ DOCLING_API_KEY: Optional[str] = None
+ DOCLING_PARAMS: Optional[dict] = None
DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None
DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
+ MISTRAL_OCR_API_BASE_URL: Optional[str] = None
MISTRAL_OCR_API_KEY: Optional[str] = None
+ # MinerU settings
+ MINERU_API_MODE: Optional[str] = None
+ MINERU_API_URL: Optional[str] = None
+ MINERU_API_KEY: Optional[str] = None
+ MINERU_PARAMS: Optional[dict] = None
+
# Reranking settings
RAG_RERANKING_MODEL: Optional[str] = None
RAG_RERANKING_ENGINE: Optional[str] = None
@@ -645,9 +714,11 @@ async def update_rag_config(
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.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = (
+ form_data.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS
+ if form_data.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS is not None
+ else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS
+ )
request.app.state.config.TOP_K_RERANKER = (
form_data.TOP_K_RERANKER
@@ -681,10 +752,15 @@ async def update_rag_config(
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_API_BASE_URL = (
+ form_data.DATALAB_MARKER_API_BASE_URL
+ if form_data.DATALAB_MARKER_API_BASE_URL is not None
+ else request.app.state.config.DATALAB_MARKER_API_BASE_URL
+ )
+ request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG = (
+ form_data.DATALAB_MARKER_ADDITIONAL_CONFIG
+ if form_data.DATALAB_MARKER_ADDITIONAL_CONFIG is not None
+ else request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG
)
request.app.state.config.DATALAB_MARKER_SKIP_CACHE = (
form_data.DATALAB_MARKER_SKIP_CACHE
@@ -711,6 +787,11 @@ async def update_rag_config(
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_FORMAT_LINES = (
+ form_data.DATALAB_MARKER_FORMAT_LINES
+ if form_data.DATALAB_MARKER_FORMAT_LINES is not None
+ else request.app.state.config.DATALAB_MARKER_FORMAT_LINES
+ )
request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = (
form_data.DATALAB_MARKER_OUTPUT_FORMAT
if form_data.DATALAB_MARKER_OUTPUT_FORMAT is not None
@@ -741,39 +822,16 @@ async def update_rag_config(
if form_data.DOCLING_SERVER_URL is not None
else request.app.state.config.DOCLING_SERVER_URL
)
- request.app.state.config.DOCLING_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_API_KEY = (
+ form_data.DOCLING_API_KEY
+ if form_data.DOCLING_API_KEY is not None
+ else request.app.state.config.DOCLING_API_KEY
)
- 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_PARAMS = (
+ form_data.DOCLING_PARAMS
+ if form_data.DOCLING_PARAMS is not None
+ else request.app.state.config.DOCLING_PARAMS
)
-
- 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.DOCLING_PICTURE_DESCRIPTION_MODE = (
- form_data.DOCLING_PICTURE_DESCRIPTION_MODE
- if form_data.DOCLING_PICTURE_DESCRIPTION_MODE is not None
- else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE
- )
- request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = (
- form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL
- if form_data.DOCLING_PICTURE_DESCRIPTION_LOCAL is not None
- else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL
- )
- request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API = (
- form_data.DOCLING_PICTURE_DESCRIPTION_API
- if form_data.DOCLING_PICTURE_DESCRIPTION_API is not None
- else request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API
- )
-
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.DOCUMENT_INTELLIGENCE_ENDPOINT
if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None
@@ -784,13 +842,53 @@ async def update_rag_config(
if form_data.DOCUMENT_INTELLIGENCE_KEY is not None
else request.app.state.config.DOCUMENT_INTELLIGENCE_KEY
)
+
+ request.app.state.config.MISTRAL_OCR_API_BASE_URL = (
+ form_data.MISTRAL_OCR_API_BASE_URL
+ if form_data.MISTRAL_OCR_API_BASE_URL is not None
+ else request.app.state.config.MISTRAL_OCR_API_BASE_URL
+ )
request.app.state.config.MISTRAL_OCR_API_KEY = (
form_data.MISTRAL_OCR_API_KEY
if form_data.MISTRAL_OCR_API_KEY is not None
else request.app.state.config.MISTRAL_OCR_API_KEY
)
+ # MinerU settings
+ request.app.state.config.MINERU_API_MODE = (
+ form_data.MINERU_API_MODE
+ if form_data.MINERU_API_MODE is not None
+ else request.app.state.config.MINERU_API_MODE
+ )
+ request.app.state.config.MINERU_API_URL = (
+ form_data.MINERU_API_URL
+ if form_data.MINERU_API_URL is not None
+ else request.app.state.config.MINERU_API_URL
+ )
+ request.app.state.config.MINERU_API_KEY = (
+ form_data.MINERU_API_KEY
+ if form_data.MINERU_API_KEY is not None
+ else request.app.state.config.MINERU_API_KEY
+ )
+ request.app.state.config.MINERU_PARAMS = (
+ form_data.MINERU_PARAMS
+ if form_data.MINERU_PARAMS is not None
+ else request.app.state.config.MINERU_PARAMS
+ )
+
# Reranking settings
+ if request.app.state.config.RAG_RERANKING_ENGINE == "":
+ # Unloading the internal reranker and clear VRAM memory
+ request.app.state.rf = None
+ request.app.state.RERANKING_FUNCTION = None
+ import gc
+
+ gc.collect()
+ if DEVICE_TYPE == "cuda":
+ import torch
+
+ if torch.cuda.is_available():
+ torch.cuda.empty_cache()
request.app.state.config.RAG_RERANKING_ENGINE = (
form_data.RAG_RERANKING_ENGINE
if form_data.RAG_RERANKING_ENGINE is not None
@@ -813,16 +911,30 @@ async def update_rag_config(
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
+ request.app.state.config.RAG_RERANKING_MODEL = (
+ form_data.RAG_RERANKING_MODEL
+ if form_data.RAG_RERANKING_MODEL is not None
+ else request.app.state.config.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,
- )
+ if (
+ request.app.state.config.ENABLE_RAG_HYBRID_SEARCH
+ and not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
+ ):
+ 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,
+ )
+
+ request.app.state.RERANKING_FUNCTION = get_reranking_function(
+ request.app.state.config.RAG_RERANKING_ENGINE,
+ request.app.state.config.RAG_RERANKING_MODEL,
+ request.app.state.rf,
+ )
except Exception as e:
log.error(f"Error loading reranking model: {e}")
request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
@@ -890,6 +1002,9 @@ async def update_rag_config(
request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = (
form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS
)
+ request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = (
+ form_data.web.WEB_LOADER_CONCURRENT_REQUESTS
+ )
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = (
form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST
)
@@ -899,6 +1014,9 @@ async def update_rag_config(
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
)
+ request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
+ form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
+ )
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
@@ -939,6 +1057,9 @@ async def update_rag_config(
request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = (
form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE
)
+ request.app.state.config.PERPLEXITY_SEARCH_API_URL = (
+ form_data.web.PERPLEXITY_SEARCH_API_URL
+ )
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
@@ -994,7 +1115,8 @@ async def update_rag_config(
"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_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL,
+ "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
"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,
@@ -1006,15 +1128,17 @@ async def update_rag_config(
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
- "DOCLING_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,
- "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
- "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
- "DOCLING_PICTURE_DESCRIPTION_API": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
+ "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY,
+ "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
+ "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL,
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
+ # MinerU settings
+ "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
+ "MINERU_API_URL": request.app.state.config.MINERU_API_URL,
+ "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
+ "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
# Reranking settings
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
@@ -1040,9 +1164,11 @@ async def update_rag_config(
"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_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_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,
+ "OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"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,
@@ -1069,6 +1195,7 @@ async def update_rag_config(
"PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY,
"PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL,
"PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE,
+ "PERPLEXITY_SEARCH_API_URL": request.app.state.config.PERPLEXITY_SEARCH_API_URL,
"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,
@@ -1146,6 +1273,7 @@ def save_docs_to_vector_db(
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
add_start_index=True,
)
+ docs = text_splitter.split_documents(docs)
elif request.app.state.config.TEXT_SPLITTER == "token":
log.info(
f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}"
@@ -1158,11 +1286,56 @@ def save_docs_to_vector_db(
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
add_start_index=True,
)
+ docs = text_splitter.split_documents(docs)
+ elif request.app.state.config.TEXT_SPLITTER == "markdown_header":
+ log.info("Using markdown header text splitter")
+
+ # Define headers to split on - covering most common markdown header levels
+ headers_to_split_on = [
+ ("#", "Header 1"),
+ ("##", "Header 2"),
+ ("###", "Header 3"),
+ ("####", "Header 4"),
+ ("#####", "Header 5"),
+ ("######", "Header 6"),
+ ]
+
+ markdown_splitter = MarkdownHeaderTextSplitter(
+ headers_to_split_on=headers_to_split_on,
+ strip_headers=False, # Keep headers in content for context
+ )
+
+ md_split_docs = []
+ for doc in docs:
+ md_header_splits = markdown_splitter.split_text(doc.page_content)
+ text_splitter = RecursiveCharacterTextSplitter(
+ chunk_size=request.app.state.config.CHUNK_SIZE,
+ chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
+ add_start_index=True,
+ )
+ md_header_splits = text_splitter.split_documents(md_header_splits)
+
+ # Convert back to Document objects, preserving original metadata
+ for split_chunk in md_header_splits:
+ headings_list = []
+ # Extract header values in order based on headers_to_split_on
+ for _, header_meta_key_name in headers_to_split_on:
+ if header_meta_key_name in split_chunk.metadata:
+ headings_list.append(
+ split_chunk.metadata[header_meta_key_name]
+ )
+
+ md_split_docs.append(
+ Document(
+ page_content=split_chunk.page_content,
+ metadata={**doc.metadata, "headings": headings_list},
+ )
+ )
+
+ docs = md_split_docs
else:
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
- docs = text_splitter.split_documents(docs)
-
if len(docs) == 0:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
@@ -1171,27 +1344,14 @@ def save_docs_to_vector_db(
{
**doc.metadata,
**(metadata if metadata else {}),
- "embedding_config": json.dumps(
- {
- "engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
- "model": request.app.state.config.RAG_EMBEDDING_MODEL,
- }
- ),
+ "embedding_config": {
+ "engine": request.app.state.config.RAG_EMBEDDING_ENGINE,
+ "model": request.app.state.config.RAG_EMBEDDING_MODEL,
+ },
}
for doc in docs
]
- # ChromaDB does not like datetime formats
- # for meta-data so convert them to string.
- for metadata in metadatas:
- for key, value in metadata.items():
- if (
- isinstance(value, datetime)
- or isinstance(value, list)
- or isinstance(value, dict)
- ):
- metadata[key] = str(value)
-
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name):
log.info(f"collection {collection_name} already exists")
@@ -1205,7 +1365,7 @@ def save_docs_to_vector_db(
)
return True
- log.info(f"adding to collection {collection_name}")
+ log.info(f"generating embeddings for {collection_name}")
embedding_function = get_embedding_function(
request.app.state.config.RAG_EMBEDDING_ENGINE,
request.app.state.config.RAG_EMBEDDING_MODEL,
@@ -1236,11 +1396,15 @@ def save_docs_to_vector_db(
),
)
- embeddings = embedding_function(
- list(map(lambda x: x.replace("\n", " "), texts)),
- prefix=RAG_EMBEDDING_CONTENT_PREFIX,
- user=user,
+ # Run async embedding in sync context
+ embeddings = asyncio.run(
+ embedding_function(
+ list(map(lambda x: x.replace("\n", " "), texts)),
+ prefix=RAG_EMBEDDING_CONTENT_PREFIX,
+ user=user,
+ )
)
+ log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
items = [
{
@@ -1252,11 +1416,13 @@ def save_docs_to_vector_db(
for idx, text in enumerate(texts)
]
+ log.info(f"adding to collection {collection_name}")
VECTOR_DB_CLIENT.insert(
collection_name=collection_name,
items=items,
)
+ log.info(f"added {len(items)} items to collection {collection_name}")
return True
except Exception as e:
log.exception(e)
@@ -1275,196 +1441,226 @@ def process_file(
form_data: ProcessFileForm,
user=Depends(get_verified_user),
):
- try:
+ """
+ Process a file and save its content to the vector database.
+ """
+ if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id)
+ else:
+ file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
- collection_name = form_data.collection_name
+ if file:
+ try:
- if collection_name is None:
- collection_name = f"file-{file.id}"
+ collection_name = form_data.collection_name
- if form_data.content:
- # Update the content in the file
- # Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
+ if collection_name is None:
+ 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
+ if form_data.content:
+ # Update the content in the file
+ # Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
- docs = [
- Document(
- page_content=form_data.content.replace(" ", "\n"),
- metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
- },
+ 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(
+ page_content=form_data.content.replace(" ", "\n"),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
+
+ text_content = form_data.content
+ elif form_data.collection_name:
+ # Check if the file has already been processed and save the content
+ # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
+
+ result = VECTOR_DB_CLIENT.query(
+ collection_name=f"file-{file.id}", filter={"file_id": file.id}
)
- ]
- text_content = form_data.content
- elif form_data.collection_name:
- # Check if the file has already been processed and save the content
- # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
+ if result is not None and len(result.ids[0]) > 0:
+ docs = [
+ Document(
+ page_content=result.documents[0][idx],
+ metadata=result.metadatas[0][idx],
+ )
+ for idx, id in enumerate(result.ids[0])
+ ]
+ else:
+ docs = [
+ Document(
+ page_content=file.data.get("content", ""),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
- result = VECTOR_DB_CLIENT.query(
- collection_name=f"file-{file.id}", filter={"file_id": file.id}
+ text_content = file.data.get("content", "")
+ else:
+ # Process the file and save the content
+ # Usage: /files/
+ file_path = file.path
+ if file_path:
+ file_path = Storage.get_file(file_path)
+ loader = Loader(
+ engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
+ user=user,
+ DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
+ DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
+ DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
+ 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_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
+ 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_API_KEY=request.app.state.config.DOCLING_API_KEY,
+ DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS,
+ PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
+ DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
+ DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
+ MISTRAL_OCR_API_BASE_URL=request.app.state.config.MISTRAL_OCR_API_BASE_URL,
+ MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
+ MINERU_API_MODE=request.app.state.config.MINERU_API_MODE,
+ MINERU_API_URL=request.app.state.config.MINERU_API_URL,
+ MINERU_API_KEY=request.app.state.config.MINERU_API_KEY,
+ MINERU_PARAMS=request.app.state.config.MINERU_PARAMS,
+ )
+ docs = loader.load(
+ file.filename, file.meta.get("content_type"), file_path
+ )
+
+ docs = [
+ Document(
+ page_content=doc.page_content,
+ metadata={
+ **filter_metadata(doc.metadata),
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ for doc in docs
+ ]
+ else:
+ docs = [
+ Document(
+ page_content=file.data.get("content", ""),
+ metadata={
+ **file.meta,
+ "name": file.filename,
+ "created_by": file.user_id,
+ "file_id": file.id,
+ "source": file.filename,
+ },
+ )
+ ]
+ text_content = " ".join([doc.page_content for doc in docs])
+
+ log.debug(f"text_content: {text_content}")
+ Files.update_file_data_by_id(
+ file.id,
+ {"content": text_content},
)
+ hash = calculate_sha256_string(text_content)
+ Files.update_file_hash_by_id(file.id, hash)
- if result is not None and len(result.ids[0]) > 0:
- docs = [
- Document(
- page_content=result.documents[0][idx],
- metadata=result.metadatas[0][idx],
- )
- for idx, id in enumerate(result.ids[0])
- ]
+ if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
+ Files.update_file_data_by_id(file.id, {"status": "completed"})
+ return {
+ "status": True,
+ "collection_name": None,
+ "filename": file.filename,
+ "content": text_content,
+ }
else:
- docs = [
- Document(
- page_content=file.data.get("content", ""),
+ try:
+ result = save_docs_to_vector_db(
+ request,
+ docs=docs,
+ collection_name=collection_name,
metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
"file_id": file.id,
- "source": file.filename,
- },
- )
- ]
-
- text_content = file.data.get("content", "")
- else:
- # Process the file and save the content
- # Usage: /files/
- file_path = file.path
- if file_path:
- 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_PARAMS={
- "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
- "ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
- "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
- "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
- "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
- "picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
- },
- 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
- )
-
- docs = [
- Document(
- page_content=doc.page_content,
- metadata={
- **doc.metadata,
"name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
+ "hash": hash,
},
+ add=(True if form_data.collection_name else False),
+ user=user,
)
- for doc in docs
- ]
- else:
- docs = [
- Document(
- page_content=file.data.get("content", ""),
- metadata={
- **file.meta,
- "name": file.filename,
- "created_by": file.user_id,
- "file_id": file.id,
- "source": file.filename,
- },
- )
- ]
- text_content = " ".join([doc.page_content for doc in docs])
+ log.info(f"added {len(docs)} items to collection {collection_name}")
- log.debug(f"text_content: {text_content}")
- Files.update_file_data_by_id(
- file.id,
- {"content": text_content},
- )
+ if result:
+ Files.update_file_metadata_by_id(
+ file.id,
+ {
+ "collection_name": collection_name,
+ },
+ )
- hash = calculate_sha256_string(text_content)
- Files.update_file_hash_by_id(file.id, hash)
+ Files.update_file_data_by_id(
+ file.id,
+ {"status": "completed"},
+ )
- 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,
- )
-
- if result:
- Files.update_file_metadata_by_id(
- file.id,
- {
+ return {
+ "status": True,
"collection_name": collection_name,
- },
- )
+ "filename": file.filename,
+ "content": text_content,
+ }
+ else:
+ raise Exception("Error saving document to vector database")
+ except Exception as e:
+ raise e
- 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)
+ Files.update_file_data_by_id(
+ file.id,
+ {"status": "failed"},
+ )
- except Exception as e:
- log.exception(e)
- if "No pandoc was found" in str(e):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
- )
- else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=str(e),
- )
+ if "No pandoc was found" in str(e):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(e),
+ )
+
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
class ProcessTextForm(BaseModel):
@@ -1474,7 +1670,7 @@ class ProcessTextForm(BaseModel):
@router.post("/process/text")
-def process_text(
+async def process_text(
request: Request,
form_data: ProcessTextForm,
user=Depends(get_verified_user),
@@ -1492,7 +1688,9 @@ def process_text(
text_content = form_data.content
log.debug(f"text_content: {text_content}")
- result = save_docs_to_vector_db(request, docs, collection_name, user=user)
+ result = await run_in_threadpool(
+ save_docs_to_vector_db, request, docs, collection_name, user
+ )
if result:
return {
"status": True,
@@ -1507,51 +1705,8 @@ def process_text(
@router.post("/process/youtube")
-def process_youtube_video(
- request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
-):
- try:
- collection_name = form_data.collection_name
- if not collection_name:
- collection_name = calculate_sha256_string(form_data.url)[:63]
-
- loader = YoutubeLoader(
- form_data.url,
- language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
- proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
- )
-
- 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
- )
-
- return {
- "status": True,
- "collection_name": collection_name,
- "filename": form_data.url,
- "file": {
- "data": {
- "content": content,
- },
- "meta": {
- "name": form_data.url,
- },
- },
- }
- except Exception as e:
- log.exception(e)
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ERROR_MESSAGES.DEFAULT(e),
- )
-
-
@router.post("/process/web")
-def process_web(
+async def process_web(
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
):
try:
@@ -1559,19 +1714,14 @@ def process_web(
if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63]
- loader = get_web_loader(
- form_data.url,
- verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
- requests_per_second=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
+ content, docs = await run_in_threadpool(
+ get_content_from_url, request, form_data.url
)
- docs = loader.load()
- content = " ".join([doc.page_content for doc in docs])
-
log.debug(f"text_content: {content}")
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
+ await run_in_threadpool(
+ save_docs_to_vector_db, request, docs, collection_name, True, user
)
else:
collection_name = None
@@ -1598,7 +1748,9 @@ def process_web(
)
-def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
+def search_web(
+ request: Request, engine: str, query: str, user=None
+) -> 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
@@ -1622,7 +1774,27 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
"""
# TODO: add playwright to search the web
- if engine == "searxng":
+ if engine == "ollama_cloud":
+ return search_ollama_cloud(
+ "https://ollama.com",
+ request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
+ query,
+ request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+ request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ )
+ elif engine == "perplexity_search":
+ if request.app.state.config.PERPLEXITY_API_KEY:
+ return search_perplexity_search(
+ 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,
+ request.app.state.config.PERPLEXITY_SEARCH_API_URL,
+ user,
+ )
+ else:
+ raise Exception("No PERPLEXITY_API_KEY found in environment variables")
+ elif engine == "searxng":
if request.app.state.config.SEARXNG_QUERY_URL:
return search_searxng(
request.app.state.config.SEARXNG_QUERY_URL,
@@ -1655,6 +1827,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ referer=request.app.state.config.WEBUI_URL,
)
else:
raise Exception(
@@ -1727,7 +1900,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.SERPLY_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
- request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No SERPLY_API_KEY found in environment variables")
@@ -1736,6 +1909,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
)
elif engine == "tavily":
if request.app.state.config.TAVILY_API_KEY:
@@ -1747,6 +1921,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
else:
raise Exception("No TAVILY_API_KEY found in environment variables")
+ elif engine == "exa":
+ if request.app.state.config.EXA_API_KEY:
+ return search_exa(
+ request.app.state.config.EXA_API_KEY,
+ query,
+ request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+ request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ )
+ else:
+ raise Exception("No EXA_API_KEY found in environment variables")
elif engine == "searchapi":
if request.app.state.config.SEARCHAPI_API_KEY:
return search_searchapi(
@@ -1784,6 +1968,31 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
+ elif engine == "azure":
+ if (
+ request.app.state.config.AZURE_AI_SEARCH_API_KEY
+ and request.app.state.config.AZURE_AI_SEARCH_ENDPOINT
+ and request.app.state.config.AZURE_AI_SEARCH_INDEX_NAME
+ ):
+ return search_azure(
+ request.app.state.config.AZURE_AI_SEARCH_API_KEY,
+ request.app.state.config.AZURE_AI_SEARCH_ENDPOINT,
+ request.app.state.config.AZURE_AI_SEARCH_INDEX_NAME,
+ query,
+ request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+ request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+ )
+ else:
+ raise Exception(
+ "AZURE_AI_SEARCH_API_KEY, AZURE_AI_SEARCH_ENDPOINT, and AZURE_AI_SEARCH_INDEX_NAME are required for Azure AI Search"
+ )
+ elif engine == "exa":
+ return search_exa(
+ request.app.state.config.EXA_API_KEY,
+ query,
+ 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,
@@ -1819,11 +2028,13 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
elif engine == "external":
return search_external(
+ request,
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,
+ user=user,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -1835,8 +2046,10 @@ async def process_web_search(
):
urls = []
+ result_items = []
+
try:
- logging.info(
+ logging.debug(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
)
@@ -1846,6 +2059,7 @@ async def process_web_search(
request,
request.app.state.config.WEB_SEARCH_ENGINE,
query,
+ user,
)
for query in form_data.queries
]
@@ -1856,6 +2070,7 @@ async def process_web_search(
if result:
for item in result:
if item and item.link:
+ result_items.append(item)
urls.append(item.link)
urls = list(dict.fromkeys(urls))
@@ -1869,6 +2084,12 @@ async def process_web_search(
detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
)
+ if len(urls) == 0:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=ERROR_MESSAGES.DEFAULT("No results found from web search"),
+ )
+
try:
if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
search_results = [
@@ -1886,13 +2107,13 @@ async def process_web_search(
},
)
for result in search_results
- if hasattr(result, "snippet")
+ if hasattr(result, "snippet") and result.snippet is not None
]
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,
+ requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.WEB_SEARCH_TRUST_ENV,
)
docs = await loader.aload()
@@ -1900,12 +2121,16 @@ async def process_web_search(
urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader
+ result_items = [
+ dict(item) for item in result_items if item.link in urls
+ ] # only keep the search results that have been loaded
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
"status": True,
"collection_name": None,
"filenames": urls,
+ "items": result_items,
"docs": [
{
"content": doc.page_content,
@@ -1938,6 +2163,7 @@ async def process_web_search(
return {
"status": True,
"collection_names": [collection_name],
+ "items": result_items,
"filenames": urls,
"loaded_count": len(docs),
}
@@ -1959,18 +2185,20 @@ class QueryDocForm(BaseModel):
@router.post("/query/doc")
-def query_doc_handler(
+async def query_doc_handler(
request: Request,
form_data: QueryDocForm,
user=Depends(get_verified_user),
):
try:
- if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
+ if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
+ form_data.hybrid is None or form_data.hybrid
+ ):
collection_results = {}
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
collection_name=form_data.collection_name
)
- return query_doc_with_hybrid_search(
+ return await query_doc_with_hybrid_search(
collection_name=form_data.collection_name,
collection_result=collection_results[form_data.collection_name],
query=form_data.query,
@@ -1978,7 +2206,15 @@ def query_doc_handler(
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,
+ reranking_function=(
+ (
+ lambda query, documents: request.app.state.RERANKING_FUNCTION(
+ query, documents, user=user
+ )
+ )
+ if request.app.state.RERANKING_FUNCTION
+ else None
+ ),
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
@@ -1994,11 +2230,12 @@ def query_doc_handler(
user=user,
)
else:
+ query_embedding = await request.app.state.EMBEDDING_FUNCTION(
+ form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
+ )
return query_doc(
collection_name=form_data.collection_name,
- query_embedding=request.app.state.EMBEDDING_FUNCTION(
- form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
- ),
+ query_embedding=query_embedding,
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user,
)
@@ -2018,24 +2255,35 @@ class QueryCollectionsForm(BaseModel):
r: Optional[float] = None
hybrid: Optional[bool] = None
hybrid_bm25_weight: Optional[float] = None
+ enable_enriched_texts: Optional[bool] = None
@router.post("/query/collection")
-def query_collection_handler(
+async def query_collection_handler(
request: Request,
form_data: QueryCollectionsForm,
user=Depends(get_verified_user),
):
try:
- if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
- return query_collection_with_hybrid_search(
+ if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
+ form_data.hybrid is None or form_data.hybrid
+ ):
+ return await query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
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,
+ reranking_function=(
+ (
+ lambda query, documents: request.app.state.RERANKING_FUNCTION(
+ query, documents, user=user
+ )
+ )
+ if request.app.state.RERANKING_FUNCTION
+ else None
+ ),
k_reranker=form_data.k_reranker
or request.app.state.config.TOP_K_RERANKER,
r=(
@@ -2048,9 +2296,14 @@ def query_collection_handler(
if form_data.hybrid_bm25_weight
else request.app.state.config.HYBRID_BM25_WEIGHT
),
+ enable_enriched_texts=(
+ form_data.enable_enriched_texts
+ if form_data.enable_enriched_texts is not None
+ else request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS
+ ),
)
else:
- return query_collection(
+ return await query_collection(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
@@ -2132,7 +2385,7 @@ 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(
+ "result": await request.app.state.EMBEDDING_FUNCTION(
text, prefix=RAG_EMBEDDING_QUERY_PREFIX
)
}
@@ -2155,7 +2408,7 @@ class BatchProcessFilesResponse(BaseModel):
@router.post("/process/files/batch")
-def process_files_batch(
+async def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
@@ -2163,16 +2416,19 @@ def process_files_batch(
"""
Process a batch of files and save them to the vector database.
"""
- results: List[BatchProcessFilesResult] = []
- errors: List[BatchProcessFilesResult] = []
+
collection_name = form_data.collection_name
+ file_results: List[BatchProcessFilesResult] = []
+ file_errors: List[BatchProcessFilesResult] = []
+ file_updates: List[FileUpdateForm] = []
+
# Prepare all documents first
all_docs: List[Document] = []
+
for file in form_data.files:
try:
text_content = file.data.get("content", "")
-
docs: List[Document] = [
Document(
page_content=text_content.replace(" ", "\n"),
@@ -2186,45 +2442,44 @@ def process_files_batch(
)
]
- hash = calculate_sha256_string(text_content)
- Files.update_file_hash_by_id(file.id, hash)
- Files.update_file_data_by_id(file.id, {"content": text_content})
-
all_docs.extend(docs)
- results.append(BatchProcessFilesResult(file_id=file.id, status="prepared"))
+
+ file_updates.append(
+ FileUpdateForm(
+ hash=calculate_sha256_string(text_content),
+ data={"content": text_content},
+ )
+ )
+ file_results.append(
+ BatchProcessFilesResult(file_id=file.id, status="prepared")
+ )
except Exception as e:
log.error(f"process_files_batch: Error processing file {file.id}: {str(e)}")
- errors.append(
+ file_errors.append(
BatchProcessFilesResult(file_id=file.id, status="failed", error=str(e))
)
# Save all documents in one batch
if all_docs:
try:
- save_docs_to_vector_db(
- request=request,
- docs=all_docs,
- collection_name=collection_name,
- add=True,
- user=user,
+ await run_in_threadpool(
+ save_docs_to_vector_db, request, all_docs, collection_name, True, user
)
# Update all files with collection name
- for result in results:
- Files.update_file_metadata_by_id(
- result.file_id, {"collection_name": collection_name}
- )
- result.status = "completed"
+ for file_update, file_result in zip(file_updates, file_results):
+ Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
+ file_result.status = "completed"
except Exception as e:
log.error(
f"process_files_batch: Error saving documents to vector DB: {str(e)}"
)
- for result in results:
- result.status = "failed"
- errors.append(
- BatchProcessFilesResult(file_id=result.file_id, error=str(e))
+ for file_result in file_results:
+ file_result.status = "failed"
+ file_errors.append(
+ BatchProcessFilesResult(file_id=file_result.file_id, error=str(e))
)
- return BatchProcessFilesResponse(results=results, errors=errors)
+ return BatchProcessFilesResponse(results=file_results, errors=file_errors)
diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py
new file mode 100644
index 0000000000..b5d0e029ec
--- /dev/null
+++ b/backend/open_webui/routers/scim.py
@@ -0,0 +1,931 @@
+"""
+Experimental SCIM 2.0 Implementation for Open WebUI
+Provides System for Cross-domain Identity Management endpoints for users and groups
+
+NOTE: This is an experimental implementation and may not fully comply with SCIM 2.0 standards, and is subject to change.
+"""
+
+import logging
+import uuid
+import time
+from typing import Optional, List, Dict, Any
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header, status
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field, ConfigDict
+
+from open_webui.models.users import Users, UserModel
+from open_webui.models.groups import Groups, GroupModel
+from open_webui.utils.auth import (
+ get_admin_user,
+ get_current_user,
+ decode_token,
+ get_verified_user,
+)
+from open_webui.constants import ERROR_MESSAGES
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MAIN"])
+
+router = APIRouter()
+
+# SCIM 2.0 Schema URIs
+SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
+SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
+SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
+SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error"
+
+# SCIM Resource Types
+SCIM_RESOURCE_TYPE_USER = "User"
+SCIM_RESOURCE_TYPE_GROUP = "Group"
+
+
+def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None):
+ """Create a SCIM-compliant error response"""
+ error_body = {
+ "schemas": [SCIM_ERROR_SCHEMA],
+ "status": str(status_code),
+ "detail": detail,
+ }
+
+ if scim_type:
+ error_body["scimType"] = scim_type
+ elif status_code == 404:
+ error_body["scimType"] = "invalidValue"
+ elif status_code == 409:
+ error_body["scimType"] = "uniqueness"
+ elif status_code == 400:
+ error_body["scimType"] = "invalidSyntax"
+
+ return JSONResponse(status_code=status_code, content=error_body)
+
+
+class SCIMError(BaseModel):
+ """SCIM Error Response"""
+
+ schemas: List[str] = [SCIM_ERROR_SCHEMA]
+ status: str
+ scimType: Optional[str] = None
+ detail: Optional[str] = None
+
+
+class SCIMMeta(BaseModel):
+ """SCIM Resource Metadata"""
+
+ resourceType: str
+ created: str
+ lastModified: str
+ location: Optional[str] = None
+ version: Optional[str] = None
+
+
+class SCIMName(BaseModel):
+ """SCIM User Name"""
+
+ formatted: Optional[str] = None
+ familyName: Optional[str] = None
+ givenName: Optional[str] = None
+ middleName: Optional[str] = None
+ honorificPrefix: Optional[str] = None
+ honorificSuffix: Optional[str] = None
+
+
+class SCIMEmail(BaseModel):
+ """SCIM Email"""
+
+ value: str
+ type: Optional[str] = "work"
+ primary: bool = True
+ display: Optional[str] = None
+
+
+class SCIMPhoto(BaseModel):
+ """SCIM Photo"""
+
+ value: str
+ type: Optional[str] = "photo"
+ primary: bool = True
+ display: Optional[str] = None
+
+
+class SCIMGroupMember(BaseModel):
+ """SCIM Group Member"""
+
+ value: str # User ID
+ ref: Optional[str] = Field(None, alias="$ref")
+ type: Optional[str] = "User"
+ display: Optional[str] = None
+
+
+class SCIMUser(BaseModel):
+ """SCIM User Resource"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_USER_SCHEMA]
+ id: str
+ externalId: Optional[str] = None
+ userName: str
+ name: Optional[SCIMName] = None
+ displayName: str
+ emails: List[SCIMEmail]
+ active: bool = True
+ photos: Optional[List[SCIMPhoto]] = None
+ groups: Optional[List[Dict[str, str]]] = None
+ meta: SCIMMeta
+
+
+class SCIMUserCreateRequest(BaseModel):
+ """SCIM User Create Request"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_USER_SCHEMA]
+ externalId: Optional[str] = None
+ userName: str
+ name: Optional[SCIMName] = None
+ displayName: str
+ emails: List[SCIMEmail]
+ active: bool = True
+ password: Optional[str] = None
+ photos: Optional[List[SCIMPhoto]] = None
+
+
+class SCIMUserUpdateRequest(BaseModel):
+ """SCIM User Update Request"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_USER_SCHEMA]
+ id: Optional[str] = None
+ externalId: Optional[str] = None
+ userName: Optional[str] = None
+ name: Optional[SCIMName] = None
+ displayName: Optional[str] = None
+ emails: Optional[List[SCIMEmail]] = None
+ active: Optional[bool] = None
+ photos: Optional[List[SCIMPhoto]] = None
+
+
+class SCIMGroup(BaseModel):
+ """SCIM Group Resource"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_GROUP_SCHEMA]
+ id: str
+ displayName: str
+ members: Optional[List[SCIMGroupMember]] = []
+ meta: SCIMMeta
+
+
+class SCIMGroupCreateRequest(BaseModel):
+ """SCIM Group Create Request"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_GROUP_SCHEMA]
+ displayName: str
+ members: Optional[List[SCIMGroupMember]] = []
+
+
+class SCIMGroupUpdateRequest(BaseModel):
+ """SCIM Group Update Request"""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ schemas: List[str] = [SCIM_GROUP_SCHEMA]
+ displayName: Optional[str] = None
+ members: Optional[List[SCIMGroupMember]] = None
+
+
+class SCIMListResponse(BaseModel):
+ """SCIM List Response"""
+
+ schemas: List[str] = [SCIM_LIST_RESPONSE_SCHEMA]
+ totalResults: int
+ itemsPerPage: int
+ startIndex: int
+ Resources: List[Any]
+
+
+class SCIMPatchOperation(BaseModel):
+ """SCIM Patch Operation"""
+
+ op: str # "add", "replace", "remove"
+ path: Optional[str] = None
+ value: Optional[Any] = None
+
+
+class SCIMPatchRequest(BaseModel):
+ """SCIM Patch Request"""
+
+ schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]
+ Operations: List[SCIMPatchOperation]
+
+
+def get_scim_auth(
+ request: Request, authorization: Optional[str] = Header(None)
+) -> bool:
+ """
+ Verify SCIM authentication
+ Checks for SCIM-specific bearer token configured in the system
+ """
+ if not authorization:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authorization header required",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ try:
+ parts = authorization.split()
+ if len(parts) != 2:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authorization format. Expected: Bearer ",
+ )
+
+ scheme, token = parts
+ if scheme.lower() != "bearer":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid authentication scheme",
+ )
+
+ # Check if SCIM is enabled
+ enable_scim = getattr(request.app.state, "ENABLE_SCIM", False)
+ log.info(
+ f"SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}"
+ )
+
+ # Handle both PersistentConfig and direct value
+ if hasattr(enable_scim, "value"):
+ enable_scim = enable_scim.value
+
+ if not enable_scim:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="SCIM is not enabled",
+ )
+
+ # Verify the SCIM token
+ scim_token = getattr(request.app.state, "SCIM_TOKEN", None)
+ # Handle both PersistentConfig and direct value
+ if hasattr(scim_token, "value"):
+ scim_token = scim_token.value
+ log.debug(f"SCIM token configured: {bool(scim_token)}")
+ if not scim_token or token != scim_token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid SCIM token",
+ )
+
+ return True
+ except HTTPException:
+ # Re-raise HTTP exceptions as-is
+ raise
+ except Exception as e:
+ log.error(f"SCIM authentication error: {e}")
+ import traceback
+
+ log.error(f"Traceback: {traceback.format_exc()}")
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Authentication failed",
+ )
+
+
+def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
+ """Convert internal User model to SCIM User"""
+ # Parse display name into name components
+ name_parts = user.name.split(" ", 1) if user.name else ["", ""]
+ given_name = name_parts[0] if name_parts else ""
+ family_name = name_parts[1] if len(name_parts) > 1 else ""
+
+ # Get user's groups
+ user_groups = Groups.get_groups_by_member_id(user.id)
+ groups = [
+ {
+ "value": group.id,
+ "display": group.name,
+ "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
+ "type": "direct",
+ }
+ for group in user_groups
+ ]
+
+ return SCIMUser(
+ id=user.id,
+ userName=user.email,
+ name=SCIMName(
+ formatted=user.name,
+ givenName=given_name,
+ familyName=family_name,
+ ),
+ displayName=user.name,
+ emails=[SCIMEmail(value=user.email)],
+ active=user.role != "pending",
+ photos=(
+ [SCIMPhoto(value=user.profile_image_url)]
+ if user.profile_image_url
+ else None
+ ),
+ groups=groups if groups else None,
+ meta=SCIMMeta(
+ resourceType=SCIM_RESOURCE_TYPE_USER,
+ created=datetime.fromtimestamp(
+ user.created_at, tz=timezone.utc
+ ).isoformat(),
+ lastModified=datetime.fromtimestamp(
+ user.updated_at, tz=timezone.utc
+ ).isoformat(),
+ location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
+ ),
+ )
+
+
+def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
+ """Convert internal Group model to SCIM Group"""
+ member_ids = Groups.get_group_user_ids_by_id(group.id)
+ members = []
+
+ for user_id in member_ids:
+ user = Users.get_user_by_id(user_id)
+ if user:
+ members.append(
+ SCIMGroupMember(
+ value=user.id,
+ ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}",
+ display=user.name,
+ )
+ )
+
+ return SCIMGroup(
+ id=group.id,
+ displayName=group.name,
+ members=members,
+ meta=SCIMMeta(
+ resourceType=SCIM_RESOURCE_TYPE_GROUP,
+ created=datetime.fromtimestamp(
+ group.created_at, tz=timezone.utc
+ ).isoformat(),
+ lastModified=datetime.fromtimestamp(
+ group.updated_at, tz=timezone.utc
+ ).isoformat(),
+ location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}",
+ ),
+ )
+
+
+# SCIM Service Provider Config
+@router.get("/ServiceProviderConfig")
+async def get_service_provider_config():
+ """Get SCIM Service Provider Configuration"""
+ return {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
+ "patch": {"supported": True},
+ "bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576},
+ "filter": {"supported": True, "maxResults": 200},
+ "changePassword": {"supported": False},
+ "sort": {"supported": False},
+ "etag": {"supported": False},
+ "authenticationSchemes": [
+ {
+ "type": "oauthbearertoken",
+ "name": "OAuth Bearer Token",
+ "description": "Authentication using OAuth 2.0 Bearer Token",
+ }
+ ],
+ }
+
+
+# SCIM Resource Types
+@router.get("/ResourceTypes")
+async def get_resource_types(request: Request):
+ """Get SCIM Resource Types"""
+ return [
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
+ "id": "User",
+ "name": "User",
+ "endpoint": "/Users",
+ "schema": SCIM_USER_SCHEMA,
+ "meta": {
+ "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User",
+ "resourceType": "ResourceType",
+ },
+ },
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
+ "id": "Group",
+ "name": "Group",
+ "endpoint": "/Groups",
+ "schema": SCIM_GROUP_SCHEMA,
+ "meta": {
+ "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group",
+ "resourceType": "ResourceType",
+ },
+ },
+ ]
+
+
+# SCIM Schemas
+@router.get("/Schemas")
+async def get_schemas():
+ """Get SCIM Schemas"""
+ return [
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
+ "id": SCIM_USER_SCHEMA,
+ "name": "User",
+ "description": "User Account",
+ "attributes": [
+ {
+ "name": "userName",
+ "type": "string",
+ "required": True,
+ "uniqueness": "server",
+ },
+ {"name": "displayName", "type": "string", "required": True},
+ {
+ "name": "emails",
+ "type": "complex",
+ "multiValued": True,
+ "required": True,
+ },
+ {"name": "active", "type": "boolean", "required": False},
+ ],
+ },
+ {
+ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
+ "id": SCIM_GROUP_SCHEMA,
+ "name": "Group",
+ "description": "Group",
+ "attributes": [
+ {"name": "displayName", "type": "string", "required": True},
+ {
+ "name": "members",
+ "type": "complex",
+ "multiValued": True,
+ "required": False,
+ },
+ ],
+ },
+ ]
+
+
+# Users endpoints
+@router.get("/Users", response_model=SCIMListResponse)
+async def get_users(
+ request: Request,
+ startIndex: int = Query(1, ge=1),
+ count: int = Query(20, ge=1, le=100),
+ filter: Optional[str] = None,
+ _: bool = Depends(get_scim_auth),
+):
+ """List SCIM Users"""
+ skip = startIndex - 1
+ limit = count
+
+ # Get users from database
+ if filter:
+ # Simple filter parsing - supports userName eq "email"
+ # In production, you'd want a more robust filter parser
+ if "userName eq" in filter:
+ email = filter.split('"')[1]
+ user = Users.get_user_by_email(email)
+ users_list = [user] if user else []
+ total = 1 if user else 0
+ else:
+ response = Users.get_users(skip=skip, limit=limit)
+ users_list = response["users"]
+ total = response["total"]
+ else:
+ response = Users.get_users(skip=skip, limit=limit)
+ users_list = response["users"]
+ total = response["total"]
+
+ # Convert to SCIM format
+ scim_users = [user_to_scim(user, request) for user in users_list]
+
+ return SCIMListResponse(
+ totalResults=total,
+ itemsPerPage=len(scim_users),
+ startIndex=startIndex,
+ Resources=scim_users,
+ )
+
+
+@router.get("/Users/{user_id}", response_model=SCIMUser)
+async def get_user(
+ user_id: str,
+ request: Request,
+ _: bool = Depends(get_scim_auth),
+):
+ """Get SCIM User by ID"""
+ user = Users.get_user_by_id(user_id)
+ if not user:
+ return scim_error(
+ status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
+ )
+
+ return user_to_scim(user, request)
+
+
+@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
+async def create_user(
+ request: Request,
+ user_data: SCIMUserCreateRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Create SCIM User"""
+ # Check if user already exists
+ existing_user = Users.get_user_by_email(user_data.userName)
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"User with email {user_data.userName} already exists",
+ )
+
+ # Create user
+ user_id = str(uuid.uuid4())
+ email = user_data.emails[0].value if user_data.emails else user_data.userName
+
+ # Parse name if provided
+ name = user_data.displayName
+ if user_data.name:
+ if user_data.name.formatted:
+ name = user_data.name.formatted
+ elif user_data.name.givenName or user_data.name.familyName:
+ name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
+
+ # Get profile image if provided
+ profile_image = "/user.png"
+ if user_data.photos and len(user_data.photos) > 0:
+ profile_image = user_data.photos[0].value
+
+ # Create user
+ new_user = Users.insert_new_user(
+ id=user_id,
+ name=name,
+ email=email,
+ profile_image_url=profile_image,
+ role="user" if user_data.active else "pending",
+ )
+
+ if not new_user:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to create user",
+ )
+
+ return user_to_scim(new_user, request)
+
+
+@router.put("/Users/{user_id}", response_model=SCIMUser)
+async def update_user(
+ user_id: str,
+ request: Request,
+ user_data: SCIMUserUpdateRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Update SCIM User (full update)"""
+ user = Users.get_user_by_id(user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"User {user_id} not found",
+ )
+
+ # Build update dict
+ update_data = {}
+
+ if user_data.userName:
+ update_data["email"] = user_data.userName
+
+ if user_data.displayName:
+ update_data["name"] = user_data.displayName
+ elif user_data.name:
+ if user_data.name.formatted:
+ update_data["name"] = user_data.name.formatted
+ elif user_data.name.givenName or user_data.name.familyName:
+ update_data["name"] = (
+ f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip()
+ )
+
+ if user_data.emails and len(user_data.emails) > 0:
+ update_data["email"] = user_data.emails[0].value
+
+ if user_data.active is not None:
+ update_data["role"] = "user" if user_data.active else "pending"
+
+ if user_data.photos and len(user_data.photos) > 0:
+ update_data["profile_image_url"] = user_data.photos[0].value
+
+ # Update user
+ updated_user = Users.update_user_by_id(user_id, update_data)
+ if not updated_user:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update user",
+ )
+
+ return user_to_scim(updated_user, request)
+
+
+@router.patch("/Users/{user_id}", response_model=SCIMUser)
+async def patch_user(
+ user_id: str,
+ request: Request,
+ patch_data: SCIMPatchRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Update SCIM User (partial update)"""
+ user = Users.get_user_by_id(user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"User {user_id} not found",
+ )
+
+ update_data = {}
+
+ for operation in patch_data.Operations:
+ op = operation.op.lower()
+ path = operation.path
+ value = operation.value
+
+ if op == "replace":
+ if path == "active":
+ update_data["role"] = "user" if value else "pending"
+ elif path == "userName":
+ update_data["email"] = value
+ elif path == "displayName":
+ update_data["name"] = value
+ elif path == "emails[primary eq true].value":
+ update_data["email"] = value
+ elif path == "name.formatted":
+ update_data["name"] = value
+
+ # Update user
+ if update_data:
+ updated_user = Users.update_user_by_id(user_id, update_data)
+ if not updated_user:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update user",
+ )
+ else:
+ updated_user = user
+
+ return user_to_scim(updated_user, request)
+
+
+@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_user(
+ user_id: str,
+ request: Request,
+ _: bool = Depends(get_scim_auth),
+):
+ """Delete SCIM User"""
+ user = Users.get_user_by_id(user_id)
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"User {user_id} not found",
+ )
+
+ success = Users.delete_user_by_id(user_id)
+ if not success:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to delete user",
+ )
+
+ return None
+
+
+# Groups endpoints
+@router.get("/Groups", response_model=SCIMListResponse)
+async def get_groups(
+ request: Request,
+ startIndex: int = Query(1, ge=1),
+ count: int = Query(20, ge=1, le=100),
+ filter: Optional[str] = None,
+ _: bool = Depends(get_scim_auth),
+):
+ """List SCIM Groups"""
+ # Get all groups
+ groups_list = Groups.get_groups()
+
+ # Apply pagination
+ total = len(groups_list)
+ start = startIndex - 1
+ end = start + count
+ paginated_groups = groups_list[start:end]
+
+ # Convert to SCIM format
+ scim_groups = [group_to_scim(group, request) for group in paginated_groups]
+
+ return SCIMListResponse(
+ totalResults=total,
+ itemsPerPage=len(scim_groups),
+ startIndex=startIndex,
+ Resources=scim_groups,
+ )
+
+
+@router.get("/Groups/{group_id}", response_model=SCIMGroup)
+async def get_group(
+ group_id: str,
+ request: Request,
+ _: bool = Depends(get_scim_auth),
+):
+ """Get SCIM Group by ID"""
+ group = Groups.get_group_by_id(group_id)
+ if not group:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Group {group_id} not found",
+ )
+
+ return group_to_scim(group, request)
+
+
+@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
+async def create_group(
+ request: Request,
+ group_data: SCIMGroupCreateRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Create SCIM Group"""
+ # Extract member IDs
+ member_ids = []
+ if group_data.members:
+ for member in group_data.members:
+ member_ids.append(member.value)
+
+ # Create group
+ from open_webui.models.groups import GroupForm
+
+ form = GroupForm(
+ name=group_data.displayName,
+ description="",
+ )
+
+ # Need to get the creating user's ID - we'll use the first admin
+ admin_user = Users.get_super_admin_user()
+ if not admin_user:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="No admin user found",
+ )
+
+ new_group = Groups.insert_new_group(admin_user.id, form)
+ if not new_group:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to create group",
+ )
+
+ # Add members if provided
+ if member_ids:
+ from open_webui.models.groups import GroupUpdateForm
+
+ update_form = GroupUpdateForm(
+ name=new_group.name,
+ description=new_group.description,
+ )
+
+ Groups.update_group_by_id(new_group.id, update_form)
+ Groups.set_group_user_ids_by_id(new_group.id, member_ids)
+
+ new_group = Groups.get_group_by_id(new_group.id)
+
+ return group_to_scim(new_group, request)
+
+
+@router.put("/Groups/{group_id}", response_model=SCIMGroup)
+async def update_group(
+ group_id: str,
+ request: Request,
+ group_data: SCIMGroupUpdateRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Update SCIM Group (full update)"""
+ group = Groups.get_group_by_id(group_id)
+ if not group:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Group {group_id} not found",
+ )
+
+ # Build update form
+ from open_webui.models.groups import GroupUpdateForm
+
+ update_form = GroupUpdateForm(
+ name=group_data.displayName if group_data.displayName else group.name,
+ description=group.description,
+ )
+
+ # Handle members if provided
+ if group_data.members is not None:
+ member_ids = [member.value for member in group_data.members]
+ Groups.set_group_user_ids_by_id(group_id, member_ids)
+
+ # Update group
+ updated_group = Groups.update_group_by_id(group_id, update_form)
+ if not updated_group:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update group",
+ )
+
+ return group_to_scim(updated_group, request)
+
+
+@router.patch("/Groups/{group_id}", response_model=SCIMGroup)
+async def patch_group(
+ group_id: str,
+ request: Request,
+ patch_data: SCIMPatchRequest,
+ _: bool = Depends(get_scim_auth),
+):
+ """Update SCIM Group (partial update)"""
+ group = Groups.get_group_by_id(group_id)
+ if not group:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Group {group_id} not found",
+ )
+
+ from open_webui.models.groups import GroupUpdateForm
+
+ update_form = GroupUpdateForm(
+ name=group.name,
+ description=group.description,
+ )
+
+ for operation in patch_data.Operations:
+ op = operation.op.lower()
+ path = operation.path
+ value = operation.value
+
+ if op == "replace":
+ if path == "displayName":
+ update_form.name = value
+ elif path == "members":
+ # Replace all members
+ Groups.set_group_user_ids_by_id(
+ group_id, [member["value"] for member in value]
+ )
+
+ elif op == "add":
+ if path == "members":
+ # Add members
+ if isinstance(value, list):
+ for member in value:
+ if isinstance(member, dict) and "value" in member:
+ Groups.add_users_to_group(group_id, [member["value"]])
+ elif op == "remove":
+ if path and path.startswith("members[value eq"):
+ # Remove specific member
+ member_id = path.split('"')[1]
+ Groups.remove_users_from_group(group_id, [member_id])
+
+ # Update group
+ updated_group = Groups.update_group_by_id(group_id, update_form)
+ if not updated_group:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to update group",
+ )
+
+ return group_to_scim(updated_group, request)
+
+
+@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_group(
+ group_id: str,
+ request: Request,
+ _: bool = Depends(get_scim_auth),
+):
+ """Delete SCIM Group"""
+ group = Groups.get_group_by_id(group_id)
+ if not group:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Group {group_id} not found",
+ )
+
+ success = Groups.delete_group_by_id(group_id)
+ if not success:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to delete group",
+ )
+
+ return None
diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py
index 3832c0306b..040c2382d7 100644
--- a/backend/open_webui/routers/tasks.py
+++ b/backend/open_webui/routers/tasks.py
@@ -33,6 +33,7 @@ from open_webui.config import (
DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE,
DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE,
+ DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -68,6 +69,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
+ "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@@ -87,6 +89,7 @@ class TaskConfigForm(BaseModel):
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
QUERY_GENERATION_PROMPT_TEMPLATE: str
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
+ VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
@router.post("/config/update")
@@ -136,6 +139,10 @@ async def update_task_config(
form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
)
+ request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = (
+ form_data.VOICE_MODE_PROMPT_TEMPLATE
+ )
+
return {
"TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
@@ -152,6 +159,7 @@ async def update_task_config(
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
+ "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@@ -198,14 +206,7 @@ async def generate_title(
else:
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
- content = title_generation_template(
- template,
- form_data["messages"],
- {
- "name": user.name,
- "location": user.info.get("location") if user.info else None,
- },
- )
+ content = title_generation_template(template, form_data["messages"], user)
max_tokens = (
models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000)
@@ -289,14 +290,7 @@ async def generate_follow_ups(
else:
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
- content = follow_up_generation_template(
- template,
- form_data["messages"],
- {
- "name": user.name,
- "location": user.info.get("location") if user.info else None,
- },
- )
+ content = follow_up_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -369,9 +363,7 @@ async def generate_chat_tags(
else:
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
- content = tags_generation_template(
- template, form_data["messages"], {"name": user.name}
- )
+ content = tags_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -437,13 +429,7 @@ async def generate_image_prompt(
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
- content = image_prompt_generation_template(
- template,
- form_data["messages"],
- user={
- "name": user.name,
- },
- )
+ content = image_prompt_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -492,6 +478,10 @@ async def generate_queries(
detail=f"Query generation is disabled",
)
+ if getattr(request.state, "cached_queries", None):
+ log.info(f"Reusing cached queries: {request.state.cached_queries}")
+ return request.state.cached_queries
+
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
@@ -524,9 +514,7 @@ async def generate_queries(
else:
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
- content = query_generation_template(
- template, form_data["messages"], {"name": user.name}
- )
+ content = query_generation_template(template, form_data["messages"], user)
payload = {
"model": task_model_id,
@@ -611,9 +599,7 @@ async def generate_autocompletion(
else:
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
- content = autocomplete_generation_template(
- template, prompt, messages, type, {"name": user.name}
- )
+ content = autocomplete_generation_template(template, prompt, messages, type, user)
payload = {
"model": task_model_id,
@@ -675,14 +661,7 @@ async def generate_emoji(
template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE
- content = emoji_generation_template(
- template,
- form_data["prompt"],
- {
- "name": user.name,
- "location": user.info.get("location") if user.info else None,
- },
- )
+ content = emoji_generation_template(template, form_data["prompt"], user)
payload = {
"model": task_model_id,
@@ -695,11 +674,11 @@ async def generate_emoji(
"max_completion_tokens": 4,
}
),
- "chat_id": form_data.get("chat_id", None),
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.EMOJI_GENERATION),
"task_body": form_data,
+ "chat_id": form_data.get("chat_id", None),
},
}
diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py
index f726368eba..aa8d95943a 100644
--- a/backend/open_webui/routers/tools.py
+++ b/backend/open_webui/routers/tools.py
@@ -4,8 +4,12 @@ from typing import Optional
import time
import re
import aiohttp
+from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.tools import (
ToolForm,
ToolModel,
@@ -13,16 +17,19 @@ from open_webui.models.tools import (
ToolUserResponse,
Tools,
)
-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.plugin import (
+ load_tool_module_by_id,
+ replace_imports,
+ get_tool_module_from_cache,
+)
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
-from open_webui.utils.tools import get_tool_servers_data
+from open_webui.env import SRC_LOG_LEVELS
+from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
+from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
@@ -31,6 +38,15 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter()
+
+def get_tool_module(request, tool_id, load_from_db=True):
+ """
+ Get the tool module by its ID.
+ """
+ tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
+ return tool_module
+
+
############################
# GetTools
############################
@@ -38,23 +54,27 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)):
+ tools = []
- 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:
+ # Local Tools
+ for tool in Tools.get_tools():
+ tool_module = get_tool_module(request, tool.id)
tools.append(
ToolUserResponse(
**{
- "id": f"server:{server['idx']}",
- "user_id": f"server:{server['idx']}",
+ **tool.model_dump(),
+ "has_user_valves": hasattr(tool_module, "UserValves"),
+ }
+ )
+ )
+
+ # OpenAPI Tool Servers
+ for server in await get_tool_servers(request):
+ tools.append(
+ ToolUserResponse(
+ **{
+ "id": f"server:{server.get('id')}",
+ "user_id": f"server:{server.get('id')}",
"name": server.get("openapi", {})
.get("info", {})
.get("title", "Tool Server"),
@@ -64,7 +84,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
.get("description", ""),
},
"access_control": request.app.state.config.TOOL_SERVER_CONNECTIONS[
- server["idx"]
+ server.get("idx", 0)
]
.get("config", {})
.get("access_control", None),
@@ -74,15 +94,62 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
)
)
- if user.role != "admin":
+ # MCP Tool Servers
+ for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+ if server.get("type", "openapi") == "mcp":
+ server_id = server.get("info", {}).get("id")
+ auth_type = server.get("auth_type", "none")
+
+ session_token = None
+ if auth_type == "oauth_2.1":
+ splits = server_id.split(":")
+ server_id = splits[-1] if len(splits) > 1 else server_id
+
+ session_token = (
+ await request.app.state.oauth_client_manager.get_oauth_token(
+ user.id, f"mcp:{server_id}"
+ )
+ )
+
+ tools.append(
+ ToolUserResponse(
+ **{
+ "id": f"server:mcp:{server.get('info', {}).get('id')}",
+ "user_id": f"server:mcp:{server.get('info', {}).get('id')}",
+ "name": server.get("info", {}).get("name", "MCP Tool Server"),
+ "meta": {
+ "description": server.get("info", {}).get(
+ "description", ""
+ ),
+ },
+ "access_control": server.get("config", {}).get(
+ "access_control", None
+ ),
+ "updated_at": int(time.time()),
+ "created_at": int(time.time()),
+ **(
+ {
+ "authenticated": session_token is not None,
+ }
+ if auth_type == "oauth_2.1"
+ else {}
+ ),
+ }
+ )
+ )
+
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
+ # Admin can see all tools
+ return tools
+ else:
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
tools = [
tool
for tool in tools
if tool.user_id == user.id
- or has_access(user.id, "read", tool.access_control)
+ or has_access(user.id, "read", tool.access_control, user_group_ids)
]
-
- return tools
+ return tools
############################
@@ -92,7 +159,7 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
@router.get("/list", response_model=list[ToolUserResponse])
async def get_tool_list(user=Depends(get_verified_user)):
- if user.role == "admin":
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools()
else:
tools = Tools.get_tools_by_user_id(user.id, "write")
@@ -153,7 +220,7 @@ async def load_tool_from_url(
)
try:
- async with aiohttp.ClientSession() as session:
+ async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
url, headers={"Content-Type": "application/json"}
) as resp:
@@ -180,9 +247,19 @@ async def load_tool_from_url(
@router.get("/export", response_model=list[ToolModel])
-async def export_tools(user=Depends(get_admin_user)):
- tools = Tools.get_tools()
- return tools
+async def export_tools(request: Request, user=Depends(get_verified_user)):
+ if user.role != "admin" and not has_permission(
+ user.id, "workspace.tools_export", request.app.state.config.USER_PERMISSIONS
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+
+ if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
+ return Tools.get_tools()
+ else:
+ return Tools.get_tools_by_user_id(user.id, "read")
############################
@@ -196,8 +273,13 @@ async def create_new_tools(
form_data: ToolForm,
user=Depends(get_verified_user),
):
- if user.role != "admin" and not has_permission(
- user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
+ if user.role != "admin" and not (
+ has_permission(
+ user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
+ )
+ or has_permission(
+ user.id, "workspace.tools_import", request.app.state.config.USER_PERMISSIONS
+ )
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -465,8 +547,9 @@ async def update_tools_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data)
- Tools.update_tool_valves_by_id(id, valves.model_dump())
- return valves.model_dump()
+ valves_dict = valves.model_dump(exclude_unset=True)
+ Tools.update_tool_valves_by_id(id, valves_dict)
+ return valves_dict
except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException(
@@ -541,10 +624,11 @@ async def update_tools_user_valves_by_id(
try:
form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data)
+ user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id(
- id, user.id, user_valves.model_dump()
+ id, user.id, user_valves_dict
)
- return user_valves.model_dump()
+ return user_valves_dict
except Exception as e:
log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException(
diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py
index 16cc2c375f..0b44e4319a 100644
--- a/backend/open_webui/routers/users.py
+++ b/backend/open_webui/routers/users.py
@@ -1,12 +1,25 @@
import logging
from typing import Optional
+import base64
+import io
+
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi.responses import Response, StreamingResponse, FileResponse
+from pydantic import BaseModel
+
from open_webui.models.auths import Auths
+from open_webui.models.oauth_sessions import OAuthSessions
+
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
UserModel,
- UserListResponse,
+ UserGroupIdsModel,
+ UserGroupIdsListResponse,
+ UserInfoListResponse,
+ UserIdNameListResponse,
UserRoleUpdateForm,
Users,
UserSettings,
@@ -20,11 +33,15 @@ from open_webui.socket.main import (
get_user_active_status,
)
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.env import SRC_LOG_LEVELS, STATIC_DIR
-from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
+
+from open_webui.utils.auth import (
+ get_admin_user,
+ get_password_hash,
+ get_verified_user,
+ validate_password,
+)
from open_webui.utils.access_control import get_permissions, has_permission
@@ -59,7 +76,7 @@ async def get_active_users(
PAGE_ITEM_COUNT = 30
-@router.get("/", response_model=UserListResponse)
+@router.get("/", response_model=UserGroupIdsListResponse)
async def get_users(
query: Optional[str] = None,
order_by: Optional[str] = None,
@@ -80,16 +97,51 @@ async def get_users(
if direction:
filter["direction"] = direction
- return Users.get_users(filter=filter, skip=skip, limit=limit)
+ result = Users.get_users(filter=filter, skip=skip, limit=limit)
+
+ users = result["users"]
+ total = result["total"]
+
+ return {
+ "users": [
+ UserGroupIdsModel(
+ **{
+ **user.model_dump(),
+ "group_ids": [
+ group.id for group in Groups.get_groups_by_member_id(user.id)
+ ],
+ }
+ )
+ for user in users
+ ],
+ "total": total,
+ }
-@router.get("/all", response_model=UserListResponse)
+@router.get("/all", response_model=UserInfoListResponse)
async def get_all_users(
user=Depends(get_admin_user),
):
return Users.get_users()
+@router.get("/search", response_model=UserIdNameListResponse)
+async def search_users(
+ query: Optional[str] = None,
+ user=Depends(get_verified_user),
+):
+ limit = PAGE_ITEM_COUNT
+
+ page = 1 # Always return the first page for search
+ skip = (page - 1) * limit
+
+ filter = {}
+ if query:
+ filter["query"] = query
+
+ return Users.get_users(filter=filter, skip=skip, limit=limit)
+
+
############################
# User Groups
############################
@@ -122,20 +174,38 @@ class WorkspacePermissions(BaseModel):
knowledge: bool = False
prompts: bool = False
tools: bool = False
+ models_import: bool = False
+ models_export: bool = False
+ prompts_import: bool = False
+ prompts_export: bool = False
+ tools_import: bool = False
+ tools_export: bool = False
class SharingPermissions(BaseModel):
- public_models: bool = True
- public_knowledge: bool = True
- public_prompts: bool = True
+ models: bool = False
+ public_models: bool = False
+ knowledge: bool = False
+ public_knowledge: bool = False
+ prompts: bool = False
+ public_prompts: bool = False
+ tools: bool = False
public_tools: bool = True
+ notes: bool = False
+ public_notes: bool = True
class ChatPermissions(BaseModel):
controls: bool = True
+ valves: bool = True
system_prompt: bool = True
+ params: bool = True
file_upload: bool = True
delete: bool = True
+ delete_message: bool = True
+ continue_response: bool = True
+ regenerate_response: bool = True
+ rate_response: bool = True
edit: bool = True
share: bool = True
export: bool = True
@@ -148,6 +218,7 @@ class ChatPermissions(BaseModel):
class FeaturesPermissions(BaseModel):
+ api_keys: bool = False
direct_tool_servers: bool = False
web_search: bool = True
image_generation: bool = True
@@ -326,6 +397,55 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
)
+@router.get("/{user_id}/oauth/sessions")
+async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
+ sessions = OAuthSessions.get_sessions_by_user_id(user_id)
+ if sessions and len(sessions) > 0:
+ return sessions
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.USER_NOT_FOUND,
+ )
+
+
+############################
+# GetUserProfileImageById
+############################
+
+
+@router.get("/{user_id}/profile/image")
+async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
+ user = Users.get_user_by_id(user_id)
+ if user:
+ if user.profile_image_url:
+ # check if it's url or base64
+ if user.profile_image_url.startswith("http"):
+ return Response(
+ status_code=status.HTTP_302_FOUND,
+ headers={"Location": user.profile_image_url},
+ )
+ elif user.profile_image_url.startswith("data:image"):
+ try:
+ header, base64_data = user.profile_image_url.split(",", 1)
+ image_data = base64.b64decode(base64_data)
+ image_buffer = io.BytesIO(image_data)
+
+ return StreamingResponse(
+ image_buffer,
+ media_type="image/png",
+ headers={"Content-Disposition": "inline; filename=image.png"},
+ )
+ except Exception as e:
+ pass
+ return FileResponse(f"{STATIC_DIR}/user.png")
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=ERROR_MESSAGES.USER_NOT_FOUND,
+ )
+
+
############################
# GetUserActiveStatusById
############################
@@ -387,8 +507,12 @@ async def update_user_by_id(
)
if form_data.password:
+ try:
+ validate_password(form_data.password)
+ except Exception as e:
+ raise HTTPException(400, detail=str(e))
+
hashed = get_password_hash(form_data.password)
- log.debug(f"hashed: {hashed}")
Auths.update_user_password_by_id(user_id, hashed)
Auths.update_email_by_id(user_id, form_data.email.lower())
@@ -454,3 +578,13 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
+
+
+############################
+# GetUserGroupsById
+############################
+
+
+@router.get("/{user_id}/groups")
+async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)):
+ return Groups.get_groups_by_member_id(user_id)
diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py
index 0e6768a671..b2a44e5488 100644
--- a/backend/open_webui/routers/utils.py
+++ b/backend/open_webui/routers/utils.py
@@ -124,12 +124,3 @@ async def download_db(user=Depends(get_admin_user)):
media_type="application/octet-stream",
filename="webui.db",
)
-
-
-@router.get("/litellm/config")
-async def download_litellm_config_yaml(user=Depends(get_admin_user)):
- return FileResponse(
- f"{DATA_DIR}/litellm/config.yaml",
- media_type="application/octet-stream",
- filename="config.yaml",
- )
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index 35e40dccb2..f79d1dd958 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -1,28 +1,49 @@
import asyncio
+import random
+
import socketio
import logging
import sys
import time
+from typing import Dict, Set
from redis import asyncio as aioredis
+import pycrdt as Y
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.models.notes import Notes, NoteUpdateForm
from open_webui.utils.redis import (
get_sentinels_from_env,
get_sentinel_url_from_env,
)
+from open_webui.config import (
+ CORS_ALLOW_ORIGIN,
+)
+
from open_webui.env import (
+ VERSION,
ENABLE_WEBSOCKET_SUPPORT,
WEBSOCKET_MANAGER,
WEBSOCKET_REDIS_URL,
+ WEBSOCKET_REDIS_CLUSTER,
WEBSOCKET_REDIS_LOCK_TIMEOUT,
WEBSOCKET_SENTINEL_PORT,
WEBSOCKET_SENTINEL_HOSTS,
+ REDIS_KEY_PREFIX,
+ WEBSOCKET_REDIS_OPTIONS,
+ WEBSOCKET_SERVER_PING_TIMEOUT,
+ WEBSOCKET_SERVER_PING_INTERVAL,
+ WEBSOCKET_SERVER_LOGGING,
+ WEBSOCKET_SERVER_ENGINEIO_LOGGING,
)
from open_webui.utils.auth import decode_token
-from open_webui.socket.utils import RedisDict, RedisLock
+from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
+from open_webui.tasks import create_task, stop_item_tasks
+from open_webui.utils.redis import get_redis_connection
+from open_webui.utils.access_control import has_access, get_users_with_access
+
from open_webui.env import (
GLOBAL_LOG_LEVEL,
@@ -35,30 +56,46 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["SOCKET"])
+REDIS = None
+
+# Configure CORS for Socket.IO
+SOCKETIO_CORS_ORIGINS = "*" if CORS_ALLOW_ORIGIN == ["*"] else CORS_ALLOW_ORIGIN
+
if WEBSOCKET_MANAGER == "redis":
if WEBSOCKET_SENTINEL_HOSTS:
mgr = socketio.AsyncRedisManager(
get_sentinel_url_from_env(
WEBSOCKET_REDIS_URL, WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
- )
+ ),
+ redis_options=WEBSOCKET_REDIS_OPTIONS,
)
else:
- mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL)
+ mgr = socketio.AsyncRedisManager(
+ WEBSOCKET_REDIS_URL, redis_options=WEBSOCKET_REDIS_OPTIONS
+ )
sio = socketio.AsyncServer(
- cors_allowed_origins=[],
+ cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode="asgi",
transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
client_manager=mgr,
+ logger=WEBSOCKET_SERVER_LOGGING,
+ ping_interval=WEBSOCKET_SERVER_PING_INTERVAL,
+ ping_timeout=WEBSOCKET_SERVER_PING_TIMEOUT,
+ engineio_logger=WEBSOCKET_SERVER_ENGINEIO_LOGGING,
)
else:
sio = socketio.AsyncServer(
- cors_allowed_origins=[],
+ cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode="asgi",
transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
always_connect=True,
+ logger=WEBSOCKET_SERVER_LOGGING,
+ ping_interval=WEBSOCKET_SERVER_PING_INTERVAL,
+ ping_timeout=WEBSOCKET_SERVER_PING_TIMEOUT,
+ engineio_logger=WEBSOCKET_SERVER_ENGINEIO_LOGGING,
)
@@ -69,30 +106,43 @@ TIMEOUT_DURATION = 3
if WEBSOCKET_MANAGER == "redis":
log.debug("Using Redis to manage websockets.")
+ REDIS = get_redis_connection(
+ redis_url=WEBSOCKET_REDIS_URL,
+ redis_sentinels=get_sentinels_from_env(
+ WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
+ ),
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
+ async_mode=True,
+ )
+
redis_sentinels = get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
SESSION_POOL = RedisDict(
- "open-webui:session_pool",
+ f"{REDIS_KEY_PREFIX}:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USER_POOL = RedisDict(
- "open-webui:user_pool",
+ f"{REDIS_KEY_PREFIX}:user_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
USAGE_POOL = RedisDict(
- "open-webui:usage_pool",
+ f"{REDIS_KEY_PREFIX}:usage_pool",
redis_url=WEBSOCKET_REDIS_URL,
redis_sentinels=redis_sentinels,
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
- lock_name="usage_cleanup_lock",
+ lock_name=f"{REDIS_KEY_PREFIX}:usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
)
aquire_func = clean_up_lock.aquire_lock
renew_func = clean_up_lock.renew_lock
@@ -101,14 +151,37 @@ else:
SESSION_POOL = {}
USER_POOL = {}
USAGE_POOL = {}
+
aquire_func = release_func = renew_func = lambda: True
+YDOC_MANAGER = YdocManager(
+ redis=REDIS,
+ redis_key_prefix=f"{REDIS_KEY_PREFIX}:ydoc:documents",
+)
+
+
async def periodic_usage_pool_cleanup():
- if not aquire_func():
- log.debug("Usage pool cleanup lock already exists. Not running it.")
- return
- log.debug("Running periodic_usage_pool_cleanup")
+ max_retries = 2
+ retry_delay = random.uniform(
+ WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT
+ )
+ for attempt in range(max_retries + 1):
+ if aquire_func():
+ break
+ else:
+ if attempt < max_retries:
+ log.debug(
+ f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..."
+ )
+ await asyncio.sleep(retry_delay)
+ else:
+ log.warning(
+ "Failed to acquire cleanup lock after retries. Skipping cleanup."
+ )
+ return
+
+ log.debug("Running periodic_cleanup")
try:
while True:
if not renew_func():
@@ -169,16 +242,20 @@ def get_user_id_from_session_pool(sid):
return None
-def get_user_ids_from_room(room):
+def get_session_ids_from_room(room):
+ """Get all session IDs from a specific room."""
active_session_ids = sio.manager.get_participants(
namespace="/",
room=room,
)
+ return [session_id[0] for session_id in active_session_ids]
+
+
+def get_user_ids_from_room(room):
+ active_session_ids = get_session_ids_from_room(room)
active_user_ids = list(
- set(
- [SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
- )
+ set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids])
)
return active_user_ids
@@ -213,12 +290,16 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
- SESSION_POOL[sid] = user.model_dump()
+ SESSION_POOL[sid] = user.model_dump(
+ exclude=["date_of_birth", "bio", "gender"]
+ )
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
+ await sio.enter_room(sid, f"user:{user.id}")
+
@sio.on("user-join")
async def user_join(sid, data):
@@ -235,12 +316,13 @@ async def user_join(sid, data):
if not user:
return
- SESSION_POOL[sid] = user.model_dump()
+ SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"])
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
USER_POOL[user.id] = [sid]
+ await sio.enter_room(sid, f"user:{user.id}")
# Join all the channels
channels = Channels.get_channels_by_user_id(user.id)
log.debug(f"{channels=}")
@@ -270,7 +352,38 @@ async def join_channel(sid, data):
await sio.enter_room(sid, f"channel:{channel.id}")
-@sio.on("channel-events")
+@sio.on("join-note")
+async def join_note(sid, data):
+ auth = data["auth"] if "auth" in data else None
+ if not auth or "token" not in auth:
+ return
+
+ token_data = decode_token(auth["token"])
+ if token_data is None or "id" not in token_data:
+ return
+
+ user = Users.get_user_by_id(token_data["id"])
+ if not user:
+ return
+
+ note = Notes.get_note_by_id(data["note_id"])
+ if not note:
+ log.error(f"Note {data['note_id']} not found for user {user.id}")
+ return
+
+ if (
+ user.role != "admin"
+ and user.id != note.user_id
+ and not has_access(user.id, type="read", access_control=note.access_control)
+ ):
+ log.error(f"User {user.id} does not have access to note {data['note_id']}")
+ return
+
+ log.debug(f"Joining note {note.id} for user {user.id}")
+ await sio.enter_room(sid, f"note:{note.id}")
+
+
+@sio.on("events:channel")
async def channel_events(sid, data):
room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants(
@@ -287,7 +400,7 @@ async def channel_events(sid, data):
if event_type == "typing":
await sio.emit(
- "channel-events",
+ "events:channel",
{
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
@@ -298,6 +411,242 @@ async def channel_events(sid, data):
)
+@sio.on("ydoc:document:join")
+async def ydoc_document_join(sid, data):
+ """Handle user joining a document"""
+ user = SESSION_POOL.get(sid)
+
+ try:
+ document_id = data["document_id"]
+
+ if document_id.startswith("note:"):
+ note_id = document_id.split(":")[1]
+ note = Notes.get_note_by_id(note_id)
+ if not note:
+ log.error(f"Note {note_id} not found")
+ return
+
+ if (
+ user.get("role") != "admin"
+ and user.get("id") != note.user_id
+ and not has_access(
+ user.get("id"), type="read", access_control=note.access_control
+ )
+ ):
+ log.error(
+ f"User {user.get('id')} does not have access to note {note_id}"
+ )
+ return
+
+ user_id = data.get("user_id", sid)
+ user_name = data.get("user_name", "Anonymous")
+ user_color = data.get("user_color", "#000000")
+
+ log.info(f"User {user_id} joining document {document_id}")
+ await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)
+
+ # Join Socket.IO room
+ await sio.enter_room(sid, f"doc_{document_id}")
+
+ active_session_ids = get_session_ids_from_room(f"doc_{document_id}")
+
+ # Get the Yjs document state
+ ydoc = Y.Doc()
+ updates = await YDOC_MANAGER.get_updates(document_id)
+ for update in updates:
+ ydoc.apply_update(bytes(update))
+
+ # Encode the entire document state as an update
+ state_update = ydoc.get_update()
+ await sio.emit(
+ "ydoc:document:state",
+ {
+ "document_id": document_id,
+ "state": list(state_update), # Convert bytes to list for JSON
+ "sessions": active_session_ids,
+ },
+ room=sid,
+ )
+
+ # Notify other users about the new user
+ await sio.emit(
+ "ydoc:user:joined",
+ {
+ "document_id": document_id,
+ "user_id": user_id,
+ "user_name": user_name,
+ "user_color": user_color,
+ },
+ room=f"doc_{document_id}",
+ skip_sid=sid,
+ )
+
+ log.info(f"User {user_id} successfully joined document {document_id}")
+
+ except Exception as e:
+ log.error(f"Error in yjs_document_join: {e}")
+ await sio.emit("error", {"message": "Failed to join document"}, room=sid)
+
+
+async def document_save_handler(document_id, data, user):
+ if document_id.startswith("note:"):
+ note_id = document_id.split(":")[1]
+ note = Notes.get_note_by_id(note_id)
+ if not note:
+ log.error(f"Note {note_id} not found")
+ return
+
+ if (
+ user.get("role") != "admin"
+ and user.get("id") != note.user_id
+ and not has_access(
+ user.get("id"), type="read", access_control=note.access_control
+ )
+ ):
+ log.error(f"User {user.get('id')} does not have access to note {note_id}")
+ return
+
+ Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
+
+
+@sio.on("ydoc:document:state")
+async def yjs_document_state(sid, data):
+ """Send the current state of the Yjs document to the user"""
+ try:
+ document_id = data["document_id"]
+ room = f"doc_{document_id}"
+
+ active_session_ids = get_session_ids_from_room(room)
+
+ if sid not in active_session_ids:
+ log.warning(f"Session {sid} not in room {room}. Cannot send state.")
+ return
+
+ if not await YDOC_MANAGER.document_exists(document_id):
+ log.warning(f"Document {document_id} not found")
+ return
+
+ # Get the Yjs document state
+ ydoc = Y.Doc()
+ updates = await YDOC_MANAGER.get_updates(document_id)
+ for update in updates:
+ ydoc.apply_update(bytes(update))
+
+ # Encode the entire document state as an update
+ state_update = ydoc.get_update()
+
+ await sio.emit(
+ "ydoc:document:state",
+ {
+ "document_id": document_id,
+ "state": list(state_update), # Convert bytes to list for JSON
+ "sessions": active_session_ids,
+ },
+ room=sid,
+ )
+ except Exception as e:
+ log.error(f"Error in yjs_document_state: {e}")
+
+
+@sio.on("ydoc:document:update")
+async def yjs_document_update(sid, data):
+ """Handle Yjs document updates"""
+ try:
+ document_id = data["document_id"]
+
+ try:
+ await stop_item_tasks(REDIS, document_id)
+ except:
+ pass
+
+ user_id = data.get("user_id", sid)
+
+ update = data["update"] # List of bytes from frontend
+
+ await YDOC_MANAGER.append_to_updates(
+ document_id=document_id,
+ update=update, # Convert list of bytes to bytes
+ )
+
+ # Broadcast update to all other users in the document
+ await sio.emit(
+ "ydoc:document:update",
+ {
+ "document_id": document_id,
+ "user_id": user_id,
+ "update": update,
+ "socket_id": sid, # Add socket_id to match frontend filtering
+ },
+ room=f"doc_{document_id}",
+ skip_sid=sid,
+ )
+
+ async def debounced_save():
+ await asyncio.sleep(0.5)
+ await document_save_handler(
+ document_id, data.get("data", {}), SESSION_POOL.get(sid)
+ )
+
+ if data.get("data"):
+ await create_task(REDIS, debounced_save(), document_id)
+
+ except Exception as e:
+ log.error(f"Error in yjs_document_update: {e}")
+
+
+@sio.on("ydoc:document:leave")
+async def yjs_document_leave(sid, data):
+ """Handle user leaving a document"""
+ try:
+ document_id = data["document_id"]
+ user_id = data.get("user_id", sid)
+
+ log.info(f"User {user_id} leaving document {document_id}")
+
+ # Remove user from the document
+ await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
+
+ # Leave Socket.IO room
+ await sio.leave_room(sid, f"doc_{document_id}")
+
+ # Notify other users
+ await sio.emit(
+ "ydoc:user:left",
+ {"document_id": document_id, "user_id": user_id},
+ room=f"doc_{document_id}",
+ )
+
+ if (
+ await YDOC_MANAGER.document_exists(document_id)
+ and len(await YDOC_MANAGER.get_users(document_id)) == 0
+ ):
+ log.info(f"Cleaning up document {document_id} as no users are left")
+ await YDOC_MANAGER.clear_document(document_id)
+
+ except Exception as e:
+ log.error(f"Error in yjs_document_leave: {e}")
+
+
+@sio.on("ydoc:awareness:update")
+async def yjs_awareness_update(sid, data):
+ """Handle awareness updates (cursors, selections, etc.)"""
+ try:
+ document_id = data["document_id"]
+ user_id = data.get("user_id", sid)
+ update = data["update"]
+
+ # Broadcast awareness update to all other users in the document
+ await sio.emit(
+ "ydoc:awareness:update",
+ {"document_id": document_id, "user_id": user_id, "update": update},
+ room=f"doc_{document_id}",
+ skip_sid=sid,
+ )
+
+ except Exception as e:
+ log.error(f"Error in yjs_awareness_update: {e}")
+
+
@sio.event
async def disconnect(sid):
if sid in SESSION_POOL:
@@ -309,6 +658,8 @@ async def disconnect(sid):
if len(USER_POOL[user_id]) == 0:
del USER_POOL[user_id]
+
+ await YDOC_MANAGER.remove_user_from_all_documents(sid)
else:
pass
# print(f"Unknown session ID {sid} disconnected")
@@ -317,34 +668,24 @@ async def disconnect(sid):
def get_event_emitter(request_info, update_db=True):
async def __event_emitter__(event_data):
user_id = request_info["user_id"]
+ chat_id = request_info["chat_id"]
+ message_id = request_info["message_id"]
- session_ids = list(
- set(
- USER_POOL.get(user_id, [])
- + (
- [request_info.get("session_id")]
- if request_info.get("session_id")
- else []
- )
- )
+ await sio.emit(
+ "events",
+ {
+ "chat_id": chat_id,
+ "message_id": message_id,
+ "data": event_data,
+ },
+ room=f"user:{user_id}",
)
+ if (
+ update_db
+ and message_id
+ and not request_info.get("chat_id", "").startswith("local:")
+ ):
- emit_tasks = [
- sio.emit(
- "chat-events",
- {
- "chat_id": request_info.get("chat_id", None),
- "message_id": request_info.get("message_id", None),
- "data": event_data,
- },
- to=session_id,
- )
- for session_id in session_ids
- ]
-
- await asyncio.gather(*emit_tasks)
-
- 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"],
@@ -381,13 +722,73 @@ def get_event_emitter(request_info, update_db=True):
},
)
- return __event_emitter__
+ if "type" in event_data and event_data["type"] == "embeds":
+ message = Chats.get_message_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ )
+
+ embeds = event_data.get("data", {}).get("embeds", [])
+ embeds.extend(message.get("embeds", []))
+
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ {
+ "embeds": embeds,
+ },
+ )
+
+ if "type" in event_data and event_data["type"] == "files":
+ message = Chats.get_message_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ )
+
+ files = event_data.get("data", {}).get("files", [])
+ files.extend(message.get("files", []))
+
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ {
+ "files": files,
+ },
+ )
+
+ if event_data.get("type") in ["source", "citation"]:
+ data = event_data.get("data", {})
+ if data.get("type") == None:
+ message = Chats.get_message_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ )
+
+ sources = message.get("sources", [])
+ sources.append(data)
+
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ request_info["chat_id"],
+ request_info["message_id"],
+ {
+ "sources": sources,
+ },
+ )
+
+ if (
+ "user_id" in request_info
+ and "chat_id" in request_info
+ and "message_id" in request_info
+ ):
+ return __event_emitter__
+ else:
+ return None
def get_event_call(request_info):
async def __event_caller__(event_data):
response = await sio.call(
- "chat-events",
+ "events",
{
"chat_id": request_info.get("chat_id", None),
"message_id": request_info.get("message_id", None),
@@ -397,7 +798,14 @@ def get_event_call(request_info):
)
return response
- return __event_caller__
+ if (
+ "session_id" in request_info
+ and "chat_id" in request_info
+ and "message_id" in request_info
+ ):
+ return __event_caller__
+ else:
+ return None
get_event_caller = get_event_call
diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py
index 85a8bb7909..168d2fd88e 100644
--- a/backend/open_webui/socket/utils.py
+++ b/backend/open_webui/socket/utils.py
@@ -1,16 +1,30 @@
import json
import uuid
from open_webui.utils.redis import get_redis_connection
+from open_webui.env import REDIS_KEY_PREFIX
+from typing import Optional, List, Tuple
+import pycrdt as Y
class RedisLock:
- def __init__(self, redis_url, lock_name, timeout_secs, redis_sentinels=[]):
+ def __init__(
+ self,
+ redis_url,
+ lock_name,
+ timeout_secs,
+ redis_sentinels=[],
+ redis_cluster=False,
+ ):
+
self.lock_name = lock_name
self.lock_id = str(uuid.uuid4())
self.timeout_secs = timeout_secs
self.lock_obtained = False
self.redis = get_redis_connection(
- redis_url, redis_sentinels, decode_responses=True
+ redis_url,
+ redis_sentinels,
+ redis_cluster=redis_cluster,
+ decode_responses=True,
)
def aquire_lock(self):
@@ -33,10 +47,13 @@ class RedisLock:
class RedisDict:
- def __init__(self, name, redis_url, redis_sentinels=[]):
+ def __init__(self, name, redis_url, redis_sentinels=[], redis_cluster=False):
self.name = name
self.redis = get_redis_connection(
- redis_url, redis_sentinels, decode_responses=True
+ redis_url,
+ redis_sentinels,
+ redis_cluster=redis_cluster,
+ decode_responses=True,
)
def __setitem__(self, key, value):
@@ -89,3 +106,109 @@ class RedisDict:
if key not in self:
self[key] = default
return self[key]
+
+
+class YdocManager:
+ def __init__(
+ self,
+ redis=None,
+ redis_key_prefix: str = f"{REDIS_KEY_PREFIX}:ydoc:documents",
+ ):
+ self._updates = {}
+ self._users = {}
+ self._redis = redis
+ self._redis_key_prefix = redis_key_prefix
+
+ async def append_to_updates(self, document_id: str, update: bytes):
+ document_id = document_id.replace(":", "_")
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+ await self._redis.rpush(redis_key, json.dumps(list(update)))
+ else:
+ if document_id not in self._updates:
+ self._updates[document_id] = []
+ self._updates[document_id].append(update)
+
+ async def get_updates(self, document_id: str) -> List[bytes]:
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+ updates = await self._redis.lrange(redis_key, 0, -1)
+ return [bytes(json.loads(update)) for update in updates]
+ else:
+ return self._updates.get(document_id, [])
+
+ async def document_exists(self, document_id: str) -> bool:
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+ return await self._redis.exists(redis_key) > 0
+ else:
+ return document_id in self._updates
+
+ async def get_users(self, document_id: str) -> List[str]:
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+ users = await self._redis.smembers(redis_key)
+ return list(users)
+ else:
+ return self._users.get(document_id, [])
+
+ async def add_user(self, document_id: str, user_id: str):
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+ await self._redis.sadd(redis_key, user_id)
+ else:
+ if document_id not in self._users:
+ self._users[document_id] = set()
+ self._users[document_id].add(user_id)
+
+ async def remove_user(self, document_id: str, user_id: str):
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+ await self._redis.srem(redis_key, user_id)
+ else:
+ if document_id in self._users and user_id in self._users[document_id]:
+ self._users[document_id].remove(user_id)
+
+ async def remove_user_from_all_documents(self, user_id: str):
+ if self._redis:
+ keys = await self._redis.keys(f"{self._redis_key_prefix}:*")
+ for key in keys:
+ if key.endswith(":users"):
+ await self._redis.srem(key, user_id)
+
+ document_id = key.split(":")[-2]
+ if len(await self.get_users(document_id)) == 0:
+ await self.clear_document(document_id)
+
+ else:
+ for document_id in list(self._users.keys()):
+ if user_id in self._users[document_id]:
+ self._users[document_id].remove(user_id)
+ if not self._users[document_id]:
+ del self._users[document_id]
+
+ await self.clear_document(document_id)
+
+ async def clear_document(self, document_id: str):
+ document_id = document_id.replace(":", "_")
+
+ if self._redis:
+ redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+ await self._redis.delete(redis_key)
+ redis_users_key = f"{self._redis_key_prefix}:{document_id}:users"
+ await self._redis.delete(redis_users_key)
+ else:
+ if document_id in self._updates:
+ del self._updates[document_id]
+ if document_id in self._users:
+ del self._users[document_id]
diff --git a/backend/open_webui/static/user.png b/backend/open_webui/static/user.png
new file mode 100644
index 0000000000..7bdc70d159
Binary files /dev/null and b/backend/open_webui/static/user.png differ
diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py
index 41a92fafe9..4292e53827 100644
--- a/backend/open_webui/storage/provider.py
+++ b/backend/open_webui/storage/provider.py
@@ -112,6 +112,9 @@ class S3StorageProvider(StorageProvider):
"use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT,
"addressing_style": S3_ADDRESSING_STYLE,
},
+ # KIT change - see https://github.com/boto/boto3/issues/4400#issuecomment-2600742103∆
+ request_checksum_calculation="when_required",
+ response_checksum_validation="when_required",
)
# If access key and secret are provided, use them for authentication
diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py
index 2d3955f0a2..3e31438281 100644
--- a/backend/open_webui/tasks.py
+++ b/backend/open_webui/tasks.py
@@ -3,23 +3,25 @@ import asyncio
from typing import Dict
from uuid import uuid4
import json
+import logging
from redis.asyncio import Redis
from fastapi import Request
from typing import Dict, List, Optional
+from open_webui.env import SRC_LOG_LEVELS, REDIS_KEY_PREFIX
+
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MAIN"])
+
# A dictionary to keep track of active tasks
tasks: Dict[str, asyncio.Task] = {}
-chat_tasks = {}
+item_tasks = {}
-REDIS_TASKS_KEY = "open-webui:tasks"
-REDIS_CHAT_TASKS_KEY = "open-webui:tasks:chat"
-REDIS_PUBSUB_CHANNEL = "open-webui:tasks:commands"
-
-
-def is_redis(request: Request) -> bool:
- # Called everywhere a request is available to check Redis
- return hasattr(request.app.state, "redis") and (request.app.state.redis is not None)
+REDIS_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks"
+REDIS_ITEM_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks:item"
+REDIS_PUBSUB_CHANNEL = f"{REDIS_KEY_PREFIX}:tasks:commands"
async def redis_task_command_listener(app):
@@ -38,7 +40,7 @@ async def redis_task_command_listener(app):
if local_task:
local_task.cancel()
except Exception as e:
- print(f"Error handling distributed task command: {e}")
+ log.exception(f"Error handling distributed task command: {e}")
### ------------------------------
@@ -46,21 +48,21 @@ async def redis_task_command_listener(app):
### ------------------------------
-async def redis_save_task(redis: Redis, task_id: str, chat_id: Optional[str]):
+async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]):
pipe = redis.pipeline()
- pipe.hset(REDIS_TASKS_KEY, task_id, chat_id or "")
- if chat_id:
- pipe.sadd(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
+ pipe.hset(REDIS_TASKS_KEY, task_id, item_id or "")
+ if item_id:
+ pipe.sadd(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
await pipe.execute()
-async def redis_cleanup_task(redis: Redis, task_id: str, chat_id: Optional[str]):
+async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]):
pipe = redis.pipeline()
pipe.hdel(REDIS_TASKS_KEY, task_id)
- if chat_id:
- pipe.srem(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}", task_id)
- if (await pipe.scard(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}").execute())[-1] == 0:
- pipe.delete(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}") # Remove if empty set
+ if item_id:
+ pipe.srem(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id)
+ if (await pipe.scard(f"{REDIS_ITEM_TASKS_KEY}:{item_id}").execute())[-1] == 0:
+ pipe.delete(f"{REDIS_ITEM_TASKS_KEY}:{item_id}") # Remove if empty set
await pipe.execute()
@@ -68,31 +70,31 @@ async def redis_list_tasks(redis: Redis) -> List[str]:
return list(await redis.hkeys(REDIS_TASKS_KEY))
-async def redis_list_chat_tasks(redis: Redis, chat_id: str) -> List[str]:
- return list(await redis.smembers(f"{REDIS_CHAT_TASKS_KEY}:{chat_id}"))
+async def redis_list_item_tasks(redis: Redis, item_id: str) -> List[str]:
+ return list(await redis.smembers(f"{REDIS_ITEM_TASKS_KEY}:{item_id}"))
async def redis_send_command(redis: Redis, command: dict):
await redis.publish(REDIS_PUBSUB_CHANNEL, json.dumps(command))
-async def cleanup_task(request, task_id: str, id=None):
+async def cleanup_task(redis, task_id: str, id=None):
"""
Remove a completed or canceled task from the global `tasks` dictionary.
"""
- if is_redis(request):
- await redis_cleanup_task(request.app.state.redis, task_id, id)
+ if redis:
+ await redis_cleanup_task(redis, task_id, id)
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)
+ # If an ID is provided, remove the task from the item_tasks dictionary
+ if id and task_id in item_tasks.get(id, []):
+ item_tasks[id].remove(task_id)
+ if not item_tasks[id]: # If no tasks left for this ID, remove the entry
+ item_tasks.pop(id, None)
-async def create_task(request, coroutine, id=None):
+async def create_task(redis, coroutine, id=None):
"""
Create a new asyncio task and add it to the global task dictionary.
"""
@@ -101,48 +103,48 @@ async def create_task(request, coroutine, id=None):
# Add a done callback for cleanup
task.add_done_callback(
- lambda t: asyncio.create_task(cleanup_task(request, task_id, id))
+ lambda t: asyncio.create_task(cleanup_task(redis, 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)
+ if item_tasks.get(id):
+ item_tasks[id].append(task_id)
else:
- chat_tasks[id] = [task_id]
+ item_tasks[id] = [task_id]
- if is_redis(request):
- await redis_save_task(request.app.state.redis, task_id, id)
+ if redis:
+ await redis_save_task(redis, task_id, id)
return task_id, task
-async def list_tasks(request):
+async def list_tasks(redis):
"""
List all currently active task IDs.
"""
- if is_redis(request):
- return await redis_list_tasks(request.app.state.redis)
+ if redis:
+ return await redis_list_tasks(redis)
return list(tasks.keys())
-async def list_task_ids_by_chat_id(request, id):
+async def list_task_ids_by_item_id(redis, id):
"""
List all tasks associated with a specific ID.
"""
- if is_redis(request):
- return await redis_list_chat_tasks(request.app.state.redis, id)
- return chat_tasks.get(id, [])
+ if redis:
+ return await redis_list_item_tasks(redis, id)
+ return item_tasks.get(id, [])
-async def stop_task(request, task_id: str):
+async def stop_task(redis, task_id: str):
"""
Cancel a running task and remove it from the global task list.
"""
- if is_redis(request):
+ if redis:
# PUBSUB: All instances check if they have this task, and stop if so.
await redis_send_command(
- request.app.state.redis,
+ redis,
{
"action": "stop",
"task_id": task_id,
@@ -151,16 +153,34 @@ async def stop_task(request, task_id: str):
# Optionally check if task_id still in Redis a few moments later for feedback?
return {"status": True, "message": f"Stop signal sent for {task_id}"}
- task = tasks.get(task_id)
+ task = tasks.pop(task_id, None)
if not task:
- raise ValueError(f"Task with ID {task_id} not found.")
+ return {"status": False, "message": f"Task with ID {task_id} not found."}
task.cancel() # Request task cancellation
try:
await task # Wait for the task to handle the cancellation
except asyncio.CancelledError:
# Task successfully canceled
- tasks.pop(task_id, None) # Remove it from the dictionary
return {"status": True, "message": f"Task {task_id} successfully stopped."}
- return {"status": False, "message": f"Failed to stop task {task_id}."}
+ if task.cancelled() or task.done():
+ return {"status": True, "message": f"Task {task_id} successfully cancelled."}
+
+ return {"status": True, "message": f"Cancellation requested for {task_id}."}
+
+
+async def stop_item_tasks(redis: Redis, item_id: str):
+ """
+ Stop all tasks associated with a specific item ID.
+ """
+ task_ids = await list_task_ids_by_item_id(redis, item_id)
+ if not task_ids:
+ return {"status": True, "message": f"No tasks found for item {item_id}."}
+
+ for task_id in task_ids:
+ result = await stop_task(redis, task_id)
+ if not result["status"]:
+ return result # Return the first failure
+
+ return {"status": True, "message": f"All tasks for item {item_id} stopped."}
diff --git a/backend/open_webui/test/util/test_redis.py b/backend/open_webui/test/util/test_redis.py
new file mode 100644
index 0000000000..8c393ce9d9
--- /dev/null
+++ b/backend/open_webui/test/util/test_redis.py
@@ -0,0 +1,793 @@
+import pytest
+from unittest.mock import Mock, patch, AsyncMock
+import redis
+from open_webui.utils.redis import (
+ SentinelRedisProxy,
+ parse_redis_service_url,
+ get_redis_connection,
+ get_sentinels_from_env,
+ MAX_RETRY_COUNT,
+)
+import inspect
+
+
+class TestSentinelRedisProxy:
+ """Test Redis Sentinel failover functionality"""
+
+ def test_parse_redis_service_url_valid(self):
+ """Test parsing valid Redis service URL"""
+ url = "redis://user:pass@mymaster:6379/0"
+ result = parse_redis_service_url(url)
+
+ assert result["username"] == "user"
+ assert result["password"] == "pass"
+ assert result["service"] == "mymaster"
+ assert result["port"] == 6379
+ assert result["db"] == 0
+
+ def test_parse_redis_service_url_defaults(self):
+ """Test parsing Redis service URL with defaults"""
+ url = "redis://mymaster"
+ result = parse_redis_service_url(url)
+
+ assert result["username"] is None
+ assert result["password"] is None
+ assert result["service"] == "mymaster"
+ assert result["port"] == 6379
+ assert result["db"] == 0
+
+ def test_parse_redis_service_url_invalid_scheme(self):
+ """Test parsing invalid URL scheme"""
+ with pytest.raises(ValueError, match="Invalid Redis URL scheme"):
+ parse_redis_service_url("http://invalid")
+
+ def test_get_sentinels_from_env(self):
+ """Test parsing sentinel hosts from environment"""
+ hosts = "sentinel1,sentinel2,sentinel3"
+ port = "26379"
+
+ result = get_sentinels_from_env(hosts, port)
+ expected = [("sentinel1", 26379), ("sentinel2", 26379), ("sentinel3", 26379)]
+
+ assert result == expected
+
+ def test_get_sentinels_from_env_empty(self):
+ """Test empty sentinel hosts"""
+ result = get_sentinels_from_env(None, "26379")
+ assert result == []
+
+ @patch("redis.sentinel.Sentinel")
+ def test_sentinel_redis_proxy_sync_success(self, mock_sentinel_class):
+ """Test successful sync operation with SentinelRedisProxy"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_master.get.return_value = "test_value"
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test attribute access
+ get_method = proxy.__getattr__("get")
+ result = get_method("test_key")
+
+ assert result == "test_value"
+ mock_sentinel.master_for.assert_called_with("mymaster")
+ mock_master.get.assert_called_with("test_key")
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_sentinel_redis_proxy_async_success(self, mock_sentinel_class):
+ """Test successful async operation with SentinelRedisProxy"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_master.get = AsyncMock(return_value="test_value")
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test async attribute access
+ get_method = proxy.__getattr__("get")
+ result = await get_method("test_key")
+
+ assert result == "test_value"
+ mock_sentinel.master_for.assert_called_with("mymaster")
+ mock_master.get.assert_called_with("test_key")
+
+ @patch("redis.sentinel.Sentinel")
+ def test_sentinel_redis_proxy_failover_retry(self, mock_sentinel_class):
+ """Test retry mechanism during failover"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call fails, second succeeds
+ mock_master.get.side_effect = [
+ redis.exceptions.ConnectionError("Master down"),
+ "test_value",
+ ]
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ get_method = proxy.__getattr__("get")
+ result = get_method("test_key")
+
+ assert result == "test_value"
+ assert mock_master.get.call_count == 2
+
+ @patch("redis.sentinel.Sentinel")
+ def test_sentinel_redis_proxy_max_retries_exceeded(self, mock_sentinel_class):
+ """Test failure after max retries exceeded"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # All calls fail
+ mock_master.get.side_effect = redis.exceptions.ConnectionError("Master down")
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ get_method = proxy.__getattr__("get")
+
+ with pytest.raises(redis.exceptions.ConnectionError):
+ get_method("test_key")
+
+ assert mock_master.get.call_count == MAX_RETRY_COUNT
+
+ @patch("redis.sentinel.Sentinel")
+ def test_sentinel_redis_proxy_readonly_error_retry(self, mock_sentinel_class):
+ """Test retry on ReadOnlyError"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call gets ReadOnlyError (old master), second succeeds (new master)
+ mock_master.get.side_effect = [
+ redis.exceptions.ReadOnlyError("Read only"),
+ "test_value",
+ ]
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ get_method = proxy.__getattr__("get")
+ result = get_method("test_key")
+
+ assert result == "test_value"
+ assert mock_master.get.call_count == 2
+
+ @patch("redis.sentinel.Sentinel")
+ def test_sentinel_redis_proxy_factory_methods(self, mock_sentinel_class):
+ """Test factory methods are passed through directly"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_pipeline = Mock()
+ mock_master.pipeline.return_value = mock_pipeline
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Factory methods should be passed through without wrapping
+ pipeline_method = proxy.__getattr__("pipeline")
+ result = pipeline_method()
+
+ assert result == mock_pipeline
+ mock_master.pipeline.assert_called_once()
+
+ @patch("redis.sentinel.Sentinel")
+ @patch("redis.from_url")
+ def test_get_redis_connection_with_sentinel(
+ self, mock_from_url, mock_sentinel_class
+ ):
+ """Test getting Redis connection with Sentinel"""
+ mock_sentinel = Mock()
+ mock_sentinel_class.return_value = mock_sentinel
+
+ sentinels = [("sentinel1", 26379), ("sentinel2", 26379)]
+ redis_url = "redis://user:pass@mymaster:6379/0"
+
+ result = get_redis_connection(
+ redis_url=redis_url, redis_sentinels=sentinels, async_mode=False
+ )
+
+ assert isinstance(result, SentinelRedisProxy)
+ mock_sentinel_class.assert_called_once()
+ mock_from_url.assert_not_called()
+
+ @patch("redis.Redis.from_url")
+ def test_get_redis_connection_without_sentinel(self, mock_from_url):
+ """Test getting Redis connection without Sentinel"""
+ mock_redis = Mock()
+ mock_from_url.return_value = mock_redis
+
+ redis_url = "redis://localhost:6379/0"
+
+ result = get_redis_connection(
+ redis_url=redis_url, redis_sentinels=None, async_mode=False
+ )
+
+ assert result == mock_redis
+ mock_from_url.assert_called_once_with(redis_url, decode_responses=True)
+
+ @patch("redis.asyncio.from_url")
+ def test_get_redis_connection_without_sentinel_async(self, mock_from_url):
+ """Test getting async Redis connection without Sentinel"""
+ mock_redis = Mock()
+ mock_from_url.return_value = mock_redis
+
+ redis_url = "redis://localhost:6379/0"
+
+ result = get_redis_connection(
+ redis_url=redis_url, redis_sentinels=None, async_mode=True
+ )
+
+ assert result == mock_redis
+ mock_from_url.assert_called_once_with(redis_url, decode_responses=True)
+
+
+class TestSentinelRedisProxyCommands:
+ """Test Redis commands through SentinelRedisProxy"""
+
+ @patch("redis.sentinel.Sentinel")
+ def test_hash_commands_sync(self, mock_sentinel_class):
+ """Test Redis hash commands in sync mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock hash command responses
+ mock_master.hset.return_value = 1
+ mock_master.hget.return_value = "test_value"
+ mock_master.hgetall.return_value = {"key1": "value1", "key2": "value2"}
+ mock_master.hdel.return_value = 1
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test hset
+ hset_method = proxy.__getattr__("hset")
+ result = hset_method("test_hash", "field1", "value1")
+ assert result == 1
+ mock_master.hset.assert_called_with("test_hash", "field1", "value1")
+
+ # Test hget
+ hget_method = proxy.__getattr__("hget")
+ result = hget_method("test_hash", "field1")
+ assert result == "test_value"
+ mock_master.hget.assert_called_with("test_hash", "field1")
+
+ # Test hgetall
+ hgetall_method = proxy.__getattr__("hgetall")
+ result = hgetall_method("test_hash")
+ assert result == {"key1": "value1", "key2": "value2"}
+ mock_master.hgetall.assert_called_with("test_hash")
+
+ # Test hdel
+ hdel_method = proxy.__getattr__("hdel")
+ result = hdel_method("test_hash", "field1")
+ assert result == 1
+ mock_master.hdel.assert_called_with("test_hash", "field1")
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_hash_commands_async(self, mock_sentinel_class):
+ """Test Redis hash commands in async mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock async hash command responses
+ mock_master.hset = AsyncMock(return_value=1)
+ mock_master.hget = AsyncMock(return_value="test_value")
+ mock_master.hgetall = AsyncMock(
+ return_value={"key1": "value1", "key2": "value2"}
+ )
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test hset
+ hset_method = proxy.__getattr__("hset")
+ result = await hset_method("test_hash", "field1", "value1")
+ assert result == 1
+ mock_master.hset.assert_called_with("test_hash", "field1", "value1")
+
+ # Test hget
+ hget_method = proxy.__getattr__("hget")
+ result = await hget_method("test_hash", "field1")
+ assert result == "test_value"
+ mock_master.hget.assert_called_with("test_hash", "field1")
+
+ # Test hgetall
+ hgetall_method = proxy.__getattr__("hgetall")
+ result = await hgetall_method("test_hash")
+ assert result == {"key1": "value1", "key2": "value2"}
+ mock_master.hgetall.assert_called_with("test_hash")
+
+ @patch("redis.sentinel.Sentinel")
+ def test_string_commands_sync(self, mock_sentinel_class):
+ """Test Redis string commands in sync mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock string command responses
+ mock_master.set.return_value = True
+ mock_master.get.return_value = "test_value"
+ mock_master.delete.return_value = 1
+ mock_master.exists.return_value = True
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test set
+ set_method = proxy.__getattr__("set")
+ result = set_method("test_key", "test_value")
+ assert result is True
+ mock_master.set.assert_called_with("test_key", "test_value")
+
+ # Test get
+ get_method = proxy.__getattr__("get")
+ result = get_method("test_key")
+ assert result == "test_value"
+ mock_master.get.assert_called_with("test_key")
+
+ # Test delete
+ delete_method = proxy.__getattr__("delete")
+ result = delete_method("test_key")
+ assert result == 1
+ mock_master.delete.assert_called_with("test_key")
+
+ # Test exists
+ exists_method = proxy.__getattr__("exists")
+ result = exists_method("test_key")
+ assert result is True
+ mock_master.exists.assert_called_with("test_key")
+
+ @patch("redis.sentinel.Sentinel")
+ def test_list_commands_sync(self, mock_sentinel_class):
+ """Test Redis list commands in sync mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock list command responses
+ mock_master.lpush.return_value = 1
+ mock_master.rpop.return_value = "test_value"
+ mock_master.llen.return_value = 5
+ mock_master.lrange.return_value = ["item1", "item2", "item3"]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test lpush
+ lpush_method = proxy.__getattr__("lpush")
+ result = lpush_method("test_list", "item1")
+ assert result == 1
+ mock_master.lpush.assert_called_with("test_list", "item1")
+
+ # Test rpop
+ rpop_method = proxy.__getattr__("rpop")
+ result = rpop_method("test_list")
+ assert result == "test_value"
+ mock_master.rpop.assert_called_with("test_list")
+
+ # Test llen
+ llen_method = proxy.__getattr__("llen")
+ result = llen_method("test_list")
+ assert result == 5
+ mock_master.llen.assert_called_with("test_list")
+
+ # Test lrange
+ lrange_method = proxy.__getattr__("lrange")
+ result = lrange_method("test_list", 0, -1)
+ assert result == ["item1", "item2", "item3"]
+ mock_master.lrange.assert_called_with("test_list", 0, -1)
+
+ @patch("redis.sentinel.Sentinel")
+ def test_pubsub_commands_sync(self, mock_sentinel_class):
+ """Test Redis pubsub commands in sync mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_pubsub = Mock()
+
+ # Mock pubsub responses
+ mock_master.pubsub.return_value = mock_pubsub
+ mock_master.publish.return_value = 1
+ mock_pubsub.subscribe.return_value = None
+ mock_pubsub.get_message.return_value = {"type": "message", "data": "test_data"}
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test pubsub (factory method - should pass through)
+ pubsub_method = proxy.__getattr__("pubsub")
+ result = pubsub_method()
+ assert result == mock_pubsub
+ mock_master.pubsub.assert_called_once()
+
+ # Test publish
+ publish_method = proxy.__getattr__("publish")
+ result = publish_method("test_channel", "test_message")
+ assert result == 1
+ mock_master.publish.assert_called_with("test_channel", "test_message")
+
+ @patch("redis.sentinel.Sentinel")
+ def test_pipeline_commands_sync(self, mock_sentinel_class):
+ """Test Redis pipeline commands in sync mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_pipeline = Mock()
+
+ # Mock pipeline responses
+ mock_master.pipeline.return_value = mock_pipeline
+ mock_pipeline.set.return_value = mock_pipeline
+ mock_pipeline.get.return_value = mock_pipeline
+ mock_pipeline.execute.return_value = [True, "test_value"]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test pipeline (factory method - should pass through)
+ pipeline_method = proxy.__getattr__("pipeline")
+ result = pipeline_method()
+ assert result == mock_pipeline
+ mock_master.pipeline.assert_called_once()
+
+ @patch("redis.sentinel.Sentinel")
+ def test_commands_with_failover_retry(self, mock_sentinel_class):
+ """Test Redis commands with failover retry mechanism"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call fails with connection error, second succeeds
+ mock_master.hget.side_effect = [
+ redis.exceptions.ConnectionError("Connection failed"),
+ "recovered_value",
+ ]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test hget with retry
+ hget_method = proxy.__getattr__("hget")
+ result = hget_method("test_hash", "field1")
+
+ assert result == "recovered_value"
+ assert mock_master.hget.call_count == 2
+
+ # Verify both calls were made with same parameters
+ expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)]
+ actual_calls = [call.args for call in mock_master.hget.call_args_list]
+ assert actual_calls == expected_calls
+
+ @patch("redis.sentinel.Sentinel")
+ def test_commands_with_readonly_error_retry(self, mock_sentinel_class):
+ """Test Redis commands with ReadOnlyError retry mechanism"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call fails with ReadOnlyError, second succeeds
+ mock_master.hset.side_effect = [
+ redis.exceptions.ReadOnlyError(
+ "READONLY You can't write against a read only replica"
+ ),
+ 1,
+ ]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+ # Test hset with retry
+ hset_method = proxy.__getattr__("hset")
+ result = hset_method("test_hash", "field1", "value1")
+
+ assert result == 1
+ assert mock_master.hset.call_count == 2
+
+ # Verify both calls were made with same parameters
+ expected_calls = [
+ (("test_hash", "field1", "value1"),),
+ (("test_hash", "field1", "value1"),),
+ ]
+ actual_calls = [call.args for call in mock_master.hset.call_args_list]
+ assert actual_calls == expected_calls
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_async_commands_with_failover_retry(self, mock_sentinel_class):
+ """Test async Redis commands with failover retry mechanism"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call fails with connection error, second succeeds
+ mock_master.hget = AsyncMock(
+ side_effect=[
+ redis.exceptions.ConnectionError("Connection failed"),
+ "recovered_value",
+ ]
+ )
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test async hget with retry
+ hget_method = proxy.__getattr__("hget")
+ result = await hget_method("test_hash", "field1")
+
+ assert result == "recovered_value"
+ assert mock_master.hget.call_count == 2
+
+ # Verify both calls were made with same parameters
+ expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)]
+ actual_calls = [call.args for call in mock_master.hget.call_args_list]
+ assert actual_calls == expected_calls
+
+
+class TestSentinelRedisProxyFactoryMethods:
+ """Test Redis factory methods in async mode - these are special cases that remain sync"""
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_pubsub_factory_method_async(self, mock_sentinel_class):
+ """Test pubsub factory method in async mode - should pass through without wrapping"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_pubsub = Mock()
+
+ # Mock pubsub factory method
+ mock_master.pubsub.return_value = mock_pubsub
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test pubsub factory method - should NOT be wrapped as async
+ pubsub_method = proxy.__getattr__("pubsub")
+ result = pubsub_method()
+
+ assert result == mock_pubsub
+ mock_master.pubsub.assert_called_once()
+
+ # Verify it's not wrapped as async (no await needed)
+ assert not inspect.iscoroutine(result)
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_pipeline_factory_method_async(self, mock_sentinel_class):
+ """Test pipeline factory method in async mode - should pass through without wrapping"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_pipeline = Mock()
+
+ # Mock pipeline factory method
+ mock_master.pipeline.return_value = mock_pipeline
+ mock_pipeline.set.return_value = mock_pipeline
+ mock_pipeline.get.return_value = mock_pipeline
+ mock_pipeline.execute.return_value = [True, "test_value"]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test pipeline factory method - should NOT be wrapped as async
+ pipeline_method = proxy.__getattr__("pipeline")
+ result = pipeline_method()
+
+ assert result == mock_pipeline
+ mock_master.pipeline.assert_called_once()
+
+ # Verify it's not wrapped as async (no await needed)
+ assert not inspect.iscoroutine(result)
+
+ # Test pipeline usage (these should also be sync)
+ pipeline_result = result.set("key", "value").get("key").execute()
+ assert pipeline_result == [True, "test_value"]
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_factory_methods_vs_regular_commands_async(self, mock_sentinel_class):
+ """Test that factory methods behave differently from regular commands in async mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock both factory method and regular command
+ mock_pubsub = Mock()
+ mock_master.pubsub.return_value = mock_pubsub
+ mock_master.get = AsyncMock(return_value="test_value")
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test factory method - should NOT be wrapped
+ pubsub_method = proxy.__getattr__("pubsub")
+ pubsub_result = pubsub_method()
+
+ # Test regular command - should be wrapped as async
+ get_method = proxy.__getattr__("get")
+ get_result = get_method("test_key")
+
+ # Factory method returns directly
+ assert pubsub_result == mock_pubsub
+ assert not inspect.iscoroutine(pubsub_result)
+
+ # Regular command returns coroutine
+ assert inspect.iscoroutine(get_result)
+
+ # Regular command needs await
+ actual_value = await get_result
+ assert actual_value == "test_value"
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_factory_methods_with_failover_async(self, mock_sentinel_class):
+ """Test factory methods with failover in async mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # First call fails, second succeeds
+ mock_pubsub = Mock()
+ mock_master.pubsub.side_effect = [
+ redis.exceptions.ConnectionError("Connection failed"),
+ mock_pubsub,
+ ]
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test pubsub factory method with failover
+ pubsub_method = proxy.__getattr__("pubsub")
+ result = pubsub_method()
+
+ assert result == mock_pubsub
+ assert mock_master.pubsub.call_count == 2 # Retry happened
+
+ # Verify it's still not wrapped as async after retry
+ assert not inspect.iscoroutine(result)
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_monitor_factory_method_async(self, mock_sentinel_class):
+ """Test monitor factory method in async mode - should pass through without wrapping"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_monitor = Mock()
+
+ # Mock monitor factory method
+ mock_master.monitor.return_value = mock_monitor
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test monitor factory method - should NOT be wrapped as async
+ monitor_method = proxy.__getattr__("monitor")
+ result = monitor_method()
+
+ assert result == mock_monitor
+ mock_master.monitor.assert_called_once()
+
+ # Verify it's not wrapped as async (no await needed)
+ assert not inspect.iscoroutine(result)
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_client_factory_method_async(self, mock_sentinel_class):
+ """Test client factory method in async mode - should pass through without wrapping"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_client = Mock()
+
+ # Mock client factory method
+ mock_master.client.return_value = mock_client
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test client factory method - should NOT be wrapped as async
+ client_method = proxy.__getattr__("client")
+ result = client_method()
+
+ assert result == mock_client
+ mock_master.client.assert_called_once()
+
+ # Verify it's not wrapped as async (no await needed)
+ assert not inspect.iscoroutine(result)
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_transaction_factory_method_async(self, mock_sentinel_class):
+ """Test transaction factory method in async mode - should pass through without wrapping"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+ mock_transaction = Mock()
+
+ # Mock transaction factory method
+ mock_master.transaction.return_value = mock_transaction
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test transaction factory method - should NOT be wrapped as async
+ transaction_method = proxy.__getattr__("transaction")
+ result = transaction_method()
+
+ assert result == mock_transaction
+ mock_master.transaction.assert_called_once()
+
+ # Verify it's not wrapped as async (no await needed)
+ assert not inspect.iscoroutine(result)
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_all_factory_methods_async(self, mock_sentinel_class):
+ """Test all factory methods in async mode - comprehensive test"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock all factory methods
+ mock_objects = {
+ "pipeline": Mock(),
+ "pubsub": Mock(),
+ "monitor": Mock(),
+ "client": Mock(),
+ "transaction": Mock(),
+ }
+
+ for method_name, mock_obj in mock_objects.items():
+ setattr(mock_master, method_name, Mock(return_value=mock_obj))
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Test all factory methods
+ for method_name, expected_obj in mock_objects.items():
+ method = proxy.__getattr__(method_name)
+ result = method()
+
+ assert result == expected_obj
+ assert not inspect.iscoroutine(result)
+ getattr(mock_master, method_name).assert_called_once()
+
+ # Reset mock for next iteration
+ getattr(mock_master, method_name).reset_mock()
+
+ @patch("redis.sentinel.Sentinel")
+ @pytest.mark.asyncio
+ async def test_mixed_factory_and_regular_commands_async(self, mock_sentinel_class):
+ """Test using both factory methods and regular commands in async mode"""
+ mock_sentinel = Mock()
+ mock_master = Mock()
+
+ # Mock pipeline factory and regular commands
+ mock_pipeline = Mock()
+ mock_master.pipeline.return_value = mock_pipeline
+ mock_pipeline.set.return_value = mock_pipeline
+ mock_pipeline.get.return_value = mock_pipeline
+ mock_pipeline.execute.return_value = [True, "pipeline_value"]
+
+ mock_master.get = AsyncMock(return_value="regular_value")
+
+ mock_sentinel.master_for.return_value = mock_master
+
+ proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+ # Use factory method (sync)
+ pipeline = proxy.__getattr__("pipeline")()
+ pipeline_result = pipeline.set("key1", "value1").get("key1").execute()
+
+ # Use regular command (async)
+ get_method = proxy.__getattr__("get")
+ regular_result = await get_method("key2")
+
+ # Verify both work correctly
+ assert pipeline_result == [True, "pipeline_value"]
+ assert regular_result == "regular_value"
+
+ # Verify calls
+ mock_master.pipeline.assert_called_once()
+ mock_master.get.assert_called_with("key2")
diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py
index c93574527f..97d0b41491 100644
--- a/backend/open_webui/utils/access_control.py
+++ b/backend/open_webui/utils/access_control.py
@@ -1,4 +1,4 @@
-from typing import Optional, Union, List, Dict, Any
+from typing import Optional, Set, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
@@ -60,8 +60,7 @@ def get_permissions(
# Combine permissions from all user groups
for group in user_groups:
- group_permissions = group.permissions or {}
- permissions = combine_permissions(permissions, group_permissions)
+ permissions = combine_permissions(permissions, group.permissions or {})
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
@@ -96,8 +95,7 @@ def has_permission(
user_groups = Groups.get_groups_by_member_id(user_id)
for group in user_groups:
- group_permissions = group.permissions
- if get_permission(group_permissions, permission_hierarchy):
+ if get_permission(group.permissions or {}, permission_hierarchy):
return True
# Check default permissions afterward if the group permissions don't allow it
@@ -107,19 +105,45 @@ def has_permission(
return get_permission(default_permissions, permission_hierarchy)
+def get_permitted_group_and_user_ids(
+ type: str = "write", access_control: Optional[dict] = None
+) -> Union[Dict[str, List[str]], None]:
+ if access_control is None:
+ return None
+
+ permission_access = access_control.get(type, {})
+ permitted_group_ids = permission_access.get("group_ids", [])
+ permitted_user_ids = permission_access.get("user_ids", [])
+
+ return {
+ "group_ids": permitted_group_ids,
+ "user_ids": permitted_user_ids,
+ }
+
+
def has_access(
user_id: str,
type: str = "write",
access_control: Optional[dict] = None,
+ user_group_ids: Optional[Set[str]] = None,
+ strict: bool = True,
) -> bool:
if access_control is None:
- return type == "read"
+ if strict:
+ return type == "read"
+ else:
+ return True
- user_groups = Groups.get_groups_by_member_id(user_id)
- user_group_ids = [group.id for group in user_groups]
- permission_access = access_control.get(type, {})
- permitted_group_ids = permission_access.get("group_ids", [])
- permitted_user_ids = permission_access.get("user_ids", [])
+ if user_group_ids is None:
+ user_groups = Groups.get_groups_by_member_id(user_id)
+ user_group_ids = {group.id for group in user_groups}
+
+ permitted_ids = get_permitted_group_and_user_ids(type, access_control)
+ if permitted_ids is None:
+ return False
+
+ permitted_group_ids = permitted_ids.get("group_ids", [])
+ permitted_user_ids = permitted_ids.get("user_ids", [])
return user_id in permitted_user_ids or any(
group_id in permitted_group_ids for group_id in user_group_ids
@@ -129,19 +153,22 @@ def has_access(
# Get all users with access to a resource
def get_users_with_access(
type: str = "write", access_control: Optional[dict] = None
-) -> List[UserModel]:
+) -> list[UserModel]:
if access_control is None:
- return Users.get_users()
+ result = Users.get_users(filter={"roles": ["!pending"]})
+ return result.get("users", [])
- permission_access = access_control.get(type, {})
- permitted_group_ids = permission_access.get("group_ids", [])
- permitted_user_ids = permission_access.get("user_ids", [])
+ permitted_ids = get_permitted_group_and_user_ids(type, access_control)
+ if permitted_ids is None:
+ return []
+
+ permitted_group_ids = permitted_ids.get("group_ids", [])
+ permitted_user_ids = permitted_ids.get("user_ids", [])
user_ids_with_access = set(permitted_user_ids)
- for group_id in permitted_group_ids:
- group_user_ids = Groups.get_group_user_ids_by_id(group_id)
- if group_user_ids:
- user_ids_with_access.update(group_user_ids)
+ group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids)
+ for user_ids in group_user_ids_map.values():
+ user_ids_with_access.update(user_ids)
return Users.get_users_by_user_ids(list(user_ids_with_access))
diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py
index 8193907d27..0cef3c91f8 100644
--- a/backend/open_webui/utils/audit.py
+++ b/backend/open_webui/utils/audit.py
@@ -195,7 +195,7 @@ class AuditLoggingMiddleware:
try:
user = get_current_user(
- request, None, get_http_authorization_cred(auth_header)
+ request, None, None, get_http_authorization_cred(auth_header)
)
return user
except Exception as e:
diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py
index 9befaf2a91..f3069a093f 100644
--- a/backend/open_webui/utils/auth.py
+++ b/backend/open_webui/utils/auth.py
@@ -6,6 +6,12 @@ import hmac
import hashlib
import requests
import os
+import bcrypt
+
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.asymmetric import ed25519
+from cryptography.hazmat.primitives import serialization
+import json
from datetime import datetime, timedelta
@@ -15,10 +21,19 @@ from typing import Optional, Union, List, Dict
from opentelemetry import trace
+
+from open_webui.utils.access_control import has_permission
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
+
from open_webui.env import (
+ ENABLE_PASSWORD_VALIDATION,
+ OFFLINE_MODE,
+ LICENSE_BLOB,
+ PASSWORD_VALIDATION_REGEX_PATTERN,
+ REDIS_KEY_PREFIX,
+ pk,
WEBUI_SECRET_KEY,
TRUSTED_SIGNATURE_KEY,
STATIC_DIR,
@@ -28,11 +43,8 @@ from open_webui.env import (
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"])
@@ -74,50 +86,110 @@ def override_static(path: str, content: str):
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,
+ def data_handler(data):
+ for k, v in data.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)
+
+ def handler(u):
+ res = requests.post(
+ f"{u}/api/v1/license/",
+ json={"key": key, "version": "1"},
+ timeout=5,
+ )
+
+ if getattr(res, "ok", False):
+ payload = getattr(res, "json", lambda: {})()
+ data_handler(payload)
+ return True
+ else:
+ log.error(
+ f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
)
- 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')}"
- )
+ if key:
+ us = [
+ "https://api.openwebui.com",
+ "https://licenses.api.openwebui.com",
+ ]
+ try:
+ for u in us:
+ if handler(u):
+ return True
except Exception as ex:
log.exception(f"License: Uncaught Exception: {ex}")
+
+ try:
+ if LICENSE_BLOB:
+ nl = 12
+ kb = hashlib.sha256((key.replace("-", "").upper()).encode()).digest()
+
+ def nt(b):
+ return b[:nl], b[nl:]
+
+ lb = base64.b64decode(LICENSE_BLOB)
+ ln, lt = nt(lb)
+
+ aesgcm = AESGCM(kb)
+ p = json.loads(aesgcm.decrypt(ln, lt, None))
+ pk.verify(base64.b64decode(p["s"]), p["p"].encode())
+
+ pb = base64.b64decode(p["p"])
+ pn, pt = nt(pb)
+
+ data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
+ if not data.get("exp") and data.get("exp") < datetime.now().date():
+ return False
+
+ data_handler(data)
+ return True
+ except Exception as e:
+ log.error(f"License: {e}")
+
return False
bearer_security = HTTPBearer(auto_error=False)
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-def verify_password(plain_password, hashed_password):
+def get_password_hash(password: str) -> str:
+ """Hash a password using bcrypt"""
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def validate_password(password: str) -> bool:
+ # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
+ if len(password.encode("utf-8")) > 72:
+ raise Exception(
+ ERROR_MESSAGES.PASSWORD_TOO_LONG,
+ )
+
+ if ENABLE_PASSWORD_VALIDATION:
+ if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password):
+ raise Exception(ERROR_MESSAGES.INVALID_PASSWORD())
+
+ return True
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against its hash"""
return (
- pwd_context.verify(plain_password, hashed_password) if hashed_password else None
+ bcrypt.checkpw(
+ plain_password.encode("utf-8"),
+ hashed_password.encode("utf-8"),
+ )
+ if hashed_password
+ else None
)
-def get_password_hash(password):
- return pwd_context.hash(password)
-
-
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
payload = data.copy()
@@ -125,6 +197,9 @@ def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> st
expire = datetime.now(UTC) + expires_delta
payload.update({"exp": expire})
+ jti = str(uuid.uuid4())
+ payload.update({"jti": jti})
+
encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM)
return encoded_jwt
@@ -137,6 +212,43 @@ def decode_token(token: str) -> Optional[dict]:
return None
+async def is_valid_token(request, decoded) -> bool:
+ # Require Redis to check revoked tokens
+ if request.app.state.redis:
+ jti = decoded.get("jti")
+
+ if jti:
+ revoked = await request.app.state.redis.get(
+ f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked"
+ )
+ if revoked:
+ return False
+
+ return True
+
+
+async def invalidate_token(request, token):
+ decoded = decode_token(token)
+
+ # Require Redis to store revoked tokens
+ if request.app.state.redis:
+ jti = decoded.get("jti")
+ exp = decoded.get("exp")
+
+ if jti:
+ ttl = exp - int(
+ datetime.now(UTC).timestamp()
+ ) # Calculate time-to-live for the token
+
+ if ttl > 0:
+ # Store the revoked token in Redis with an expiration time
+ await request.app.state.redis.set(
+ f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked",
+ "1",
+ ex=ttl,
+ )
+
+
def extract_token_from_auth_header(auth_header: str):
return auth_header[len("Bearer ") :]
@@ -156,7 +268,7 @@ def get_http_authorization_cred(auth_header: Optional[str]):
return None
-def get_current_user(
+async def get_current_user(
request: Request,
response: Response,
background_tasks: BackgroundTasks,
@@ -171,34 +283,11 @@ def get_current_user(
token = request.cookies.get("token")
if token is None:
- raise HTTPException(status_code=403, detail="Not authenticated")
+ raise HTTPException(status_code=401, detail="Not authenticated")
# auth by api key
if token.startswith("sk-"):
- if not request.state.enable_api_key:
- raise HTTPException(
- status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
- )
-
- if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
- allowed_paths = [
- path.strip()
- for path in str(
- request.app.state.config.API_KEY_ALLOWED_ENDPOINTS
- ).split(",")
- ]
-
- # 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
- )
-
- user = get_current_user_by_api_key(token)
+ user = get_current_user_by_api_key(request, token)
# Add user info to current span
current_span = trace.get_current_span()
@@ -212,57 +301,74 @@ def get_current_user(
# auth by jwt token
try:
- data = decode_token(token)
- except Exception as e:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid token",
- )
-
- if data is not None and "id" in data:
- user = Users.get_user_by_id(data["id"])
- if user is None:
+ try:
+ data = decode_token(token)
+ except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
- detail=ERROR_MESSAGES.INVALID_TOKEN,
+ detail="Invalid token",
)
- else:
- if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
- trusted_email = request.headers.get(
- WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
- ).lower()
- if trusted_email and user.email != trusted_email:
- # Delete the token cookie
- response.delete_cookie("token")
- # Delete OAuth token if present
- if request.cookies.get("oauth_id_token"):
- response.delete_cookie("oauth_id_token")
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="User mismatch. Please sign in again.",
+
+ if data is not None and "id" in data:
+ if data.get("jti") and not await is_valid_token(request, data):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid token",
+ )
+
+ user = Users.get_user_by_id(data["id"])
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.INVALID_TOKEN,
+ )
+ else:
+ if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
+ trusted_email = request.headers.get(
+ WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
+ ).lower()
+ if trusted_email and user.email != trusted_email:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User mismatch. Please sign in again.",
+ )
+
+ # Add user info to current span
+ current_span = trace.get_current_span()
+ if current_span:
+ current_span.set_attribute("client.user.id", user.id)
+ current_span.set_attribute("client.user.email", user.email)
+ current_span.set_attribute("client.user.role", user.role)
+ current_span.set_attribute("client.auth.type", "jwt")
+
+ # Refresh the user's last active timestamp asynchronously
+ # to prevent blocking the request
+ if background_tasks:
+ background_tasks.add_task(
+ Users.update_user_last_active_by_id, user.id
)
+ return user
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.UNAUTHORIZED,
+ )
+ except Exception as e:
+ # Delete the token cookie
+ if request.cookies.get("token"):
+ response.delete_cookie("token")
- # Add user info to current span
- current_span = trace.get_current_span()
- if current_span:
- current_span.set_attribute("client.user.id", user.id)
- current_span.set_attribute("client.user.email", user.email)
- current_span.set_attribute("client.user.role", user.role)
- current_span.set_attribute("client.auth.type", "jwt")
+ if request.cookies.get("oauth_id_token"):
+ response.delete_cookie("oauth_id_token")
- # Refresh the user's last active timestamp asynchronously
- # to prevent blocking the request
- if background_tasks:
- background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
- return user
- else:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail=ERROR_MESSAGES.UNAUTHORIZED,
- )
+ # Delete OAuth session if present
+ if request.cookies.get("oauth_session_id"):
+ response.delete_cookie("oauth_session_id")
+
+ raise e
-def get_current_user_by_api_key(api_key: str):
+def get_current_user_by_api_key(request, api_key: str):
user = Users.get_user_by_api_key(api_key)
if user is None:
@@ -270,16 +376,28 @@ def get_current_user_by_api_key(api_key: str):
status_code=status.HTTP_401_UNAUTHORIZED,
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)
+ if not request.state.enable_api_keys or (
+ user.role != "admin"
+ and not has_permission(
+ user.id,
+ "features.api_keys",
+ request.app.state.config.USER_PERMISSIONS,
+ )
+ ):
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
+ )
+
+ # 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/channels.py b/backend/open_webui/utils/channels.py
new file mode 100644
index 0000000000..312b5ea24c
--- /dev/null
+++ b/backend/open_webui/utils/channels.py
@@ -0,0 +1,31 @@
+import re
+
+
+def extract_mentions(message: str, triggerChar: str = "@"):
+ # Escape triggerChar in case it's a regex special character
+ triggerChar = re.escape(triggerChar)
+ pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
+
+ matches = re.findall(pattern, message)
+ return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
+
+
+def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
+ """
+ Replace mentions in the message with either their label (after the pipe `|`)
+ or their id if no label exists.
+
+ Example:
+ "<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True)
+ "<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
+ """
+ # Escape triggerChar
+ triggerChar = re.escape(triggerChar)
+
+ def replacer(match):
+ id_type, id_value, label = match.groups()
+ return label if use_label and label else id_value
+
+ # Regex captures: idType, id, optional label
+ pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
+ return re.sub(pattern, replacer, message)
diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py
index 268c910e3e..8b6a0b9da2 100644
--- a/backend/open_webui/utils/chat.py
+++ b/backend/open_webui/utils/chat.py
@@ -80,6 +80,7 @@ async def generate_direct_chat_completion(
event_caller = get_event_call(metadata)
channel = f"{user_id}:{session_id}:{request_id}"
+ logging.info(f"WebSocket channel: {channel}")
if form_data.get("stream"):
q = asyncio.Queue()
@@ -121,7 +122,10 @@ async def generate_direct_chat_completion(
yield f"data: {json.dumps(data)}\n\n"
elif isinstance(data, str):
- yield data
+ if "data:" in data:
+ yield f"{data}\n\n"
+ else:
+ yield f"data: {data}\n\n"
except Exception as e:
log.debug(f"Error in event generator: {e}")
pass
@@ -419,7 +423,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
params[key] = value
if "__user__" in sig.parameters:
- __user__ = (user.model_dump() if isinstance(user, UserModel) else {},)
+ __user__ = user.model_dump() if isinstance(user, UserModel) else {}
try:
if hasattr(function_module, "UserValves"):
diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py
new file mode 100644
index 0000000000..4f9564b7d4
--- /dev/null
+++ b/backend/open_webui/utils/files.py
@@ -0,0 +1,115 @@
+from open_webui.routers.images import (
+ get_image_data,
+ upload_image,
+)
+
+from fastapi import (
+ APIRouter,
+ Depends,
+ HTTPException,
+ Request,
+ UploadFile,
+)
+
+from open_webui.routers.files import upload_file_handler
+
+import mimetypes
+import base64
+import io
+import re
+
+
+BASE64_IMAGE_URL_PREFIX = re.compile(r"data:image/\w+;base64,", re.IGNORECASE)
+MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE)
+
+
+def get_image_url_from_base64(request, base64_image_string, metadata, user):
+ if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
+ image_url = ""
+ # Extract base64 image data from the line
+ image_data, content_type = get_image_data(base64_image_string)
+ if image_data is not None:
+ image_url = upload_image(
+ request,
+ image_data,
+ content_type,
+ metadata,
+ user,
+ )
+ return image_url
+ return None
+
+
+def convert_markdown_base64_images(request, content: str, metadata, user):
+ def replace(match):
+ base64_string = match.group(2)
+ MIN_REPLACEMENT_URL_LENGTH = 1024
+ if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH:
+ url = get_image_url_from_base64(request, base64_string, metadata, user)
+ if url:
+ return f""
+ return match.group(0)
+
+ return MARKDOWN_IMAGE_URL_PATTERN.sub(replace, content)
+
+
+def load_b64_audio_data(b64_str):
+ try:
+ if "," in b64_str:
+ header, b64_data = b64_str.split(",", 1)
+ else:
+ b64_data = b64_str
+ header = "data:audio/wav;base64"
+ audio_data = base64.b64decode(b64_data)
+ content_type = (
+ header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
+ )
+ return audio_data, content_type
+ except Exception as e:
+ print(f"Error decoding base64 audio data: {e}")
+ return None, None
+
+
+def upload_audio(request, audio_data, content_type, metadata, user):
+ audio_format = mimetypes.guess_extension(content_type)
+ file = UploadFile(
+ file=io.BytesIO(audio_data),
+ filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
+ headers={
+ "content-type": content_type,
+ },
+ )
+ file_item = upload_file_handler(
+ request,
+ file=file,
+ metadata=metadata,
+ process=False,
+ user=user,
+ )
+ url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
+ return url
+
+
+def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
+ if "data:audio/wav;base64" in base64_audio_string:
+ audio_url = ""
+ # Extract base64 audio data from the line
+ audio_data, content_type = load_b64_audio_data(base64_audio_string)
+ if audio_data is not None:
+ audio_url = upload_audio(
+ request,
+ audio_data,
+ content_type,
+ metadata,
+ user,
+ )
+ return audio_url
+ return None
+
+
+def get_file_url_from_base64(request, base64_file_string, metadata, user):
+ if "data:image/png;base64" in base64_file_string:
+ return get_image_url_from_base64(request, base64_file_string, metadata, user)
+ elif "data:audio/wav;base64" in base64_file_string:
+ return get_audio_url_from_base64(request, base64_file_string, metadata, user)
+ return None
diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py
index 1986e55b64..663b4e3fb7 100644
--- a/backend/open_webui/utils/filter.py
+++ b/backend/open_webui/utils/filter.py
@@ -127,8 +127,10 @@ async def process_filter_functions(
raise e
# Handle file cleanup for inlet
- if skip_files and "files" in form_data.get("metadata", {}):
- del form_data["files"]
- del form_data["metadata"]["files"]
+ if skip_files:
+ if "files" in form_data.get("metadata", {}):
+ del form_data["metadata"]["files"]
+ if "files" in form_data:
+ del form_data["files"]
return form_data, {}
diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py
new file mode 100644
index 0000000000..3caee50334
--- /dev/null
+++ b/backend/open_webui/utils/headers.py
@@ -0,0 +1,11 @@
+from urllib.parse import quote
+
+
+def include_user_info_headers(headers, user):
+ return {
+ **headers,
+ "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
+ "X-OpenWebUI-User-Id": user.id,
+ "X-OpenWebUI-User-Email": user.email,
+ "X-OpenWebUI-User-Role": user.role,
+ }
diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py
index b86c257591..506723bc92 100644
--- a/backend/open_webui/utils/images/comfyui.py
+++ b/backend/open_webui/utils/images/comfyui.py
@@ -2,6 +2,8 @@ import asyncio
import json
import logging
import random
+import requests
+import aiohttp
import urllib.parse
import urllib.request
from typing import Optional
@@ -91,6 +93,25 @@ def get_images(ws, prompt, client_id, base_url, api_key):
return {"data": output_images}
+async def comfyui_upload_image(image_file_item, base_url, api_key):
+ url = f"{base_url}/api/upload/image"
+ headers = {}
+
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ _, (filename, file_bytes, mime_type) = image_file_item
+
+ form = aiohttp.FormData()
+ form.add_field("image", file_bytes, filename=filename, content_type=mime_type)
+ form.add_field("type", "input") # required by ComfyUI
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(url, data=form, headers=headers) as resp:
+ resp.raise_for_status()
+ return await resp.json()
+
+
class ComfyUINodeInput(BaseModel):
type: Optional[str] = None
node_ids: list[str] = []
@@ -103,7 +124,7 @@ class ComfyUIWorkflow(BaseModel):
nodes: list[ComfyUINodeInput]
-class ComfyUIGenerateImageForm(BaseModel):
+class ComfyUICreateImageForm(BaseModel):
workflow: ComfyUIWorkflow
prompt: str
@@ -116,8 +137,8 @@ class ComfyUIGenerateImageForm(BaseModel):
seed: Optional[int] = None
-async def comfyui_generate_image(
- model: str, payload: ComfyUIGenerateImageForm, client_id, base_url, api_key
+async def comfyui_create_image(
+ model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key
):
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
workflow = json.loads(payload.workflow.workflow)
@@ -191,3 +212,102 @@ async def comfyui_generate_image(
ws.close()
return images
+
+
+class ComfyUIEditImageForm(BaseModel):
+ workflow: ComfyUIWorkflow
+
+ image: str | list[str]
+ prompt: str
+ width: Optional[int] = None
+ height: Optional[int] = None
+ n: Optional[int] = None
+
+ steps: Optional[int] = None
+ seed: Optional[int] = None
+
+
+async def comfyui_edit_image(
+ model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key
+):
+ ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
+ workflow = json.loads(payload.workflow.workflow)
+
+ for node in payload.workflow.nodes:
+ if node.type:
+ if node.type == "model":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][node.key] = model
+ elif node.type == "image":
+ if isinstance(payload.image, list):
+ # check if multiple images are provided
+ for idx, node_id in enumerate(node.node_ids):
+ if idx < len(payload.image):
+ workflow[node_id]["inputs"][node.key] = payload.image[idx]
+ else:
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][node.key] = payload.image
+ elif node.type == "prompt":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "text"
+ ] = payload.prompt
+ elif node.type == "negative_prompt":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "text"
+ ] = payload.negative_prompt
+ elif node.type == "width":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "width"
+ ] = payload.width
+ elif node.type == "height":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "height"
+ ] = payload.height
+ elif node.type == "n":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "batch_size"
+ ] = payload.n
+ elif node.type == "steps":
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][
+ node.key if node.key else "steps"
+ ] = payload.steps
+ elif node.type == "seed":
+ seed = (
+ payload.seed
+ if payload.seed
+ else random.randint(0, 1125899906842624)
+ )
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][node.key] = seed
+ else:
+ for node_id in node.node_ids:
+ workflow[node_id]["inputs"][node.key] = node.value
+
+ try:
+ ws = websocket.WebSocket()
+ headers = {"Authorization": f"Bearer {api_key}"}
+ ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers)
+ log.info("WebSocket connection established.")
+ except Exception as e:
+ log.exception(f"Failed to connect to WebSocket server: {e}")
+ return None
+
+ try:
+ log.info("Sending workflow to WebSocket server.")
+ log.info(f"Workflow: {workflow}")
+ images = await asyncio.to_thread(
+ get_images, ws, workflow, client_id, base_url, api_key
+ )
+ except Exception as e:
+ log.exception(f"Error while receiving images: {e}")
+ images = None
+
+ ws.close()
+
+ return images
diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py
index 2557610060..540527bf82 100644
--- a/backend/open_webui/utils/logger.py
+++ b/backend/open_webui/utils/logger.py
@@ -4,12 +4,15 @@ import sys
from typing import TYPE_CHECKING
from loguru import logger
-
+from opentelemetry import trace
from open_webui.env import (
+ AUDIT_UVICORN_LOGGER_NAMES,
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL,
+ ENABLE_OTEL,
+ ENABLE_OTEL_LOGS,
)
@@ -26,13 +29,16 @@ def stdout_format(record: "Record") -> str:
Returns:
str: A formatted log string intended for stdout.
"""
- record["extra"]["extra_json"] = json.dumps(record["extra"])
+ if record["extra"]:
+ record["extra"]["extra_json"] = json.dumps(record["extra"])
+ extra_format = " - {extra[extra_json]}"
+ else:
+ extra_format = ""
return (
"{time:YYYY-MM-DD HH:mm:ss.SSS} | "
"{level: <8} | "
"{name} :{function} :{line} - "
- "{message} - {extra[extra_json]}"
- "\n{exception}"
+ "{message} " + extra_format + "\n{exception}"
)
@@ -58,9 +64,24 @@ class InterceptHandler(logging.Handler):
frame = frame.f_back
depth += 1
- logger.opt(depth=depth, exception=record.exc_info).log(
- level, record.getMessage()
- )
+ logger.opt(depth=depth, exception=record.exc_info).bind(
+ **self._get_extras()
+ ).log(level, record.getMessage())
+ if ENABLE_OTEL and ENABLE_OTEL_LOGS:
+ from open_webui.utils.telemetry.logs import otel_handler
+
+ otel_handler.emit(record)
+
+ def _get_extras(self):
+ if not ENABLE_OTEL:
+ return {}
+
+ extras = {}
+ context = trace.get_current_span().get_span_context()
+ if context.is_valid:
+ extras["trace_id"] = trace.format_trace_id(context.trace_id)
+ extras["span_id"] = trace.format_span_id(context.span_id)
+ return extras
def file_format(record: "Record"):
@@ -111,7 +132,6 @@ def start_logger():
format=stdout_format,
filter=lambda record: "auditable" not in record["extra"],
)
-
if AUDIT_LOG_LEVEL != "NONE":
try:
logger.add(
@@ -128,11 +148,13 @@ def start_logger():
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"]:
+
+ for uvicorn_logger_name in AUDIT_UVICORN_LOGGER_NAMES:
uvicorn_logger = logging.getLogger(uvicorn_logger_name)
uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL)
uvicorn_logger.handlers = [InterceptHandler()]
diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py
new file mode 100644
index 0000000000..6edfca4f6c
--- /dev/null
+++ b/backend/open_webui/utils/mcp/client.py
@@ -0,0 +1,115 @@
+import asyncio
+from typing import Optional
+from contextlib import AsyncExitStack
+
+import anyio
+
+from mcp import ClientSession
+from mcp.client.auth import OAuthClientProvider, TokenStorage
+from mcp.client.streamable_http import streamablehttp_client
+from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
+
+
+class MCPClient:
+ def __init__(self):
+ self.session: Optional[ClientSession] = None
+ self.exit_stack = None
+
+ async def connect(self, url: str, headers: Optional[dict] = None):
+ async with AsyncExitStack() as exit_stack:
+ try:
+ self._streams_context = streamablehttp_client(url, headers=headers)
+
+ transport = await exit_stack.enter_async_context(self._streams_context)
+ read_stream, write_stream, _ = transport
+
+ self._session_context = ClientSession(
+ read_stream, write_stream
+ ) # pylint: disable=W0201
+
+ self.session = await exit_stack.enter_async_context(
+ self._session_context
+ )
+ with anyio.fail_after(10):
+ await self.session.initialize()
+ self.exit_stack = exit_stack.pop_all()
+ except Exception as e:
+ await asyncio.shield(self.disconnect())
+ raise e
+
+ async def list_tool_specs(self) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.list_tools()
+ tools = result.tools
+
+ tool_specs = []
+ for tool in tools:
+ name = tool.name
+ description = tool.description
+
+ inputSchema = tool.inputSchema
+
+ # TODO: handle outputSchema if needed
+ outputSchema = getattr(tool, "outputSchema", None)
+
+ tool_specs.append(
+ {"name": name, "description": description, "parameters": inputSchema}
+ )
+
+ return tool_specs
+
+ async def call_tool(
+ self, function_name: str, function_args: dict
+ ) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.call_tool(function_name, function_args)
+ if not result:
+ raise Exception("No result returned from MCP tool call.")
+
+ result_dict = result.model_dump(mode="json")
+ result_content = result_dict.get("content", {})
+
+ if result.isError:
+ raise Exception(result_content)
+ else:
+ return result_content
+
+ async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.list_resources(cursor=cursor)
+ if not result:
+ raise Exception("No result returned from MCP list_resources call.")
+
+ result_dict = result.model_dump()
+ resources = result_dict.get("resources", [])
+
+ return resources
+
+ async def read_resource(self, uri: str) -> Optional[dict]:
+ if not self.session:
+ raise RuntimeError("MCP client is not connected.")
+
+ result = await self.session.read_resource(uri)
+ if not result:
+ raise Exception("No result returned from MCP read_resource call.")
+ result_dict = result.model_dump()
+
+ return result_dict
+
+ async def disconnect(self):
+ # Clean up and close the session
+ await self.exit_stack.aclose()
+
+ async def __aenter__(self):
+ await self.exit_stack.__aenter__()
+ return self
+
+ async def __aexit__(self, exc_type, exc_value, traceback):
+ await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
+ await self.disconnect()
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index b1e69db264..efa187a382 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -3,6 +3,7 @@ import logging
import sys
import os
import base64
+import textwrap
import asyncio
from aiocache import cached
@@ -19,10 +20,14 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException
-from starlette.responses import Response, StreamingResponse
+from fastapi.responses import HTMLResponse
+from starlette.responses import Response, StreamingResponse, JSONResponse
+from open_webui.utils.misc import is_string_allowed
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.chats import Chats
+from open_webui.models.folders import Folders
from open_webui.models.users import Users
from open_webui.socket.main import (
get_event_call,
@@ -36,12 +41,15 @@ from open_webui.routers.tasks import (
generate_image_prompt,
generate_chat_tags,
)
-from open_webui.routers.retrieval import process_web_search, SearchForm
+from open_webui.routers.retrieval import (
+ process_web_search,
+ SearchForm,
+)
from open_webui.routers.images import (
- load_b64_image_data,
image_generations,
- GenerateImageForm,
- upload_image,
+ CreateImageForm,
+ image_edits,
+ EditImageForm,
)
from open_webui.routers.pipelines import (
process_pipeline_inlet_filter,
@@ -50,13 +58,18 @@ from open_webui.routers.pipelines import (
from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook
+from open_webui.utils.files import (
+ convert_markdown_base64_images,
+ get_file_url_from_base64,
+ get_image_url_from_base64,
+)
from open_webui.models.users import UserModel
from open_webui.models.functions import Functions
from open_webui.models.models import Models
-from open_webui.retrieval.utils import get_sources_from_files
+from open_webui.retrieval.utils import get_sources_from_items
from open_webui.utils.chat import generate_chat_completion
@@ -67,34 +80,45 @@ from open_webui.utils.task import (
)
from open_webui.utils.misc import (
deep_update,
+ extract_urls,
get_message_list,
add_or_update_system_message,
add_or_update_user_message,
get_last_user_message,
+ get_last_user_message_item,
get_last_assistant_message,
+ get_system_message,
prepend_to_first_user_message_content,
convert_logit_bias_input_to_json,
+ get_content_from_message,
)
-from open_webui.utils.tools import get_tools
+from open_webui.utils.tools import get_tools, get_updated_tool_function
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.filter import (
get_sorted_filter_ids,
process_filter_functions,
)
from open_webui.utils.code_interpreter import execute_code_jupyter
+from open_webui.utils.payload import apply_system_prompt_to_body
+from open_webui.utils.mcp.client import MCPClient
-from open_webui.tasks import create_task
from open_webui.config import (
CACHE_DIR,
+ DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
DEFAULT_CODE_INTERPRETER_PROMPT,
+ CODE_INTERPRETER_BLOCKED_MODULES,
)
from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
+ ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION,
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
+ CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
+ ENABLE_QUERIES_CACHE,
)
from open_webui.constants import TASKS
@@ -104,6 +128,163 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
+DEFAULT_REASONING_TAGS = [
+ ("", " "),
+ ("", " "),
+ ("", " "),
+ ("", " "),
+ ("", " "),
+ ("", " "),
+ ("<|begin_of_thought|>", "<|end_of_thought|>"),
+ ("◁think▷", "◁/think▷"),
+]
+DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
+DEFAULT_CODE_INTERPRETER_TAGS = [("", " ")]
+
+
+def process_tool_result(
+ request,
+ tool_function_name,
+ tool_result,
+ tool_type,
+ direct_tool=False,
+ metadata=None,
+ user=None,
+):
+ tool_result_embeds = []
+
+ if isinstance(tool_result, HTMLResponse):
+ content_disposition = tool_result.headers.get("Content-Disposition", "")
+ if "inline" in content_disposition:
+ content = tool_result.body.decode("utf-8", "replace")
+ tool_result_embeds.append(content)
+
+ if 200 <= tool_result.status_code < 300:
+ tool_result = {
+ "status": "success",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
+ }
+ elif 400 <= tool_result.status_code < 500:
+ tool_result = {
+ "status": "error",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.",
+ }
+ elif 500 <= tool_result.status_code < 600:
+ tool_result = {
+ "status": "error",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.",
+ }
+ else:
+ tool_result = {
+ "status": "error",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.",
+ }
+ else:
+ tool_result = tool_result.body.decode("utf-8", "replace")
+
+ elif (tool_type == "external" and isinstance(tool_result, tuple)) or (
+ direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
+ ):
+ tool_result, tool_response_headers = tool_result
+
+ try:
+ if not isinstance(tool_response_headers, dict):
+ tool_response_headers = dict(tool_response_headers)
+ except Exception as e:
+ tool_response_headers = {}
+ log.debug(e)
+
+ if tool_response_headers and isinstance(tool_response_headers, dict):
+ content_disposition = tool_response_headers.get(
+ "Content-Disposition",
+ tool_response_headers.get("content-disposition", ""),
+ )
+
+ if "inline" in content_disposition:
+ content_type = tool_response_headers.get(
+ "Content-Type",
+ tool_response_headers.get("content-type", ""),
+ )
+ location = tool_response_headers.get(
+ "Location",
+ tool_response_headers.get("location", ""),
+ )
+
+ if "text/html" in content_type:
+ # Display as iframe embed
+ tool_result_embeds.append(tool_result)
+ tool_result = {
+ "status": "success",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
+ }
+ elif location:
+ tool_result_embeds.append(location)
+ tool_result = {
+ "status": "success",
+ "code": "ui_component",
+ "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
+ }
+
+ tool_result_files = []
+
+ if isinstance(tool_result, list):
+ if tool_type == "mcp": # MCP
+ tool_response = []
+ for item in tool_result:
+ if isinstance(item, dict):
+ if item.get("type") == "text":
+ text = item.get("text", "")
+ if isinstance(text, str):
+ try:
+ text = json.loads(text)
+ except json.JSONDecodeError:
+ pass
+ tool_response.append(text)
+ elif item.get("type") in ["image", "audio"]:
+ file_url = get_file_url_from_base64(
+ request,
+ f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
+ {
+ "chat_id": metadata.get("chat_id", None),
+ "message_id": metadata.get("message_id", None),
+ "session_id": metadata.get("session_id", None),
+ "result": item,
+ },
+ user,
+ )
+
+ tool_result_files.append(
+ {
+ "type": item.get("type", "data"),
+ "url": file_url,
+ }
+ )
+ tool_result = tool_response[0] if len(tool_response) == 1 else tool_response
+ else: # OpenAPI
+ for item in tool_result:
+ if isinstance(item, str) and item.startswith("data:"):
+ tool_result_files.append(
+ {
+ "type": "data",
+ "content": item,
+ }
+ )
+ tool_result.remove(item)
+
+ if isinstance(tool_result, list):
+ tool_result = {"results": tool_result}
+
+ if isinstance(tool_result, dict) or isinstance(tool_result, list):
+ tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
+
+ return tool_result, tool_result_files, tool_result_embeds
+
+
async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
@@ -111,7 +292,7 @@ async def chat_completion_tools_handler(
content = None
if hasattr(response, "body_iterator"):
async for chunk in response.body_iterator:
- data = json.loads(chunk.decode("utf-8"))
+ data = json.loads(chunk.decode("utf-8", "replace"))
content = data["choices"][0]["message"]["content"]
# Cleanup any remaining background tasks if necessary
@@ -123,24 +304,35 @@ async def chat_completion_tools_handler(
def get_tools_function_calling_payload(messages, task_model_id, content):
user_message = get_last_user_message(messages)
- history = "\n".join(
- f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
- for message in messages[::-1][:4]
+
+ if user_message and messages and messages[-1]["role"] == "user":
+ # Remove the last user message to avoid duplication
+ messages = messages[:-1]
+
+ recent_messages = messages[-4:] if len(messages) > 4 else messages
+ chat_history = "\n".join(
+ f"{message['role'].upper()}: \"\"\"{get_content_from_message(message)}\"\"\""
+ for message in recent_messages
)
- prompt = f"History:\n{history}\nQuery: {user_message}"
+ prompt = (
+ f"History:\n{chat_history}\nQuery: {user_message}"
+ if chat_history
+ else f"Query: {user_message}"
+ )
return {
"model": task_model_id,
"messages": [
{"role": "system", "content": content},
- {"role": "user", "content": f"Query: {prompt}"},
+ {"role": "user", "content": prompt},
],
"stream": False,
"metadata": {"task": str(TASKS.FUNCTION_CALLING)},
}
event_caller = extra_params["__event_call__"]
+ event_emitter = extra_params["__event_emitter__"]
metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id(
@@ -195,8 +387,14 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {})
+ tool = None
+ tool_type = ""
+ direct_tool = False
+
try:
tool = tools[tool_function_name]
+ tool_type = tool.get("type", "")
+ direct_tool = tool.get("direct", False)
spec = tool.get("spec", {})
allowed_params = (
@@ -228,18 +426,46 @@ async def chat_completion_tools_handler(
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)
+ tool_result, tool_result_files, tool_result_embeds = (
+ process_tool_result(
+ request,
+ tool_function_name,
+ tool_result,
+ tool_type,
+ direct_tool,
+ metadata,
+ user,
+ )
+ )
- if isinstance(tool_result, dict) or isinstance(tool_result, list):
- tool_result = json.dumps(tool_result, indent=2)
+ if event_emitter:
+ if tool_result_files:
+ await event_emitter(
+ {
+ "type": "files",
+ "data": {
+ "files": tool_result_files,
+ },
+ }
+ )
- if isinstance(tool_result, str):
+ if tool_result_embeds:
+ await event_emitter(
+ {
+ "type": "embeds",
+ "data": {
+ "embeds": tool_result_embeds,
+ },
+ }
+ )
+
+ print(
+ f"Tool {tool_function_name} result: {tool_result}",
+ tool_result_files,
+ tool_result_embeds,
+ )
+
+ if tool_result:
tool = tools[tool_function_name]
tool_id = tool.get("tool_id", "")
@@ -248,30 +474,29 @@ async def chat_completion_tools_handler(
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:{tool_name}"),
- },
- "document": [tool_result],
- "metadata": [
- {
- "source": (f"TOOL:{tool_name}"),
- "parameters": tool_function_params,
- }
- ],
- }
- )
- else:
- # Citation is not enabled for this tool
- body["messages"] = add_or_update_user_message(
- f"\nTool `{tool_name}` Output: {tool_result}",
- body["messages"],
- )
+
+ # Citation is enabled for this tool
+ sources.append(
+ {
+ "source": {
+ "name": (f"{tool_name}"),
+ },
+ "document": [str(tool_result)],
+ "metadata": [
+ {
+ "source": (f"{tool_name}"),
+ "parameters": tool_function_params,
+ }
+ ],
+ "tool_result": True,
+ }
+ )
+
+ # 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]
@@ -350,7 +575,7 @@ async def chat_web_search_handler(
"type": "status",
"data": {
"action": "web_search",
- "description": "Generating search query",
+ "description": "Searching the web",
"done": False,
},
}
@@ -387,6 +612,9 @@ async def chat_web_search_handler(
except Exception as e:
queries = [response]
+ if ENABLE_QUERIES_CACHE:
+ request.state.cached_queries = queries
+
except Exception as e:
log.exception(e)
queries = [user_message]
@@ -413,8 +641,8 @@ async def chat_web_search_handler(
{
"type": "status",
"data": {
- "action": "web_search",
- "description": "Searching the web",
+ "action": "web_search_queries_generated",
+ "queries": queries,
"done": False,
},
}
@@ -465,6 +693,7 @@ async def chat_web_search_handler(
"action": "web_search",
"description": "Searched {{count}} sites",
"urls": results["filenames"],
+ "items": results.get("items", []),
"done": True,
},
}
@@ -500,98 +729,207 @@ async def chat_web_search_handler(
return form_data
+def get_last_images(message_list):
+ images = []
+ for message in reversed(message_list):
+ images_flag = False
+ for file in message.get("files", []):
+ if file.get("type") == "image":
+ images.append(file.get("url"))
+ images_flag = True
+
+ if images_flag:
+ break
+
+ return images
+
+
+def get_image_urls(delta_images, request, metadata, user) -> list[str]:
+ if not isinstance(delta_images, list):
+ return []
+
+ image_urls = []
+ for img in delta_images:
+ if not isinstance(img, dict) or img.get("type") != "image_url":
+ continue
+
+ url = img.get("image_url", {}).get("url")
+ if not url:
+ continue
+
+ if url.startswith("data:image/png;base64"):
+ url = get_image_url_from_base64(request, url, metadata, user)
+
+ image_urls.append(url)
+
+ return image_urls
+
+
async def chat_image_generation_handler(
request: Request, form_data: dict, extra_params: dict, user
):
+ metadata = extra_params.get("__metadata__", {})
+ chat_id = metadata.get("chat_id", None)
+ if not chat_id:
+ return form_data
+
+ chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
+
__event_emitter__ = extra_params["__event_emitter__"]
await __event_emitter__(
{
"type": "status",
- "data": {"description": "Generating an image", "done": False},
+ "data": {"description": "Creating image", "done": False},
}
)
- messages = form_data["messages"]
- user_message = get_last_user_message(messages)
+ messages_map = chat.chat.get("history", {}).get("messages", {})
+ message_id = chat.chat.get("history", {}).get("currentId")
+ message_list = get_message_list(messages_map, message_id)
+ user_message = get_last_user_message(message_list)
prompt = user_message
- negative_prompt = ""
-
- if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
- try:
- res = await generate_image_prompt(
- request,
- {
- "model": form_data["model"],
- "messages": messages,
- },
- user,
- )
-
- response = res["choices"][0]["message"]["content"]
-
- try:
- bracket_start = response.find("{")
- bracket_end = response.rfind("}") + 1
-
- if bracket_start == -1 or bracket_end == -1:
- raise Exception("No JSON object found in the response")
-
- response = response[bracket_start:bracket_end]
- response = json.loads(response)
- prompt = response.get("prompt", [])
- except Exception as e:
- prompt = user_message
-
- except Exception as e:
- log.exception(e)
- prompt = user_message
+ input_images = get_last_images(message_list)
system_message_content = ""
- try:
- images = await image_generations(
- request=request,
- form_data=GenerateImageForm(**{"prompt": prompt}),
- user=user,
- )
+ if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT:
+ # Edit image(s)
+ try:
+ images = await image_edits(
+ request=request,
+ form_data=EditImageForm(**{"prompt": prompt, "image": input_images}),
+ user=user,
+ )
- await __event_emitter__(
- {
- "type": "status",
- "data": {"description": "Generated an image", "done": True},
- }
- )
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {"description": "Image created", "done": True},
+ }
+ )
- await __event_emitter__(
- {
- "type": "files",
- "data": {
- "files": [
- {
- "type": "image",
- "url": image["url"],
- }
- for image in images
- ]
- },
- }
- )
+ 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:
- log.exception(e)
- await __event_emitter__(
- {
- "type": "status",
- "data": {
- "description": f"An error occurred while generating an image",
- "done": True,
- },
- }
- )
+ system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated. "
+ except Exception as e:
+ log.debug(e)
- system_message_content = "Unable to generate an image, tell the user that an error occurred "
+ error_message = ""
+ if isinstance(e, HTTPException):
+ if e.detail and isinstance(e.detail, dict):
+ error_message = e.detail.get("message", str(e.detail))
+ else:
+ error_message = str(e.detail)
+
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {
+ "description": f"An error occurred while generating an image",
+ "done": True,
+ },
+ }
+ )
+
+ system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message} "
+
+ else:
+ # Create image(s)
+ if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
+ try:
+ res = await generate_image_prompt(
+ request,
+ {
+ "model": form_data["model"],
+ "messages": form_data["messages"],
+ },
+ user,
+ )
+
+ response = res["choices"][0]["message"]["content"]
+
+ try:
+ bracket_start = response.find("{")
+ bracket_end = response.rfind("}") + 1
+
+ if bracket_start == -1 or bracket_end == -1:
+ raise Exception("No JSON object found in the response")
+
+ response = response[bracket_start:bracket_end]
+ response = json.loads(response)
+ prompt = response.get("prompt", [])
+ except Exception as e:
+ prompt = user_message
+
+ except Exception as e:
+ log.exception(e)
+ prompt = user_message
+
+ try:
+ images = await image_generations(
+ request=request,
+ form_data=CreateImageForm(**{"prompt": prompt}),
+ user=user,
+ )
+
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {"description": "Image created", "done": True},
+ }
+ )
+
+ await __event_emitter__(
+ {
+ "type": "files",
+ "data": {
+ "files": [
+ {
+ "type": "image",
+ "url": image["url"],
+ }
+ for image in images
+ ]
+ },
+ }
+ )
+
+ system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated. "
+ except Exception as e:
+ log.debug(e)
+
+ error_message = ""
+ if isinstance(e, HTTPException):
+ if e.detail and isinstance(e.detail, dict):
+ error_message = e.detail.get("message", str(e.detail))
+ else:
+ error_message = str(e.detail)
+
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {
+ "description": f"An error occurred while generating an image",
+ "done": True,
+ },
+ }
+ )
+
+ system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message} "
if system_message_content:
form_data["messages"] = add_or_update_system_message(
@@ -602,70 +940,121 @@ async def chat_image_generation_handler(
async def chat_completion_files_handler(
- request: Request, body: dict, user: UserModel
+ request: Request, body: dict, extra_params: dict, user: UserModel
) -> tuple[dict, dict[str, list]]:
+ __event_emitter__ = extra_params["__event_emitter__"]
sources = []
if files := body.get("metadata", {}).get("files", None):
+ # Check if all files are in full context mode
+ all_full_context = all(item.get("context") == "full" for item in files)
+
queries = []
- try:
- queries_response = await generate_queries(
- request,
- {
- "model": body["model"],
- "messages": body["messages"],
- "type": "retrieval",
- },
- user,
- )
- queries_response = queries_response["choices"][0]["message"]["content"]
-
+ if not all_full_context:
try:
- bracket_start = queries_response.find("{")
- bracket_end = queries_response.rfind("}") + 1
+ queries_response = await generate_queries(
+ request,
+ {
+ "model": body["model"],
+ "messages": body["messages"],
+ "type": "retrieval",
+ },
+ user,
+ )
+ queries_response = queries_response["choices"][0]["message"]["content"]
- if bracket_start == -1 or bracket_end == -1:
- raise Exception("No JSON object found in the response")
+ try:
+ bracket_start = queries_response.find("{")
+ bracket_end = queries_response.rfind("}") + 1
- queries_response = queries_response[bracket_start:bracket_end]
- queries_response = json.loads(queries_response)
- except Exception as e:
- queries_response = {"queries": [queries_response]}
+ if bracket_start == -1 or bracket_end == -1:
+ raise Exception("No JSON object found in the response")
- queries = queries_response.get("queries", [])
- except:
- pass
+ queries_response = queries_response[bracket_start:bracket_end]
+ queries_response = json.loads(queries_response)
+ except Exception as e:
+ queries_response = {"queries": [queries_response]}
+
+ queries = queries_response.get("queries", [])
+ except:
+ pass
+
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {
+ "action": "queries_generated",
+ "queries": queries,
+ "done": False,
+ },
+ }
+ )
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
try:
- # Offload get_sources_from_files to a separate thread
- loop = asyncio.get_running_loop()
- with ThreadPoolExecutor() as executor:
- sources = await loop.run_in_executor(
- executor,
- lambda: get_sources_from_files(
- request=request,
- files=files,
- queries=queries,
- 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,
- ),
- )
+ # Directly await async get_sources_from_items (no thread needed - fully async now)
+ sources = await get_sources_from_items(
+ request=request,
+ items=files,
+ queries=queries,
+ embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION(
+ query, prefix=prefix, user=user
+ ),
+ k=request.app.state.config.TOP_K,
+ reranking_function=(
+ (
+ lambda query, documents: request.app.state.RERANKING_FUNCTION(
+ query, documents, user=user
+ )
+ )
+ if request.app.state.RERANKING_FUNCTION
+ else None
+ ),
+ 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=all_full_context
+ or request.app.state.config.RAG_FULL_CONTEXT,
+ user=user,
+ )
except Exception as e:
log.exception(e)
log.debug(f"rag_contexts:sources: {sources}")
+ unique_ids = set()
+ for source in sources or []:
+ if not source or len(source.keys()) == 0:
+ continue
+
+ documents = source.get("document") or []
+ metadatas = source.get("metadata") or []
+ src_info = source.get("source") or {}
+
+ for index, _ in enumerate(documents):
+ metadata = metadatas[index] if index < len(metadatas) else None
+ _id = (
+ (metadata or {}).get("source")
+ or (src_info or {}).get("id")
+ or "N/A"
+ )
+ unique_ids.add(_id)
+
+ sources_count = len(unique_ids)
+ await __event_emitter__(
+ {
+ "type": "status",
+ "data": {
+ "action": "sources_retrieved",
+ "count": sources_count,
+ "done": True,
+ },
+ }
+ )
+
return body, {"sources": sources}
@@ -675,7 +1064,9 @@ def apply_params_to_form_data(form_data, model):
open_webui_params = {
"stream_response": bool,
+ "stream_delta_chunk_size": int,
"function_calling": str,
+ "reasoning_tags": list,
"system": str,
}
@@ -718,21 +1109,44 @@ def apply_params_to_form_data(form_data, model):
async def process_chat_payload(request, form_data, user, metadata, model):
+ # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
+ # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
+ # -> Chat Files
+
form_data = apply_params_to_form_data(form_data, model)
log.debug(f"form_data: {form_data}")
+ system_message = get_system_message(form_data.get("messages", []))
+ if system_message: # Chat Controls/User Settings
+ try:
+ form_data = apply_system_prompt_to_body(
+ system_message.get("content"), form_data, metadata, user, replace=True
+ ) # Required to handle system prompt variables
+ except:
+ pass
+
event_emitter = get_event_emitter(metadata)
- event_call = get_event_call(metadata)
+ event_caller = get_event_call(metadata)
+
+ oauth_token = None
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+ except Exception as e:
+ log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": event_emitter,
- "__event_call__": event_call,
+ "__event_call__": event_caller,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
+ "__oauth_token__": oauth_token,
"__request__": request,
"__model__": model,
}
-
# Initialize events to store additional event to be sent to the client
# Initialize contexts and citation
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
@@ -752,6 +1166,26 @@ async def process_chat_payload(request, form_data, user, metadata, model):
events = []
sources = []
+ # Folder "Project" handling
+ # Check if the request has chat_id and is inside of a folder
+ chat_id = metadata.get("chat_id", None)
+ if chat_id and user:
+ chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
+ if chat and chat.folder_id:
+ folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id)
+
+ if folder and folder.data:
+ if "system_prompt" in folder.data:
+ form_data = apply_system_prompt_to_body(
+ folder.data["system_prompt"], form_data, metadata, user
+ )
+ if "files" in folder.data:
+ form_data["files"] = [
+ *folder.data["files"],
+ *form_data.get("files", []),
+ ]
+
+ # Model "Knowledge" handling
user_message = get_last_user_message(form_data["messages"])
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
@@ -804,7 +1238,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise e
try:
-
filter_functions = [
Functions.get_function_by_id(filter_id)
for filter_id in get_sorted_filter_ids(
@@ -820,10 +1253,22 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params=extra_params,
)
except Exception as e:
- raise Exception(f"Error: {e}")
+ raise Exception(f"{e}")
features = form_data.pop("features", None)
if features:
+ if "voice" in features and features["voice"]:
+ if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None:
+ if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != "":
+ template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE
+ else:
+ template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE
+
+ form_data["messages"] = add_or_update_system_message(
+ template,
+ form_data["messages"],
+ )
+
if "memory" in features and features["memory"]:
form_data = await chat_memory_handler(
request, form_data, extra_params, user
@@ -852,8 +1297,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
- # Remove files duplicates
+ prompt = get_last_user_message(form_data["messages"])
+ # TODO: re-enable URL extraction from prompt
+ # urls = []
+ # if prompt and len(prompt or "") < 500 and (not files or len(files) == 0):
+ # urls = extract_urls(prompt)
+
if files:
+ if not files:
+ files = []
+
+ for file_item in files:
+ if file_item.get("type", "file") == "folder":
+ # Get folder files
+ folder_id = file_item.get("id", None)
+ if folder_id:
+ folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id)
+ if folder and folder.data and "files" in folder.data:
+ files = [f for f in files if f.get("id", None) != folder_id]
+ files = [*files, *folder.data["files"]]
+
+ # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
+ # Remove duplicate files based on their content
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
metadata = {
@@ -866,15 +1331,139 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
- tool_servers = metadata.get("tool_servers", None)
+ direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
- log.debug(f"{tool_servers=}")
+ log.debug(f"{direct_tool_servers=}")
tools_dict = {}
+ mcp_clients = {}
+ mcp_tools_dict = {}
+
if tool_ids:
- tools_dict = get_tools(
+ for tool_id in tool_ids:
+ if tool_id.startswith("server:mcp:"):
+ try:
+ server_id = tool_id[len("server:mcp:") :]
+
+ mcp_server_connection = None
+ for (
+ server_connection
+ ) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+ if (
+ server_connection.get("type", "") == "mcp"
+ and server_connection.get("info", {}).get("id") == server_id
+ ):
+ mcp_server_connection = server_connection
+ break
+
+ if not mcp_server_connection:
+ log.error(f"MCP server with id {server_id} not found")
+ continue
+
+ auth_type = mcp_server_connection.get("auth_type", "")
+ headers = {}
+ if auth_type == "bearer":
+ headers["Authorization"] = (
+ f"Bearer {mcp_server_connection.get('key', '')}"
+ )
+ elif auth_type == "none":
+ # No authentication
+ pass
+ elif auth_type == "session":
+ headers["Authorization"] = (
+ f"Bearer {request.state.token.credentials}"
+ )
+ elif auth_type == "system_oauth":
+ oauth_token = extra_params.get("__oauth_token__", None)
+ if oauth_token:
+ headers["Authorization"] = (
+ f"Bearer {oauth_token.get('access_token', '')}"
+ )
+ elif auth_type == "oauth_2.1":
+ try:
+ splits = server_id.split(":")
+ server_id = splits[-1] if len(splits) > 1 else server_id
+
+ oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
+ user.id, f"mcp:{server_id}"
+ )
+
+ if oauth_token:
+ headers["Authorization"] = (
+ f"Bearer {oauth_token.get('access_token', '')}"
+ )
+ except Exception as e:
+ log.error(f"Error getting OAuth token: {e}")
+ oauth_token = None
+
+ connection_headers = mcp_server_connection.get("headers", None)
+ if connection_headers and isinstance(connection_headers, dict):
+ for key, value in connection_headers.items():
+ headers[key] = value
+
+ mcp_clients[server_id] = MCPClient()
+ await mcp_clients[server_id].connect(
+ url=mcp_server_connection.get("url", ""),
+ headers=headers if headers else None,
+ )
+
+ function_name_filter_list = (
+ mcp_server_connection.get("config", {})
+ .get("function_name_filter_list", "")
+ .split(",")
+ )
+
+ tool_specs = await mcp_clients[server_id].list_tool_specs()
+ for tool_spec in tool_specs:
+
+ def make_tool_function(client, function_name):
+ async def tool_function(**kwargs):
+ return await client.call_tool(
+ function_name,
+ function_args=kwargs,
+ )
+
+ return tool_function
+
+ if function_name_filter_list:
+ if not is_string_allowed(
+ tool_spec["name"], function_name_filter_list
+ ):
+ # Skip this function
+ continue
+
+ tool_function = make_tool_function(
+ mcp_clients[server_id], tool_spec["name"]
+ )
+
+ mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
+ "spec": {
+ **tool_spec,
+ "name": f"{server_id}_{tool_spec['name']}",
+ },
+ "callable": tool_function,
+ "type": "mcp",
+ "client": mcp_clients[server_id],
+ "direct": False,
+ }
+ except Exception as e:
+ log.debug(e)
+ if event_emitter:
+ await event_emitter(
+ {
+ "type": "chat:message:error",
+ "data": {
+ "error": {
+ "content": f"Failed to connect to MCP server '{server_id}'"
+ }
+ },
+ }
+ )
+ continue
+
+ tools_dict = await get_tools(
request,
tool_ids,
user,
@@ -886,8 +1475,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
},
)
- if tool_servers:
- for tool_server in tool_servers:
+ if mcp_tools_dict:
+ tools_dict = {**tools_dict, **mcp_tools_dict}
+
+ if direct_tool_servers:
+ for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
@@ -897,8 +1489,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"server": tool_server,
}
+ if mcp_clients:
+ metadata["mcp_clients"] = mcp_clients
+
if tools_dict:
- if metadata.get("function_calling") == "native":
+ if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
@@ -912,12 +1507,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
request, form_data, extra_params, user, models, tools_dict
)
sources.extend(flags.get("sources", []))
-
except Exception as e:
log.exception(e)
try:
- form_data, flags = await chat_completion_files_handler(request, form_data, user)
+ form_data, flags = await chat_completion_files_handler(
+ request, form_data, extra_params, user
+ )
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
@@ -925,54 +1521,42 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# If context is not empty, insert it into the messages
if len(sources) > 0:
context_string = ""
- citation_idx = {}
+ citation_idx_map = {}
+
for source in sources:
if "document" in source:
- for doc_context, doc_meta in zip(
+ for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
source_name = source.get("source", {}).get("name", None)
- citation_id = (
- doc_meta.get("source", None)
+ source_id = (
+ document_metadata.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
+
+ if source_id not in citation_idx_map:
+ citation_idx_map[source_id] = len(citation_idx_map) + 1
+
context_string += (
- f'{doc_context} \n"
+ + f">{document_text}\n"
)
context_string = context_string.strip()
- prompt = get_last_user_message(form_data["messages"])
-
if prompt is None:
raise Exception("No user message found")
- if (
- request.app.state.config.RELEVANCE_THRESHOLD == 0
- and context_string.strip() == ""
- ):
- log.debug(
- f"With a 0 relevancy threshold for RAG, the context cannot be empty"
- )
- # Workaround for Ollama 2.0+ system prompt issue
- # TODO: replace with add_or_update_system_message
- if model.get("owned_by") == "ollama":
- form_data["messages"] = prepend_to_first_user_message_content(
+ if context_string != "":
+ form_data["messages"] = add_or_update_user_message(
rag_template(
- request.app.state.config.RAG_TEMPLATE, context_string, prompt
- ),
- form_data["messages"],
- )
- else:
- form_data["messages"] = add_or_update_system_message(
- rag_template(
- request.app.state.config.RAG_TEMPLATE, context_string, prompt
+ request.app.state.config.RAG_TEMPLATE,
+ context_string,
+ prompt,
),
form_data["messages"],
+ append=False,
)
# If there are citations, add them to the data_items
@@ -1006,11 +1590,14 @@ async def process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
):
async def background_tasks_handler():
- message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
- message = message_map.get(metadata["message_id"]) if message_map else None
+ message = None
+ messages = []
- if message:
- message_list = get_message_list(message_map, metadata["message_id"])
+ if "chat_id" in metadata and not metadata["chat_id"].startswith("local:"):
+ messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
+ message = messages_map.get(metadata["message_id"]) if messages_map else None
+
+ message_list = get_message_list(messages_map, metadata["message_id"])
# Remove details tags and files from the messages.
# as get_message_list creates a new list, it does not affect
@@ -1042,7 +1629,14 @@ async def process_chat_response(
"content": content,
}
)
+ else:
+ # Local temp chat, get the model and message from the form_data
+ message = get_last_user_message_item(form_data.get("messages", []))
+ messages = form_data.get("messages", [])
+ if message:
+ message["model"] = form_data.get("model")
+ if message and "model" in message:
if tasks and messages:
if (
TASKS.FOLLOW_UP_GENERATION in tasks
@@ -1061,11 +1655,13 @@ async def process_chat_response(
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
- follow_ups_string = (
- res.get("choices", [])[0]
- .get("message", {})
- .get("content", "")
+ response_message = res.get("choices", [])[0].get(
+ "message", {}
)
+
+ follow_ups_string = response_message.get(
+ "content"
+ ) or response_message.get("reasoning_content", "")
else:
follow_ups_string = ""
@@ -1078,15 +1674,6 @@ async def process_chat_response(
follow_ups = json.loads(follow_ups_string).get(
"follow_ups", []
)
-
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "followUps": follow_ups,
- },
- )
-
await event_emitter(
{
"type": "chat:message:follow_ups",
@@ -1095,17 +1682,94 @@ async def process_chat_response(
},
}
)
+
+ if not metadata.get("chat_id", "").startswith("local:"):
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "followUps": follow_ups,
+ },
+ )
+
except Exception as e:
pass
- if TASKS.TITLE_GENERATION in tasks:
- user_message = get_last_user_message(messages)
- if user_message and len(user_message) > 100:
- user_message = user_message[:100] + "..."
+ if not metadata.get("chat_id", "").startswith(
+ "local:"
+ ): # Only update titles and tags for non-temp chats
+ if TASKS.TITLE_GENERATION in tasks:
+ user_message = get_last_user_message(messages)
+ if user_message and len(user_message) > 100:
+ user_message = user_message[:100] + "..."
- if tasks[TASKS.TITLE_GENERATION]:
+ title = None
+ if tasks[TASKS.TITLE_GENERATION]:
+ res = await generate_title(
+ request,
+ {
+ "model": message["model"],
+ "messages": messages,
+ "chat_id": metadata["chat_id"],
+ },
+ user,
+ )
- res = await generate_title(
+ if res and isinstance(res, dict):
+ if len(res.get("choices", [])) == 1:
+ response_message = res.get("choices", [])[0].get(
+ "message", {}
+ )
+
+ title_string = (
+ response_message.get("content")
+ or response_message.get(
+ "reasoning_content",
+ )
+ or message.get("content", user_message)
+ )
+ else:
+ title_string = ""
+
+ title_string = title_string[
+ title_string.find("{") : title_string.rfind("}") + 1
+ ]
+
+ try:
+ title = json.loads(title_string).get(
+ "title", user_message
+ )
+ except Exception as e:
+ title = ""
+
+ if not title:
+ title = messages[0].get("content", user_message)
+
+ Chats.update_chat_title_by_id(
+ metadata["chat_id"], title
+ )
+
+ await event_emitter(
+ {
+ "type": "chat:title",
+ "data": title,
+ }
+ )
+
+ if title == None and len(messages) == 2:
+ title = messages[0].get("content", user_message)
+
+ Chats.update_chat_title_by_id(metadata["chat_id"], title)
+
+ await event_emitter(
+ {
+ "type": "chat:title",
+ "data": message.get("content", user_message),
+ }
+ )
+
+ if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
+ res = await generate_chat_tags(
request,
{
"model": message["model"],
@@ -1117,89 +1781,34 @@ async def process_chat_response(
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
- title_string = (
- res.get("choices", [])[0]
- .get("message", {})
- .get(
- "content", message.get("content", user_message)
- )
+ response_message = res.get("choices", [])[0].get(
+ "message", {}
)
- else:
- title_string = ""
- title_string = title_string[
- title_string.find("{") : title_string.rfind("}") + 1
+ tags_string = response_message.get(
+ "content"
+ ) or response_message.get("reasoning_content", "")
+ else:
+ tags_string = ""
+
+ tags_string = tags_string[
+ tags_string.find("{") : tags_string.rfind("}") + 1
]
try:
- title = json.loads(title_string).get(
- "title", user_message
+ tags = json.loads(tags_string).get("tags", [])
+ Chats.update_chat_tags_by_id(
+ metadata["chat_id"], tags, user
+ )
+
+ await event_emitter(
+ {
+ "type": "chat:tags",
+ "data": tags,
+ }
)
except Exception as e:
- title = ""
-
- if not title:
- title = messages[0].get("content", user_message)
-
- Chats.update_chat_title_by_id(metadata["chat_id"], title)
-
- await event_emitter(
- {
- "type": "chat:title",
- "data": title,
- }
- )
- elif len(messages) == 2:
- title = messages[0].get("content", user_message)
-
- Chats.update_chat_title_by_id(metadata["chat_id"], title)
-
- await event_emitter(
- {
- "type": "chat:title",
- "data": message.get("content", user_message),
- }
- )
-
- if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
- res = await generate_chat_tags(
- request,
- {
- "model": message["model"],
- "messages": messages,
- "chat_id": metadata["chat_id"],
- },
- user,
- )
-
- if res and isinstance(res, dict):
- if len(res.get("choices", [])) == 1:
- tags_string = (
- res.get("choices", [])[0]
- .get("message", {})
- .get("content", "")
- )
- else:
- tags_string = ""
-
- tags_string = tags_string[
- tags_string.find("{") : tags_string.rfind("}") + 1
- ]
-
- try:
- tags = json.loads(tags_string).get("tags", [])
- Chats.update_chat_tags_by_id(
- metadata["chat_id"], tags, user
- )
-
- await event_emitter(
- {
- "type": "chat:tags",
- "data": tags,
- }
- )
- except Exception as e:
- pass
+ pass
event_emitter = None
event_caller = None
@@ -1217,91 +1826,136 @@ 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},
- },
- )
+ try:
+ if isinstance(response, dict) or isinstance(response, JSONResponse):
+ if isinstance(response, list) and len(response) == 1:
+ # If the response is a single-item list, unwrap it #17213
+ response = response[0]
- if "selected_model_id" in response:
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "selectedModelId": response["selected_model_id"],
- },
- )
+ if isinstance(response, JSONResponse) and isinstance(
+ response.body, bytes
+ ):
+ try:
+ response_data = json.loads(
+ response.body.decode("utf-8", "replace")
+ )
+ except json.JSONDecodeError:
+ response_data = {
+ "error": {"detail": "Invalid JSON response"}
+ }
+ else:
+ response_data = response
- choices = response.get("choices", [])
- if choices and choices[0].get("message", {}).get("content"):
- content = response["choices"][0]["message"]["content"]
+ if "error" in response_data:
+ error = response_data.get("error")
- if content:
+ if isinstance(error, dict):
+ error = error.get("detail", error)
+ else:
+ error = str(error)
- await event_emitter(
- {
- "type": "chat:completion",
- "data": response,
- }
- )
-
- title = Chats.get_chat_title_by_id(metadata["chat_id"])
-
- await event_emitter(
- {
- "type": "chat:completion",
- "data": {
- "done": True,
- "content": content,
- "title": title,
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "error": {"content": error},
},
- }
- )
-
- # Save message in the database
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "role": "assistant",
- "content": content,
- },
- )
-
- # Send a webhook notification if the user is not active
- if not get_active_status_by_user_id(user.id):
- webhook_url = Users.get_user_webhook_url_by_id(user.id)
- if webhook_url:
- post_webhook(
- request.app.state.WEBUI_NAME,
- webhook_url,
- f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
+ )
+ if isinstance(error, str) or isinstance(error, dict):
+ await event_emitter(
{
- "action": "chat",
- "message": content,
- "title": title,
- "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
+ "type": "chat:message:error",
+ "data": {"error": {"content": error}},
+ }
+ )
+
+ if "selected_model_id" in response_data:
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "selectedModelId": response_data["selected_model_id"],
+ },
+ )
+
+ choices = response_data.get("choices", [])
+ if choices and choices[0].get("message", {}).get("content"):
+ content = response_data["choices"][0]["message"]["content"]
+
+ if content:
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": response_data,
+ }
+ )
+
+ title = Chats.get_chat_title_by_id(metadata["chat_id"])
+
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": {
+ "done": True,
+ "content": content,
+ "title": title,
+ },
+ }
+ )
+
+ # Save message in the database
+ Chats.upsert_message_to_chat_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ {
+ "role": "assistant",
+ "content": content,
},
)
- await background_tasks_handler()
+ # Send a webhook notification if the user is not active
+ if not get_active_status_by_user_id(user.id):
+ webhook_url = Users.get_user_webhook_url_by_id(user.id)
+ if webhook_url:
+ await post_webhook(
+ request.app.state.WEBUI_NAME,
+ webhook_url,
+ f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
+ {
+ "action": "chat",
+ "message": content,
+ "title": title,
+ "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
+ },
+ )
- 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
+ await background_tasks_handler()
- response = {
- **extra_response,
- **response,
- }
+ if events and isinstance(events, list):
+ extra_response = {}
+ for event in events:
+ if isinstance(event, dict):
+ extra_response.update(event)
+ else:
+ extra_response[event] = True
+
+ response_data = {
+ **extra_response,
+ **response_data,
+ }
+
+ if isinstance(response, dict):
+ response = response_data
+ if isinstance(response, JSONResponse):
+ response = JSONResponse(
+ content=response_data,
+ headers=response.headers,
+ status_code=response.status_code,
+ )
+
+ except Exception as e:
+ log.debug(f"Error occurred while processing request: {e}")
+ pass
return response
else:
@@ -1327,11 +1981,22 @@ async def process_chat_response(
):
return response
+ oauth_token = None
+ try:
+ if request.cookies.get("oauth_session_id", None):
+ oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+ user.id,
+ request.cookies.get("oauth_session_id", None),
+ )
+ except Exception as e:
+ log.error(f"Error getting OAuth token: {e}")
+
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_caller,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
+ "__oauth_token__": oauth_token,
"__request__": request,
"__model__": model,
}
@@ -1347,14 +2012,6 @@ async def process_chat_response(
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get("model", "")
- Chats.upsert_message_to_chat_by_id_and_message_id(
- metadata["chat_id"],
- metadata["message_id"],
- {
- "model": model_id,
- },
- )
-
def split_content_and_whitespace(content):
content_stripped = content.rstrip()
original_whitespace = (
@@ -1370,19 +2027,24 @@ async def process_chat_response(
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
# Handle as a background task
- async def post_response_handler(response, events):
+ async def response_handler(response, events):
def serialize_content_blocks(content_blocks, raw=False):
content = ""
for block in content_blocks:
if block["type"] == "text":
- content = f"{content}{block['content'].strip()}\n"
+ block_content = block["content"].strip()
+ if block_content:
+ content = f"{content}{block_content}\n"
elif block["type"] == "tool_calls":
attributes = block.get("attributes", {})
tool_calls = block.get("content", [])
results = block.get("results", [])
+ if content and not content.endswith("\n"):
+ content += "\n"
+
if results:
tool_calls_display_content = ""
@@ -1404,13 +2066,14 @@ async def process_chat_response(
tool_result_files = result.get("files", None)
break
- if tool_result:
- tool_calls_display_content = f'{tool_calls_display_content}\n\nTool Executed \n \n'
+ if tool_result is not None:
+ tool_result_embeds = result.get("embeds", "")
+ tool_calls_display_content = f'{tool_calls_display_content}\nTool Executed \n \n'
else:
- tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting... \n '
+ tool_calls_display_content = f'{tool_calls_display_content}\nExecuting... \n \n'
if not raw:
- content = f"{content}\n{tool_calls_display_content}\n\n"
+ content = f"{content}{tool_calls_display_content}"
else:
tool_calls_display_content = ""
@@ -1423,29 +2086,41 @@ async def process_chat_response(
"arguments", ""
)
- tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting... \n '
+ tool_calls_display_content = f'{tool_calls_display_content}\n\nExecuting... \n \n'
if not raw:
- content = f"{content}\n{tool_calls_display_content}\n\n"
+ content = f"{content}{tool_calls_display_content}"
elif block["type"] == "reasoning":
- reasoning_display_content = "\n".join(
- (f"> {line}" if not line.startswith(">") else line)
- for line in block["content"].splitlines()
+ reasoning_display_content = html.escape(
+ "\n".join(
+ (f"> {line}" if not line.startswith(">") else line)
+ for line in block["content"].splitlines()
+ )
)
reasoning_duration = block.get("duration", None)
+ start_tag = block.get("start_tag", "")
+ end_tag = block.get("end_tag", "")
+
+ if content and not content.endswith("\n"):
+ content += "\n"
+
if reasoning_duration is not None:
if raw:
- content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
+ content = (
+ f'{content}{start_tag}{block["content"]}{end_tag}\n'
+ )
else:
- content = f'{content}\n\nThought for {reasoning_duration} seconds \n{reasoning_display_content}\n \n'
+ content = f'{content}\nThought for {reasoning_duration} seconds \n{reasoning_display_content}\n \n'
else:
if raw:
- content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
+ content = (
+ f'{content}{start_tag}{block["content"]}{end_tag}\n'
+ )
else:
- content = f'{content}\n\nThinking… \n{reasoning_display_content}\n \n'
+ content = f'{content}\nThinking… \n{reasoning_display_content}\n \n'
elif block["type"] == "code_interpreter":
attributes = block.get("attributes", {})
@@ -1465,26 +2140,30 @@ async def process_chat_response(
# Keep content as is - either closing backticks or no backticks
content = content_stripped + original_whitespace
+ if content and not content.endswith("\n"):
+ content += "\n"
+
if output:
output = html.escape(json.dumps(output))
if raw:
- content = f'{content}\n\n{block["content"]}\n \n```output\n{output}\n```\n'
+ content = f'{content}\n{block["content"]}\n \n```output\n{output}\n```\n'
else:
- content = f'{content}\n\nAnalyzed \n```{lang}\n{block["content"]}\n```\n \n'
+ content = f'{content}\nAnalyzed \n```{lang}\n{block["content"]}\n```\n \n'
else:
if raw:
- content = f'{content}\n\n{block["content"]}\n \n'
+ content = f'{content}\n{block["content"]}\n \n'
else:
- content = f'{content}\n\nAnalyzing... \n```{lang}\n{block["content"]}\n```\n \n'
+ content = f'{content}\nAnalyzing... \n```{lang}\n{block["content"]}\n```\n \n'
else:
block_content = str(block["content"]).strip()
- content = f"{content}{block['type']}: {block_content}\n"
+ if block_content:
+ content = f"{content}{block['type']}: {block_content}\n"
return content.strip()
- def convert_content_blocks_to_messages(content_blocks):
+ def convert_content_blocks_to_messages(content_blocks, raw=False):
messages = []
temp_blocks = []
@@ -1493,7 +2172,7 @@ async def process_chat_response(
messages.append(
{
"role": "assistant",
- "content": serialize_content_blocks(temp_blocks),
+ "content": serialize_content_blocks(temp_blocks, raw),
"tool_calls": block.get("content"),
}
)
@@ -1505,7 +2184,7 @@ async def process_chat_response(
{
"role": "tool",
"tool_call_id": result["tool_call_id"],
- "content": result["content"],
+ "content": result.get("content", "") or "",
}
)
temp_blocks = []
@@ -1513,7 +2192,7 @@ async def process_chat_response(
temp_blocks.append(block)
if temp_blocks:
- content = serialize_content_blocks(temp_blocks)
+ content = serialize_content_blocks(temp_blocks, raw)
if content:
messages.append(
{
@@ -1540,13 +2219,25 @@ async def process_chat_response(
if content_blocks[-1]["type"] == "text":
for start_tag, end_tag in tags:
- # Match start tag e.g., or
- start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>"
+
+ start_tag_pattern = rf"{re.escape(start_tag)}"
+ if start_tag.startswith("<") and start_tag.endswith(">"):
+ # Match start tag e.g., or
+ # remove both '<' and '>' from start_tag
+ # Match start tag with attributes
+ start_tag_pattern = (
+ rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
+ )
+
match = re.search(start_tag_pattern, content)
if match:
- attr_content = (
- match.group(1) if match.group(1) else ""
- ) # Ensure it's not None
+ try:
+ attr_content = (
+ match.group(1) if match.group(1) else ""
+ ) # Ensure it's not None
+ except:
+ attr_content = ""
+
attributes = extract_attributes(
attr_content
) # Extract attributes safely
@@ -1592,8 +2283,13 @@ async def process_chat_response(
elif content_blocks[-1]["type"] == content_type:
start_tag = content_blocks[-1]["start_tag"]
end_tag = content_blocks[-1]["end_tag"]
- # Match end tag e.g.,
- end_tag_pattern = rf"<{re.escape(end_tag)}>"
+
+ if end_tag.startswith("<") and end_tag.endswith(">"):
+ # Match end tag e.g.,
+ end_tag_pattern = rf"{re.escape(end_tag)}"
+ else:
+ # Handle cases where end_tag is just a tag name
+ end_tag_pattern = rf"{re.escape(end_tag)}"
# Check if the content has the end tag
if re.search(end_tag_pattern, content):
@@ -1665,8 +2361,17 @@ async def process_chat_response(
)
# Clean processed content
+ start_tag_pattern = rf"{re.escape(start_tag)}"
+ if start_tag.startswith("<") and start_tag.endswith(">"):
+ # Match start tag e.g., or
+ # remove both '<' and '>' from start_tag
+ # Match start tag with attributes
+ start_tag_pattern = (
+ rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
+ )
+
content = re.sub(
- rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>",
+ rf"{start_tag_pattern}(.|\n)*?{re.escape(end_tag)}",
"",
content,
flags=re.DOTALL,
@@ -1702,26 +2407,23 @@ async def process_chat_response(
}
]
- # We might want to disable this by default
- DETECT_REASONING = True
- DETECT_SOLUTION = True
+ reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
+ DETECT_REASONING_TAGS = reasoning_tags_param is not False
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
"code_interpreter", False
)
- reasoning_tags = [
- ("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")]
-
- solution_tags = [("|begin_of_solution|", "|end_of_solution|")]
+ reasoning_tags = []
+ if DETECT_REASONING_TAGS:
+ if (
+ isinstance(reasoning_tags_param, list)
+ and len(reasoning_tags_param) == 2
+ ):
+ reasoning_tags = [
+ (reasoning_tags_param[0], reasoning_tags_param[1])
+ ]
+ else:
+ reasoning_tags = DEFAULT_REASONING_TAGS
try:
for event in events:
@@ -1741,14 +2443,42 @@ async def process_chat_response(
},
)
- async def stream_body_handler(response):
+ async def stream_body_handler(response, form_data):
nonlocal content
nonlocal content_blocks
response_tool_calls = []
+ delta_count = 0
+ delta_chunk_size = max(
+ CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
+ int(
+ metadata.get("params", {}).get("stream_delta_chunk_size")
+ or 1
+ ),
+ )
+ last_delta_data = None
+
+ async def flush_pending_delta_data(threshold: int = 0):
+ nonlocal delta_count
+ nonlocal last_delta_data
+
+ if delta_count >= threshold and last_delta_data:
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": last_delta_data,
+ }
+ )
+ delta_count = 0
+ last_delta_data = None
+
async for line in response.body_iterator:
- line = line.decode("utf-8") if isinstance(line, bytes) else line
+ line = (
+ line.decode("utf-8", "replace")
+ if isinstance(line, bytes)
+ else line
+ )
data = line
# Skip empty lines
@@ -1770,11 +2500,13 @@ async def process_chat_response(
filter_functions=filter_functions,
filter_type="stream",
form_data=data,
- extra_params=extra_params,
+ extra_params={"__body__": form_data, **extra_params},
)
if data:
- if "event" in data:
+ if "event" in data and not getattr(
+ request.state, "direct", False
+ ):
await event_emitter(data.get("event", {}))
if "selected_model_id" in data:
@@ -1786,8 +2518,28 @@ async def process_chat_response(
"selectedModelId": model_id,
},
)
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": data,
+ }
+ )
else:
choices = data.get("choices", [])
+
+ # 17421
+ usage = data.get("usage", {}) or {}
+ usage.update(data.get("timings", {})) # llama.cpp
+ if usage:
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": {
+ "usage": usage,
+ },
+ }
+ )
+
if not choices:
error = data.get("error", {})
if error:
@@ -1799,16 +2551,6 @@ async def process_chat_response(
},
}
)
- usage = data.get("usage", {})
- if usage:
- await event_emitter(
- {
- "type": "chat:completion",
- "data": {
- "usage": usage,
- },
- }
- )
continue
delta = choices[0].get("delta", {})
@@ -1872,6 +2614,26 @@ async def process_chat_response(
"arguments"
] += delta_arguments
+ image_urls = get_image_urls(
+ delta.get("images", []), request, metadata, user
+ )
+ if image_urls:
+ message_files = Chats.add_message_files_by_id_and_message_id(
+ metadata["chat_id"],
+ metadata["message_id"],
+ [
+ {"type": "image", "url": url}
+ for url in image_urls
+ ],
+ )
+
+ await event_emitter(
+ {
+ "type": "files",
+ "data": {"files": message_files},
+ }
+ )
+
value = delta.get("content")
reasoning_content = (
@@ -1886,8 +2648,8 @@ async def process_chat_response(
):
reasoning_block = {
"type": "reasoning",
- "start_tag": "think",
- "end_tag": "/think",
+ "start_tag": "",
+ "end_tag": " ",
"attributes": {
"type": "reasoning_content"
},
@@ -1930,6 +2692,11 @@ async def process_chat_response(
}
)
+ if ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION:
+ value = convert_markdown_base64_images(
+ request, value, metadata, user
+ )
+
content = f"{content}{value}"
if not content_blocks:
content_blocks.append(
@@ -1943,7 +2710,7 @@ async def process_chat_response(
content_blocks[-1]["content"] + value
)
- if DETECT_REASONING:
+ if DETECT_REASONING_TAGS:
content, content_blocks, _ = (
tag_content_handler(
"reasoning",
@@ -1953,11 +2720,20 @@ async def process_chat_response(
)
)
+ content, content_blocks, _ = (
+ tag_content_handler(
+ "solution",
+ DEFAULT_SOLUTION_TAGS,
+ content,
+ content_blocks,
+ )
+ )
+
if DETECT_CODE_INTERPRETER:
content, content_blocks, end = (
tag_content_handler(
"code_interpreter",
- code_interpreter_tags,
+ DEFAULT_CODE_INTERPRETER_TAGS,
content,
content_blocks,
)
@@ -1966,16 +2742,6 @@ async def process_chat_response(
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(
@@ -1994,19 +2760,26 @@ async def process_chat_response(
),
}
- await event_emitter(
- {
- "type": "chat:completion",
- "data": data,
- }
- )
+ if delta:
+ delta_count += 1
+ last_delta_data = data
+ if delta_count >= delta_chunk_size:
+ await flush_pending_delta_data(delta_chunk_size)
+ else:
+ await event_emitter(
+ {
+ "type": "chat:completion",
+ "data": data,
+ }
+ )
except Exception as e:
done = "data: [DONE]" in line
if done:
pass
else:
- log.debug("Error: ", e)
+ log.debug(f"Error: {e}")
continue
+ await flush_pending_delta_data()
if content_blocks:
# Clean up the last text block
@@ -2026,18 +2799,30 @@ async def process_chat_response(
}
)
+ if content_blocks[-1]["type"] == "reasoning":
+ reasoning_block = content_blocks[-1]
+ if reasoning_block.get("ended_at") is None:
+ reasoning_block["ended_at"] = time.time()
+ reasoning_block["duration"] = int(
+ reasoning_block["ended_at"]
+ - reasoning_block["started_at"]
+ )
+
if response_tool_calls:
tool_calls.append(response_tool_calls)
if response.background:
await response.background()
- await stream_body_handler(response)
+ await stream_body_handler(response, form_data)
- MAX_TOOL_CALL_RETRIES = 10
tool_call_retries = 0
- while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
+ while (
+ len(tool_calls) > 0
+ and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES
+ ):
+
tool_call_retries += 1
response_tool_calls = tool_calls.pop(0)
@@ -2064,7 +2849,9 @@ async def process_chat_response(
for tool_call in response_tool_calls:
tool_call_id = tool_call.get("id", "")
- tool_name = tool_call.get("function", {}).get("name", "")
+ tool_function_name = tool_call.get("function", {}).get(
+ "name", ""
+ )
tool_args = tool_call.get("function", {}).get("arguments", "{}")
tool_function_params = {}
@@ -2094,11 +2881,17 @@ async def process_chat_response(
)
tool_result = None
+ tool = None
+ tool_type = None
+ direct_tool = False
- if tool_name in tools:
- tool = tools[tool_name]
+ if tool_function_name in tools:
+ tool = tools[tool_function_name]
spec = tool.get("spec", {})
+ tool_type = tool.get("type", "")
+ direct_tool = tool.get("direct", False)
+
try:
allowed_params = (
spec.get("parameters", {})
@@ -2112,13 +2905,13 @@ async def process_chat_response(
if k in allowed_params
}
- if tool.get("direct", False):
+ if direct_tool:
tool_result = await event_caller(
{
"type": "execute:tool",
"data": {
"id": str(uuid4()),
- "name": tool_name,
+ "name": tool_function_name,
"params": tool_function_params,
"server": tool.get("server", {}),
"session_id": metadata.get(
@@ -2129,7 +2922,16 @@ async def process_chat_response(
)
else:
- tool_function = tool["callable"]
+ tool_function = get_updated_tool_function(
+ function=tool["callable"],
+ extra_params={
+ "__messages__": form_data.get(
+ "messages", []
+ ),
+ "__files__": metadata.get("files", []),
+ },
+ )
+
tool_result = await tool_function(
**tool_function_params
)
@@ -2137,33 +2939,36 @@ async def process_chat_response(
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)
+ tool_result, tool_result_files, tool_result_embeds = (
+ process_tool_result(
+ request,
+ tool_function_name,
+ tool_result,
+ tool_type,
+ direct_tool,
+ metadata,
+ user,
+ )
+ )
results.append(
{
"tool_call_id": tool_call_id,
- "content": tool_result,
+ "content": tool_result or "",
**(
{"files": tool_result_files}
if tool_result_files
else {}
),
+ **(
+ {"embeds": tool_result_embeds}
+ if tool_result_embeds
+ else {}
+ ),
}
)
content_blocks[-1]["results"] = results
-
content_blocks.append(
{
"type": "text",
@@ -2181,22 +2986,26 @@ async def process_chat_response(
)
try:
+ new_form_data = {
+ **form_data,
+ "model": model_id,
+ "stream": True,
+ "messages": [
+ *form_data["messages"],
+ *convert_content_blocks_to_messages(
+ content_blocks, True
+ ),
+ ],
+ }
+
res = await generate_chat_completion(
request,
- {
- "model": model_id,
- "stream": True,
- "tools": form_data["tools"],
- "messages": [
- *form_data["messages"],
- *convert_content_blocks_to_messages(content_blocks),
- ],
- },
+ new_form_data,
user,
)
if isinstance(res, StreamingResponse):
- await stream_body_handler(res)
+ await stream_body_handler(res, new_form_data)
else:
break
except Exception as e:
@@ -2211,6 +3020,7 @@ async def process_chat_response(
content_blocks[-1]["type"] == "code_interpreter"
and retries < MAX_RETRIES
):
+
await event_emitter(
{
"type": "chat:completion",
@@ -2227,6 +3037,27 @@ async def process_chat_response(
try:
if content_blocks[-1]["attributes"].get("type") == "code":
code = content_blocks[-1]["content"]
+ if CODE_INTERPRETER_BLOCKED_MODULES:
+ blocking_code = textwrap.dedent(
+ f"""
+ import builtins
+
+ BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
+
+ _real_import = builtins.__import__
+ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if name.split('.')[0] in BLOCKED_MODULES:
+ importer_name = globals.get('__name__') if globals else None
+ if importer_name == '__main__':
+ raise ImportError(
+ f"Direct import of module {{name}} is restricted."
+ )
+ return _real_import(name, globals, locals, fromlist, level)
+
+ builtins.__import__ = restricted_import
+ """
+ )
+ code = blocking_code + "\n" + code
if (
request.app.state.config.CODE_INTERPRETER_ENGINE
@@ -2278,23 +3109,18 @@ async def process_chat_response(
if isinstance(stdout, str):
stdoutLines = stdout.split("\n")
for idx, line in enumerate(stdoutLines):
+
if "data:image/png;base64" in line:
- image_url = ""
- # Extract base64 image data from the line
- image_data, content_type = (
- load_b64_image_data(line)
+ image_url = get_image_url_from_base64(
+ request,
+ line,
+ metadata,
+ user,
)
- if image_data is not None:
- image_url = upload_image(
- request,
- image_data,
- content_type,
- metadata,
- user,
+ if image_url:
+ stdoutLines[idx] = (
+ f""
)
- stdoutLines[idx] = (
- f""
- )
output["stdout"] = "\n".join(stdoutLines)
@@ -2304,19 +3130,12 @@ async def process_chat_response(
resultLines = result.split("\n")
for idx, line in enumerate(resultLines):
if "data:image/png;base64" in line:
- image_url = ""
- # Extract base64 image data from the line
- image_data, content_type = (
- load_b64_image_data(line)
+ image_url = get_image_url_from_base64(
+ request,
+ line,
+ metadata,
+ user,
)
- if image_data is not None:
- image_url = upload_image(
- request,
- image_data,
- content_type,
- metadata,
- user,
- )
resultLines[idx] = (
f""
)
@@ -2343,26 +3162,29 @@ async def process_chat_response(
)
try:
+ new_form_data = {
+ **form_data,
+ "model": model_id,
+ "stream": True,
+ "messages": [
+ *form_data["messages"],
+ {
+ "role": "assistant",
+ "content": serialize_content_blocks(
+ content_blocks, raw=True
+ ),
+ },
+ ],
+ }
+
res = await generate_chat_completion(
request,
- {
- "model": model_id,
- "stream": True,
- "messages": [
- *form_data["messages"],
- {
- "role": "assistant",
- "content": serialize_content_blocks(
- content_blocks, raw=True
- ),
- },
- ],
- },
+ new_form_data,
user,
)
if isinstance(res, StreamingResponse):
- await stream_body_handler(res)
+ await stream_body_handler(res, new_form_data)
else:
break
except Exception as e:
@@ -2390,7 +3212,7 @@ async def process_chat_response(
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(
+ await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
@@ -2412,7 +3234,7 @@ async def process_chat_response(
await background_tasks_handler()
except asyncio.CancelledError:
log.warning("Task was cancelled!")
- await event_emitter({"type": "task-cancelled"})
+ await event_emitter({"type": "chat:tasks:cancel"})
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
@@ -2427,11 +3249,7 @@ async def process_chat_response(
if response.background is not None:
await response.background()
- # background_tasks.add_task(post_response_handler, response, events)
- task_id, _ = await create_task(
- request, post_response_handler(response, events), id=metadata["chat_id"]
- )
- return {"status": True, "task_id": task_id}
+ return await response_handler(response, events)
else:
# Fallback to the original response
diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py
index 107e2ed252..5591fcdb3f 100644
--- a/backend/open_webui/utils/misc.py
+++ b/backend/open_webui/utils/misc.py
@@ -1,5 +1,6 @@
import hashlib
import re
+import threading
import time
import uuid
import logging
@@ -7,10 +8,11 @@ from datetime import timedelta
from pathlib import Path
from typing import Callable, Optional
import json
+import aiohttp
import collections.abc
-from open_webui.env import SRC_LOG_LEVELS
+from open_webui.env import SRC_LOG_LEVELS, CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
@@ -25,7 +27,48 @@ def deep_update(d, u):
return d
-def get_message_list(messages, message_id):
+def get_allow_block_lists(filter_list):
+ allow_list = []
+ block_list = []
+
+ if filter_list:
+ for d in filter_list:
+ if d.startswith("!"):
+ # Domains starting with "!" → blocked
+ block_list.append(d[1:].strip())
+ else:
+ # Domains starting without "!" → allowed
+ allow_list.append(d.strip())
+
+ return allow_list, block_list
+
+
+def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> bool:
+ """
+ Checks if a string is allowed based on the provided filter list.
+ :param string: The string to check (e.g., domain or hostname).
+ :param filter_list: List of allowed/blocked strings. Strings starting with "!" are blocked.
+ :return: True if the string is allowed, False otherwise.
+ """
+ if not filter_list:
+ return True
+
+ allow_list, block_list = get_allow_block_lists(filter_list)
+ print(string, allow_list, block_list)
+
+ # If allow list is non-empty, require domain to match one of them
+ if allow_list:
+ if not any(string.endswith(allowed) for allowed in allow_list):
+ return False
+
+ # Block list always removes matches
+ if any(string.endswith(blocked) for blocked in block_list):
+ return False
+
+ return True
+
+
+def get_message_list(messages_map, message_id):
"""
Reconstructs a list of messages in order up to the specified message_id.
@@ -35,11 +78,11 @@ def get_message_list(messages, message_id):
"""
# Handle case where messages is None
- if not messages:
+ if not messages_map:
return [] # Return empty list instead of None to prevent iteration errors
# Find the message by its id
- current_message = messages.get(message_id)
+ current_message = messages_map.get(message_id)
if not current_message:
return [] # Return empty list instead of None to prevent iteration errors
@@ -52,7 +95,7 @@ def get_message_list(messages, message_id):
0, current_message
) # Insert the message at the beginning of the list
parent_id = current_message.get("parentId") # Use .get() for safety
- current_message = messages.get(parent_id) if parent_id else None
+ current_message = messages_map.get(parent_id) if parent_id else None
return message_list
@@ -119,17 +162,26 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
return get_system_message(messages), remove_system_message(messages)
-def prepend_to_first_user_message_content(
- content: str, messages: list[dict]
-) -> list[dict]:
+def update_message_content(message: dict, content: str, append: bool = True) -> dict:
+ if isinstance(message["content"], list):
+ for item in message["content"]:
+ if item["type"] == "text":
+ if append:
+ item["text"] = f"{item['text']}\n{content}"
+ else:
+ item["text"] = f"{content}\n{item['text']}"
+ else:
+ if append:
+ message["content"] = f"{message['content']}\n{content}"
+ else:
+ message["content"] = f"{content}\n{message['content']}"
+ return message
+
+
+def replace_system_message_content(content: str, messages: list[dict]) -> dict:
for message in messages:
- if message["role"] == "user":
- if isinstance(message["content"], list):
- for item in message["content"]:
- if item["type"] == "text":
- item["text"] = f"{content}\n{item['text']}"
- else:
- message["content"] = f"{content}\n{message['content']}"
+ if message["role"] == "system":
+ message["content"] = content
break
return messages
@@ -147,10 +199,7 @@ def add_or_update_system_message(
"""
if messages and messages[0].get("role") == "system":
- if append:
- messages[0]["content"] = f"{messages[0]['content']}\n{content}"
- else:
- messages[0]["content"] = f"{content}\n{messages[0]['content']}"
+ messages[0] = update_message_content(messages[0], content, append)
else:
# Insert at the beginning
messages.insert(0, {"role": "system", "content": content})
@@ -158,7 +207,7 @@ def add_or_update_system_message(
return messages
-def add_or_update_user_message(content: str, messages: list[dict]):
+def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
"""
Adds a new user message at the end of the messages list
or updates the existing user message at the end.
@@ -169,7 +218,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
"""
if messages and messages[-1].get("role") == "user":
- messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
+ messages[-1] = update_message_content(messages[-1], content, append)
else:
# Insert at the end
messages.append({"role": "user", "content": content})
@@ -177,6 +226,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
return messages
+def prepend_to_first_user_message_content(
+ content: str, messages: list[dict]
+) -> list[dict]:
+ for message in messages:
+ if message["role"] == "user":
+ message = update_message_content(message, content, append=False)
+ break
+ return messages
+
+
def append_or_update_assistant_message(content: str, messages: list[dict]):
"""
Adds a new assistant message at the end of the messages list
@@ -227,7 +286,7 @@ def openai_chat_chunk_message_template(
if tool_calls:
template["choices"][0]["delta"]["tool_calls"] = tool_calls
- if not content and not tool_calls:
+ if not content and not reasoning_content and not tool_calls:
template["choices"][0]["finish_reason"] = "stop"
if usage:
@@ -382,17 +441,10 @@ def parse_ollama_modelfile(model_text):
"top_k": int,
"top_p": float,
"num_keep": int,
- "typical_p": float,
"presence_penalty": float,
"frequency_penalty": float,
- "penalize_newline": bool,
- "numa": bool,
"num_batch": int,
"num_gpu": int,
- "main_gpu": int,
- "low_vram": bool,
- "f16_kv": bool,
- "vocab_only": bool,
"use_mmap": bool,
"use_mlock": bool,
"num_thread": int,
@@ -478,3 +530,119 @@ def convert_logit_bias_input_to_json(user_input):
bias = 100 if bias > 100 else -100 if bias < -100 else bias
logit_bias_json[token] = bias
return json.dumps(logit_bias_json)
+
+
+def freeze(value):
+ """
+ Freeze a value to make it hashable.
+ """
+ if isinstance(value, dict):
+ return frozenset((k, freeze(v)) for k, v in value.items())
+ elif isinstance(value, list):
+ return tuple(freeze(v) for v in value)
+ return value
+
+
+def throttle(interval: float = 10.0):
+ """
+ Decorator to prevent a function from being called more than once within a specified duration.
+ If the function is called again within the duration, it returns None. To avoid returning
+ different types, the return type of the function should be Optional[T].
+
+ :param interval: Duration in seconds to wait before allowing the function to be called again.
+ """
+
+ def decorator(func):
+ last_calls = {}
+ lock = threading.Lock()
+
+ def wrapper(*args, **kwargs):
+ if interval is None:
+ return func(*args, **kwargs)
+
+ key = (args, freeze(kwargs))
+ now = time.time()
+ if now - last_calls.get(key, 0) < interval:
+ return None
+ with lock:
+ if now - last_calls.get(key, 0) < interval:
+ return None
+ last_calls[key] = now
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+def extract_urls(text: str) -> list[str]:
+ # Regex pattern to match URLs
+ url_pattern = re.compile(
+ r"(https?://[^\s]+)", re.IGNORECASE
+ ) # Matches http and https URLs
+ return url_pattern.findall(text)
+
+
+def stream_chunks_handler(stream: aiohttp.StreamReader):
+ """
+ Handle stream response chunks, supporting large data chunks that exceed the original 16kb limit.
+ When a single line exceeds max_buffer_size, returns an empty JSON string {} and skips subsequent data
+ until encountering normally sized data.
+
+ :param stream: The stream reader to handle.
+ :return: An async generator that yields the stream data.
+ """
+
+ max_buffer_size = CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE
+ if max_buffer_size is None or max_buffer_size <= 0:
+ return stream
+
+ async def yield_safe_stream_chunks():
+ buffer = b""
+ skip_mode = False
+
+ async for data, _ in stream.iter_chunks():
+ if not data:
+ continue
+
+ # In skip_mode, if buffer already exceeds the limit, clear it (it's part of an oversized line)
+ if skip_mode and len(buffer) > max_buffer_size:
+ buffer = b""
+
+ lines = (buffer + data).split(b"\n")
+
+ # Process complete lines (except the last possibly incomplete fragment)
+ for i in range(len(lines) - 1):
+ line = lines[i]
+
+ if skip_mode:
+ # Skip mode: check if current line is small enough to exit skip mode
+ if len(line) <= max_buffer_size:
+ skip_mode = False
+ yield line
+ else:
+ yield b"data: {}"
+ else:
+ # Normal mode: check if line exceeds limit
+ if len(line) > max_buffer_size:
+ skip_mode = True
+ yield b"data: {}"
+ log.info(f"Skip mode triggered, line size: {len(line)}")
+ else:
+ yield line
+
+ # Save the last incomplete fragment
+ buffer = lines[-1]
+
+ # Check if buffer exceeds limit
+ if not skip_mode and len(buffer) > max_buffer_size:
+ skip_mode = True
+ log.info(f"Skip mode triggered, buffer size: {len(buffer)}")
+ # Clear oversized buffer to prevent unlimited growth
+ buffer = b""
+
+ # Process remaining buffer data
+ if buffer and not skip_mode:
+ yield buffer
+
+ return yield_safe_stream_chunks()
diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py
index f637449ba9..8b53ce5193 100644
--- a/backend/open_webui/utils/models.py
+++ b/backend/open_webui/utils/models.py
@@ -12,6 +12,7 @@ from open_webui.functions import get_function_models
from open_webui.models.functions import Functions
from open_webui.models.models import Models
+from open_webui.models.groups import Groups
from open_webui.utils.plugin import (
@@ -22,10 +23,11 @@ from open_webui.utils.access_control import has_access
from open_webui.config import (
+ BYPASS_ADMIN_ACCESS_CONTROL,
DEFAULT_ARENA_MODEL,
)
-from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
+from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.models.users import UserModel
@@ -76,8 +78,19 @@ async def get_all_base_models(request: Request, user: UserModel = None):
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)
+async def get_all_models(request, refresh: bool = False, user: UserModel = None):
+ if (
+ request.app.state.MODELS
+ and request.app.state.BASE_MODELS
+ and (request.app.state.config.ENABLE_BASE_MODELS_CACHE and not refresh)
+ ):
+ base_models = request.app.state.BASE_MODELS
+ else:
+ base_models = await get_all_base_models(request, user=user)
+ request.app.state.BASE_MODELS = base_models
+
+ # deep copy the base models to avoid modifying the original list
+ models = [model.copy() for model in base_models]
# If there are no models, return an empty list
if len(models) == 0:
@@ -137,6 +150,7 @@ async def get_all_models(request, user: UserModel = None):
custom_models = Models.get_all_models()
for custom_model in custom_models:
if custom_model.base_model_id is None:
+ # Applied directly to a base model
for model in models:
if custom_model.id == model["id"] or (
model.get("owned_by") == "ollama"
@@ -153,13 +167,18 @@ async def get_all_models(request, user: UserModel = None):
action_ids = []
filter_ids = []
- if "info" in model and "meta" in model["info"]:
- action_ids.extend(
- model["info"]["meta"].get("actionIds", [])
- )
- filter_ids.extend(
- model["info"]["meta"].get("filterIds", [])
- )
+ if "info" in model:
+ if "meta" in model["info"]:
+ action_ids.extend(
+ model["info"]["meta"].get("actionIds", [])
+ )
+ filter_ids.extend(
+ model["info"]["meta"].get("filterIds", [])
+ )
+
+ if "params" in model["info"]:
+ # Remove params to avoid exposing sensitive info
+ del model["info"]["params"]
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids
@@ -169,22 +188,40 @@ async def get_all_models(request, user: UserModel = None):
elif custom_model.is_active and (
custom_model.id not in [model["id"] for model in models]
):
+ # Custom model based on a base model
owned_by = "openai"
pipe = None
+ for m in models:
+ if (
+ custom_model.base_model_id == m["id"]
+ or custom_model.base_model_id == m["id"].split(":")[0]
+ ):
+ owned_by = m.get("owned_by", "unknown")
+ if "pipe" in m:
+ pipe = m["pipe"]
+ break
+
+ model = {
+ "id": f"{custom_model.id}",
+ "name": custom_model.name,
+ "object": "model",
+ "created": custom_model.created_at,
+ "owned_by": owned_by,
+ "preset": True,
+ **({"pipe": pipe} if pipe is not None else {}),
+ }
+
+ info = custom_model.model_dump()
+ if "params" in info:
+ # Remove params to avoid exposing sensitive info
+ del info["params"]
+
+ model["info"] = info
+
action_ids = []
filter_ids = []
- 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.get("owned_by", "unknown owner")
- if "pipe" in model:
- pipe = model["pipe"]
- break
-
if custom_model.meta:
meta = custom_model.meta.model_dump()
@@ -194,20 +231,10 @@ async def get_all_models(request, user: UserModel = None):
if "filterIds" in meta:
filter_ids.extend(meta["filterIds"])
- models.append(
- {
- "id": f"{custom_model.id}",
- "name": custom_model.name,
- "object": "model",
- "created": custom_model.created_at,
- "owned_by": owned_by,
- "info": custom_model.model_dump(),
- "preset": True,
- **({"pipe": pipe} if pipe is not None else {}),
- "action_ids": action_ids,
- "filter_ids": filter_ids,
- }
- )
+ model["action_ids"] = action_ids
+ model["filter_ids"] = filter_ids
+
+ models.append(model)
# Process action_ids to get the actions
def get_action_items_from_module(function, module):
@@ -250,6 +277,7 @@ async def get_all_models(request, user: UserModel = None):
"icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None)
or getattr(module, "icon", None),
+ "has_user_valves": hasattr(module, "UserValves"),
}
]
@@ -320,3 +348,43 @@ def check_model_access(user, model):
)
):
raise Exception("Model not found")
+
+
+def get_filtered_models(models, user):
+ # Filter out models that the user does not have access to
+ if (
+ user.role == "user"
+ or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
+ ) and not BYPASS_MODEL_ACCESS_CONTROL:
+ filtered_models = []
+ user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
+ for model in models:
+ if model.get("arena"):
+ if has_access(
+ user.id,
+ type="read",
+ access_control=model.get("info", {})
+ .get("meta", {})
+ .get("access_control", {}),
+ user_group_ids=user_group_ids,
+ ):
+ filtered_models.append(model)
+ continue
+
+ model_info = Models.get_model_by_id(model["id"])
+ if model_info:
+ if (
+ (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
+ or user.id == model_info.user_id
+ or has_access(
+ user.id,
+ type="read",
+ access_control=model_info.access_control,
+ user_group_ids=user_group_ids,
+ )
+ ):
+ filtered_models.append(model)
+
+ return filtered_models
+ else:
+ return models
diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py
index f9d1122a8a..dc53be7ac5 100644
--- a/backend/open_webui/utils/oauth.py
+++ b/backend/open_webui/utils/oauth.py
@@ -1,9 +1,20 @@
import base64
+import copy
+import hashlib
import logging
import mimetypes
import sys
+import urllib
import uuid
import json
+from datetime import datetime, timedelta
+
+import re
+import fnmatch
+import time
+import secrets
+from cryptography.fernet import Fernet
+from typing import Literal
from urllib.parse import quote
import aiohttp
@@ -14,9 +25,14 @@ from fastapi import (
status,
)
from starlette.responses import RedirectResponse
+from typing import Optional
+
from open_webui.models.auths import Auths
+from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
+
+
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
from open_webui.config import (
DEFAULT_USER_ROLE,
@@ -27,7 +43,9 @@ from open_webui.config import (
ENABLE_OAUTH_GROUP_MANAGEMENT,
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_BLOCKED_GROUPS,
+ OAUTH_GROUPS_SEPARATOR,
OAUTH_ROLES_CLAIM,
+ OAUTH_SUB_CLAIM,
OAUTH_GROUPS_CLAIM,
OAUTH_EMAIL_CLAIM,
OAUTH_PICTURE_CLAIM,
@@ -36,6 +54,7 @@ from open_webui.config import (
OAUTH_ADMIN_ROLES,
OAUTH_ALLOWED_DOMAINS,
OAUTH_UPDATE_PICTURE_ON_LOGIN,
+ OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
WEBHOOK_URL,
JWT_EXPIRES_IN,
GOOGLE_OAUTH_SCOPE,
@@ -47,11 +66,40 @@ from open_webui.env import (
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
+ ENABLE_OAUTH_ID_TOKEN_COOKIE,
+ ENABLE_OAUTH_EMAIL_FALLBACK,
+ OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
)
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
+from mcp.shared.auth import (
+ OAuthClientMetadata as MCPOAuthClientMetadata,
+ OAuthMetadata,
+)
+
+from authlib.oauth2.rfc6749.errors import OAuth2Error
+
+
+class OAuthClientMetadata(MCPOAuthClientMetadata):
+ token_endpoint_auth_method: Literal[
+ "none", "client_secret_basic", "client_secret_post"
+ ] = "client_secret_post"
+ pass
+
+
+class OAuthClientInformationFull(OAuthClientMetadata):
+ issuer: Optional[str] = None # URL of the OAuth server that issued this client
+
+ client_id: str
+ client_secret: str | None = None
+ client_id_issued_at: int | None = None
+ client_secret_expires_at: int | None = None
+
+ server_metadata: Optional[OAuthMetadata] = None # Fetched from the OAuth server
+
+
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@@ -67,6 +115,7 @@ auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMEN
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_SUB_CLAIM = OAUTH_SUB_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
@@ -79,22 +128,884 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
+FERNET = None
+
+if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
+ key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest()
+ OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes)
+else:
+ OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()
+
+try:
+ FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY)
+except Exception as e:
+ log.error(f"Error initializing Fernet with provided key: {e}")
+ raise
+
+
+def encrypt_data(data) -> str:
+ """Encrypt data for storage"""
+ try:
+ data_json = json.dumps(data)
+ encrypted = FERNET.encrypt(data_json.encode()).decode()
+ return encrypted
+ except Exception as e:
+ log.error(f"Error encrypting data: {e}")
+ raise
+
+
+def decrypt_data(data: str):
+ """Decrypt data from storage"""
+ try:
+ decrypted = FERNET.decrypt(data.encode()).decode()
+ return json.loads(decrypted)
+ except Exception as e:
+ log.error(f"Error decrypting data: {e}")
+ raise
+
+
+def _build_oauth_callback_error_message(e: Exception) -> str:
+ """
+ Produce a user-facing callback error string with actionable context.
+ Keeps the message short and strips newlines for safe redirect usage.
+ """
+ if isinstance(e, OAuth2Error):
+ parts = [p for p in [e.error, e.description] if p]
+ detail = " - ".join(parts)
+ elif isinstance(e, HTTPException):
+ detail = e.detail if isinstance(e.detail, str) else str(e.detail)
+ elif isinstance(e, aiohttp.ClientResponseError):
+ detail = f"Upstream provider returned {e.status}: {e.message}"
+ elif isinstance(e, aiohttp.ClientError):
+ detail = str(e)
+ elif isinstance(e, KeyError):
+ missing = str(e).strip("'")
+ if missing.lower() == "state":
+ detail = "Missing state parameter in callback (session may have expired)"
+ else:
+ detail = f"Missing expected key '{missing}' in OAuth response"
+ else:
+ detail = str(e)
+
+ detail = detail.replace("\n", " ").strip()
+ if not detail:
+ detail = e.__class__.__name__
+
+ message = f"OAuth callback failed: {detail}"
+ return message[:197] + "..." if len(message) > 200 else message
+
+
+def is_in_blocked_groups(group_name: str, groups: list) -> bool:
+ """
+ Check if a group name matches any blocked pattern.
+ Supports exact matches, shell-style wildcards (*, ?), and regex patterns.
+
+ Args:
+ group_name: The group name to check
+ groups: List of patterns to match against
+
+ Returns:
+ True if the group is blocked, False otherwise
+ """
+ if not groups:
+ return False
+
+ for group_pattern in groups:
+ if not group_pattern: # Skip empty patterns
+ continue
+
+ # Exact match
+ if group_name == group_pattern:
+ return True
+
+ # Try as regex pattern first if it contains regex-specific characters
+ if any(
+ char in group_pattern
+ for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"]
+ ):
+ try:
+ # Use the original pattern as-is for regex matching
+ if re.search(group_pattern, group_name):
+ return True
+ except re.error:
+ # If regex is invalid, fall through to wildcard check
+ pass
+
+ # Shell-style wildcard match (supports * and ?)
+ if "*" in group_pattern or "?" in group_pattern:
+ if fnmatch.fnmatch(group_name, group_pattern):
+ return True
+
+ return False
+
+
+def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
+ parsed = urllib.parse.urlparse(server_url)
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
+ return parsed, base_url
+
+
+def get_discovery_urls(server_url) -> list[str]:
+ parsed, base_url = get_parsed_and_base_url(server_url)
+
+ urls = []
+
+ if parsed.path and parsed.path != "/":
+ # Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery
+ tenant = parsed.path.rstrip("/")
+ urls.extend(
+ [
+ urllib.parse.urljoin(
+ base_url,
+ f"/.well-known/oauth-authorization-server{tenant}",
+ ),
+ urllib.parse.urljoin(
+ base_url, f"/.well-known/openid-configuration{tenant}"
+ ),
+ urllib.parse.urljoin(
+ base_url, f"{tenant}/.well-known/openid-configuration"
+ ),
+ ]
+ )
+
+ urls.extend(
+ [
+ urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"),
+ urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"),
+ ]
+ )
+
+ return urls
+
+
+# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
+# This is not currently supported.
+async def get_oauth_client_info_with_dynamic_client_registration(
+ request,
+ client_id: str,
+ oauth_server_url: str,
+ oauth_server_key: Optional[str] = None,
+) -> OAuthClientInformationFull:
+ try:
+ oauth_server_metadata = None
+ oauth_server_metadata_url = None
+
+ redirect_base_url = (
+ str(request.app.state.config.WEBUI_URL or request.base_url)
+ ).rstrip("/")
+
+ oauth_client_metadata = OAuthClientMetadata(
+ client_name="Open WebUI",
+ redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"],
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ )
+
+ # Attempt to fetch OAuth server metadata to get registration endpoint & scopes
+ discovery_urls = get_discovery_urls(oauth_server_url)
+ for url in discovery_urls:
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.get(
+ url, ssl=AIOHTTP_CLIENT_SESSION_SSL
+ ) as oauth_server_metadata_response:
+ if oauth_server_metadata_response.status == 200:
+ try:
+ oauth_server_metadata = OAuthMetadata.model_validate(
+ await oauth_server_metadata_response.json()
+ )
+ oauth_server_metadata_url = url
+ if (
+ oauth_client_metadata.scope is None
+ and oauth_server_metadata.scopes_supported is not None
+ ):
+ oauth_client_metadata.scope = " ".join(
+ oauth_server_metadata.scopes_supported
+ )
+
+ if (
+ oauth_server_metadata.token_endpoint_auth_methods_supported
+ and oauth_client_metadata.token_endpoint_auth_method
+ not in oauth_server_metadata.token_endpoint_auth_methods_supported
+ ):
+ # Pick the first supported method from the server
+ oauth_client_metadata.token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[
+ 0
+ ]
+
+ break
+ except Exception as e:
+ log.error(f"Error parsing OAuth metadata from {url}: {e}")
+ continue
+
+ registration_url = None
+ if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
+ registration_url = str(oauth_server_metadata.registration_endpoint)
+ else:
+ _, base_url = get_parsed_and_base_url(oauth_server_url)
+ registration_url = urllib.parse.urljoin(base_url, "/register")
+
+ registration_data = oauth_client_metadata.model_dump(
+ exclude_none=True,
+ mode="json",
+ by_alias=True,
+ )
+
+ # Perform dynamic client registration and return client info
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
+ ) as oauth_client_registration_response:
+ try:
+ registration_response_json = (
+ await oauth_client_registration_response.json()
+ )
+
+ # The mcp package requires optional unset values to be None. If an empty string is passed, it gets validated and fails.
+ # This replaces all empty strings with None.
+ registration_response_json = {
+ k: (None if v == "" else v)
+ for k, v in registration_response_json.items()
+ }
+ oauth_client_info = OAuthClientInformationFull.model_validate(
+ {
+ **registration_response_json,
+ **{"issuer": oauth_server_metadata_url},
+ **{"server_metadata": oauth_server_metadata},
+ }
+ )
+ log.info(
+ f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}"
+ )
+ return oauth_client_info
+ except Exception as e:
+ error_text = None
+ try:
+ error_text = await oauth_client_registration_response.text()
+ log.error(
+ f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}"
+ )
+ except Exception as e:
+ pass
+
+ log.error(f"Error parsing client registration response: {e}")
+ raise Exception(
+ f"Dynamic client registration failed: {error_text}"
+ if error_text
+ else "Error parsing client registration response"
+ )
+ raise Exception("Dynamic client registration failed")
+ except Exception as e:
+ log.error(f"Exception during dynamic client registration: {e}")
+ raise e
+
+
+class OAuthClientManager:
+ def __init__(self, app):
+ self.oauth = OAuth()
+ self.app = app
+ self.clients = {}
+
+ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
+ kwargs = {
+ "name": client_id,
+ "client_id": oauth_client_info.client_id,
+ "client_secret": oauth_client_info.client_secret,
+ "client_kwargs": {
+ **(
+ {"scope": oauth_client_info.scope}
+ if oauth_client_info.scope
+ else {}
+ ),
+ **(
+ {
+ "token_endpoint_auth_method": oauth_client_info.token_endpoint_auth_method
+ }
+ if oauth_client_info.token_endpoint_auth_method
+ else {}
+ ),
+ },
+ "server_metadata_url": (
+ oauth_client_info.issuer if oauth_client_info.issuer else None
+ ),
+ }
+
+ if (
+ oauth_client_info.server_metadata
+ and oauth_client_info.server_metadata.code_challenge_methods_supported
+ ):
+ if (
+ isinstance(
+ oauth_client_info.server_metadata.code_challenge_methods_supported,
+ list,
+ )
+ and "S256"
+ in oauth_client_info.server_metadata.code_challenge_methods_supported
+ ):
+ kwargs["code_challenge_method"] = "S256"
+
+ self.clients[client_id] = {
+ "client": self.oauth.register(**kwargs),
+ "client_info": oauth_client_info,
+ }
+ return self.clients[client_id]
+
+ def remove_client(self, client_id):
+ if client_id in self.clients:
+ del self.clients[client_id]
+ log.info(f"Removed OAuth client {client_id}")
+
+ if hasattr(self.oauth, "_clients"):
+ if client_id in self.oauth._clients:
+ self.oauth._clients.pop(client_id, None)
+
+ if hasattr(self.oauth, "_registry"):
+ if client_id in self.oauth._registry:
+ self.oauth._registry.pop(client_id, None)
+
+ return True
+
+ async def _preflight_authorization_url(
+ self, client, client_info: OAuthClientInformationFull
+ ) -> bool:
+ # TODO: Replace this logic with a more robust OAuth client registration validation
+ # Only perform preflight checks for Starlette OAuth clients
+ if not hasattr(client, "create_authorization_url"):
+ return True
+
+ redirect_uri = None
+ if client_info.redirect_uris:
+ redirect_uri = str(client_info.redirect_uris[0])
+
+ try:
+ auth_data = await client.create_authorization_url(redirect_uri=redirect_uri)
+ authorization_url = auth_data.get("url")
+
+ if not authorization_url:
+ return True
+ except Exception as e:
+ log.debug(
+ f"Skipping OAuth preflight for client {client_info.client_id}: {e}",
+ )
+ return True
+
+ try:
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.get(
+ authorization_url,
+ allow_redirects=False,
+ ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ ) as resp:
+ if resp.status < 400:
+ return True
+ response_text = await resp.text()
+
+ error = None
+ error_description = ""
+
+ content_type = resp.headers.get("content-type", "")
+ if "application/json" in content_type:
+ try:
+ payload = json.loads(response_text)
+ error = payload.get("error")
+ error_description = payload.get("error_description", "")
+ except:
+ pass
+ else:
+ error_description = response_text
+
+ error_message = f"{error or ''} {error_description or ''}".lower()
+
+ if any(
+ keyword in error_message
+ for keyword in ("invalid_client", "invalid client", "client id")
+ ):
+ log.warning(
+ f"OAuth client preflight detected invalid registration for {client_info.client_id}: {error} {error_description}"
+ )
+
+ return False
+ except Exception as e:
+ log.debug(
+ f"Skipping OAuth preflight network check for client {client_info.client_id}: {e}"
+ )
+
+ return True
+
+ def get_client(self, client_id):
+ client = self.clients.get(client_id)
+ return client["client"] if client else None
+
+ def get_client_info(self, client_id):
+ client = self.clients.get(client_id)
+ return client["client_info"] if client else None
+
+ def get_server_metadata_url(self, client_id):
+ if client_id in self.clients:
+ client = self.clients[client_id]
+ return (
+ client._server_metadata_url
+ if hasattr(client, "_server_metadata_url")
+ else None
+ )
+ return None
+
+ async def get_oauth_token(
+ self, user_id: str, client_id: str, force_refresh: bool = False
+ ):
+ """
+ Get a valid OAuth token for the user, automatically refreshing if needed.
+
+ Args:
+ user_id: The user ID
+ client_id: The OAuth client ID (provider)
+ force_refresh: Force token refresh even if current token appears valid
+
+ Returns:
+ dict: OAuth token data with access_token, or None if no valid token available
+ """
+ try:
+ # Get the OAuth session
+ session = OAuthSessions.get_session_by_provider_and_user_id(
+ client_id, user_id
+ )
+ if not session:
+ log.warning(
+ f"No OAuth session found for user {user_id}, client_id {client_id}"
+ )
+ return None
+
+ if force_refresh or datetime.now() + timedelta(
+ minutes=5
+ ) >= datetime.fromtimestamp(session.expires_at):
+ log.debug(
+ f"Token refresh needed for user {user_id}, client_id {session.provider}"
+ )
+ refreshed_token = await self._refresh_token(session)
+ if refreshed_token:
+ return refreshed_token
+ else:
+ log.warning(
+ f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}"
+ )
+ OAuthSessions.delete_session_by_id(session.id)
+ return None
+ return session.token
+
+ except Exception as e:
+ log.error(f"Error getting OAuth token for user {user_id}: {e}")
+ return None
+
+ async def _refresh_token(self, session) -> dict:
+ """
+ Refresh an OAuth token if needed, with concurrency protection.
+
+ Args:
+ session: The OAuth session object
+
+ Returns:
+ dict: Refreshed token data, or None if refresh failed
+ """
+ try:
+ # Perform the actual refresh
+ refreshed_token = await self._perform_token_refresh(session)
+
+ if refreshed_token:
+ # Update the session with new token data
+ session = OAuthSessions.update_session_by_id(
+ session.id, refreshed_token
+ )
+ log.info(f"Successfully refreshed token for session {session.id}")
+ return session.token
+ else:
+ log.error(f"Failed to refresh token for session {session.id}")
+ return None
+
+ except Exception as e:
+ log.error(f"Error refreshing token for session {session.id}: {e}")
+ return None
+
+ async def _perform_token_refresh(self, session) -> dict:
+ """
+ Perform the actual OAuth token refresh.
+
+ Args:
+ session: The OAuth session object
+
+ Returns:
+ dict: New token data, or None if refresh failed
+ """
+ client_id = session.provider
+ token_data = session.token
+
+ if not token_data.get("refresh_token"):
+ log.warning(f"No refresh token available for session {session.id}")
+ return None
+
+ try:
+ client = self.get_client(client_id)
+ if not client:
+ log.error(f"No OAuth client found for provider {client_id}")
+ return None
+
+ token_endpoint = None
+ async with aiohttp.ClientSession(trust_env=True) as session_http:
+ async with session_http.get(
+ self.get_server_metadata_url(client_id)
+ ) as r:
+ if r.status == 200:
+ openid_data = await r.json()
+ token_endpoint = openid_data.get("token_endpoint")
+ else:
+ log.error(
+ f"Failed to fetch OpenID configuration for client_id {client_id}"
+ )
+ if not token_endpoint:
+ log.error(f"No token endpoint found for client_id {client_id}")
+ return None
+
+ # Prepare refresh request
+ refresh_data = {
+ "grant_type": "refresh_token",
+ "refresh_token": token_data["refresh_token"],
+ "client_id": client.client_id,
+ }
+ if hasattr(client, "client_secret") and client.client_secret:
+ refresh_data["client_secret"] = client.client_secret
+
+ # Make refresh request
+ async with aiohttp.ClientSession(trust_env=True) as session_http:
+ async with session_http.post(
+ token_endpoint,
+ data=refresh_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ ) as r:
+ if r.status == 200:
+ new_token_data = await r.json()
+
+ # Merge with existing token data (preserve refresh_token if not provided)
+ if "refresh_token" not in new_token_data:
+ new_token_data["refresh_token"] = token_data[
+ "refresh_token"
+ ]
+
+ # Add timestamp for tracking
+ new_token_data["issued_at"] = datetime.now().timestamp()
+
+ # Calculate expires_at if we have expires_in
+ if (
+ "expires_in" in new_token_data
+ and "expires_at" not in new_token_data
+ ):
+ new_token_data["expires_at"] = int(
+ datetime.now().timestamp()
+ + new_token_data["expires_in"]
+ )
+
+ log.debug(f"Token refresh successful for client_id {client_id}")
+ return new_token_data
+ else:
+ error_text = await r.text()
+ log.error(
+ f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}"
+ )
+ return None
+
+ except Exception as e:
+ log.error(f"Exception during token refresh for client_id {client_id}: {e}")
+ return None
+
+ async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
+ client = self.get_client(client_id)
+ if client is None:
+ raise HTTPException(404)
+ client_info = self.get_client_info(client_id)
+ if client_info is None:
+ raise HTTPException(404)
+
+ redirect_uri = (
+ client_info.redirect_uris[0] if client_info.redirect_uris else None
+ )
+ redirect_uri_str = str(redirect_uri) if redirect_uri else None
+ return await client.authorize_redirect(request, redirect_uri_str)
+
+ async def handle_callback(self, request, client_id: str, user_id: str, response):
+ client = self.get_client(client_id)
+ if client is None:
+ raise HTTPException(404)
+
+ error_message = None
+ try:
+ client_info = self.get_client_info(client_id)
+
+ auth_params = {}
+ if (
+ client_info
+ and hasattr(client_info, "client_id")
+ and hasattr(client_info, "client_secret")
+ ):
+ auth_params["client_id"] = client_info.client_id
+ auth_params["client_secret"] = client_info.client_secret
+
+ token = await client.authorize_access_token(request, **auth_params)
+ if token:
+ try:
+ # Add timestamp for tracking
+ token["issued_at"] = datetime.now().timestamp()
+
+ # Calculate expires_at if we have expires_in
+ if "expires_in" in token and "expires_at" not in token:
+ token["expires_at"] = (
+ datetime.now().timestamp() + token["expires_in"]
+ )
+
+ # Clean up any existing sessions for this user/client_id first
+ sessions = OAuthSessions.get_sessions_by_user_id(user_id)
+ for session in sessions:
+ if session.provider == client_id:
+ OAuthSessions.delete_session_by_id(session.id)
+
+ session = OAuthSessions.create_session(
+ user_id=user_id,
+ provider=client_id,
+ token=token,
+ )
+ log.info(
+ f"Stored OAuth session server-side for user {user_id}, client_id {client_id}"
+ )
+ except Exception as e:
+ error_message = "Failed to store OAuth session server-side"
+ log.error(f"Failed to store OAuth session server-side: {e}")
+ else:
+ error_message = "Failed to obtain OAuth token"
+ log.warning(error_message)
+ except Exception as e:
+ error_message = _build_oauth_callback_error_message(e)
+ log.warning(
+ "OAuth callback error for user_id=%s client_id=%s: %s",
+ user_id,
+ client_id,
+ error_message,
+ exc_info=True,
+ )
+
+ redirect_url = (
+ str(request.app.state.config.WEBUI_URL or request.base_url)
+ ).rstrip("/")
+
+ if error_message:
+ log.debug(error_message)
+ redirect_url = (
+ f"{redirect_url}/?error={urllib.parse.quote_plus(error_message)}"
+ )
+ return RedirectResponse(url=redirect_url, headers=response.headers)
+
+ response = RedirectResponse(url=redirect_url, headers=response.headers)
+ return response
+
+
class OAuthManager:
def __init__(self, app):
self.oauth = OAuth()
self.app = app
- for _, provider_config in OAUTH_PROVIDERS.items():
- provider_config["register"](self.oauth)
+
+ self._clients = {}
+
+ for name, provider_config in OAUTH_PROVIDERS.items():
+ if "register" not in provider_config:
+ log.error(f"OAuth provider {name} missing register function")
+ continue
+
+ client = provider_config["register"](self.oauth)
+ self._clients[name] = client
def get_client(self, provider_name):
- return self.oauth.create_client(provider_name)
+ if provider_name not in self._clients:
+ self._clients[provider_name] = self.oauth.create_client(provider_name)
+ return self._clients[provider_name]
+
+ def get_server_metadata_url(self, provider_name):
+ if provider_name in self._clients:
+ client = self._clients[provider_name]
+ return (
+ client._server_metadata_url
+ if hasattr(client, "_server_metadata_url")
+ else None
+ )
+ return None
+
+ async def get_oauth_token(
+ self, user_id: str, session_id: str, force_refresh: bool = False
+ ):
+ """
+ Get a valid OAuth token for the user, automatically refreshing if needed.
+
+ Args:
+ user_id: The user ID
+ provider: Optional provider name. If None, gets the most recent session.
+ force_refresh: Force token refresh even if current token appears valid
+
+ Returns:
+ dict: OAuth token data with access_token, or None if no valid token available
+ """
+ try:
+ # Get the OAuth session
+ session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
+ if not session:
+ log.warning(
+ f"No OAuth session found for user {user_id}, session {session_id}"
+ )
+ return None
+
+ if force_refresh or datetime.now() + timedelta(
+ minutes=5
+ ) >= datetime.fromtimestamp(session.expires_at):
+ log.debug(
+ f"Token refresh needed for user {user_id}, provider {session.provider}"
+ )
+ refreshed_token = await self._refresh_token(session)
+ if refreshed_token:
+ return refreshed_token
+ else:
+ log.warning(
+ f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}"
+ )
+ OAuthSessions.delete_session_by_id(session.id)
+
+ return None
+ return session.token
+
+ except Exception as e:
+ log.error(f"Error getting OAuth token for user {user_id}: {e}")
+ return None
+
+ async def _refresh_token(self, session) -> dict:
+ """
+ Refresh an OAuth token if needed, with concurrency protection.
+
+ Args:
+ session: The OAuth session object
+
+ Returns:
+ dict: Refreshed token data, or None if refresh failed
+ """
+ try:
+ # Perform the actual refresh
+ refreshed_token = await self._perform_token_refresh(session)
+
+ if refreshed_token:
+ # Update the session with new token data
+ session = OAuthSessions.update_session_by_id(
+ session.id, refreshed_token
+ )
+ log.info(f"Successfully refreshed token for session {session.id}")
+ return session.token
+ else:
+ log.error(f"Failed to refresh token for session {session.id}")
+ return None
+
+ except Exception as e:
+ log.error(f"Error refreshing token for session {session.id}: {e}")
+ return None
+
+ async def _perform_token_refresh(self, session) -> dict:
+ """
+ Perform the actual OAuth token refresh.
+
+ Args:
+ session: The OAuth session object
+
+ Returns:
+ dict: New token data, or None if refresh failed
+ """
+ provider = session.provider
+ token_data = session.token
+
+ if not token_data.get("refresh_token"):
+ log.warning(f"No refresh token available for session {session.id}")
+ return None
+
+ try:
+ client = self.get_client(provider)
+ if not client:
+ log.error(f"No OAuth client found for provider {provider}")
+ return None
+
+ server_metadata_url = self.get_server_metadata_url(provider)
+ token_endpoint = None
+ async with aiohttp.ClientSession(trust_env=True) as session_http:
+ async with session_http.get(server_metadata_url) as r:
+ if r.status == 200:
+ openid_data = await r.json()
+ token_endpoint = openid_data.get("token_endpoint")
+ else:
+ log.error(
+ f"Failed to fetch OpenID configuration for provider {provider}"
+ )
+ if not token_endpoint:
+ log.error(f"No token endpoint found for provider {provider}")
+ return None
+
+ # Prepare refresh request
+ refresh_data = {
+ "grant_type": "refresh_token",
+ "refresh_token": token_data["refresh_token"],
+ "client_id": client.client_id,
+ }
+ # Add client_secret if available (some providers require it)
+ if hasattr(client, "client_secret") and client.client_secret:
+ refresh_data["client_secret"] = client.client_secret
+
+ # Make refresh request
+ async with aiohttp.ClientSession(trust_env=True) as session_http:
+ async with session_http.post(
+ token_endpoint,
+ data=refresh_data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ ) as r:
+ if r.status == 200:
+ new_token_data = await r.json()
+
+ # Merge with existing token data (preserve refresh_token if not provided)
+ if "refresh_token" not in new_token_data:
+ new_token_data["refresh_token"] = token_data[
+ "refresh_token"
+ ]
+
+ # Add timestamp for tracking
+ new_token_data["issued_at"] = datetime.now().timestamp()
+
+ # Calculate expires_at if we have expires_in
+ if (
+ "expires_in" in new_token_data
+ and "expires_at" not in new_token_data
+ ):
+ new_token_data["expires_at"] = int(
+ datetime.now().timestamp()
+ + new_token_data["expires_in"]
+ )
+
+ log.debug(f"Token refresh successful for provider {provider}")
+ return new_token_data
+ else:
+ error_text = await r.text()
+ log.error(
+ f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
+ )
+ return None
+
+ except Exception as e:
+ log.error(f"Exception during token refresh for provider {provider}: {e}")
+ return None
async def get_user_role(self, user, user_data, provider=None, access_token=None):
- if user and Users.get_num_users() == 1:
+ user_count = Users.get_num_users()
+ if user and user_count == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
log.debug("Assigning the only user the admin role")
return "admin"
- if not user and Users.get_num_users() == 0:
+ if not user and user_count == 0:
# If there are no users, assign the role "admin", as the first user will be an admin
log.debug("Assigning the first user the admin role")
return "admin"
@@ -110,10 +1021,10 @@ class OAuthManager:
# Check if this is Google OAuth with Cloud Identity scope
if (
- provider == "google"
- and access_token
- and "https://www.googleapis.com/auth/cloud-identity.groups.readonly"
- in GOOGLE_OAUTH_SCOPE.value
+ provider == "google"
+ and access_token
+ and "https://www.googleapis.com/auth/cloud-identity.groups.readonly"
+ in GOOGLE_OAUTH_SCOPE.value
):
log.debug(
@@ -147,7 +1058,17 @@ 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 []
+
+ # Try flat claim structure as alternative
+ if not claim_data:
+ claim_data = user_data.get(oauth_claim, {})
+
+ oauth_roles = []
+
+ if isinstance(claim_data, list):
+ oauth_roles = claim_data
+ if isinstance(claim_data, str) or isinstance(claim_data, int):
+ oauth_roles = [str(claim_data)]
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
@@ -196,7 +1117,7 @@ class OAuthManager:
"""
groups = []
base_url = "https://content-cloudidentity.googleapis.com/v1/groups/-/memberships:searchTransitiveGroups"
-
+
# Create the query string with proper URL encoding
query_string = f"member_key_id == '{user_email}' && 'cloudidentity.googleapis.com/groups.security' in labels"
encoded_query = quote(query_string)
@@ -213,7 +1134,7 @@ class OAuthManager:
while True:
# Build URL with query parameter
url = f"{base_url}?query={encoded_query}"
-
+
# Add page token to URL if present
if page_token:
url += f"&pageToken={quote(page_token)}"
@@ -291,7 +1212,11 @@ class OAuthManager:
if isinstance(claim_data, list):
user_oauth_groups = claim_data
elif isinstance(claim_data, str):
- user_oauth_groups = [claim_data]
+ # Split by the configured separator if present
+ if OAUTH_GROUPS_SEPARATOR in claim_data:
+ user_oauth_groups = claim_data.split(OAUTH_GROUPS_SEPARATOR)
+ else:
+ user_oauth_groups = [claim_data]
else:
user_oauth_groups = []
@@ -355,29 +1280,28 @@ class OAuthManager:
if (
user_oauth_groups
and group_model.name not in user_oauth_groups
- and group_model.name not in blocked_groups
+ and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Remove group from user
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
)
- user_ids = group_model.user_ids
- user_ids = [i for i in user_ids if i != user.id]
+ Groups.remove_users_from_group(group_model.id, [user.id])
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
if not group_permissions:
group_permissions = default_permissions
- update_form = GroupUpdateForm(
- name=group_model.name,
- description=group_model.description,
- permissions=group_permissions,
- user_ids=user_ids,
- )
Groups.update_group_by_id(
- id=group_model.id, form_data=update_form, overwrite=False
+ id=group_model.id,
+ form_data=GroupUpdateForm(
+ name=group_model.name,
+ description=group_model.description,
+ permissions=group_permissions,
+ ),
+ overwrite=False,
)
# Add user to new groups
@@ -386,29 +1310,28 @@ class OAuthManager:
user_oauth_groups
and group_model.name in user_oauth_groups
and not any(gm.name == group_model.name for gm in user_current_groups)
- and group_model.name not in blocked_groups
+ and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Add user to group
log.debug(
f"Adding user to group {group_model.name} as it was found in their oauth groups"
)
- user_ids = group_model.user_ids
- user_ids.append(user.id)
+ Groups.add_users_to_group(group_model.id, [user.id])
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
if not group_permissions:
group_permissions = default_permissions
- update_form = GroupUpdateForm(
- name=group_model.name,
- description=group_model.description,
- permissions=group_permissions,
- user_ids=user_ids,
- )
Groups.update_group_by_id(
- id=group_model.id, form_data=update_form, overwrite=False
+ id=group_model.id,
+ form_data=GroupUpdateForm(
+ name=group_model.name,
+ description=group_model.description,
+ permissions=group_permissions,
+ ),
+ overwrite=False,
)
async def _process_picture_url(
@@ -461,7 +1384,7 @@ class OAuthManager:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for(
- "oauth_callback", provider=provider
+ "oauth_login_callback", provider=provider
)
client = self.get_client(provider)
if client is None:
@@ -471,136 +1394,183 @@ class OAuthManager:
async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
- client = self.get_client(provider)
- try:
- token = await client.authorize_access_token(request)
- except Exception as e:
- log.warning(f"OAuth callback error: {e}")
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- user_data: UserInfo = token.get("userinfo")
- 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}")
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
- if not sub:
- log.warning(f"OAuth callback failed, sub is missing: {user_data}")
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- provider_sub = f"{provider}@{sub}"
- email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
- email = user_data.get(email_claim, "")
- # We currently mandate that email addresses are provided
- if not email:
- # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
- if provider == "github":
- try:
- access_token = token.get("access_token")
- headers = {"Authorization": f"Bearer {access_token}"}
- async with aiohttp.ClientSession(trust_env=True) as session:
- async with session.get(
- "https://api.github.com/user/emails",
- headers=headers,
- ssl=AIOHTTP_CLIENT_SESSION_SSL,
- ) as resp:
- if resp.ok:
- emails = await resp.json()
- # use the primary email as the user's email
- primary_email = next(
- (e["email"] for e in emails if e.get("primary")),
- None,
- )
- if primary_email:
- email = primary_email
- else:
- log.warning(
- "No primary email found in GitHub response"
+ error_message = None
+ try:
+ client = self.get_client(provider)
+
+ auth_params = {}
+
+ if client:
+ if (
+ hasattr(client, "client_id")
+ and OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID
+ ):
+ auth_params["client_id"] = client.client_id
+
+ try:
+ token = await client.authorize_access_token(request, **auth_params)
+ except Exception as e:
+ detailed_error = _build_oauth_callback_error_message(e)
+ log.warning(
+ "OAuth callback error during authorize_access_token for provider %s: %s",
+ provider,
+ detailed_error,
+ exc_info=True,
+ )
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+ # Try to get userinfo from the token first, some providers include it there
+ user_data: UserInfo = token.get("userinfo")
+ if (
+ (not user_data)
+ or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
+ or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
+ ):
+ user_data: UserInfo = await client.userinfo(token=token)
+ if (
+ provider == "feishu"
+ and isinstance(user_data, dict)
+ and "data" in user_data
+ ):
+ user_data = user_data["data"]
+ if not user_data:
+ log.warning(f"OAuth callback failed, user data is missing: {token}")
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+ # Extract the "sub" claim, using custom claim if configured
+ if auth_manager_config.OAUTH_SUB_CLAIM:
+ sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
+ else:
+ # Fallback to the default sub claim if not configured
+ sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
+ if not sub:
+ log.warning(f"OAuth callback failed, sub is missing: {user_data}")
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+ provider_sub = f"{provider}@{sub}"
+
+ # Email extraction
+ email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
+ email = user_data.get(email_claim, "")
+ # We currently mandate that email addresses are provided
+ if not email:
+ # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
+ if provider == "github":
+ try:
+ access_token = token.get("access_token")
+ headers = {"Authorization": f"Bearer {access_token}"}
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.get(
+ "https://api.github.com/user/emails",
+ headers=headers,
+ ssl=AIOHTTP_CLIENT_SESSION_SSL,
+ ) as resp:
+ if resp.ok:
+ emails = await resp.json()
+ # use the primary email as the user's email
+ primary_email = next(
+ (
+ e["email"]
+ for e in emails
+ if e.get("primary")
+ ),
+ None,
)
+ if primary_email:
+ email = primary_email
+ else:
+ log.warning(
+ "No primary email found in GitHub response"
+ )
+ raise HTTPException(
+ 400, detail=ERROR_MESSAGES.INVALID_CRED
+ )
+ else:
+ log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
- else:
- log.warning("Failed to fetch GitHub email")
- raise HTTPException(
- 400, detail=ERROR_MESSAGES.INVALID_CRED
- )
- except Exception as e:
- log.warning(f"Error fetching GitHub email: {e}")
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- else:
- log.warning(f"OAuth callback failed, email is missing: {user_data}")
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- email = email.lower()
- if (
- "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
- and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
- ):
- log.warning(
- f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
- )
- raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-
- # Check if the user exists
- user = Users.get_user_by_oauth_sub(provider_sub)
-
- if not user:
- # If the user does not exist, check if merging is enabled
- if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
- # Check if the user exists by email
- user = Users.get_user_by_email(email)
- if user:
- # Update the user with the new oauth sub
- Users.update_user_oauth_sub_by_id(user.id, provider_sub)
-
- if user:
- determined_role = await self.get_user_role(
- user, user_data, provider, token.get("access_token")
- )
- if user.role != determined_role:
- Users.update_user_role_by_id(user.id, determined_role)
-
- # Update profile picture if enabled and different from current
- if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
- picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
- if picture_claim:
- new_picture_url = user_data.get(
- picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
- )
- processed_picture_url = await self._process_picture_url(
- new_picture_url, token.get("access_token")
- )
- if processed_picture_url != user.profile_image_url:
- Users.update_user_profile_image_url_by_id(
- user.id, processed_picture_url
- )
- log.debug(f"Updated profile picture for user {user.email}")
-
- if not user:
- # If the user does not exist, check if signups are enabled
- if auth_manager_config.ENABLE_OAUTH_SIGNUP:
- # Check if an existing user with the same email already exists
- existing_user = Users.get_user_by_email(email)
- if existing_user:
- raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
-
- picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
- if picture_claim:
- picture_url = user_data.get(
- picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
- )
- picture_url = await self._process_picture_url(
- picture_url, token.get("access_token")
- )
+ except Exception as e:
+ log.warning(f"Error fetching GitHub email: {e}")
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+ elif ENABLE_OAUTH_EMAIL_FALLBACK:
+ email = f"{provider_sub}.local"
else:
- picture_url = "/user.png"
+ log.warning(f"OAuth callback failed, email is missing: {user_data}")
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+ email = email.lower()
- username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
+ # If allowed domains are configured, check if the email domain is in the list
+ if (
+ "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+ and email.split("@")[-1]
+ not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+ ):
+ log.warning(
+ f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
+ )
+ raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
- name = user_data.get(username_claim)
- if not name:
- log.warning("Username claim is missing, using email as name")
- name = email
+ # Check if the user exists
+ user = Users.get_user_by_oauth_sub(provider_sub)
+ if not user:
+ # If the user does not exist, check if merging is enabled
+ if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
+ # Check if the user exists by email
+ user = Users.get_user_by_email(email)
+ if user:
+ # Update the user with the new oauth sub
+ Users.update_user_oauth_sub_by_id(user.id, provider_sub)
+
+ if user:
+ determined_role = await self.get_user_role(
+ user, user_data, provider, token.get("access_token")
+ )
+ if user.role != determined_role:
+ Users.update_user_role_by_id(user.id, determined_role)
+
+ # Update profile picture if enabled and different from current
+ if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
+ picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
+ if picture_claim:
+ new_picture_url = user_data.get(
+ picture_claim,
+ OAUTH_PROVIDERS[provider].get("picture_url", ""),
+ )
+ processed_picture_url = await self._process_picture_url(
+ new_picture_url, token.get("access_token")
+ )
+ if processed_picture_url != user.profile_image_url:
+ Users.update_user_profile_image_url_by_id(
+ user.id, processed_picture_url
+ )
+ log.debug(f"Updated profile picture for user {user.email}")
+ if not user:
+ # If the user does not exist, check if signups are enabled
+ if auth_manager_config.ENABLE_OAUTH_SIGNUP:
+ # Check if an existing user with the same email already exists
+ existing_user = Users.get_user_by_email(email)
+ if existing_user:
+ raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
+
+ picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
+ if picture_claim:
+ picture_url = user_data.get(
+ picture_claim,
+ OAUTH_PROVIDERS[provider].get("picture_url", ""),
+ )
+ picture_url = await self._process_picture_url(
+ picture_url, token.get("access_token")
+ )
+ else:
+ picture_url = "/user.png"
+ username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
+
+ name = user_data.get(username_claim)
+ if not name:
+ log.warning("Username claim is missing, using email as name")
+ name = email
role = await self.get_user_role(
None, user_data, provider, token.get("access_token")
@@ -618,7 +1588,7 @@ class OAuthManager:
)
if auth_manager_config.WEBHOOK_URL:
- post_webhook(
+ await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
@@ -628,48 +1598,97 @@ class OAuthManager:
"user": user.model_dump_json(exclude_none=True),
},
)
- else:
- raise HTTPException(
- status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
- )
+ else:
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+ )
- jwt_token = create_token(
- data={"id": user.id},
- expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
- )
-
- if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
- await self.update_user_groups(
- user=user,
- user_data=user_data,
- default_permissions=request.app.state.config.USER_PERMISSIONS,
- provider=provider,
- access_token=token.get("access_token"),
+ jwt_token = create_token(
+ data={"id": user.id},
+ expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
+ if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
+ await self.update_user_groups(
+ user=user,
+ user_data=user_data,
+ default_permissions=request.app.state.config.USER_PERMISSIONS,
+ provider=provider,
+ access_token=token.get("access_token"),
+ )
+
+ except Exception as e:
+ log.error(f"Error during OAuth process: {e}")
+ error_message = (
+ e.detail
+ if isinstance(e, HTTPException) and e.detail
+ else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
+ )
+
+ redirect_base_url = (
+ str(request.app.state.config.WEBUI_URL or request.base_url)
+ ).rstrip("/")
+ redirect_url = f"{redirect_base_url}/auth"
+
+ if error_message:
+ redirect_url = f"{redirect_url}?error={error_message}"
+ return RedirectResponse(url=redirect_url, headers=response.headers)
+
+ response = RedirectResponse(url=redirect_url, headers=response.headers)
+
# Set the cookie token
+ # Redirect back to the frontend with the JWT token
response.set_cookie(
key="token",
value=jwt_token,
- httponly=True, # Ensures the cookie is not accessible via JavaScript
+ httponly=False, # Required for frontend access
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
- if ENABLE_OAUTH_SIGNUP.value:
- oauth_id_token = token.get("id_token")
+ # Legacy cookies for compatibility with older frontend versions
+ if ENABLE_OAUTH_ID_TOKEN_COOKIE:
response.set_cookie(
key="oauth_id_token",
- value=oauth_id_token,
+ value=token.get("id_token"),
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
- # Redirect back to the frontend with the JWT token
- redirect_base_url = str(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}"
+ try:
+ # Add timestamp for tracking
+ token["issued_at"] = datetime.now().timestamp()
- return RedirectResponse(url=redirect_url, headers=response.headers)
+ # Calculate expires_at if we have expires_in
+ if "expires_in" in token and "expires_at" not in token:
+ token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
+
+ # Clean up any existing sessions for this user/provider first
+ sessions = OAuthSessions.get_sessions_by_user_id(user.id)
+ for session in sessions:
+ if session.provider == provider:
+ OAuthSessions.delete_session_by_id(session.id)
+
+ session = OAuthSessions.create_session(
+ user_id=user.id,
+ provider=provider,
+ token=token,
+ )
+
+ response.set_cookie(
+ key="oauth_session_id",
+ value=session.id,
+ httponly=True,
+ samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
+ secure=WEBUI_AUTH_COOKIE_SECURE,
+ )
+
+ log.info(
+ f"Stored OAuth session server-side for user {user.id}, provider {provider}"
+ )
+ except Exception as e:
+ log.error(f"Failed to store OAuth session server-side: {e}")
+
+ return response
diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py
index 9b7f748359..bf372e0e76 100644
--- a/backend/open_webui/utils/payload.py
+++ b/backend/open_webui/utils/payload.py
@@ -2,6 +2,7 @@ from open_webui.utils.task import prompt_template, prompt_variables_template
from open_webui.utils.misc import (
deep_update,
add_or_update_system_message,
+ replace_system_message_content,
)
from typing import Callable, Optional
@@ -9,8 +10,12 @@ import json
# inplace function: form_data is modified
-def apply_model_system_prompt_to_body(
- system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None
+def apply_system_prompt_to_body(
+ system: Optional[str],
+ form_data: dict,
+ metadata: Optional[dict] = None,
+ user=None,
+ replace: bool = False,
) -> dict:
if not system:
return form_data
@@ -22,19 +27,17 @@ def apply_model_system_prompt_to_body(
system = prompt_variables_template(system, variables)
# Legacy (API Usage)
- if user:
- template_params = {
- "user_name": user.name,
- "user_location": user.info.get("location") if user.info else None,
- }
+ system = prompt_template(system, user)
+
+ if replace:
+ form_data["messages"] = replace_system_message_content(
+ system, form_data.get("messages", [])
+ )
else:
- template_params = {}
+ form_data["messages"] = add_or_update_system_message(
+ system, form_data.get("messages", [])
+ )
- system = prompt_template(system, **template_params)
-
- form_data["messages"] = add_or_update_system_message(
- system, form_data.get("messages", [])
- )
return form_data
@@ -69,7 +72,9 @@ def remove_open_webui_params(params: dict) -> dict:
"""
open_webui_params = {
"stream_response": bool,
+ "stream_delta_chunk_size": int,
"function_calling": str,
+ "reasoning_tags": list,
"system": str,
}
@@ -159,17 +164,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"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,
@@ -298,6 +297,10 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
if "tools" in openai_payload:
ollama_payload["tools"] = openai_payload["tools"]
+ if "max_tokens" in openai_payload:
+ ollama_payload["num_predict"] = openai_payload["max_tokens"]
+ del openai_payload["max_tokens"]
+
# If there are advanced parameters in the payload, format them in Ollama's options field
if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"]
diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py
index 9d539f4840..51c3f4f5f7 100644
--- a/backend/open_webui/utils/plugin.py
+++ b/backend/open_webui/utils/plugin.py
@@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
os.unlink(temp_file.name)
+def get_tool_module_from_cache(request, tool_id, load_from_db=True):
+ if load_from_db:
+ # Always load from the database by default
+ tool = Tools.get_tool_by_id(tool_id)
+ if not tool:
+ raise Exception(f"Tool not found: {tool_id}")
+ content = tool.content
+
+ new_content = replace_imports(content)
+ if new_content != content:
+ content = new_content
+ # Update the tool content in the database
+ Tools.update_tool_by_id(tool_id, {"content": content})
+
+ if (
+ hasattr(request.app.state, "TOOL_CONTENTS")
+ and tool_id in request.app.state.TOOL_CONTENTS
+ ) and (
+ hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
+ ):
+ if request.app.state.TOOL_CONTENTS[tool_id] == content:
+ return request.app.state.TOOLS[tool_id], None
+
+ tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
+ else:
+ if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
+ return request.app.state.TOOLS[tool_id], None
+
+ tool_module, frontmatter = load_tool_module_by_id(tool_id)
+
+ if not hasattr(request.app.state, "TOOLS"):
+ request.app.state.TOOLS = {}
+
+ if not hasattr(request.app.state, "TOOL_CONTENTS"):
+ request.app.state.TOOL_CONTENTS = {}
+
+ request.app.state.TOOLS[tool_id] = tool_module
+ request.app.state.TOOL_CONTENTS[tool_id] = content
+
+ return tool_module, frontmatter
+
+
def get_function_module_from_cache(request, function_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
@@ -260,7 +302,7 @@ def install_tool_and_function_dependencies():
all_dependencies += f"{dependencies}, "
for tool in tool_list:
# Only install requirements for admin tools
- if tool.user.role == "admin":
+ if tool.user and tool.user.role == "admin":
frontmatter = extract_frontmatter(replace_imports(tool.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py
index 70ae18f115..c60a6fa517 100644
--- a/backend/open_webui/utils/redis.py
+++ b/backend/open_webui/utils/redis.py
@@ -1,12 +1,103 @@
-import socketio
+import inspect
from urllib.parse import urlparse
-from typing import Optional
+
+import logging
+
+import redis
+
+from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT
+
+log = logging.getLogger(__name__)
+
+
+_CONNECTION_CACHE = {}
+
+
+class SentinelRedisProxy:
+ def __init__(self, sentinel, service, *, async_mode: bool = True, **kw):
+ self._sentinel = sentinel
+ self._service = service
+ self._kw = kw
+ self._async_mode = async_mode
+
+ def _master(self):
+ return self._sentinel.master_for(self._service, **self._kw)
+
+ def __getattr__(self, item):
+ master = self._master()
+ orig_attr = getattr(master, item)
+
+ if not callable(orig_attr):
+ return orig_attr
+
+ FACTORY_METHODS = {"pipeline", "pubsub", "monitor", "client", "transaction"}
+ if item in FACTORY_METHODS:
+ return orig_attr
+
+ if self._async_mode:
+
+ async def _wrapped(*args, **kwargs):
+ for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
+ try:
+ method = getattr(self._master(), item)
+ result = method(*args, **kwargs)
+ if inspect.iscoroutine(result):
+ return await result
+ return result
+ except (
+ redis.exceptions.ConnectionError,
+ redis.exceptions.ReadOnlyError,
+ ) as e:
+ if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
+ log.debug(
+ "Redis sentinel fail-over (%s). Retry %s/%s",
+ type(e).__name__,
+ i + 1,
+ REDIS_SENTINEL_MAX_RETRY_COUNT,
+ )
+ continue
+ log.error(
+ "Redis operation failed after %s retries: %s",
+ REDIS_SENTINEL_MAX_RETRY_COUNT,
+ e,
+ )
+ raise e from e
+
+ return _wrapped
+
+ else:
+
+ def _wrapped(*args, **kwargs):
+ for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
+ try:
+ method = getattr(self._master(), item)
+ return method(*args, **kwargs)
+ except (
+ redis.exceptions.ConnectionError,
+ redis.exceptions.ReadOnlyError,
+ ) as e:
+ if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
+ log.debug(
+ "Redis sentinel fail-over (%s). Retry %s/%s",
+ type(e).__name__,
+ i + 1,
+ REDIS_SENTINEL_MAX_RETRY_COUNT,
+ )
+ continue
+ log.error(
+ "Redis operation failed after %s retries: %s",
+ REDIS_SENTINEL_MAX_RETRY_COUNT,
+ e,
+ )
+ raise e from e
+
+ return _wrapped
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'.")
+ if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss":
+ raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.")
return {
"username": parsed_url.username or None,
@@ -18,8 +109,25 @@ def parse_redis_service_url(redis_url):
def get_redis_connection(
- redis_url, redis_sentinels, async_mode=False, decode_responses=True
+ redis_url,
+ redis_sentinels,
+ redis_cluster=False,
+ async_mode=False,
+ decode_responses=True,
):
+
+ cache_key = (
+ redis_url,
+ tuple(redis_sentinels) if redis_sentinels else (),
+ async_mode,
+ decode_responses,
+ )
+
+ if cache_key in _CONNECTION_CACHE:
+ return _CONNECTION_CACHE[cache_key]
+
+ connection = None
+
if async_mode:
import redis.asyncio as redis
@@ -34,11 +142,19 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
- return sentinel.master_for(redis_config["service"])
+ connection = SentinelRedisProxy(
+ sentinel,
+ redis_config["service"],
+ async_mode=async_mode,
+ )
+ elif redis_cluster:
+ if not redis_url:
+ raise ValueError("Redis URL must be provided for cluster mode.")
+ return redis.cluster.RedisCluster.from_url(
+ redis_url, decode_responses=decode_responses
+ )
elif redis_url:
- return redis.from_url(redis_url, decode_responses=decode_responses)
- else:
- return None
+ connection = redis.from_url(redis_url, decode_responses=decode_responses)
else:
import redis
@@ -52,11 +168,24 @@ def get_redis_connection(
password=redis_config["password"],
decode_responses=decode_responses,
)
- return sentinel.master_for(redis_config["service"])
+ connection = SentinelRedisProxy(
+ sentinel,
+ redis_config["service"],
+ async_mode=async_mode,
+ )
+ elif redis_cluster:
+ if not redis_url:
+ raise ValueError("Redis URL must be provided for cluster mode.")
+ return redis.cluster.RedisCluster.from_url(
+ redis_url, decode_responses=decode_responses
+ )
elif redis_url:
- return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
- else:
- return None
+ connection = redis.Redis.from_url(
+ redis_url, decode_responses=decode_responses
+ )
+
+ _CONNECTION_CACHE[cache_key] = connection
+ return connection
def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env):
diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py
index 8ddd502e2e..2a54b9af62 100644
--- a/backend/open_webui/utils/response.py
+++ b/backend/open_webui/utils/response.py
@@ -6,18 +6,17 @@ from open_webui.utils.misc import (
)
-def convert_ollama_tool_call_to_openai(tool_calls: dict) -> dict:
+def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
openai_tool_calls = []
for tool_call in tool_calls:
+ function = tool_call.get("function", {})
openai_tool_call = {
- "index": tool_call.get("index", 0),
+ "index": tool_call.get("index", function.get("index", 0)),
"id": tool_call.get("id", f"call_{str(uuid4())}"),
"type": "function",
"function": {
- "name": tool_call.get("function", {}).get("name", ""),
- "arguments": json.dumps(
- tool_call.get("function", {}).get("arguments", {})
- ),
+ "name": function.get("name", ""),
+ "arguments": json.dumps(function.get("arguments", {})),
},
}
openai_tool_calls.append(openai_tool_call)
diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py
index 42b44d5167..7f90e96330 100644
--- a/backend/open_webui/utils/task.py
+++ b/backend/open_webui/utils/task.py
@@ -2,7 +2,7 @@ import logging
import math
import re
from datetime import datetime
-from typing import Optional
+from typing import Optional, Any
import uuid
@@ -38,9 +38,46 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
return template
-def prompt_template(
- template: str, user_name: Optional[str] = None, user_location: Optional[str] = None
-) -> str:
+def prompt_template(template: str, user: Optional[Any] = None) -> str:
+
+ USER_VARIABLES = {}
+
+ if user:
+ if hasattr(user, "model_dump"):
+ user = user.model_dump()
+
+ if isinstance(user, dict):
+ user_info = user.get("info", {}) or {}
+ birth_date = user.get("date_of_birth")
+ age = None
+
+ if birth_date:
+ try:
+ # If birth_date is str, convert to datetime
+ if isinstance(birth_date, str):
+ birth_date = datetime.strptime(birth_date, "%Y-%m-%d")
+
+ today = datetime.now()
+ age = (
+ today.year
+ - birth_date.year
+ - (
+ (today.month, today.day)
+ < (birth_date.month, birth_date.day)
+ )
+ )
+ except Exception as e:
+ pass
+
+ USER_VARIABLES = {
+ "name": str(user.get("name")),
+ "location": str(user_info.get("location")),
+ "bio": str(user.get("bio")),
+ "gender": str(user.get("gender")),
+ "birth_date": str(birth_date),
+ "age": str(age),
+ }
+
# Get the current date
current_date = datetime.now()
@@ -56,19 +93,20 @@ def prompt_template(
)
template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday)
- if user_name:
- # Replace {{USER_NAME}} in the template with the user's name
- template = template.replace("{{USER_NAME}}", user_name)
- else:
- # Replace {{USER_NAME}} in the template with "Unknown"
- template = template.replace("{{USER_NAME}}", "Unknown")
-
- if user_location:
- # Replace {{USER_LOCATION}} in the template with the current location
- template = template.replace("{{USER_LOCATION}}", user_location)
- else:
- # Replace {{USER_LOCATION}} in the template with "Unknown"
- template = template.replace("{{USER_LOCATION}}", "Unknown")
+ template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown"))
+ template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown"))
+ template = template.replace(
+ "{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown")
+ )
+ template = template.replace(
+ "{{USER_BIRTH_DATE}}", USER_VARIABLES.get("birth_date", "Unknown")
+ )
+ template = template.replace(
+ "{{USER_AGE}}", str(USER_VARIABLES.get("age", "Unknown"))
+ )
+ template = template.replace(
+ "{{USER_LOCATION}}", USER_VARIABLES.get("location", "Unknown")
+ )
return template
@@ -170,109 +208,76 @@ def rag_template(template: str, context: str, query: str):
if "[query]" in context:
query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}"
template = template.replace("[query]", query_placeholder)
- query_placeholders.append(query_placeholder)
+ query_placeholders.append((query_placeholder, "[query]"))
if "{{QUERY}}" in context:
query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}"
template = template.replace("{{QUERY}}", query_placeholder)
- query_placeholders.append(query_placeholder)
+ query_placeholders.append((query_placeholder, "{{QUERY}}"))
template = template.replace("[context]", context)
template = template.replace("{{CONTEXT}}", context)
+
template = template.replace("[query]", query)
template = template.replace("{{QUERY}}", query)
- for query_placeholder in query_placeholders:
- template = template.replace(query_placeholder, query)
+ for query_placeholder, original_placeholder in query_placeholders:
+ template = template.replace(query_placeholder, original_placeholder)
return template
def title_generation_template(
- template: str, messages: list[dict], user: Optional[dict] = None
+ template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
+
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
def follow_up_generation_template(
- template: str, messages: list[dict], user: Optional[dict] = None
+ template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
def tags_generation_template(
- template: str, messages: list[dict], user: Optional[dict] = None
+ template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
def image_prompt_generation_template(
- template: str, messages: list[dict], user: Optional[dict] = None
+ template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
def emoji_generation_template(
- template: str, prompt: str, user: Optional[dict] = None
+ template: str, prompt: str, user: Optional[Any] = None
) -> str:
template = replace_prompt_variable(template, prompt)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
@@ -282,38 +287,24 @@ def autocomplete_generation_template(
prompt: str,
messages: Optional[list[dict]] = None,
type: Optional[str] = None,
- user: Optional[dict] = None,
+ user: Optional[Any] = None,
) -> str:
template = template.replace("{{TYPE}}", type if type else "")
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
def query_generation_template(
- template: str, messages: list[dict], user: Optional[dict] = None
+ template: str, messages: list[dict], user: Optional[Any] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
- template = prompt_template(
- template,
- **(
- {"user_name": user.get("name"), "user_location": user.get("location")}
- if user
- else {}
- ),
- )
+ template = prompt_template(template, user)
return template
diff --git a/backend/open_webui/utils/telemetry/exporters.py b/backend/open_webui/utils/telemetry/exporters.py
deleted file mode 100644
index 4bf166e655..0000000000
--- a/backend/open_webui/utils/telemetry/exporters.py
+++ /dev/null
@@ -1,31 +0,0 @@
-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/logs.py b/backend/open_webui/utils/telemetry/logs.py
new file mode 100644
index 0000000000..00d3e28c07
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/logs.py
@@ -0,0 +1,53 @@
+import logging
+from base64 import b64encode
+from opentelemetry.sdk._logs import (
+ LoggingHandler,
+ LoggerProvider,
+)
+from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
+from opentelemetry.exporter.otlp.proto.http._log_exporter import (
+ OTLPLogExporter as HttpOTLPLogExporter,
+)
+from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
+from opentelemetry._logs import set_logger_provider
+from opentelemetry.sdk.resources import SERVICE_NAME, Resource
+from open_webui.env import (
+ OTEL_SERVICE_NAME,
+ OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
+ OTEL_LOGS_EXPORTER_OTLP_INSECURE,
+ OTEL_LOGS_BASIC_AUTH_USERNAME,
+ OTEL_LOGS_BASIC_AUTH_PASSWORD,
+ OTEL_LOGS_OTLP_SPAN_EXPORTER,
+)
+
+
+def setup_logging():
+ headers = []
+ if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD:
+ auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}"
+ auth_header = b64encode(auth_string.encode()).decode()
+ headers = [("authorization", f"Basic {auth_header}")]
+ resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
+
+ if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http":
+ exporter = HttpOTLPLogExporter(
+ endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
+ headers=headers,
+ )
+ else:
+ exporter = OTLPLogExporter(
+ endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT,
+ insecure=OTEL_LOGS_EXPORTER_OTLP_INSECURE,
+ headers=headers,
+ )
+ logger_provider = LoggerProvider(resource=resource)
+ set_logger_provider(logger_provider)
+
+ logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
+
+ otel_handler = LoggingHandler(logger_provider=logger_provider)
+
+ return otel_handler
+
+
+otel_handler = setup_logging()
diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py
index 8a0298d091..85bd418844 100644
--- a/backend/open_webui/utils/telemetry/metrics.py
+++ b/backend/open_webui/utils/telemetry/metrics.py
@@ -19,35 +19,69 @@ from __future__ import annotations
import time
from typing import Dict, List, Sequence, Any
+from base64 import b64encode
from fastapi import FastAPI, Request
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
+
+from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
+ OTLPMetricExporter as OTLPHttpMetricExporter,
+)
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.view import View
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
-from opentelemetry.sdk.resources import SERVICE_NAME, Resource
-
-from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
+from opentelemetry.sdk.resources import Resource
+from open_webui.env import (
+ OTEL_SERVICE_NAME,
+ OTEL_METRICS_EXPORTER_OTLP_ENDPOINT,
+ OTEL_METRICS_BASIC_AUTH_USERNAME,
+ OTEL_METRICS_BASIC_AUTH_PASSWORD,
+ OTEL_METRICS_OTLP_SPAN_EXPORTER,
+ OTEL_METRICS_EXPORTER_OTLP_INSECURE,
+)
+from open_webui.socket.main import get_active_user_ids
+from open_webui.models.users import Users
_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds
-def _build_meter_provider() -> MeterProvider:
+def _build_meter_provider(resource: Resource) -> MeterProvider:
"""Return a configured MeterProvider."""
+ headers = []
+ if OTEL_METRICS_BASIC_AUTH_USERNAME and OTEL_METRICS_BASIC_AUTH_PASSWORD:
+ auth_string = (
+ f"{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}"
+ )
+ auth_header = b64encode(auth_string.encode()).decode()
+ headers = [("authorization", f"Basic {auth_header}")]
# Periodic reader pushes metrics over OTLP/gRPC to collector
- readers: List[PeriodicExportingMetricReader] = [
- PeriodicExportingMetricReader(
- OTLPMetricExporter(endpoint=OTEL_EXPORTER_OTLP_ENDPOINT),
- export_interval_millis=_EXPORT_INTERVAL_MILLIS,
- )
- ]
+ if OTEL_METRICS_OTLP_SPAN_EXPORTER == "http":
+ readers: List[PeriodicExportingMetricReader] = [
+ PeriodicExportingMetricReader(
+ OTLPHttpMetricExporter(
+ endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers
+ ),
+ export_interval_millis=_EXPORT_INTERVAL_MILLIS,
+ )
+ ]
+ else:
+ readers: List[PeriodicExportingMetricReader] = [
+ PeriodicExportingMetricReader(
+ OTLPMetricExporter(
+ endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT,
+ insecure=OTEL_METRICS_EXPORTER_OTLP_INSECURE,
+ headers=headers,
+ ),
+ export_interval_millis=_EXPORT_INTERVAL_MILLIS,
+ )
+ ]
# Optional view to limit cardinality: drop user-agent etc.
views: List[View] = [
@@ -59,20 +93,29 @@ def _build_meter_provider() -> MeterProvider:
instrument_name="http.server.requests",
attribute_keys=["http.method", "http.route", "http.status_code"],
),
+ View(
+ instrument_name="webui.users.total",
+ ),
+ View(
+ instrument_name="webui.users.active",
+ ),
+ View(
+ instrument_name="webui.users.active.today",
+ ),
]
provider = MeterProvider(
- resource=Resource.create({SERVICE_NAME: OTEL_SERVICE_NAME}),
+ resource=resource,
metric_readers=list(readers),
views=views,
)
return provider
-def setup_metrics(app: FastAPI) -> None:
+def setup_metrics(app: FastAPI, resource: Resource) -> None:
"""Attach OTel metrics middleware to *app* and initialise provider."""
- metrics.set_meter_provider(_build_meter_provider())
+ metrics.set_meter_provider(_build_meter_provider(resource))
meter = metrics.get_meter(__name__)
# Instruments
@@ -87,24 +130,75 @@ def setup_metrics(app: FastAPI) -> None:
unit="ms",
)
+ def observe_active_users(
+ options: metrics.CallbackOptions,
+ ) -> Sequence[metrics.Observation]:
+ return [
+ metrics.Observation(
+ value=len(get_active_user_ids()),
+ )
+ ]
+
+ def observe_total_registered_users(
+ options: metrics.CallbackOptions,
+ ) -> Sequence[metrics.Observation]:
+ return [
+ metrics.Observation(
+ value=len(Users.get_users()["users"]),
+ )
+ ]
+
+ meter.create_observable_gauge(
+ name="webui.users.total",
+ description="Total number of registered users",
+ unit="users",
+ callbacks=[observe_total_registered_users],
+ )
+
+ meter.create_observable_gauge(
+ name="webui.users.active",
+ description="Number of currently active users",
+ unit="users",
+ callbacks=[observe_active_users],
+ )
+
+ def observe_users_active_today(
+ options: metrics.CallbackOptions,
+ ) -> Sequence[metrics.Observation]:
+ return [metrics.Observation(value=Users.get_num_users_active_today())]
+
+ meter.create_observable_gauge(
+ name="webui.users.active.today",
+ description="Number of users active since midnight today",
+ unit="users",
+ callbacks=[observe_users_active_today],
+ )
+
# FastAPI middleware
@app.middleware("http")
async def _metrics_middleware(request: Request, call_next):
start_time = time.perf_counter()
- response = await call_next(request)
- elapsed_ms = (time.perf_counter() - start_time) * 1000.0
- # Route template e.g. "/items/{item_id}" instead of real path.
- route = request.scope.get("route")
- route_path = getattr(route, "path", request.url.path)
+ status_code = None
+ try:
+ response = await call_next(request)
+ status_code = getattr(response, "status_code", 500)
+ return response
+ except Exception:
+ status_code = 500
+ raise
+ finally:
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
- attrs: Dict[str, str | int] = {
- "http.method": request.method,
- "http.route": route_path,
- "http.status_code": response.status_code,
- }
+ # Route template e.g. "/items/{item_id}" instead of real path.
+ route = request.scope.get("route")
+ route_path = getattr(route, "path", request.url.path)
- request_counter.add(1, attrs)
- duration_histogram.record(elapsed_ms, attrs)
+ attrs: Dict[str, str | int] = {
+ "http.method": request.method,
+ "http.route": route_path,
+ "http.status_code": status_code,
+ }
- return response
+ request_counter.add(1, attrs)
+ duration_histogram.record(elapsed_ms, attrs)
diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py
index 62632cff52..36294b4e56 100644
--- a/backend/open_webui/utils/telemetry/setup.py
+++ b/backend/open_webui/utils/telemetry/setup.py
@@ -1,32 +1,58 @@
from fastapi import FastAPI
from opentelemetry import trace
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
+ OTLPSpanExporter as HttpOTLPSpanExporter,
+)
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
from sqlalchemy import Engine
+from base64 import b64encode
-from open_webui.utils.telemetry.exporters import LazyBatchSpanProcessor
from open_webui.utils.telemetry.instrumentors import Instrumentor
from open_webui.utils.telemetry.metrics import setup_metrics
from open_webui.env import (
OTEL_SERVICE_NAME,
OTEL_EXPORTER_OTLP_ENDPOINT,
+ OTEL_EXPORTER_OTLP_INSECURE,
+ ENABLE_OTEL_TRACES,
ENABLE_OTEL_METRICS,
+ OTEL_BASIC_AUTH_USERNAME,
+ OTEL_BASIC_AUTH_PASSWORD,
+ OTEL_OTLP_SPAN_EXPORTER,
)
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()
+ resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME})
+ if ENABLE_OTEL_TRACES:
+ trace.set_tracer_provider(TracerProvider(resource=resource))
+
+ # Add basic auth header only if both username and password are not empty
+ headers = []
+ if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
+ auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}"
+ auth_header = b64encode(auth_string.encode()).decode()
+ headers = [("authorization", f"Basic {auth_header}")]
+
+ # otlp export
+ if OTEL_OTLP_SPAN_EXPORTER == "http":
+ exporter = HttpOTLPSpanExporter(
+ endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
+ headers=headers,
+ )
+ else:
+ exporter = OTLPSpanExporter(
+ endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
+ insecure=OTEL_EXPORTER_OTLP_INSECURE,
+ headers=headers,
+ )
+ trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(exporter))
+ Instrumentor(app=app, db_engine=db_engine).instrument()
# set up metrics only if enabled
if ENABLE_OTEL_METRICS:
- setup_metrics(app)
+ setup_metrics(app, resource)
diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py
index dda2635ec7..268624135d 100644
--- a/backend/open_webui/utils/tools.py
+++ b/backend/open_webui/utils/tools.py
@@ -5,6 +5,7 @@ import inspect
import aiohttp
import asyncio
import yaml
+import json
from pydantic import BaseModel
from pydantic.fields import FieldInfo
@@ -33,11 +34,13 @@ from langchain_core.utils.function_calling import (
)
+from open_webui.utils.misc import is_string_allowed
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.env import (
SRC_LOG_LEVELS,
+ AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
)
@@ -55,19 +58,55 @@ def get_async_tool_function_and_apply_extra_params(
extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
partial_func = partial(function, **extra_params)
+ # Remove the 'frozen' keyword arguments from the signature
+ # python-genai uses the signature to infer the tool properties for native function calling
+ parameters = []
+ for name, parameter in sig.parameters.items():
+ # Exclude keyword arguments that are frozen
+ if name in extra_params:
+ continue
+ # Keep remaining parameters
+ parameters.append(parameter)
+
+ new_sig = inspect.Signature(
+ parameters=parameters, return_annotation=sig.return_annotation
+ )
+
if inspect.iscoroutinefunction(function):
- update_wrapper(partial_func, function)
- return partial_func
+ # wrap the functools.partial as python-genai has trouble with it
+ # https://github.com/googleapis/python-genai/issues/907
+ async def new_function(*args, **kwargs):
+ return await partial_func(*args, **kwargs)
+
else:
- # Make it a coroutine function
+ # Make it a coroutine function when it is not already
async def new_function(*args, **kwargs):
return partial_func(*args, **kwargs)
- update_wrapper(new_function, function)
- return new_function
+ update_wrapper(new_function, function)
+ new_function.__signature__ = new_sig
+
+ new_function.__function__ = function # type: ignore
+ new_function.__extra_params__ = extra_params # type: ignore
+
+ return new_function
-def get_tools(
+def get_updated_tool_function(function: Callable, extra_params: dict):
+ # Get the original function and merge updated params
+ __function__ = getattr(function, "__function__", None)
+ __extra_params__ = getattr(function, "__extra_params__", None)
+
+ if __function__ is not None and __extra_params__ is not None:
+ return get_async_tool_function_and_apply_extra_params(
+ __function__,
+ {**__extra_params__, **extra_params},
+ )
+
+ return function
+
+
+async def get_tools(
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]:
tools_dict = {}
@@ -75,68 +114,134 @@ def get_tools(
for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id)
if tool is None:
+
if tool_id.startswith("server:"):
- server_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", [])
+ splits = tool_id.split(":")
- for spec in specs:
- function_name = spec["name"]
+ if len(splits) == 2:
+ type = "openapi"
+ server_id = splits[1]
+ elif len(splits) == 3:
+ type = splits[1]
+ server_id = splits[2]
- auth_type = tool_server_connection.get("auth_type", "bearer")
- token = None
+ server_id_splits = server_id.split("|")
+ if len(server_id_splits) == 2:
+ server_id = server_id_splits[0]
+ function_names = server_id_splits[1].split(",")
- if auth_type == "bearer":
- token = tool_server_connection.get("key", "")
- elif auth_type == "session":
- token = request.state.token.credentials
+ if type == "openapi":
- 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,
- )
+ tool_server_data = None
+ for server in await get_tool_servers(request):
+ if server["id"] == server_id:
+ tool_server_data = server
+ break
- return tool_function
+ if tool_server_data is None:
+ log.warning(f"Tool server data not found for {server_id}")
+ continue
- tool_function = make_tool_function(
- function_name, token, tool_server_data
+ tool_server_idx = tool_server_data.get("idx", 0)
+ tool_server_connection = (
+ request.app.state.config.TOOL_SERVER_CONNECTIONS[
+ tool_server_idx
+ ]
)
- callable = get_async_tool_function_and_apply_extra_params(
- tool_function,
- {},
+ specs = tool_server_data.get("specs", [])
+ function_name_filter_list = (
+ tool_server_connection.get("config", {})
+ .get("function_name_filter_list", "")
+ .split(",")
)
- tool_dict = {
- "tool_id": tool_id,
- "callable": callable,
- "spec": spec,
- }
+ for spec in specs:
+ function_name = spec["name"]
+ if function_name_filter_list:
+ if not is_string_allowed(
+ function_name, function_name_filter_list
+ ):
+ # Skip this function
+ continue
- # TODO: if collision, prepend toolkit name
- if function_name in tools_dict:
- log.warning(
- f"Tool {function_name} already exists in another tools!"
+ auth_type = tool_server_connection.get("auth_type", "bearer")
+
+ cookies = {}
+ headers = {
+ "Content-Type": "application/json",
+ }
+
+ if auth_type == "bearer":
+ headers["Authorization"] = (
+ f"Bearer {tool_server_connection.get('key', '')}"
+ )
+ elif auth_type == "none":
+ # No authentication
+ pass
+ elif auth_type == "session":
+ cookies = request.cookies
+ headers["Authorization"] = (
+ f"Bearer {request.state.token.credentials}"
+ )
+ elif auth_type == "system_oauth":
+ cookies = request.cookies
+ oauth_token = extra_params.get("__oauth_token__", None)
+ if oauth_token:
+ headers["Authorization"] = (
+ f"Bearer {oauth_token.get('access_token', '')}"
+ )
+
+ connection_headers = tool_server_connection.get("headers", None)
+ if connection_headers and isinstance(connection_headers, dict):
+ for key, value in connection_headers.items():
+ headers[key] = value
+
+ def make_tool_function(
+ function_name, tool_server_data, headers
+ ):
+ async def tool_function(**kwargs):
+ return await execute_tool_server(
+ url=tool_server_data["url"],
+ headers=headers,
+ cookies=cookies,
+ name=function_name,
+ params=kwargs,
+ server_data=tool_server_data,
+ )
+
+ return tool_function
+
+ tool_function = make_tool_function(
+ function_name, tool_server_data, headers
)
- log.warning(f"Discarding {tool_id}.{function_name}")
- else:
+
+ callable = get_async_tool_function_and_apply_extra_params(
+ tool_function,
+ {},
+ )
+
+ tool_dict = {
+ "tool_id": tool_id,
+ "callable": callable,
+ "spec": spec,
+ # Misc info
+ "type": "external",
+ }
+
+ # Handle function name collisions
+ while function_name in tools_dict:
+ log.warning(
+ f"Tool {function_name} already exists in another tools!"
+ )
+ # Prepend server ID to function name
+ function_name = f"{server_id}_{function_name}"
+
tools_dict[function_name] = tool_dict
+
+ else:
+ continue
+
else:
continue
else:
@@ -145,14 +250,16 @@ def get_tools(
module, _ = load_tool_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
- extra_params["__id__"] = tool_id
+ __user__ = {
+ **extra_params["__user__"],
+ }
# 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
+ __user__["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
@@ -174,7 +281,12 @@ def get_tools(
function_name = spec["name"]
tool_function = getattr(module, function_name)
callable = get_async_tool_function_and_apply_extra_params(
- tool_function, extra_params
+ tool_function,
+ {
+ **extra_params,
+ "__id__": tool_id,
+ "__user__": __user__,
+ },
)
# TODO: Support Pydantic models as parameters
@@ -196,14 +308,15 @@ def get_tools(
},
}
- # TODO: if collision, prepend toolkit name
- if function_name in tools_dict:
+ # Handle function name collisions
+ while 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
+ # Prepend tool ID to function name
+ function_name = f"{tool_id}_{function_name}"
+
+ tools_dict[function_name] = tool_dict
return tools_dict
@@ -286,15 +399,15 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
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 ...
param_description = function_param_descriptions.get(name, None)
if param_description:
- field_defs[name] = type_hint, Field(
- default_value, description=param_description
+ field_defs[name] = (
+ type_hint,
+ Field(default_value, description=param_description),
)
else:
field_defs[name] = type_hint, default_value
@@ -380,7 +493,6 @@ def convert_openapi_to_tool_payload(openapi_spec):
for method, operation in methods.items():
if operation.get("operationId"):
tool = {
- "type": "function",
"name": operation.get("operationId"),
"description": operation.get(
"description",
@@ -402,10 +514,16 @@ def convert_openapi_to_tool_payload(openapi_spec):
description += (
f". Possible values: {', '.join(param_schema.get('enum'))}"
)
- tool["parameters"]["properties"][param_name] = {
+ param_property = {
"type": param_schema.get("type"),
"description": description,
}
+
+ # Include items property for array types (required by OpenAI)
+ if param_schema.get("type") == "array" and "items" in param_schema:
+ param_property["items"] = param_schema["items"]
+
+ tool["parameters"]["properties"][param_name] = param_property
if param.get("required"):
tool["parameters"]["required"].append(param_name)
@@ -440,31 +558,71 @@ def convert_openapi_to_tool_payload(openapi_spec):
return tool_payload
-async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
- headers = {
+async def set_tool_servers(request: Request):
+ request.app.state.TOOL_SERVERS = await get_tool_servers_data(
+ request.app.state.config.TOOL_SERVER_CONNECTIONS
+ )
+
+ if request.app.state.redis is not None:
+ await request.app.state.redis.set(
+ "tool_servers", json.dumps(request.app.state.TOOL_SERVERS)
+ )
+
+ return request.app.state.TOOL_SERVERS
+
+
+async def get_tool_servers(request: Request):
+ tool_servers = []
+ if request.app.state.redis is not None:
+ try:
+ tool_servers = json.loads(await request.app.state.redis.get("tool_servers"))
+ request.app.state.TOOL_SERVERS = tool_servers
+ except Exception as e:
+ log.error(f"Error fetching tool_servers from Redis: {e}")
+
+ if not tool_servers:
+ tool_servers = await set_tool_servers(request)
+
+ return tool_servers
+
+
+async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]:
+ _headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
- if token:
- headers["Authorization"] = f"Bearer {token}"
+
+ if headers:
+ _headers.update(headers)
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
+ 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)
+ text_content = None
+
# Check if URL ends with .yaml or .yml to determine format
if url.lower().endswith((".yaml", ".yml")):
text_content = await response.text()
res = yaml.safe_load(text_content)
else:
- res = await response.json()
+ text_content = await response.text()
+
+ try:
+ res = json.loads(text_content)
+ except json.JSONDecodeError:
+ try:
+ res = yaml.safe_load(text_content)
+ except Exception as e:
+ raise e
+
except Exception as err:
log.exception(f"Could not fetch tool server spec from {url}")
if isinstance(err, dict) and "detail" in err:
@@ -473,35 +631,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
error = str(err)
raise Exception(error)
- data = {
- "openapi": res,
- "info": res.get("info", {}),
- "specs": convert_openapi_to_tool_payload(res),
- }
-
- log.info(f"Fetched data: {data}")
- return data
+ log.debug(f"Fetched data: {res}")
+ return res
-async def get_tool_servers_data(
- servers: List[Dict[str, Any]], session_token: Optional[str] = None
-) -> List[Dict[str, Any]]:
+async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index
+
+ tasks = []
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}"
-
+ if (
+ server.get("config", {}).get("enable")
+ and server.get("type", "openapi") == "openapi"
+ ):
info = server.get("info", {})
auth_type = server.get("auth_type", "bearer")
@@ -509,28 +652,66 @@ async def get_tool_servers_data(
if auth_type == "bearer":
token = server.get("key", "")
- elif auth_type == "session":
- token = session_token
- server_entries.append((idx, server, full_url, info, token))
+ elif auth_type == "none":
+ # No authentication
+ pass
- # Create async tasks to fetch data
- tasks = [
- get_tool_server_data(token, url) for (_, _, url, _, token) in server_entries
- ]
+ id = info.get("id")
+ if not id:
+ id = str(idx)
+
+ server_url = server.get("url")
+ spec_type = server.get("spec_type", "url")
+
+ # Create async tasks to fetch data
+ task = None
+ if spec_type == "url":
+ # 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")
+ spec_url = get_tool_server_url(server_url, openapi_path)
+ # Fetch from URL
+ task = get_tool_server_data(
+ spec_url,
+ {"Authorization": f"Bearer {token}"} if token else None,
+ )
+ elif spec_type == "json" and server.get("spec", ""):
+ # Use provided JSON spec
+ spec_json = None
+ try:
+ spec_json = json.loads(server.get("spec", ""))
+ except Exception as e:
+ log.error(f"Error parsing JSON spec for tool server {id}: {e}")
+
+ if spec_json:
+ task = asyncio.sleep(
+ 0,
+ result=spec_json,
+ )
+
+ if task:
+ tasks.append(task)
+ server_entries.append((id, idx, server, server_url, info, token))
# 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):
+ for (id, 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", {})
+ response = {
+ "openapi": response,
+ "info": response.get("info", {}),
+ "specs": convert_openapi_to_tool_payload(response),
+ }
+ openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict):
+ openapi_data["info"] = openapi_data.get("info", {})
+
if "name" in info:
openapi_data["info"]["title"] = info.get("name", "Tool Server")
@@ -539,6 +720,7 @@ async def get_tool_servers_data(
results.append(
{
+ "id": str(id),
"idx": idx,
"url": server.get("url"),
"openapi": openapi_data,
@@ -551,8 +733,13 @@ async def get_tool_servers_data(
async def execute_tool_server(
- token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any]
-) -> Any:
+ url: str,
+ headers: Dict[str, str],
+ cookies: Dict[str, str],
+ name: str,
+ params: Dict[str, Any],
+ server_data: Dict[str, Any],
+) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
error = None
try:
openapi = server_data.get("openapi", {})
@@ -607,42 +794,66 @@ async def execute_tool_server(
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:
+ async with aiohttp.ClientSession(
+ trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
+ ) as session:
request_method = getattr(session, http_method.lower())
- if http_method in ["post", "put", "patch"]:
+ if http_method in ["post", "put", "patch", "delete"]:
async with request_method(
final_url,
json=body_params,
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+ allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
- return await response.json()
+
+ try:
+ response_data = await response.json()
+ except Exception:
+ response_data = await response.text()
+
+ response_headers = response.headers
+ return (response_data, response_headers)
else:
async with request_method(
final_url,
headers=headers,
+ cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+ allow_redirects=False,
) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
- return await response.json()
+
+ try:
+ response_data = await response.json()
+ except Exception:
+ response_data = await response.text()
+
+ response_headers = response.headers
+ return (response_data, response_headers)
except Exception as err:
error = str(err)
log.exception(f"API Request Error: {error}")
- return {"error": error}
+ return ({"error": error}, None)
+
+
+def get_tool_server_url(url: Optional[str], path: str) -> str:
+ """
+ Build the full URL for a tool server, given a base url and a path.
+ """
+ if "://" in path:
+ # If it contains "://", it's a full URL
+ return path
+ if not path.startswith("/"):
+ # Ensure the path starts with a slash
+ path = f"/{path}"
+ return f"{url}{path}"
diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py
index bf0b334d82..4424c651ac 100644
--- a/backend/open_webui/utils/webhook.py
+++ b/backend/open_webui/utils/webhook.py
@@ -1,7 +1,7 @@
import json
import logging
+import aiohttp
-import requests
from open_webui.config import WEBUI_FAVICON_URL
from open_webui.env import SRC_LOG_LEVELS, VERSION
@@ -9,7 +9,7 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["WEBHOOK"])
-def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
+async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
try:
log.debug(f"post_webhook: {url}, {message}, {event_data}")
payload = {}
@@ -51,9 +51,12 @@ def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
payload = {**event_data}
log.debug(f"payload: {payload}")
- r = requests.post(url, json=payload)
- r.raise_for_status()
- log.debug(f"r.text: {r.text}")
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(url, json=payload) as r:
+ r_text = await r.text()
+ r.raise_for_status()
+ log.debug(f"r.text: {r_text}")
+
return True
except Exception as e:
log.exception(e)
diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt
new file mode 100644
index 0000000000..c09f1af820
--- /dev/null
+++ b/backend/requirements-min.txt
@@ -0,0 +1,51 @@
+# Minimal requirements for backend to run
+# WIP: use this as a reference to build a minimal docker image
+
+fastapi==0.118.0
+uvicorn[standard]==0.37.0
+pydantic==2.11.9
+python-multipart==0.0.20
+itsdangerous==2.2.0
+
+python-socketio==5.14.0
+python-jose==3.5.0
+cryptography
+bcrypt==5.0.0
+argon2-cffi==25.1.0
+PyJWT[crypto]==2.10.1
+authlib==1.6.5
+
+requests==2.32.5
+aiohttp==3.12.15
+async-timeout
+aiocache
+aiofiles
+starlette-compress==1.6.0
+httpx[socks,http2,zstd,cli,brotli]==0.28.1
+starsessions[redis]==2.2.1
+
+sqlalchemy==2.0.38
+alembic==1.14.0
+peewee==3.18.1
+peewee-migrate==1.12.2
+
+pycrdt==0.12.25
+redis
+
+APScheduler==3.10.4
+RestrictedPython==8.0
+
+loguru==0.7.3
+asgiref==3.8.1
+
+mcp==1.21.2
+openai
+
+langchain==0.3.27
+langchain-community==0.3.29
+fake-useragent==2.2.0
+
+chromadb==1.1.0
+black==25.9.0
+pydub
+chardet==5.2.0
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7f7bf82fee..658e249090 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,75 +1,74 @@
-fastapi==0.115.7
-uvicorn[standard]==0.34.2
-pydantic==2.10.6
+fastapi==0.118.0
+uvicorn[standard]==0.37.0
+pydantic==2.11.9
python-multipart==0.0.20
+itsdangerous==2.2.0
-python-socketio==5.13.0
-python-jose==3.4.0
-passlib[bcrypt]==1.7.4
+python-socketio==5.14.0
+python-jose==3.5.0
+cryptography
+bcrypt==5.0.0
+argon2-cffi==25.1.0
+PyJWT[crypto]==2.10.1
+authlib==1.6.5
-requests==2.32.4
-aiohttp==3.11.11
+requests==2.32.5
+aiohttp==3.12.15
async-timeout
aiocache
aiofiles
starlette-compress==1.6.0
+httpx[socks,http2,zstd,cli,brotli]==0.28.1
+starsessions[redis]==2.2.1
sqlalchemy==2.0.38
alembic==1.14.0
peewee==3.18.1
peewee-migrate==1.12.2
-psycopg2-binary==2.9.9
-pgvector==0.4.0
-PyMySQL==1.1.1
-bcrypt==4.3.0
-pymongo
+pycrdt==0.12.25
redis
-boto3==1.35.53
-argon2-cffi==23.1.0
APScheduler==3.10.4
-
RestrictedPython==8.0
loguru==0.7.3
asgiref==3.8.1
# AI libraries
+tiktoken
+mcp==1.21.2
+
openai
anthropic
-google-genai==1.15.0
+google-genai==1.52.0
google-generativeai==0.8.5
-tiktoken
-langchain==0.3.24
-langchain-community==0.3.23
+langchain==0.3.27
+langchain-community==0.3.29
-fake-useragent==2.1.0
-chromadb==0.6.3
-pymilvus==2.5.0
-qdrant-client~=1.12.0
+fake-useragent==2.2.0
+chromadb==1.1.0
+weaviate-client==4.17.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==4.1.0
+sentence-transformers==5.1.1
accelerate
-colbert-ai==0.2.21
-einops==0.8.1
-
+pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
+einops==0.8.1
ftfy==6.2.3
-pypdf==4.3.1
+chardet==5.2.0
+pypdf==6.0.0
fpdf2==2.8.2
pymdown-extensions==10.14.2
docx2txt==0.8
python-pptx==1.0.2
-unstructured==0.16.17
+unstructured==0.18.18
+msoffcrypto-tool==5.4.2
nltk==3.9.1
-Markdown==3.7
+Markdown==3.9
pypandoc==1.15
pandas==2.2.3
openpyxl==3.1.5
@@ -79,64 +78,75 @@ validators==0.35.0
psutil
sentencepiece
soundfile==0.13.1
-azure-ai-documentintelligence==1.0.2
-pillow==11.2.1
+pillow==11.3.0
opencv-python-headless==4.11.0.86
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==25.1.0
-langfuse==2.44.0
-youtube-transcript-api==1.1.0
+black==25.9.0
+youtube-transcript-api==1.2.2
pytube==15.0.0
-extract_msg
pydub
-duckduckgo-search==8.0.2
+ddgs==9.0.0
+
+azure-ai-documentintelligence==1.0.2
+azure-identity==1.25.0
+azure-storage-blob==12.24.1
+azure-search-documents==11.6.0
## Google Drive
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
-## Tests
-docker~=7.1.0
-pytest~=8.3.5
-pytest-docker~=3.1.1
-
-googleapis-common-protos==1.63.2
+googleapis-common-protos==1.70.0
google-cloud-storage==2.19.0
-azure-identity==1.21.0
-azure-storage-blob==12.24.1
+## Databases
+pymongo
+psycopg2-binary==2.9.10
+pgvector==0.4.1
+PyMySQL==1.1.1
+boto3==1.40.5
+
+pymilvus==2.6.2
+qdrant-client==1.14.3
+playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
+elasticsearch==9.1.0
+pinecone==6.0.2
+oracledb==3.2.0
+
+av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
+
+colbert-ai==0.2.21
+
+
+## Tests
+docker~=7.1.0
+pytest~=8.4.1
+pytest-docker~=3.1.1
## LDAP
ldap3==2.9.1
## Firecrawl
-firecrawl-py==1.12.0
-
-# Sougou API SDK(Tencentcloud SDK)
-tencentcloud-sdk-python==3.0.1336
+firecrawl-py==4.5.0
## 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
+opentelemetry-api==1.37.0
+opentelemetry-sdk==1.37.0
+opentelemetry-exporter-otlp==1.37.0
+opentelemetry-instrumentation==0.58b0
+opentelemetry-instrumentation-fastapi==0.58b0
+opentelemetry-instrumentation-sqlalchemy==0.58b0
+opentelemetry-instrumentation-redis==0.58b0
+opentelemetry-instrumentation-requests==0.58b0
+opentelemetry-instrumentation-logging==0.58b0
+opentelemetry-instrumentation-httpx==0.58b0
+opentelemetry-instrumentation-aiohttp-client==0.58b0
diff --git a/backend/start.sh b/backend/start.sh
index 9e106760c8..31e87c9557 100755
--- a/backend/start.sh
+++ b/backend/start.sh
@@ -53,12 +53,12 @@ if [ -n "$SPACE_ID" ]; then
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
webui_pid=$!
echo "Waiting for webui to start..."
- while ! curl -s http://localhost:8080/health > /dev/null; do
+ while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do
sleep 1
done
echo "Creating admin user..."
curl \
- -X POST "http://localhost:8080/api/v1/auths/signup" \
+ -X POST "http://localhost:${PORT}/api/v1/auths/signup" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }"
@@ -70,5 +70,18 @@ if [ -n "$SPACE_ID" ]; then
fi
PYTHON_CMD=$(command -v python3 || command -v python)
+UVICORN_WORKERS="${UVICORN_WORKERS:-1}"
-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}"
+# If script is called with arguments, use them; otherwise use default workers
+if [ "$#" -gt 0 ]; then
+ ARGS=("$@")
+else
+ ARGS=(--workers "$UVICORN_WORKERS")
+fi
+
+# Run uvicorn
+WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \
+ --host "$HOST" \
+ --port "$PORT" \
+ --forwarded-allow-ips '*' \
+ "${ARGS[@]}"
\ No newline at end of file
diff --git a/backend/start_windows.bat b/backend/start_windows.bat
index e38fdb2aa6..f350d11cd1 100644
--- a/backend/start_windows.bat
+++ b/backend/start_windows.bat
@@ -28,7 +28,7 @@ SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
:: Check if WEBUI_SECRET_KEY and WEBUI_JWT_SECRET_KEY are not set
-IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " (
+IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
echo Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.
IF NOT EXIST "%KEY_FILE%" (
diff --git a/cypress/e2e/chat.cy.ts b/cypress/e2e/chat.cy.ts
index 17c4d8e735..8792947992 100644
--- a/cypress/e2e/chat.cy.ts
+++ b/cypress/e2e/chat.cy.ts
@@ -21,14 +21,14 @@ describe('Settings', () => {
// Click on the model selector
cy.get('button[aria-label="Select a model"]').click();
// Select the first model
- cy.get('button[aria-label="model-item"]').first().click();
+ cy.get('button[aria-roledescription="model-item"]').first().click();
});
it('user can perform text chat', () => {
// Click on the model selector
cy.get('button[aria-label="Select a model"]').click();
// Select the first model
- cy.get('button[aria-label="model-item"]').first().click();
+ cy.get('button[aria-roledescription="model-item"]').first().click();
// Type a message
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
@@ -48,7 +48,7 @@ describe('Settings', () => {
// Click on the model selector
cy.get('button[aria-label="Select a model"]').click();
// Select the first model
- cy.get('button[aria-label="model-item"]').first().click();
+ cy.get('button[aria-roledescription="model-item"]').first().click();
// Type a message
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
@@ -83,7 +83,7 @@ describe('Settings', () => {
// Click on the model selector
cy.get('button[aria-label="Select a model"]').click();
// Select the first model
- cy.get('button[aria-label="model-item"]').first().click();
+ cy.get('button[aria-roledescription="model-item"]').first().click();
// Type a message
cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
force: true
diff --git a/docker-compose.otel.yaml b/docker-compose.otel.yaml
new file mode 100644
index 0000000000..0ccadccdda
--- /dev/null
+++ b/docker-compose.otel.yaml
@@ -0,0 +1,35 @@
+services:
+ grafana:
+ image: grafana/otel-lgtm:latest
+ container_name: lgtm
+ ports:
+ - "3000:3000" # Grafana UI
+ - "4317:4317" # OTLP/gRPC
+ - "4318:4318" # OTLP/HTTP
+ restart: unless-stopped
+
+
+ open-webui:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: ghcr.io/open-webui/open-webui:${WEBUI_DOCKER_TAG-main}
+ container_name: open-webui
+ volumes:
+ - open-webui:/app/backend/data
+ depends_on:
+ - grafana
+ ports:
+ - ${OPEN_WEBUI_PORT-8088}:8080
+ environment:
+ - ENABLE_OTEL=true
+ - ENABLE_OTEL_METRICS=true
+ - OTEL_EXPORTER_OTLP_INSECURE=true # Use insecure connection for OTLP, remove in production
+ - OTEL_EXPORTER_OTLP_ENDPOINT=http://grafana:4317
+ - OTEL_SERVICE_NAME=open-webui
+ extra_hosts:
+ - host.docker.internal:host-gateway
+ restart: unless-stopped
+
+volumes:
+ open-webui: {}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 74249febd9..349734a939 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -11,8 +11,6 @@ services:
open-webui:
build:
context: .
- args:
- OLLAMA_BASE_URL: '/ollama'
dockerfile: Dockerfile
image: ghcr.io/open-webui/open-webui:${WEBUI_DOCKER_TAG-main}
container_name: open-webui
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index ec8a79bbce..5b37c8f8c8 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -24,6 +24,10 @@ Noticed something off? Have an idea? Check our [Issues tab](https://github.com/o
> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking.
> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue.
+> [!WARNING]
+> Reporting vulnerabilities is not wanted through Issues!
+> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements.
+
### 🧭 Scope of Support
We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience.
@@ -32,6 +36,8 @@ We've noticed an uptick in issues not directly related to Open WebUI but rather
- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects.
+- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
+
## 💡 Contributing
Looking to contribute? Great! Here's how you can help:
@@ -46,9 +52,15 @@ We welcome pull requests. Before submitting one, please:
4. Write clear, descriptive commit messages.
5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward.
+> [!NOTE]
+> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!).
+
### 📚 Documentation & Tutorials
-Help us make Open WebUI more accessible by improving documentation, writing tutorials, or creating guides on setting up and optimizing the web UI.
+Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI.
+
+Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
+We welcome tutorials, guides and other documentation improvements!
### 🌐 Translations and Internationalization
@@ -62,9 +74,12 @@ To add a new language:
- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object.
- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`.
+> [!NOTE]
+> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow.
+
### 🤔 Questions & Feedback
-Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue. We're here to help!
+Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help!
## 🙏 Thank You!
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 507e3c6069..90951e5265 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -4,10 +4,11 @@ Our primary goal is to ensure the protection and confidentiality of sensitive da
## Supported Versions
-| Version | Supported |
-| ------- | ------------------ |
-| main | :white_check_mark: |
-| others | :x: |
+| Version (Branch) | Supported |
+| ---------------- | ------------------ |
+| main | :white_check_mark: |
+| dev | :x: |
+| others | :x: |
## Zero Tolerance for External Platforms
@@ -17,28 +18,113 @@ Any reports or solicitations arriving from sources other than our designated Git
## Reporting a Vulnerability
-We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. To ensure that submissions are constructive and actionable, please adhere to the following guidelines:
-
Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable.
-1. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
+We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**:
-2. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
+1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**.
-3. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence will be disregarded.
+2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
-4. **Required Patch Submission**: Along with the PoC, reporters must provide a patch or actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
+3. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
-5. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider them for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
+4. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence may be disregarded.
-**Non-compliant submissions will be closed, and repeat violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
+> [!NOTE]
+> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
+>
+> 1. What security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
+> 2. How this vulnerability was abused
+> 3. What actions the attacker can now perform
+>
+> **Examples of valid PoCs:**
+>
+> - Step-by-step reproduction instructions with exact commands
+> - Complete exploit code with detailed execution instructions
+> - Screenshots/videos demonstrating the exploit (supplementary to written steps)
+>
+> **Failure to provide a reproducible PoC may lead to closure of the report**
+>
+> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC.
+> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed.
-## Product Security
+5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
+
+6. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider provided patches for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
+
+7. **Default Configuration Testing**: All vulnerability reports MUST be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
+
+> [!NOTE]
+> **Note**: If you believe you have found a security issue that
+>
+> 1. affects default configurations, **or**
+> 2. represents a genuine bypass of intended security controls, **or**
+> 3. works only with non-default configurations, **but the configuration in question is likely to be used by production deployments**, **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research.
+
+8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
+
+9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
+
+> [!WARNING]
+>
+> **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report.
+
+10. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
+
+> [!NOTE]
+> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
+> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
+
+11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
+
+> [!NOTE]
+> AI-aided vulnerability reports **will not be rejected by us by default**. But:
+>
+> - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
+> - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities.
+>
+> This measure was necessary due to the extreme rise in clearly AI written vulnerability reports, where the vast majority of them
+>
+> - were not a vulnerability
+> - were faulty configurations rather than a real vulnerability
+> - did not provide a PoC
+> - violated any of the rules outlined here
+> - had a clear lack of understanding of Open WebUI
+> - wrote comments with conflicting information
+> - used illogical arguments
+
+**Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
+
+## Where to report the vulnerability
+
+If you want to report a vulnerability and can meet the outlined requirements, [open a vulnerability report here](https://github.com/open-webui/open-webui/security/advisories/new).
+If you feel like you are not able to follow ALL outlined requirements for vulnerability-specific reasons, still do report it, we will check every report either way.
+
+## Product Security And For Non-Vulnerability Related Security Concerns:
+
+If your concern does not meet the vulnerability requirements outlined above, is not a vulnerability, **but is still related to security concerns**, then use the following channels instead:
+
+- **Documentation issues/improvement ideas:** Open an issue on our [Documentation Repository](https://github.com/open-webui/docs)
+- **Feature requests:** Create a discussion in [GitHub Discussions - Ideas](https://github.com/open-webui/open-webui/discussions/) to discuss with the community if this feature request is wanted by multiple people
+- **Configuration help:** Ask the community for help and guidance on our [Discord Server](https://discord.gg/5rJgQTnV4s) or on [Reddit](https://www.reddit.com/r/OpenWebUI/)
+- **General issues:** Use our [Issue Tracker](https://github.com/open-webui/open-webui/issues)
+
+**Examples of non-vulnerability, still security related concerns:**
+
+- Suggestions for better default configuration values
+- Security hardening recommendations
+- Deployment best practices guidance
+- Unclear configuration instructions
+- Need for additional security documentation
+- Feature requests for optional security enhancements (2FA, audit logging, etc.)
+- General security questions about production deployment
+
+Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the Documentation Repository, and feature requests into the Main Repository as an issue or discussion.
We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon.
-For immediate concerns or detailed reports that meet our guidelines, please create an issue in our [issue tracker](/open-webui/open-webui/issues) or contact us on [Discord](https://discord.gg/5rJgQTnV4s).
+For any other immediate concerns, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
---
-_Last updated on **2024-08-19**._
+_Last updated on **2025-11-06**._
diff --git a/hatch_build.py b/hatch_build.py
index 8ddaf0749b..28aad1b6cd 100644
--- a/hatch_build.py
+++ b/hatch_build.py
@@ -17,7 +17,7 @@ class CustomBuildHook(BuildHookInterface):
"NodeJS `npm` is required for building Open Webui but it was not found"
)
stderr.write("### npm install\n")
- subprocess.run([npm, "install"], check=True) # noqa: S603
+ subprocess.run([npm, "install", "--force"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
os.environ["APP_BUILD_HASH"] = version
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
diff --git a/package-lock.json b/package-lock.json
index d17e571808..899f3f5356 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,50 +1,62 @@
{
"name": "open-webui",
- "version": "0.6.15",
+ "version": "0.6.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
- "version": "0.6.15",
+ "version": "0.6.40",
"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",
"@codemirror/theme-one-dark": "^6.1.2",
+ "@floating-ui/dom": "^1.7.2",
"@huggingface/transformers": "^3.0.0",
+ "@joplin/turndown-plugin-gfm": "^1.0.62",
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
- "@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.11.7",
- "@tiptap/starter-kit": "^2.10.0",
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/extension-bubble-menu": "^2.26.1",
+ "@tiptap/extension-code-block-lowlight": "^3.0.7",
+ "@tiptap/extension-drag-handle": "^3.4.5",
+ "@tiptap/extension-file-handler": "^3.0.7",
+ "@tiptap/extension-floating-menu": "^2.26.1",
+ "@tiptap/extension-highlight": "^3.3.0",
+ "@tiptap/extension-image": "^3.0.7",
+ "@tiptap/extension-link": "^3.0.7",
+ "@tiptap/extension-list": "^3.0.7",
+ "@tiptap/extension-mention": "^3.0.9",
+ "@tiptap/extension-table": "^3.0.7",
+ "@tiptap/extension-typography": "^3.0.7",
+ "@tiptap/extension-youtube": "^3.0.7",
+ "@tiptap/extensions": "^3.0.7",
+ "@tiptap/pm": "^3.0.7",
+ "@tiptap/starter-kit": "^3.0.7",
+ "@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19",
+ "alpinejs": "^3.15.0",
"async": "^3.2.5",
"bits-ui": "^0.21.15",
+ "chart.js": "^4.5.0",
"codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
- "dompurify": "^3.2.5",
+ "dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",
+ "heic2any": "^0.0.4",
"highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
- "html2canvas-pro": "^1.5.8",
+ "html2canvas-pro": "^1.5.11",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
@@ -53,10 +65,14 @@
"jspdf": "^3.0.0",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
+ "leaflet": "^1.9.4",
+ "lowlight": "^3.3.0",
"marked": "^9.1.0",
- "mermaid": "^11.6.0",
+ "mermaid": "^11.10.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
+ "pdfjs-dist": "^5.4.149",
+ "prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
"prosemirror-history": "^1.4.1",
@@ -68,23 +84,27 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
- "pyodide": "^0.27.3",
+ "pyodide": "^0.28.2",
"socket.io-client": "^4.2.0",
- "sortablejs": "^1.15.2",
+ "sortablejs": "^1.15.6",
"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",
+ "vega": "^6.2.0",
+ "vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0",
- "yaml": "^2.7.1"
+ "y-prosemirror": "^1.3.7",
+ "yaml": "^2.7.1",
+ "yjs": "^13.6.27"
},
"devDependencies": {
"@sveltejs/adapter-auto": "3.2.2",
"@sveltejs/adapter-static": "^3.0.2",
- "@sveltejs/kit": "^2.5.20",
- "@sveltejs/vite-plugin-svelte": "^3.1.1",
+ "@sveltejs/kit": "^2.5.27",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
@@ -94,14 +114,14 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-cypress": "^3.4.0",
- "eslint-plugin-svelte": "^2.43.0",
+ "eslint-plugin-svelte": "^2.45.1",
"i18next-parser": "^9.0.1",
"postcss": "^8.4.31",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"sass-embedded": "^1.81.0",
- "svelte": "^4.2.18",
- "svelte-check": "^3.8.5",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
"svelte-confetti": "^1.3.2",
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
@@ -135,18 +155,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@antfu/install-pkg": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz",
@@ -169,6 +177,22 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
"node_modules/@azure/msal-browser": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz",
@@ -631,6 +655,131 @@
"node": ">=0.1.90"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
+ "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz",
+ "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@csstools/color-helpers": "^5.0.2",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@cypress/request": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz",
@@ -1170,10 +1319,11 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1201,28 +1351,28 @@
}
},
"node_modules/@floating-ui/core": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
- "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
+ "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
"license": "MIT",
"dependencies": {
- "@floating-ui/utils": "^0.2.9"
+ "@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
- "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
+ "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
"license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.7.1",
- "@floating-ui/utils": "^0.2.9"
+ "@floating-ui/core": "^1.7.2",
+ "@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
- "version": "0.2.9",
- "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
- "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@gulpjs/to-absolute-glob": {
@@ -1273,10 +1423,11 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1827,17 +1978,30 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
+ "node_modules/@joplin/turndown-plugin-gfm": {
+ "version": "1.0.62",
+ "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.62.tgz",
+ "integrity": "sha512-Ts7cZ0Y9rIRgNkPtpXYB3BVjjSP2eeWzrPnQvJgNTC+FpopSjoaYjLQvPcEj1d6JcTMegnYoZK98/WJhm02Uaw==",
+ "license": "MIT"
+ },
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
- "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -1848,18 +2012,11 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
@@ -1870,6 +2027,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@lezer/common": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
@@ -2035,27 +2198,10 @@
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="
},
- "node_modules/@melt-ui/svelte": {
- "version": "0.76.2",
- "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
- "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.3.1",
- "@floating-ui/dom": "^1.4.5",
- "@internationalized/date": "^3.5.0",
- "dequal": "^2.0.3",
- "focus-trap": "^7.5.2",
- "nanoid": "^5.0.4"
- },
- "peerDependencies": {
- "svelte": ">=3 <5"
- }
- },
"node_modules/@mermaid-js/parser": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz",
- "integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz",
+ "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
@@ -2066,6 +2212,191 @@
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz",
+ "integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "0.1.78",
+ "@napi-rs/canvas-darwin-arm64": "0.1.78",
+ "@napi-rs/canvas-darwin-x64": "0.1.78",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78",
+ "@napi-rs/canvas-linux-arm64-gnu": "0.1.78",
+ "@napi-rs/canvas-linux-arm64-musl": "0.1.78",
+ "@napi-rs/canvas-linux-riscv64-gnu": "0.1.78",
+ "@napi-rs/canvas-linux-x64-gnu": "0.1.78",
+ "@napi-rs/canvas-linux-x64-musl": "0.1.78",
+ "@napi-rs/canvas-win32-x64-msvc": "0.1.78"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz",
+ "integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz",
+ "integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz",
+ "integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz",
+ "integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz",
+ "integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz",
+ "integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz",
+ "integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz",
+ "integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz",
+ "integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "0.1.78",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz",
+ "integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2505,6 +2836,15 @@
"svelte": "^3.55.0 || ^4.0.0 || ^5.0.0"
}
},
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
+ "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
"node_modules/@sveltejs/adapter-auto": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz",
@@ -2542,16 +2882,17 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.20.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.2.tgz",
- "integrity": "sha512-Dv8TOAZC9vyfcAB9TMsvUEJsRbklRTeNfcYBPaeH6KnABJ99i3CvCB2eNx8fiiliIqe+9GIchBg4RodRH5p1BQ==",
+ "version": "2.22.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.4.tgz",
+ "integrity": "sha512-BXK9hTbP8AeQIfoz6+P3uoyVYStVHc5CIKqoTSF7hXm3Q5P9BwFMdEus4jsQuhaYmXGHzukcGlxe2QrsE8BJfQ==",
"license": "MIT",
"dependencies": {
+ "@sveltejs/acorn-typescript": "^1.0.5",
"@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
"cookie": "^0.6.0",
"devalue": "^5.1.0",
"esm-env": "^1.2.2",
- "import-meta-resolve": "^4.1.0",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
@@ -2566,9 +2907,9 @@
"node": ">=18.13"
},
"peerDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.3 || ^6.0.0"
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
}
},
"node_modules/@sveltejs/svelte-virtual-list": {
@@ -2578,42 +2919,89 @@
"license": "LIL"
},
"node_modules/@sveltejs/vite-plugin-svelte": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz",
- "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
+ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
+ "license": "MIT",
"dependencies": {
- "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
- "debug": "^4.3.4",
+ "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
+ "debug": "^4.3.7",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
- "magic-string": "^0.30.10",
- "svelte-hmr": "^0.16.0",
- "vitefu": "^0.2.5"
+ "magic-string": "^0.30.12",
+ "vitefu": "^1.0.3"
},
"engines": {
- "node": "^18.0.0 || >=20"
+ "node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
- "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
- "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
+ "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
+ "license": "MIT",
"dependencies": {
- "debug": "^4.3.4"
+ "debug": "^4.3.7"
},
"engines": {
- "node": "^18.0.0 || >=20"
+ "node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.0",
- "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0"
}
},
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
@@ -2903,75 +3291,52 @@
}
},
"node_modules/@tiptap/core": {
- "version": "2.11.9",
- "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.9.tgz",
- "integrity": "sha512-UZSxQLLyJst47xep3jlyKM6y1ebZnmvbGsB7njBVjfxf5H+4yFpRJwwNqrBHM/vyU55LCtPChojqaYC1wXLf6g==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.0.7.tgz",
+ "integrity": "sha512-/NC0BbekWzi5sC+s7gRrGIv33cUfuiZUG5DWx8TNedA6b6aTFPHUe+2wKRPaPQ0pfGdOWU0nsOkboUJ9dAjl4g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/pm": "^2.7.0"
+ "@tiptap/pm": "^3.0.7"
}
},
"node_modules/@tiptap/extension-blockquote": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.10.0.tgz",
- "integrity": "sha512-6Xmfo2lpfIRcbfkLD/NGX4YgQqfgAbu6XaZQZf5oGtHLPTrz4D7Mw20GgNBHzae2XwUCwLMt6zXOkBgU/LnlZg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.0.7.tgz",
+ "integrity": "sha512-bYJ7r4hYcBZ7GI0LSV0Oxb9rmy/qb0idAf/osvflG2r1tf5CsiW5NYAqlOYAsIVA2OCwXELDlRGCgeKBQ26Kyw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-bold": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.10.0.tgz",
- "integrity": "sha512-1wL8UI1Aii0u2cbDEvwyqsZb2pgBt8HLJdsIax/ELoF2tKCD5821nElqTGLBBg4pUGPa0ru9ZemuL8GdXZp3Qg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.0.7.tgz",
+ "integrity": "sha512-CQG07yvrIsScLe5NplAuCkVh0sd97Udv1clAGbqfzeV8YfzpV3M7J/Vb09pWyovx3SjDqfsZpkr3RemeKEPY9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
- "node_modules/@tiptap/extension-bullet-list": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
- "integrity": "sha512-Cl+DGu6D3SgF/hlKUDNet3gaZFy6cPEonOOkHwzXoybDXXdddFbaTvt9MLkBRUR3ldksXuVRP2/LwZsK5WyxJQ==",
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "2.26.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz",
+ "integrity": "sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==",
"license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/ueberdosis"
+ "dependencies": {
+ "tippy.js": "^6.3.7"
},
- "peerDependencies": {
- "@tiptap/core": "^2.7.0"
- }
- },
- "node_modules/@tiptap/extension-code": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
- "integrity": "sha512-8JznKG1Jmv8gJezZGPoka8oRmfrcAAnMEOeMpKXjwMrIbQ6QynTZpqMGGVL1kfkZlLV84PYm+CGjGgjSsT4iZw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/ueberdosis"
- },
- "peerDependencies": {
- "@tiptap/core": "^2.7.0"
- }
- },
- "node_modules/@tiptap/extension-code-block": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.10.0.tgz",
- "integrity": "sha512-QH+LP7L1s1EJlrDFnfgOP0q+Siqt0Zbkx4ICMcUGvEsycl53Ti8P0DRW7fAjRISdTCItuWJYvtmiYY7O3rYb+Q==",
- "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2981,309 +3346,477 @@
"@tiptap/pm": "^2.7.0"
}
},
- "node_modules/@tiptap/extension-code-block-lowlight": {
- "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==",
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.0.7.tgz",
+ "integrity": "sha512-9gPc3Tw2Bw7qKLbyW0s05YntE77127pOXQXcclB4I3MXAuz/K03f+DGuSRhOq9K2Oo86BPHdL5I9Ap9cmuS0Tg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0",
- "@tiptap/extension-code-block": "^2.7.0",
- "@tiptap/pm": "^2.7.0",
+ "@tiptap/extension-list": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.0.7.tgz",
+ "integrity": "sha512-6wdUqtXbnIuyKR7xteF2UCnsW2dLNtBKxWvAiOweA7L41HYvburh/tjbkffkNc5KP2XsKzdGbygpunwJMPj6+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.0.7.tgz",
+ "integrity": "sha512-WifMv7N1G1Fnd2oZ+g80FjBpV/eI/fxHKCK3hw03l8LoWgeFaU/6LC93qTV6idkfia3YwiA6WnuyOqlI0FSZ9A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block-lowlight": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.0.7.tgz",
+ "integrity": "sha512-y1sHjzxpYqIKikdT5y5ajCOw4hDIPGjPpIBP7x7iw7jyt8a/w/bI8ozUk4epLBpgOvvAwmdIqi7eV7ORMvQaGQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/extension-code-block": "^3.0.7",
+ "@tiptap/pm": "^3.0.7",
"highlight.js": "^11",
"lowlight": "^2 || ^3"
}
},
+ "node_modules/@tiptap/extension-collaboration": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
+ "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.4.5",
+ "@tiptap/pm": "^3.4.5",
+ "@tiptap/y-tiptap": "^3.0.0-beta.3",
+ "yjs": "^13"
+ }
+ },
"node_modules/@tiptap/extension-document": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.10.0.tgz",
- "integrity": "sha512-vseMW3EKiQAPgdbN48Y8F0nRqWhhrAo9DLacAfP7tu0x3uv44uotNjDBtAgp5QmJmqQVyrEdkLSZaU5vFzduhQ==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.0.7.tgz",
+ "integrity": "sha512-HJg1nPPZ9fv5oEMwpONeIfT0FjTrgNGuGAat/hgcBi/R2GUNir2/PM/3d6y8QtkR/EgkgcFakCc9azySXLmyUQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-drag-handle": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
+ "integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.6.13"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.4.5",
+ "@tiptap/extension-collaboration": "^3.4.5",
+ "@tiptap/extension-node-range": "^3.4.5",
+ "@tiptap/pm": "^3.4.5",
+ "@tiptap/y-tiptap": "^3.0.0-beta.3"
}
},
"node_modules/@tiptap/extension-dropcursor": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.0.tgz",
- "integrity": "sha512-tifxp/a3NxTjLAuYBx9XAwVo4MSDoY/mQ8E18QtuXj0vuieCFxd8Bkyre0otubIAAQePXLTVGQoxPrKmMAa+Jg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.0.7.tgz",
+ "integrity": "sha512-0i2XWdRgYbj6PEPC+pMcGiF/hwg0jl+MavPt1733qWzoDqMEls9cEBTQ9S4HS0TI/jbN/kNavTQ5LlI33kWrww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-file-handler": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-file-handler/-/extension-file-handler-3.0.7.tgz",
+ "integrity": "sha512-eNJOqLaM91erqm6W7k+ocG09fuiVI4B+adWhv97sFim9TboF0sEIWEYdl68z06N1/+tXv6w8S4zUYQCOzxlVtw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/extension-text-style": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "2.26.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz",
+ "integrity": "sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==",
+ "license": "MIT",
+ "dependencies": {
+ "tippy.js": "^6.3.7"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
- "integrity": "sha512-GViEnSnEBE74k7SYdXrQ4aXlKmWkrd9awdj/TgDSORgpZ4Dfyqtn+ENIWWby4NhL+BPM9P5hGCjkQXZsi6JKOw==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.0.7.tgz",
+ "integrity": "sha512-F4ERd5r59WHbY0ALBbrJ/2z9dl+7VSmsMV/ZkzTgq0TZV9KKz3SsCFcCdIZEYzRCEp69/yYtkTofN10xIa+J6A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0",
- "@tiptap/pm": "^2.7.0"
+ "@tiptap/extensions": "^3.0.7"
}
},
"node_modules/@tiptap/extension-hard-break": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.10.0.tgz",
- "integrity": "sha512-NL/xPYUhhvQyCnOO5Yn+BlBOMLC1ru32nw7ox12TShGmaeKBrnV0DhzBRkyJU0MqCS26oWjieNPxfu0lR3oMSA==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.0.7.tgz",
+ "integrity": "sha512-OWrFrKp9PDs9nKJRmyPX22YoscqmoW25VZYeUfvNcAYtI84xYz871s1JmLZkpxqOyI9TafUADFiaRISDnX5EcA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-heading": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.10.0.tgz",
- "integrity": "sha512-x2Uj5wrAHFaUdlChwLoQVmWtzZCuNyJpBRA19kA4idWL5z+6cIrUWepvwVBxA8ou6ictbzWW15o+blKtW7DlqA==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.0.7.tgz",
+ "integrity": "sha512-uS7fFcilFuzKEvhUgndELqlGweD+nZeLOb6oqUE5hM49vECjM7qVjVQnlhV+MH2W1w8eD08cn1lu6lDxaMOe5w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-highlight": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.10.0.tgz",
- "integrity": "sha512-HU8UuKU7ljlzNn7jg29pM8QtIX7QvePcBjcWAt6K3qVwF1cbBNguIjKRY2rmoonU2nu8I6GknQNgV847kZifCQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz",
+ "integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
- }
- },
- "node_modules/@tiptap/extension-history": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.10.0.tgz",
- "integrity": "sha512-5aYOmxqaCnw7e7wmWqFZmkpYCxxDjEzFbgVI6WknqNwqeOizR4+YJf3aAt/lTbksLJe47XF+NBX51gOm/ZBCiw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/ueberdosis"
- },
- "peerDependencies": {
- "@tiptap/core": "^2.7.0",
- "@tiptap/pm": "^2.7.0"
+ "@tiptap/core": "^3.3.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.10.0.tgz",
- "integrity": "sha512-el1SzI/x/h4HW8UltxJlyMSrRsO55ypKPLQHJC9h7F6kTTR31fJUzQa3AeTFrZvXS0kNHIFRpAMstw+N0L5TYg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.0.7.tgz",
+ "integrity": "sha512-m0r4tzfVX3r0ZD7uvDf/GAiVr7lJjYwhZHC+M+JMhYXVI6eB9OXXzhdOIsw9W5QcmhCBaqU+VuPKUusTn4TKLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0",
- "@tiptap/pm": "^2.7.0"
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.0.7.tgz",
+ "integrity": "sha512-hs6TiSmefwvAqxwhy4+ZFCbmAXiAeWq4v5Zd65kQ7dvN7epeV0NM7ME5su/oscQgoKvNAy1r/4sJVaTnHomYMQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-italic": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.10.0.tgz",
- "integrity": "sha512-MqPYbHAEeO8QBvZRIkF4J2OTf/uiUPzUiXGLJ50w1ozfMBIw1txMvfR3g2cpwfvZlcOgYTgy7M0Oq00nQz5eXg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.0.7.tgz",
+ "integrity": "sha512-L05cehSOd7iZWI/igPb90TgQ6RKk2UuuYdatmXff3QUJpYPYct6abcrMb+CeFKJqE9vaXy46dCQkOuPW+bFwkA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.0.7.tgz",
+ "integrity": "sha512-e53MddBSVKpxxQ2JmHfyZQ2VBLwqlZxqwn0DQHFMXyCKTzpdUC0DOtkvrY7OVz6HA3yz29qR+qquQxIxcDPrfg==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.2.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.0.7.tgz",
+ "integrity": "sha512-rwu5dXRO0YLyxndMHI17PoxK0x0ZaMZKRZflqOy8fSnXNwd3Tdy8/6a9tsmpgO38kOZEYuvMVaeB7J/+UeBVLg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
}
},
"node_modules/@tiptap/extension-list-item": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
- "integrity": "sha512-BxC6NNHd2xcC+mk5hpYWURUdj/mRz6TGFwH5CsyrUXPxApx0+V+EPHaAgdpu8dr+jtTEzjXF62V6e2JmOAPimg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.0.7.tgz",
+ "integrity": "sha512-QfW+dtukl5v6oOA1n4wtAYev5yY78nqc2O8jHGZD18xhqNVerh2xBVIH9wOGHPz4q5Em2Ju7xbqXYl0vg2De+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/extension-list": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.0.7.tgz",
+ "integrity": "sha512-KJWXsyHU8E6SGmlZMHNjSg+XrkmCncJT2l5QGEjTUjlhqwulu+4psTDRio9tCdtepiasTL7qEekGWAhz9wEgzQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-mention": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.0.9.tgz",
+ "integrity": "sha512-DTQNAQkHZ+7Enlt3KvjqN6eECINlqPpET4Drzwj8Mmz9kMILc87cz3G2cwEKRrS9A1Xn3H3VpWvElWE2Wq9JHw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.9",
+ "@tiptap/pm": "^3.0.9",
+ "@tiptap/suggestion": "^3.0.9"
+ }
+ },
+ "node_modules/@tiptap/extension-node-range": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
+ "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.4.5",
+ "@tiptap/pm": "^3.4.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.0.tgz",
- "integrity": "sha512-jsK+mvzs7HmxQuQOU3HgIga+v7zUbQlmSP4/danusqUihJ+lc1n0frDCIkVvJrnSB3FChvNgT6ZEA14HOhdJzg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.0.7.tgz",
+ "integrity": "sha512-F/cbG0vt1cjkoJ4A65E6vpZQizZwnE4gJHKAw3ymDdCoZKYaO4OV1UTo98W/jgryORy/HLO12+hogsRvgRvK9Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/extension-list": "^3.0.7"
}
},
"node_modules/@tiptap/extension-paragraph": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.10.0.tgz",
- "integrity": "sha512-4LUkVaJYjNdNZ7QOX6TRcA+m7oCtyrLGk49G22wl7XcPBkQPILP1mCUCU4f41bhjfhCgK5PPWP63kMtD+cEACg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.0.7.tgz",
+ "integrity": "sha512-1lp+/CbYmm1ZnR6CNlreUIWCNQk0cBzLVgS5R8SKfVyYaXo11qQq6Yq8URLhpuge4yXkPGMhClwCLzJ9D9R+eg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
- }
- },
- "node_modules/@tiptap/extension-placeholder": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.10.0.tgz",
- "integrity": "sha512-1o6azk2plgYAFgMrV3prnBb1NZjl2V1T3wwnH4n3/h9z9lJ0v5BBAk9r+TRYSrcdXknwwHAWFYnQe6dc9buG2g==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/ueberdosis"
- },
- "peerDependencies": {
- "@tiptap/core": "^2.7.0",
- "@tiptap/pm": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-strike": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.10.0.tgz",
- "integrity": "sha512-SxApLJMQkxnmPGR3lwaskvLK61yI+Bu9hGZGdwMZqNh6o3LoDOxDaXjHD5joeMYQiqQrBE9zg46506MsXtrU7Q==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.0.7.tgz",
+ "integrity": "sha512-WUCd5CMgS6pg0ZGKXsaxVrnEvO/h6XUehebL0yggAsRKSoGERInR2iLfhU4p1f4zk0cD3ydNLJdqZu0H/MIABw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"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==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.0.7.tgz",
+ "integrity": "sha512-S4tvIgagzWnvXLHfltXucgS9TlBwPcQTjQR4llbxmKHAQM4+e77+NGcXXDcQ7E1TdAp3Tk8xRGerGIP7kjCFRA==",
"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"
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
}
},
"node_modules/@tiptap/extension-text": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
- "integrity": "sha512-SSnNncADS1KucdEcJlF6WGCs5+1pAhPrD68vlw34oj3NDT3Zh05KiyXsCV3Nw4wpHOnbWahV+z3uT2SnR+xgoQ==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.0.7.tgz",
+ "integrity": "sha512-yf5dNcPLB5SbQ0cQq8qyjiMj9khx4Y4EJoyrDSAok/9zYM3ULqwTPkTSZ2eW6VX/grJeyBVleeBHk1PjJ7NiVw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-text-style": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.10.0.tgz",
- "integrity": "sha512-VZtH1dp64wg1UcFtUPpRQK+kOm4JHBIv+WXuKX7EnpIEKjHKnyfV94BBVmaqY5UE4n3kbkkmIRB2Cmix/10AMg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.0.7.tgz",
+ "integrity": "sha512-naJ1XxlbFJ1qlpA+i54lQYKuhWP1dnkUslM86OT0TZt0zJBeu7LIrqSOVGmMB++lF/btnQLMnYkYSSnkLgIw3A==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
}
},
"node_modules/@tiptap/extension-typography": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.10.0.tgz",
- "integrity": "sha512-03IOfJm4bk2hZ4SsSfxgBOVzcDxMRBlFD7ZY12H2EGNf1TKxj/0ANWhAH54FtquuOMoY5aWg5LZf0lk++8UDAw==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.0.7.tgz",
+ "integrity": "sha512-Oz0EIkq8TDd15aupMYcH2L6izdI/LEO0e7+K+OhljTK5g/sGApLxCDdTlmX2szB9EXbTbOpwLKIEz2bPc3HvBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
- "@tiptap/core": "^2.7.0"
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.0.7.tgz",
+ "integrity": "sha512-pw2v5kbkovaWaC1G2IxP7g94vmUMlRBzZlCnLEyfFxtGa9LVAsUFlFFWaYJEmq7ZPG/tblWCnFfEZuQqFVd8Sg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extension-youtube": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-3.0.7.tgz",
+ "integrity": "sha512-BD4rc7Xoi3O+puXSEArHAbBVu4dhj+9TuuVYzEFgNHI+FN/py9J5AiNf4TXGKBSlMUOYPpODaEROwyGmqAmpuA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.0.7.tgz",
+ "integrity": "sha512-GkXX5l7Q/543BKsC14j8M3qT+75ILb7138zy7cZoHm/s1ztV1XTknpEswBZIRZA9n6qq+Wd9g5qkbR879s6xhA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
}
},
"node_modules/@tiptap/pm": {
- "version": "2.11.7",
- "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
- "integrity": "sha512-7gEEfz2Q6bYKXM07vzLUD0vqXFhC5geWRA6LCozTiLdVFDdHWiBrvb2rtkL5T7mfLq03zc1QhH7rI3F6VntOEA==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.0.7.tgz",
+ "integrity": "sha512-f8PnWjYqbMCxny8cyjbFNeIyeOYLECTa/7gj8DJr53Ns+P94b4kYIt/GkveR5KoOxsbmXi8Uc4mjcR1giQPaIQ==",
"license": "MIT",
"dependencies": {
- "prosemirror-changeset": "^2.2.1",
+ "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
@@ -3293,14 +3826,14 @@
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
- "prosemirror-model": "^1.23.0",
+ "prosemirror-model": "^1.24.1",
"prosemirror-schema-basic": "^1.2.3",
- "prosemirror-schema-list": "^1.4.1",
+ "prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
- "prosemirror-view": "^1.37.0"
+ "prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
@@ -3308,38 +3841,76 @@
}
},
"node_modules/@tiptap/starter-kit": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.10.0.tgz",
- "integrity": "sha512-hMIM9a6HjYZo25EzhZHlKEIR7CFi0grRSOltEyggiyBuQqKFkI7iwCpZVVtviDV1FwV0EPANpIAxPS7aBRgFdg==",
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.0.7.tgz",
+ "integrity": "sha512-oTHZp6GXQQaZfZi8Fh7klH2YUeGq73XPF35CFw41mwdWdUUUms3ipaCKFqUyEYO21JMf3pZylJLxUucx5U7isg==",
"license": "MIT",
"dependencies": {
- "@tiptap/core": "^2.10.0",
- "@tiptap/extension-blockquote": "^2.10.0",
- "@tiptap/extension-bold": "^2.10.0",
- "@tiptap/extension-bullet-list": "^2.10.0",
- "@tiptap/extension-code": "^2.10.0",
- "@tiptap/extension-code-block": "^2.10.0",
- "@tiptap/extension-document": "^2.10.0",
- "@tiptap/extension-dropcursor": "^2.10.0",
- "@tiptap/extension-gapcursor": "^2.10.0",
- "@tiptap/extension-hard-break": "^2.10.0",
- "@tiptap/extension-heading": "^2.10.0",
- "@tiptap/extension-history": "^2.10.0",
- "@tiptap/extension-horizontal-rule": "^2.10.0",
- "@tiptap/extension-italic": "^2.10.0",
- "@tiptap/extension-list-item": "^2.10.0",
- "@tiptap/extension-ordered-list": "^2.10.0",
- "@tiptap/extension-paragraph": "^2.10.0",
- "@tiptap/extension-strike": "^2.10.0",
- "@tiptap/extension-text": "^2.10.0",
- "@tiptap/extension-text-style": "^2.10.0",
- "@tiptap/pm": "^2.10.0"
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/extension-blockquote": "^3.0.7",
+ "@tiptap/extension-bold": "^3.0.7",
+ "@tiptap/extension-bullet-list": "^3.0.7",
+ "@tiptap/extension-code": "^3.0.7",
+ "@tiptap/extension-code-block": "^3.0.7",
+ "@tiptap/extension-document": "^3.0.7",
+ "@tiptap/extension-dropcursor": "^3.0.7",
+ "@tiptap/extension-gapcursor": "^3.0.7",
+ "@tiptap/extension-hard-break": "^3.0.7",
+ "@tiptap/extension-heading": "^3.0.7",
+ "@tiptap/extension-horizontal-rule": "^3.0.7",
+ "@tiptap/extension-italic": "^3.0.7",
+ "@tiptap/extension-link": "^3.0.7",
+ "@tiptap/extension-list": "^3.0.7",
+ "@tiptap/extension-list-item": "^3.0.7",
+ "@tiptap/extension-list-keymap": "^3.0.7",
+ "@tiptap/extension-ordered-list": "^3.0.7",
+ "@tiptap/extension-paragraph": "^3.0.7",
+ "@tiptap/extension-strike": "^3.0.7",
+ "@tiptap/extension-text": "^3.0.7",
+ "@tiptap/extension-underline": "^3.0.7",
+ "@tiptap/extensions": "^3.0.7",
+ "@tiptap/pm": "^3.0.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
+ "node_modules/@tiptap/suggestion": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
+ "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.4.2",
+ "@tiptap/pm": "^3.4.2"
+ }
+ },
+ "node_modules/@tiptap/y-tiptap": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz",
+ "integrity": "sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lib0": "^0.2.100"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.7.1",
+ "prosemirror-state": "^1.2.3",
+ "prosemirror-view": "^1.9.10",
+ "y-protocols": "^1.0.1",
+ "yjs": "^13.5.38"
+ }
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -3608,7 +4179,6 @@
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -3646,12 +4216,6 @@
"undici-types": "~5.26.4"
}
},
- "node_modules/@types/pug": {
- "version": "2.0.10",
- "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
- "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
- "dev": true
- },
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3692,8 +4256,7 @@
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
- "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==",
- "peer": true
+ "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
@@ -4021,6 +4584,21 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/@vue/reactivity": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+ "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+ "license": "MIT"
+ },
"node_modules/@webreflection/fetch": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
@@ -4083,6 +4661,18 @@
"node": ">=0.4.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -4112,6 +4702,15 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/alpinejs": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
+ "integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "~3.1.1"
+ }
+ },
"node_modules/amator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
@@ -4216,11 +4815,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/aria-query": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
- "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
- "dependencies": {
- "dequal": "^2.0.3"
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
}
},
"node_modules/asn1": {
@@ -4308,11 +4908,12 @@
"dev": true
},
"node_modules/axobject-query": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
- "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
- "dependencies": {
- "dequal": "^2.0.3"
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
}
},
"node_modules/balanced-match": {
@@ -4403,6 +5004,23 @@
"svelte": "^4.0.0 || ^5.0.0-next.118"
}
},
+ "node_modules/bits-ui/node_modules/@melt-ui/svelte": {
+ "version": "0.76.2",
+ "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz",
+ "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.3.1",
+ "@floating-ui/dom": "^1.4.5",
+ "@internationalized/date": "^3.5.0",
+ "dequal": "^2.0.3",
+ "focus-trap": "^7.5.2",
+ "nanoid": "^5.0.4"
+ },
+ "peerDependencies": {
+ "svelte": ">=3 <5"
+ }
+ },
"node_modules/bl": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
@@ -4448,9 +5066,10 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -4646,6 +5265,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4723,6 +5356,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+ "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@@ -4980,6 +5625,99 @@
"node": ">=8"
}
},
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
+ "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
@@ -4995,24 +5733,13 @@
"integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==",
"dev": true
},
- "node_modules/code-red": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
- "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@types/estree": "^1.0.1",
- "acorn": "^8.10.0",
- "estree-walker": "^3.0.3",
- "periscopic": "^3.1.0"
- }
- },
- "node_modules/code-red/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dependencies": {
- "@types/estree": "^1.0.0"
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/codedent": {
@@ -5274,18 +6001,6 @@
"url": "https://github.com/sponsors/fb55"
}
},
- "node_modules/css-tree": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
- "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
- "engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
- }
- },
"node_modules/css-what": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
@@ -5311,6 +6026,31 @@
"node": ">=4"
}
},
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/cypress": {
"version": "13.15.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz",
@@ -5709,6 +6449,36 @@
"node": ">=12"
}
},
+ "node_modules/d3-geo-projection": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
+ "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "d3-array": "1 - 3",
+ "d3-geo": "1.12.0 - 3"
+ },
+ "bin": {
+ "geo2svg": "bin/geo2svg.js",
+ "geograticule": "bin/geograticule.js",
+ "geoproject": "bin/geoproject.js",
+ "geoquantize": "bin/geoquantize.js",
+ "geostitch": "bin/geostitch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo-projection/node_modules/commander": {
+ "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"
+ }
+ },
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
@@ -5936,6 +6706,22 @@
"node": ">=0.10"
}
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
@@ -5958,6 +6744,15 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/deep-eql": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
@@ -6028,15 +6823,6 @@
"node": ">=6"
}
},
- "node_modules/detect-indent": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
- "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
@@ -6055,7 +6841,6 @@
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"dequal": "^2.0.0"
},
@@ -6131,9 +6916,9 @@
}
},
"node_modules/dompurify": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
- "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
+ "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -6154,6 +6939,21 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -6268,13 +7068,11 @@
"dev": true
},
"node_modules/es-define-property": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
- "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
- "dependencies": {
- "get-intrinsic": "^1.2.4"
- },
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
}
@@ -6288,11 +7086,34 @@
"node": ">= 0.4"
}
},
- "node_modules/es6-promise": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
- "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==",
- "dev": true
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
},
"node_modules/esbuild": {
"version": "0.25.1",
@@ -6335,6 +7156,15 @@
"@esbuild/win32-x64": "0.25.1"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -6441,22 +7271,23 @@
}
},
"node_modules/eslint-plugin-svelte": {
- "version": "2.43.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz",
- "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==",
+ "version": "2.46.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz",
+ "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@jridgewell/sourcemap-codec": "^1.4.15",
"eslint-compat-utils": "^0.5.1",
"esutils": "^2.0.3",
- "known-css-properties": "^0.34.0",
+ "known-css-properties": "^0.35.0",
"postcss": "^8.4.38",
"postcss-load-config": "^3.1.4",
"postcss-safe-parser": "^6.0.0",
"postcss-selector-parser": "^6.1.0",
"semver": "^7.6.2",
- "svelte-eslint-parser": "^0.41.0"
+ "svelte-eslint-parser": "^0.43.0"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
@@ -6466,7 +7297,7 @@
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0",
- "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191"
+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"svelte": {
@@ -6516,10 +7347,11 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -6572,6 +7404,15 @@
"node": ">=0.10"
}
},
+ "node_modules/esrap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz",
+ "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -6904,13 +7745,16 @@
}
},
"node_modules/form-data": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
- "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -7043,6 +7887,27 @@
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
+ "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@@ -7054,16 +7919,22 @@
}
},
"node_modules/get-intrinsic": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
- "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
- "has-proto": "^1.0.1",
- "has-symbols": "^1.0.3",
- "hasown": "^2.0.0"
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@@ -7072,6 +7943,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -7196,12 +8081,13 @@
}
},
"node_modules/gopd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
- "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
- "dependencies": {
- "get-intrinsic": "^1.1.3"
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -7260,11 +8146,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-proto": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
- "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -7272,11 +8159,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/has-symbols": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
- "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
"engines": {
"node": ">= 0.4"
},
@@ -7295,6 +8186,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/heic2any": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
+ "integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
+ "license": "MIT"
+ },
"node_modules/heimdalljs": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz",
@@ -7336,13 +8233,29 @@
"dev": true
},
"node_modules/highlight.js": {
- "version": "11.9.0",
- "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
- "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
+ "version": "11.11.1",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+ "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/html-entities": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz",
@@ -7379,9 +8292,9 @@
}
},
"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==",
+ "version": "1.5.11",
+ "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz",
+ "integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
@@ -7411,6 +8324,22 @@
"entities": "^4.5.0"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/http-signature": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
@@ -7425,6 +8354,22 @@
"node": ">=0.10"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-signals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
@@ -7578,6 +8523,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
+ "dev": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -7772,6 +8718,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -7830,6 +8785,16 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "license": "MIT",
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@@ -7865,6 +8830,73 @@
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"dev": true
},
+ "node_modules/jsdom": {
+ "version": "24.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz",
+ "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "cssstyle": "^4.0.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^4.1.4",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -7889,6 +8921,12 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
+ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
+ "license": "MIT"
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -7986,10 +9024,11 @@
}
},
"node_modules/known-css-properties": {
- "version": "0.34.0",
- "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz",
- "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==",
- "dev": true
+ "version": "0.35.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz",
+ "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/kokoro-js": {
"version": "1.1.1",
@@ -8046,6 +9085,12 @@
"node": ">=10.13.0"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8068,6 +9113,27 @@
"@lezer/lr": "^1.3.0"
}
},
+ "node_modules/lib0": {
+ "version": "0.2.109",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz",
+ "integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz",
@@ -8331,6 +9397,12 @@
"uc.micro": "^2.0.0"
}
},
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
"node_modules/listr2": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
@@ -8569,27 +9641,33 @@
}
},
"node_modules/lowlight": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.1.0.tgz",
- "integrity": "sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+ "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
- "highlight.js": "~11.9.0"
+ "highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
"node_modules/magic-string": {
- "version": "0.30.11",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
- "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
@@ -8633,10 +9711,11 @@
}
},
"node_modules/matcher-collection/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -8654,10 +9733,15 @@
"node": "*"
}
},
- "node_modules/mdn-data": {
- "version": "2.0.30",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
- "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
},
"node_modules/mdurl": {
"version": "2.0.0",
@@ -8679,14 +9763,14 @@
}
},
"node_modules/mermaid": {
- "version": "11.6.0",
- "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz",
- "integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==",
+ "version": "11.10.1",
+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz",
+ "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.0.4",
"@iconify/utils": "^2.1.33",
- "@mermaid-js/parser": "^0.4.0",
+ "@mermaid-js/parser": "^0.6.2",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"cytoscape-cose-bilkent": "^4.1.0",
@@ -8695,11 +9779,11 @@
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.11",
"dayjs": "^1.11.13",
- "dompurify": "^3.2.4",
- "katex": "^0.16.9",
+ "dompurify": "^3.2.5",
+ "katex": "^0.16.22",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
- "marked": "^15.0.7",
+ "marked": "^16.0.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@@ -8707,15 +9791,15 @@
}
},
"node_modules/mermaid/node_modules/marked": {
- "version": "15.0.8",
- "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz",
- "integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz",
+ "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
- "node": ">= 18"
+ "node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
@@ -8773,15 +9857,6 @@
"node": ">=6"
}
},
- "node_modules/min-indent": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
- "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
- "dev": true,
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -8877,18 +9952,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/mkdirp": {
- "version": "0.5.6",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
- "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
- "dev": true,
- "dependencies": {
- "minimist": "^1.2.6"
- },
- "bin": {
- "mkdirp": "bin/cmd.js"
- }
- },
"node_modules/mktemp": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/mktemp/-/mktemp-0.4.0.tgz",
@@ -9023,6 +10086,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.21",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz",
+ "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9328,14 +10400,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
- "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
- "engines": {
- "node": "14 || >=16.14"
- }
- },
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -9352,6 +10416,18 @@
"node": "*"
}
},
+ "node_modules/pdfjs-dist": {
+ "version": "5.4.149",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz",
+ "integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.16.0 || >=22.3.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^0.1.77"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -9364,32 +10440,6 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"devOptional": true
},
- "node_modules/periscopic": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
- "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^3.0.0",
- "is-reference": "^3.0.0"
- }
- },
- "node_modules/periscopic/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/periscopic/node_modules/is-reference": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
- "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
- "dependencies": {
- "@types/estree": "*"
- }
- },
"node_modules/phonemizer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/phonemizer/-/phonemizer-1.2.1.tgz",
@@ -9587,6 +10637,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "license": "MIT",
"engines": {
"node": ">=12.0"
},
@@ -9724,9 +10775,9 @@
}
},
"node_modules/prosemirror-changeset": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz",
- "integrity": "sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==",
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
@@ -9994,10 +11045,10 @@
}
},
"node_modules/pyodide": {
- "version": "0.27.3",
- "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.3.tgz",
- "integrity": "sha512-6NwKEbPk0M3Wic2T1TCZijgZH9VE4RkHp1VGljS1sou0NjGdsmY2R/fG5oLmdDkjTRMI1iW7WYaY9pofX8gg1g==",
- "license": "Apache-2.0",
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz",
+ "integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==",
+ "license": "MPL-2.0",
"dependencies": {
"ws": "^8.5.0"
},
@@ -10079,10 +11130,11 @@
}
},
"node_modules/quick-temp/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -10307,10 +11359,11 @@
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -10405,6 +11458,15 @@
"points-on-path": "^0.2.1"
}
},
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -10473,72 +11535,6 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
- "node_modules/sander": {
- "version": "0.5.1",
- "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz",
- "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==",
- "dev": true,
- "dependencies": {
- "es6-promise": "^3.1.2",
- "graceful-fs": "^4.1.3",
- "mkdirp": "^0.5.1",
- "rimraf": "^2.5.2"
- }
- },
- "node_modules/sander/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "node_modules/sander/node_modules/glob": {
- "version": "7.2.3",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
- "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "dev": true,
- "dependencies": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.1.1",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- },
- "engines": {
- "node": "*"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/sander/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
- "dependencies": {
- "brace-expansion": "^1.1.7"
- },
- "engines": {
- "node": "*"
- }
- },
- "node_modules/sander/node_modules/rimraf": {
- "version": "2.7.1",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
- "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
- "dev": true,
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- }
- },
"node_modules/sass-embedded": {
"version": "1.81.0",
"resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.81.0.tgz",
@@ -10920,6 +11916,21 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@@ -11107,21 +12118,6 @@
"node": ">=10.0.0"
}
},
- "node_modules/sorcery": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz",
- "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==",
- "dev": true,
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.14",
- "buffer-crc32": "^0.2.5",
- "minimist": "^1.2.0",
- "sander": "^0.5.0"
- },
- "bin": {
- "sorcery": "bin/sorcery"
- }
- },
"node_modules/sort-keys": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-5.0.0.tgz",
@@ -11138,9 +12134,10 @@
}
},
"node_modules/sortablejs": {
- "version": "1.15.2",
- "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
- "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA=="
+ "version": "1.15.6",
+ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
+ "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
+ "license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
@@ -11331,18 +12328,6 @@
"node": ">=6"
}
},
- "node_modules/strip-indent": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
- "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
- "dev": true,
- "dependencies": {
- "min-indent": "^1.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -11402,47 +12387,115 @@
}
},
"node_modules/svelte": {
- "version": "4.2.19",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
- "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
+ "version": "5.42.2",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz",
+ "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==",
+ "license": "MIT",
"dependencies": {
- "@ampproject/remapping": "^2.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@jridgewell/trace-mapping": "^0.3.18",
- "@types/estree": "^1.0.1",
- "acorn": "^8.9.0",
- "aria-query": "^5.3.0",
- "axobject-query": "^4.0.0",
- "code-red": "^1.0.3",
- "css-tree": "^2.3.1",
- "estree-walker": "^3.0.3",
- "is-reference": "^3.0.1",
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.1.0",
+ "is-reference": "^3.0.3",
"locate-character": "^3.0.0",
- "magic-string": "^0.30.4",
- "periscopic": "^3.1.0"
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
}
},
"node_modules/svelte-check": {
- "version": "3.8.5",
- "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.5.tgz",
- "integrity": "sha512-3OGGgr9+bJ/+1nbPgsvulkLC48xBsqsgtc8Wam281H4G9F5v3mYGa2bHRsPuwHC5brKl4AxJH95QF73kmfihGQ==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz",
+ "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@jridgewell/trace-mapping": "^0.3.17",
- "chokidar": "^3.4.1",
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
"picocolors": "^1.0.0",
- "sade": "^1.7.4",
- "svelte-preprocess": "^5.1.3",
- "typescript": "^5.0.3"
+ "sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
+ "engines": {
+ "node": ">= 18.0.0"
+ },
"peerDependencies": {
- "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte-check/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/svelte-check/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/svelte-check/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/svelte-check/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-confetti": {
@@ -11455,10 +12508,11 @@
}
},
"node_modules/svelte-eslint-parser": {
- "version": "0.41.0",
- "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz",
- "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==",
+ "version": "0.43.0",
+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
+ "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.3",
@@ -11473,7 +12527,7 @@
"url": "https://github.com/sponsors/ota-meshi"
},
"peerDependencies": {
- "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.191"
+ "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"svelte": {
@@ -11481,80 +12535,6 @@
}
}
},
- "node_modules/svelte-hmr": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
- "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
- "engines": {
- "node": "^12.20 || ^14.13.1 || >= 16"
- },
- "peerDependencies": {
- "svelte": "^3.19.0 || ^4.0.0"
- }
- },
- "node_modules/svelte-preprocess": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz",
- "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==",
- "dev": true,
- "hasInstallScript": true,
- "dependencies": {
- "@types/pug": "^2.0.6",
- "detect-indent": "^6.1.0",
- "magic-string": "^0.30.5",
- "sorcery": "^0.11.0",
- "strip-indent": "^3.0.0"
- },
- "engines": {
- "node": ">= 16.0.0",
- "pnpm": "^8.0.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.10.2",
- "coffeescript": "^2.5.1",
- "less": "^3.11.3 || ^4.0.0",
- "postcss": "^7 || ^8",
- "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
- "pug": "^3.0.0",
- "sass": "^1.26.8",
- "stylus": "^0.55.0",
- "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
- "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0",
- "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0"
- },
- "peerDependenciesMeta": {
- "@babel/core": {
- "optional": true
- },
- "coffeescript": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "postcss": {
- "optional": true
- },
- "postcss-load-config": {
- "optional": true
- },
- "pug": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "typescript": {
- "optional": true
- }
- }
- },
"node_modules/svelte-sonner": {
"version": "0.3.28",
"resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz",
@@ -11563,20 +12543,19 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
- "node_modules/svelte/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
+ "node_modules/svelte/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
},
"node_modules/svelte/node_modules/is-reference": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
- "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
"dependencies": {
- "@types/estree": "*"
+ "@types/estree": "^1.0.6"
}
},
"node_modules/svg-pathdata": {
@@ -11589,6 +12568,15 @@
"node": ">=12.0.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/symlink-or-copy": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz",
@@ -11797,6 +12785,26 @@
"node": ">=10.13.0"
}
},
+ "node_modules/topojson-client": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
+ "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
+ "license": "ISC",
+ "dependencies": {
+ "commander": "2"
+ },
+ "bin": {
+ "topo2geo": "bin/topo2geo",
+ "topomerge": "bin/topomerge",
+ "topoquantize": "bin/topoquantize"
+ }
+ },
+ "node_modules/topojson-client/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -11830,6 +12838,21 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -11967,9 +12990,9 @@
}
},
"node_modules/undici": {
- "version": "7.3.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.3.0.tgz",
- "integrity": "sha512-Qy96NND4Dou5jKoSJ2gm8ax8AJM/Ey9o9mz7KN1bb9GP+G0l20Zw8afxTnY2f4b7hmhn/z8aC2kfArVQlAhFBw==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz",
+ "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -12059,6 +13082,417 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/vega": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/vega/-/vega-6.2.0.tgz",
+ "integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-crossfilter": "~5.1.0",
+ "vega-dataflow": "~6.1.0",
+ "vega-encode": "~5.1.0",
+ "vega-event-selector": "~4.0.0",
+ "vega-expression": "~6.1.0",
+ "vega-force": "~5.1.0",
+ "vega-format": "~2.1.0",
+ "vega-functions": "~6.1.0",
+ "vega-geo": "~5.1.0",
+ "vega-hierarchy": "~5.1.0",
+ "vega-label": "~2.1.0",
+ "vega-loader": "~5.1.0",
+ "vega-parser": "~7.1.0",
+ "vega-projection": "~2.1.0",
+ "vega-regression": "~2.1.0",
+ "vega-runtime": "~7.1.0",
+ "vega-scale": "~8.1.0",
+ "vega-scenegraph": "~5.1.0",
+ "vega-statistics": "~2.0.0",
+ "vega-time": "~3.1.0",
+ "vega-transforms": "~5.1.0",
+ "vega-typings": "~2.1.0",
+ "vega-util": "~2.1.0",
+ "vega-view": "~6.1.0",
+ "vega-view-transforms": "~5.1.0",
+ "vega-voronoi": "~5.1.0",
+ "vega-wordcloud": "~5.1.0"
+ },
+ "funding": {
+ "url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
+ }
+ },
+ "node_modules/vega-canvas": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
+ "integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/vega-crossfilter": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
+ "integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "vega-dataflow": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-dataflow": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
+ "integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-format": "^2.1.0",
+ "vega-loader": "^5.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-encode": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-5.1.0.tgz",
+ "integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-interpolate": "^3.0.1",
+ "vega-dataflow": "^6.1.0",
+ "vega-scale": "^8.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-event-selector": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
+ "integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/vega-expression": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-6.1.0.tgz",
+ "integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/estree": "^1.0.8",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-expression/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/vega-force": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz",
+ "integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-force": "^3.0.0",
+ "vega-dataflow": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-format": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-format/-/vega-format-2.1.0.tgz",
+ "integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-format": "^3.1.0",
+ "d3-time-format": "^4.1.0",
+ "vega-time": "^3.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-functions": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.0.tgz",
+ "integrity": "sha512-yooEbWt0FWMBNoohwLsl25lEh08WsWabTXbbS+q0IXZzWSpX4Cyi45+q7IFyy/2L4oaIfGIIV14dgn3srQQcGA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-color": "^3.1.0",
+ "d3-geo": "^3.1.1",
+ "vega-dataflow": "^6.1.0",
+ "vega-expression": "^6.1.0",
+ "vega-scale": "^8.1.0",
+ "vega-scenegraph": "^5.1.0",
+ "vega-selections": "^6.1.0",
+ "vega-statistics": "^2.0.0",
+ "vega-time": "^3.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-geo": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-5.1.0.tgz",
+ "integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-color": "^3.1.0",
+ "d3-geo": "^3.1.1",
+ "vega-canvas": "^2.0.0",
+ "vega-dataflow": "^6.1.0",
+ "vega-projection": "^2.1.0",
+ "vega-statistics": "^2.0.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-hierarchy": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
+ "integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-hierarchy": "^3.1.2",
+ "vega-dataflow": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-label": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-label/-/vega-label-2.1.0.tgz",
+ "integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-canvas": "^2.0.0",
+ "vega-dataflow": "^6.1.0",
+ "vega-scenegraph": "^5.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-lite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz",
+ "integrity": "sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "json-stringify-pretty-compact": "~4.0.0",
+ "tslib": "~2.8.1",
+ "vega-event-selector": "~4.0.0",
+ "vega-expression": "~6.1.0",
+ "vega-util": "~2.1.0",
+ "yargs": "~18.0.0"
+ },
+ "bin": {
+ "vl2pdf": "bin/vl2pdf",
+ "vl2png": "bin/vl2png",
+ "vl2svg": "bin/vl2svg",
+ "vl2vg": "bin/vl2vg"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
+ },
+ "peerDependencies": {
+ "vega": "^6.0.0"
+ }
+ },
+ "node_modules/vega-loader": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-5.1.0.tgz",
+ "integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-dsv": "^3.0.1",
+ "topojson-client": "^3.1.0",
+ "vega-format": "^2.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-7.1.0.tgz",
+ "integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-dataflow": "^6.1.0",
+ "vega-event-selector": "^4.0.0",
+ "vega-functions": "^6.1.0",
+ "vega-scale": "^8.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-projection": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-2.1.0.tgz",
+ "integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-geo": "^3.1.1",
+ "d3-geo-projection": "^4.0.0",
+ "vega-scale": "^8.1.0"
+ }
+ },
+ "node_modules/vega-regression": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-2.1.0.tgz",
+ "integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "vega-dataflow": "^6.1.0",
+ "vega-statistics": "^2.0.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-runtime": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
+ "integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-dataflow": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-scale": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-8.1.0.tgz",
+ "integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-scale-chromatic": "^3.1.0",
+ "vega-time": "^3.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-scenegraph": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
+ "integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "^3.1.0",
+ "d3-shape": "^3.2.0",
+ "vega-canvas": "^2.0.0",
+ "vega-loader": "^5.1.0",
+ "vega-scale": "^8.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-selections": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.0.tgz",
+ "integrity": "sha512-WaHM7D7ghHceEfMsgFeaZnDToWL0mgCFtStVOobNh/OJLh0CL7yNKeKQBqRXJv2Lx74dPNf6nj08+52ytWfW7g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "3.2.4",
+ "vega-expression": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-statistics": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
+ "integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4"
+ }
+ },
+ "node_modules/vega-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vega-time/-/vega-time-3.1.0.tgz",
+ "integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-time": "^3.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-transforms": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
+ "integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "vega-dataflow": "^6.1.0",
+ "vega-statistics": "^2.0.0",
+ "vega-time": "^3.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-typings": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-2.1.0.tgz",
+ "integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/geojson": "7946.0.16",
+ "vega-event-selector": "^4.0.0",
+ "vega-expression": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-util": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.1.0.tgz",
+ "integrity": "sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/vega-view": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-6.1.0.tgz",
+ "integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "^3.2.4",
+ "d3-timer": "^3.0.1",
+ "vega-dataflow": "^6.1.0",
+ "vega-format": "^2.1.0",
+ "vega-functions": "^6.1.0",
+ "vega-runtime": "^7.1.0",
+ "vega-scenegraph": "^5.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-view-transforms": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
+ "integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-dataflow": "^6.1.0",
+ "vega-scenegraph": "^5.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-voronoi": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
+ "integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-delaunay": "^6.0.4",
+ "vega-dataflow": "^6.1.0",
+ "vega-util": "^2.1.0"
+ }
+ },
+ "node_modules/vega-wordcloud": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
+ "integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "vega-canvas": "^2.0.0",
+ "vega-dataflow": "^6.1.0",
+ "vega-scale": "^8.1.0",
+ "vega-statistics": "^2.0.0",
+ "vega-util": "^2.1.0"
+ }
+ },
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@@ -12151,9 +13585,9 @@
}
},
"node_modules/vite": {
- "version": "5.4.15",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
- "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
+ "version": "5.4.19",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
+ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
@@ -12633,11 +14067,17 @@
}
},
"node_modules/vitefu": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
- "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -12887,6 +14327,21 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/walk-sync": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
@@ -12903,10 +14358,11 @@
}
},
"node_modules/walk-sync/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -12924,6 +14380,18 @@
"node": "*"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -12947,6 +14415,22 @@
"node": ">=18"
}
},
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/wheel": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
@@ -13094,6 +14578,27 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true
+ },
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
@@ -13111,6 +14616,60 @@
"node": ">=0.4"
}
},
+ "node_modules/y-prosemirror": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
+ "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.109"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.7.1",
+ "prosemirror-state": "^1.2.3",
+ "prosemirror-view": "^1.9.10",
+ "y-protocols": "^1.0.1",
+ "yjs": "^13.5.38"
+ }
+ },
+ "node_modules/y-protocols": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz",
+ "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lib0": "^0.2.85"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "yjs": "^13.0.0"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -13132,6 +14691,82 @@
"node": ">= 14"
}
},
+ "node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
+ "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
+ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
@@ -13142,6 +14777,23 @@
"fd-slicer": "~1.1.0"
}
},
+ "node_modules/yjs": {
+ "version": "13.6.27",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
+ "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.99"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -13153,6 +14805,12 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "license": "MIT"
}
}
}
diff --git a/package.json b/package.json
index 7f0d121be7..97bdda0871 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "open-webui",
- "version": "0.6.15",
+ "version": "0.6.40",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -24,8 +24,8 @@
"devDependencies": {
"@sveltejs/adapter-auto": "3.2.2",
"@sveltejs/adapter-static": "^3.0.2",
- "@sveltejs/kit": "^2.5.20",
- "@sveltejs/vite-plugin-svelte": "^3.1.1",
+ "@sveltejs/kit": "^2.5.27",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
@@ -35,14 +35,14 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-cypress": "^3.4.0",
- "eslint-plugin-svelte": "^2.43.0",
+ "eslint-plugin-svelte": "^2.45.1",
"i18next-parser": "^9.0.1",
"postcss": "^8.4.31",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"sass-embedded": "^1.81.0",
- "svelte": "^4.2.18",
- "svelte-check": "^3.8.5",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
"svelte-confetti": "^1.3.2",
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
@@ -57,38 +57,50 @@
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language-data": "^6.5.1",
"@codemirror/theme-one-dark": "^6.1.2",
+ "@floating-ui/dom": "^1.7.2",
"@huggingface/transformers": "^3.0.0",
+ "@joplin/turndown-plugin-gfm": "^1.0.62",
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
- "@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.11.7",
- "@tiptap/starter-kit": "^2.10.0",
+ "@tiptap/core": "^3.0.7",
+ "@tiptap/extension-bubble-menu": "^2.26.1",
+ "@tiptap/extension-code-block-lowlight": "^3.0.7",
+ "@tiptap/extension-drag-handle": "^3.4.5",
+ "@tiptap/extension-file-handler": "^3.0.7",
+ "@tiptap/extension-floating-menu": "^2.26.1",
+ "@tiptap/extension-highlight": "^3.3.0",
+ "@tiptap/extension-image": "^3.0.7",
+ "@tiptap/extension-link": "^3.0.7",
+ "@tiptap/extension-list": "^3.0.7",
+ "@tiptap/extension-mention": "^3.0.9",
+ "@tiptap/extension-table": "^3.0.7",
+ "@tiptap/extension-typography": "^3.0.7",
+ "@tiptap/extension-youtube": "^3.0.7",
+ "@tiptap/extensions": "^3.0.7",
+ "@tiptap/pm": "^3.0.7",
+ "@tiptap/starter-kit": "^3.0.7",
+ "@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19",
+ "alpinejs": "^3.15.0",
"async": "^3.2.5",
"bits-ui": "^0.21.15",
+ "chart.js": "^4.5.0",
"codemirror": "^6.0.1",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
- "dompurify": "^3.2.5",
+ "dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",
+ "heic2any": "^0.0.4",
"highlight.js": "^11.9.0",
"html-entities": "^2.5.3",
- "html2canvas-pro": "^1.5.8",
+ "html2canvas-pro": "^1.5.11",
"i18next": "^23.10.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next-resources-to-backend": "^1.2.0",
@@ -97,10 +109,14 @@
"jspdf": "^3.0.0",
"katex": "^0.16.22",
"kokoro-js": "^1.1.1",
+ "leaflet": "^1.9.4",
+ "lowlight": "^3.3.0",
"marked": "^9.1.0",
- "mermaid": "^11.6.0",
+ "mermaid": "^11.10.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
+ "pdfjs-dist": "^5.4.149",
+ "prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
"prosemirror-history": "^1.4.1",
@@ -112,17 +128,21 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
- "pyodide": "^0.27.3",
+ "pyodide": "^0.28.2",
"socket.io-client": "^4.2.0",
- "sortablejs": "^1.15.2",
+ "sortablejs": "^1.15.6",
"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",
+ "vega": "^6.2.0",
+ "vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0",
- "yaml": "^2.7.1"
+ "y-prosemirror": "^1.3.7",
+ "yaml": "^2.7.1",
+ "yjs": "^13.6.27"
},
"engines": {
"node": ">=18.13.0 <=22.x.x",
diff --git a/pyproject.toml b/pyproject.toml
index 86693f192e..f0568a4237 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,77 +6,77 @@ authors = [
]
license = { file = "LICENSE" }
dependencies = [
- "fastapi==0.115.7",
- "uvicorn[standard]==0.34.2",
- "pydantic==2.10.6",
+ "fastapi==0.118.0",
+ "uvicorn[standard]==0.37.0",
+ "pydantic==2.11.9",
"python-multipart==0.0.20",
+ "itsdangerous==2.2.0",
- "python-socketio==5.13.0",
- "python-jose==3.4.0",
- "passlib[bcrypt]==1.7.4",
+ "python-socketio==5.14.0",
+ "python-jose==3.5.0",
+ "cryptography",
+ "bcrypt==5.0.0",
+ "argon2-cffi==25.1.0",
+ "PyJWT[crypto]==2.10.1",
+ "authlib==1.6.5",
- "requests==2.32.4",
- "aiohttp==3.11.11",
+ "requests==2.32.5",
+ "aiohttp==3.12.15",
"async-timeout",
"aiocache",
"aiofiles",
"starlette-compress==1.6.0",
+ "httpx[socks,http2,zstd,cli,brotli]==0.28.1",
+ "starsessions[redis]==2.2.1",
"sqlalchemy==2.0.38",
"alembic==1.14.0",
"peewee==3.18.1",
"peewee-migrate==1.12.2",
- "psycopg2-binary==2.9.9",
- "pgvector==0.4.0",
- "PyMySQL==1.1.1",
- "bcrypt==4.3.0",
- "pymongo",
+ "pycrdt==0.12.25",
"redis",
- "boto3==1.35.53",
- "argon2-cffi==23.1.0",
"APScheduler==3.10.4",
-
-
"RestrictedPython==8.0",
"loguru==0.7.3",
"asgiref==3.8.1",
+ "tiktoken",
+ "mcp==1.21.2",
+
"openai",
"anthropic",
- "google-genai==1.15.0",
+ "google-genai==1.52.0",
"google-generativeai==0.8.5",
- "tiktoken",
- "langchain==0.3.24",
- "langchain-community==0.3.23",
+ "langchain==0.3.27",
+ "langchain-community==0.3.29",
- "fake-useragent==2.1.0",
- "chromadb==0.6.3",
- "pymilvus==2.5.0",
- "qdrant-client~=1.12.0",
+ "fake-useragent==2.2.0",
+ "chromadb==1.0.20",
"opensearch-py==2.8.0",
- "playwright==1.49.1",
- "elasticsearch==9.0.1",
- "pinecone==6.0.2",
-
+ "PyMySQL==1.1.1",
+ "boto3==1.40.5",
+
"transformers",
- "sentence-transformers==4.1.0",
+ "sentence-transformers==5.1.1",
"accelerate",
- "colbert-ai==0.2.21",
+ "pyarrow==20.0.0",
"einops==0.8.1",
"ftfy==6.2.3",
- "pypdf==4.3.1",
+ "chardet==5.2.0",
+ "pypdf==6.0.0",
"fpdf2==2.8.2",
"pymdown-extensions==10.14.2",
"docx2txt==0.8",
"python-pptx==1.0.2",
- "unstructured==0.16.17",
+ "unstructured==0.18.18",
+ "msoffcrypto-tool==5.4.2",
"nltk==3.9.1",
- "Markdown==3.7",
+ "Markdown==3.9",
"pypandoc==1.15",
"pandas==2.2.3",
"openpyxl==3.1.5",
@@ -88,51 +88,32 @@ dependencies = [
"soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2",
- "pillow==11.2.1",
+ "pillow==11.3.0",
"opencv-python-headless==4.11.0.86",
"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==25.1.0",
- "langfuse==2.44.0",
- "youtube-transcript-api==1.1.0",
+ "black==25.9.0",
+ "youtube-transcript-api==1.2.2",
"pytube==15.0.0",
- "extract_msg",
"pydub",
- "duckduckgo-search==8.0.2",
+ "ddgs==9.0.0",
"google-api-python-client",
"google-auth-httplib2",
"google-auth-oauthlib",
- "docker~=7.1.0",
- "pytest~=8.3.2",
- "pytest-docker~=3.1.1",
-
- "googleapis-common-protos==1.63.2",
+ "googleapis-common-protos==1.70.0",
"google-cloud-storage==2.19.0",
- "azure-identity==1.20.0",
+ "azure-identity==1.25.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"
@@ -147,6 +128,35 @@ classifiers = [
"Topic :: Multimedia",
]
+[project.optional-dependencies]
+postgres = [
+ "psycopg2-binary==2.9.10",
+ "pgvector==0.4.1",
+]
+
+all = [
+ "pymongo",
+ "psycopg2-binary==2.9.9",
+ "pgvector==0.4.0",
+ "moto[s3]>=5.0.26",
+ "gcp-storage-emulator>=2024.8.3",
+ "docker~=7.1.0",
+ "pytest~=8.3.2",
+ "pytest-docker~=3.1.1",
+ "playwright==1.49.1",
+ "elasticsearch==9.1.0",
+
+ "qdrant-client==1.14.3",
+ "weaviate-client==4.17.0",
+ "pymilvus==2.6.2",
+ "pinecone==6.0.2",
+ "oracledb==3.2.0",
+ "colbert-ai==0.2.21",
+
+ "firecrawl-py==4.5.0",
+ "azure-search-documents==11.6.0",
+]
+
[project.scripts]
open-webui = "open_webui:app"
@@ -188,3 +198,8 @@ skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,exam
check-hidden = true
# ignore-regex = ''
ignore-words-list = 'ans'
+
+[dependency-groups]
+dev = [
+ "pytest-asyncio>=1.0.0",
+]
diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js
index 664683a30d..716a86a388 100644
--- a/scripts/prepare-pyodide.js
+++ b/scripts/prepare-pyodide.js
@@ -13,7 +13,8 @@ const packages = [
'tiktoken',
'seaborn',
'pytz',
- 'black'
+ 'black',
+ 'openai'
];
import { loadPyodide } from 'pyodide';
@@ -74,8 +75,8 @@ async function downloadPackages() {
console.log('Pyodide version mismatch, removing static/pyodide directory');
await rmdir('static/pyodide', { recursive: true });
}
- } catch (e) {
- console.log('Pyodide package not found, proceeding with download.');
+ } catch (err) {
+ console.log('Pyodide package not found, proceeding with download.', err);
}
try {
diff --git a/src/app.css b/src/app.css
index 352a18d213..9646c0f9ce 100644
--- a/src/app.css
+++ b/src/app.css
@@ -30,8 +30,33 @@
font-display: swap;
}
+/* --app-text-scale is updated via the UI Scale slider (Interface.svelte) */
+:root {
+ --app-text-scale: 1;
+}
+
html {
word-break: break-word;
+ /* font-size scales the entire document via the same UI control */
+ font-size: calc(1rem * var(--app-text-scale, 1));
+}
+
+#sidebar-chat-item {
+ /* sidebar item sizing scales for the chat list entries */
+ min-height: calc(32px * var(--app-text-scale, 1));
+ padding-inline: calc(11px * var(--app-text-scale, 1));
+ padding-block: calc(6px * var(--app-text-scale, 1));
+}
+
+#sidebar-chat-item div[dir='auto'] {
+ /* chat title line height follows the text scale */
+ height: calc(20px * var(--app-text-scale, 1));
+ line-height: calc(20px * var(--app-text-scale, 1));
+}
+
+#sidebar-chat-item input {
+ /* editing state input height is kept in sync */
+ min-height: calc(20px * var(--app-text-scale, 1));
}
code {
@@ -40,6 +65,11 @@ code {
width: auto;
}
+.editor-selection {
+ background: rgba(180, 213, 255, 0.5);
+ border-radius: 2px;
+}
+
.font-secondary {
font-family: 'InstrumentSerif', sans-serif;
}
@@ -65,19 +95,23 @@ textarea::placeholder {
}
.input-prose {
- @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-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-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
}
.input-prose-sm {
- @apply prose dark:prose-invert prose-headings:font-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 text-sm;
+ @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
}
.markdown-prose {
- @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+ @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+}
+
+.markdown-prose-sm {
+ @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
- @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+ @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@@ -107,7 +141,7 @@ li p {
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
- background-color: rgba(215, 215, 215, 0.8);
+ background-color: rgba(215, 215, 215, 0.6);
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px;
border-width: 1px;
@@ -115,13 +149,13 @@ li p {
/* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb {
- background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
+ background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
}
::-webkit-scrollbar {
- height: 0.6rem;
- width: 0.4rem;
+ height: 0.45rem;
+ width: 0.45rem;
}
::-webkit-scrollbar-track {
@@ -143,6 +177,14 @@ select {
-webkit-appearance: none;
}
+.dark select:not([class*='bg-transparent']) {
+ @apply bg-gray-900 text-gray-300;
+}
+
+.dark select option {
+ @apply bg-gray-850 text-white;
+}
+
@keyframes shimmer {
0% {
background-position: 200% 0;
@@ -273,6 +315,14 @@ input[type='number'] {
outline: none;
}
+.cm-gutters {
+ @apply !bg-white dark:!bg-black !border-none;
+}
+
+.cm-editor {
+ @apply bg-white dark:bg-black;
+}
+
.tippy-box[data-theme~='dark'] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
}
@@ -326,6 +376,165 @@ input[type='number'] {
@apply line-clamp-1 absolute;
}
+.tiptap ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: start;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ margin-top: 0.2rem;
+ user-select: none;
+ display: flex;
+ }
+
+ > div {
+ flex: 1 1 auto;
+
+ align-items: center;
+ }
+ }
+
+ /* checked data-checked="true" */
+
+ li[data-checked='true'] {
+ > div {
+ opacity: 0.5;
+ text-decoration: line-through;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ ul[data-type='taskList'] {
+ margin: 0;
+ }
+
+ /* Reset nested regular ul elements to default styling */
+ ul:not([data-type='taskList']) {
+ list-style: disc;
+ padding-left: 1rem;
+
+ li {
+ align-items: initial;
+ display: list-item;
+
+ label {
+ flex: initial;
+ margin-right: initial;
+ margin-top: initial;
+ user-select: initial;
+ display: initial;
+ }
+
+ div {
+ flex: initial;
+ align-items: initial;
+ }
+ }
+ }
+}
+
+.mention {
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
+}
+
+.mention::after {
+ content: '\200B';
+}
+
+.tiptap .suggestion {
+ border-radius: 0.4rem;
+ box-decoration-break: clone;
+ padding: 0.1rem 0.3rem;
+ @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
+}
+
+.tiptap .suggestion::after {
+ content: '\200B';
+}
+
+.tiptap .suggestion.is-empty::after {
+ content: '\00A0';
+ border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
+}
+
+.input-prose .tiptap ul[data-type='taskList'] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+
+ li {
+ align-items: start;
+ display: flex;
+
+ > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ margin-top: 0.4rem;
+ user-select: none;
+ display: flex;
+ }
+
+ > div {
+ flex: 1 1 auto;
+
+ align-items: center;
+ }
+ }
+
+ /* checked data-checked="true" */
+
+ li[data-checked='true'] {
+ > div {
+ opacity: 0.5;
+ text-decoration: line-through;
+ }
+ }
+
+ input[type='checkbox'] {
+ cursor: pointer;
+ }
+
+ ul[data-type='taskList'] {
+ margin: 0;
+ }
+
+ /* Reset nested regular ul elements to default styling */
+ ul:not([data-type='taskList']) {
+ list-style: disc;
+ padding-left: 1rem;
+
+ li {
+ align-items: initial;
+ display: list-item;
+
+ label {
+ flex: initial;
+ margin-right: initial;
+ margin-top: initial;
+ user-select: initial;
+ display: initial;
+ }
+
+ div {
+ flex: initial;
+ align-items: initial;
+ }
+ }
+ }
+}
+
@media (prefers-color-scheme: dark) {
.ProseMirror p.is-editor-empty:first-child::before {
color: #757575;
@@ -339,21 +548,21 @@ input[type='number'] {
pointer-events: none;
}
-.tiptap > pre > code {
+.tiptap pre > code {
border-radius: 0.4rem;
font-size: 0.85rem;
padding: 0.25em 0.3em;
- @apply dark:bg-gray-800 bg-gray-100;
+ @apply dark:bg-gray-800 bg-gray-50;
}
-.tiptap > pre {
+.tiptap pre {
border-radius: 0.5rem;
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
- @apply dark:bg-gray-800 bg-gray-100;
+ @apply dark:bg-gray-800 bg-gray-50;
}
.tiptap p code {
@@ -362,7 +571,7 @@ input[type='number'] {
padding: 3px 8px;
font-size: 0.8em;
font-weight: 600;
- @apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
+ @apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5;
}
/* Code styling */
@@ -442,3 +651,155 @@ input[type='number'] {
.tiptap tr {
@apply bg-white dark:bg-gray-900 dark:border-gray-850 text-xs;
}
+
+.tippy-box[data-theme~='transparent'] {
+ @apply bg-transparent p-0 m-0;
+}
+
+/* this is a rough fix for the first cursor position when the first paragraph is empty */
+.ProseMirror > .ProseMirror-yjs-cursor:first-child {
+ margin-top: 16px;
+}
+/* This gives the remote user caret. The colors are automatically overwritten*/
+.ProseMirror-yjs-cursor {
+ position: relative;
+ margin-left: -1px;
+ margin-right: -1px;
+ border-left: 1px solid black;
+ border-right: 1px solid black;
+ border-color: orange;
+ word-break: normal;
+ pointer-events: none;
+}
+/* This renders the username above the caret */
+.ProseMirror-yjs-cursor > div {
+ position: absolute;
+ top: -1.05em;
+ left: -1px;
+ font-size: 13px;
+ background-color: rgb(250, 129, 0);
+ user-select: none;
+ color: white;
+ padding-left: 2px;
+ padding-right: 2px;
+ white-space: nowrap;
+}
+
+body {
+ background: #fff;
+ color: #000;
+}
+
+.dark body {
+ background: #171717;
+ color: #eee;
+}
+
+/* Position the handle relative to each LI */
+.pm-li--with-handle {
+ position: relative;
+ margin-left: 12px; /* make space for the handle */
+}
+
+.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
+ margin-left: 0px;
+}
+
+/* The drag handle itself */
+.pm-list-drag-handle {
+ position: absolute;
+ left: -36px; /* pull into the left gutter */
+ top: 1px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ line-height: 1;
+ border-radius: 4px;
+ cursor: grab;
+ user-select: none;
+ opacity: 0.35;
+ transition:
+ opacity 120ms ease,
+ background 120ms ease;
+}
+
+.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
+ left: -16px; /* pull into the left gutter more to avoid the checkbox */
+}
+
+.pm-list-drag-handle:active {
+ cursor: grabbing;
+}
+.pm-li--with-handle:hover > .pm-list-drag-handle {
+ opacity: 1;
+}
+.pm-list-drag-handle:hover {
+ background: rgba(0, 0, 0, 0.06);
+}
+
+:root {
+ --pm-accent: color-mix(in oklab, Highlight 70%, transparent);
+ --pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
+ --pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+ position: relative;
+}
+
+/* BEFORE/AFTER lines */
+.pm-li-drop-before::before,
+.pm-li-drop-after::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--pm-accent);
+ pointer-events: none;
+}
+.pm-li-drop-before::before {
+ top: -2px;
+}
+.pm-li-drop-after::after {
+ bottom: -2px;
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+ background: var(--pm-fill-target);
+ border-radius: 6px;
+}
+
+.pm-li-drop-outdent::before {
+ content: '';
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ width: 3px;
+ background: color-mix(in oklab, Highlight 35%, transparent);
+}
+
+.pm-li--with-handle:has(.pm-li-drop-before),
+.pm-li--with-handle:has(.pm-li-drop-after),
+.pm-li--with-handle:has(.pm-li-drop-into),
+.pm-li--with-handle:has(.pm-li-drop-outdent) {
+ background: var(--pm-fill-ancestor);
+ border-radius: 6px;
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+ position: relative;
+ z-index: 0;
+}
diff --git a/src/app.html b/src/app.html
index 6fa5b79cee..3fe87514f2 100644
--- a/src/app.html
+++ b/src/app.html
@@ -2,13 +2,27 @@
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
@@ -120,19 +121,6 @@
}
-
-
- html {
- overflow-y: hidden !important;
- }
+
+ @keyframes pulse {
+ 50% {
+ opacity: 0.65;
+ }
+ }
+
+ .animate-pulse-fast {
+ animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ }
+
+
diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts
index 842edd9c9d..5450479af5 100644
--- a/src/lib/apis/auths/index.ts
+++ b/src/lib/apis/auths/index.ts
@@ -347,6 +347,8 @@ export const userSignOut = async () => {
if (error) {
throw error;
}
+
+ sessionStorage.clear();
return res;
};
@@ -391,7 +393,7 @@ export const addUser = async (
return res;
};
-export const updateUserProfile = async (token: string, name: string, profileImageUrl: string) => {
+export const updateUserProfile = async (token: string, profile: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/update/profile`, {
@@ -401,8 +403,7 @@ export const updateUserProfile = async (token: string, name: string, profileImag
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
- name: name,
- profile_image_url: profileImageUrl
+ ...profile
})
})
.then(async (res) => {
diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts
index 548572c6fb..2872bd89f8 100644
--- a/src/lib/apis/channels/index.ts
+++ b/src/lib/apis/channels/index.ts
@@ -101,6 +101,60 @@ export const getChannelById = async (token: string = '', channel_id: string) =>
return res;
};
+export const getChannelUsersById = async (
+ token: string,
+ channel_id: string,
+ query?: string,
+ orderBy?: string,
+ direction?: string,
+ page = 1
+) => {
+ let error = null;
+ let res = null;
+
+ const 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}/channels/${channel_id}/users?${searchParams.toString()}`,
+ {
+ 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 updateChannelById = async (
token: string = '',
channel_id: string,
@@ -248,6 +302,7 @@ export const getChannelThreadMessages = async (
};
type MessageForm = {
+ reply_to_id?: string;
parent_id?: string;
content: string;
data?: object;
diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts
index 9d24b3971c..010c80a56f 100644
--- a/src/lib/apis/chats/index.ts
+++ b/src/lib/apis/chats/index.ts
@@ -1,7 +1,7 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getTimeRange } from '$lib/utils';
-export const createNewChat = async (token: string, chat: object) => {
+export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
@@ -12,7 +12,8 @@ export const createNewChat = async (token: string, chat: object) => {
authorization: `Bearer ${token}`
},
body: JSON.stringify({
- chat: chat
+ chat: chat,
+ folder_id: folderId ?? null
})
})
.then(async (res) => {
@@ -32,13 +33,39 @@ export const createNewChat = async (token: string, chat: object) => {
return res;
};
-export const importChat = async (
- token: string,
- chat: object,
- meta: object | null,
- pinned?: boolean,
- folderId?: string | null
-) => {
+export const unarchiveAllChats = async (token: string) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
+ method: 'POST',
+ 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.detail;
+
+ console.error(err);
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
+export const importChats = async (token: string, chats: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/import`, {
@@ -49,10 +76,7 @@ export const importChat = async (
authorization: `Bearer ${token}`
},
body: JSON.stringify({
- chat: chat,
- meta: meta ?? {},
- pinned: pinned,
- folder_id: folderId
+ chats
})
})
.then(async (res) => {
@@ -72,7 +96,12 @@ export const importChat = async (
return res;
};
-export const getChatList = async (token: string = '', page: number | null = null) => {
+export const getChatList = async (
+ token: string = '',
+ page: number | null = null,
+ include_pinned: boolean = false,
+ include_folders: boolean = false
+) => {
let error = null;
const searchParams = new URLSearchParams();
@@ -80,6 +109,14 @@ export const getChatList = async (token: string = '', page: number | null = null
searchParams.append('page', `${page}`);
}
+ if (include_folders) {
+ searchParams.append('include_folders', 'true');
+ }
+
+ if (include_pinned) {
+ searchParams.append('include_pinned', 'true');
+ }
+
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
method: 'GET',
headers: {
@@ -314,6 +351,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
return res;
};
+export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
+ let error = null;
+
+ const searchParams = new URLSearchParams();
+ if (page !== null) {
+ searchParams.append('page', `${page}`);
+ }
+
+ const res = await fetch(
+ `${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${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;
+};
+
export const getAllArchivedChats = async (token: string) => {
let error = null;
diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts
index 26dec26c9d..c6cfdd2b2b 100644
--- a/src/lib/apis/configs/index.ts
+++ b/src/lib/apis/configs/index.ts
@@ -1,4 +1,4 @@
-import { WEBUI_API_BASE_URL } from '$lib/constants';
+import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types';
export const importConfig = async (token: string, config) => {
@@ -58,10 +58,10 @@ export const exportConfig = async (token: string) => {
return res;
};
-export const getDirectConnectionsConfig = async (token: string) => {
+export const getConnectionsConfig = async (token: string) => {
let error = null;
- const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
+ const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -85,10 +85,10 @@ export const getDirectConnectionsConfig = async (token: string) => {
return res;
};
-export const setDirectConnectionsConfig = async (token: string, config: object) => {
+export const setConnectionsConfig = async (token: string, config: object) => {
let error = null;
- const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
+ const res = await fetch(`${WEBUI_API_BASE_URL}/configs/connections`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
return res;
};
+type RegisterOAuthClientForm = {
+ url: string;
+ client_id: string;
+ client_name?: string;
+};
+
+export const registerOAuthClient = async (
+ token: string,
+ formData: RegisterOAuthClientForm,
+ type: null | string = null
+) => {
+ let error = null;
+
+ const searchParams = type ? `?type=${type}` : '';
+ const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ ...formData
+ })
+ })
+ .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 getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
+ const oauthClientId = type ? `${type}:${clientId}` : clientId;
+ return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
+};
+
export const getCodeExecutionConfig = async (token: string) => {
let error = null;
diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts
index 96a689fcb1..1f48c7bfbf 100644
--- a/src/lib/apis/evaluations/index.ts
+++ b/src/lib/apis/evaluations/index.ts
@@ -93,6 +93,45 @@ export const getAllFeedbacks = async (token: string = '') => {
return res;
};
+export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
+ let error = null;
+
+ const searchParams = new URLSearchParams();
+ if (orderBy) searchParams.append('order_by', orderBy);
+ if (direction) searchParams.append('direction', direction);
+ if (page) searchParams.append('page', page.toString());
+
+ const res = await fetch(
+ `${WEBUI_API_BASE_URL}/evaluations/feedbacks/list?${searchParams.toString()}`,
+ {
+ 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 exportAllFeedbacks = async (token: string = '') => {
let error = null;
diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts
index a58d7cb931..8351393e3c 100644
--- a/src/lib/apis/files/index.ts
+++ b/src/lib/apis/files/index.ts
@@ -1,4 +1,5 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
+import { splitStream } from '$lib/utils';
export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
const data = new FormData();
@@ -22,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
return res.json();
})
.catch((err) => {
- error = err.detail;
+ error = err.detail || err.message;
console.error(err);
return null;
});
@@ -31,6 +32,79 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
throw error;
}
+ if (res) {
+ const status = await getFileProcessStatus(token, res.id);
+
+ if (status && status.ok) {
+ const reader = status.body
+ .pipeThrough(new TextDecoderStream())
+ .pipeThrough(splitStream('\n'))
+ .getReader();
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) {
+ break;
+ }
+
+ try {
+ let lines = value.split('\n');
+
+ for (const line of lines) {
+ if (line !== '') {
+ console.log(line);
+ if (line === 'data: [DONE]') {
+ console.log(line);
+ } else {
+ let data = JSON.parse(line.replace(/^data: /, ''));
+ console.log(data);
+
+ if (data?.error) {
+ console.error(data.error);
+ res.error = data.error;
+ }
+
+ if (res?.data) {
+ res.data = data;
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.log(error);
+ }
+ }
+ }
+ }
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
+export const getFileProcessStatus = async (token: string, id: string) => {
+ const queryParams = new URLSearchParams();
+ queryParams.append('stream', 'true');
+
+ let error = null;
+ const res = await fetch(`${WEBUI_API_BASE_URL}/files/${id}/process/status?${queryParams}`, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ authorization: `Bearer ${token}`
+ }
+ }).catch((err) => {
+ error = err.detail;
+ console.error(err);
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
return res;
};
diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts
index 21ec426b05..535adbd5f6 100644
--- a/src/lib/apis/folders/index.ts
+++ b/src/lib/apis/folders/index.ts
@@ -1,6 +1,12 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
-export const createNewFolder = async (token: string, name: string) => {
+type FolderForm = {
+ name?: string;
+ data?: Record
;
+ meta?: Record;
+};
+
+export const createNewFolder = async (token: string, folderForm: FolderForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
@@ -10,9 +16,7 @@ export const createNewFolder = async (token: string, name: string) => {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
- body: JSON.stringify({
- name: name
- })
+ body: JSON.stringify(folderForm)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@@ -92,7 +96,7 @@ export const getFolderById = async (token: string, id: string) => {
return res;
};
-export const updateFolderNameById = async (token: string, id: string, name: string) => {
+export const updateFolderById = async (token: string, id: string, folderForm: FolderForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/update`, {
@@ -102,9 +106,7 @@ export const updateFolderNameById = async (token: string, id: string, name: stri
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
- body: JSON.stringify({
- name: name
- })
+ body: JSON.stringify(folderForm)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@@ -237,10 +239,13 @@ export const updateFolderItemsById = async (token: string, id: string, items: Fo
return res;
};
-export const deleteFolderById = async (token: string, id: string) => {
+export const deleteFolderById = async (token: string, id: string, deleteContents: boolean) => {
let error = null;
- const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}`, {
+ const searchParams = new URLSearchParams();
+ searchParams.append('delete_contents', deleteContents ? 'true' : 'false');
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}?${searchParams.toString()}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
diff --git a/src/lib/apis/functions/index.ts b/src/lib/apis/functions/index.ts
index 60e88118b8..47346b4a20 100644
--- a/src/lib/apis/functions/index.ts
+++ b/src/lib/apis/functions/index.ts
@@ -62,6 +62,37 @@ export const getFunctions = async (token: string = '') => {
return res;
};
+export const getFunctionList = async (token: string = '') => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/functions/list`, {
+ 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 loadFunctionByUrl = async (token: string = '', url: string) => {
let error = null;
diff --git a/src/lib/apis/groups/index.ts b/src/lib/apis/groups/index.ts
index c55f477af5..a74c61b83d 100644
--- a/src/lib/apis/groups/index.ts
+++ b/src/lib/apis/groups/index.ts
@@ -31,10 +31,15 @@ export const createNewGroup = async (token: string, group: object) => {
return res;
};
-export const getGroups = async (token: string = '') => {
+export const getGroups = async (token: string = '', share?: boolean) => {
let error = null;
- const res = await fetch(`${WEBUI_API_BASE_URL}/groups/`, {
+ const searchParams = new URLSearchParams();
+ if (share !== undefined) {
+ searchParams.append('share', String(share));
+ }
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/groups/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -160,3 +165,73 @@ export const deleteGroupById = async (token: string, id: string) => {
return res;
};
+
+export const addUserToGroup = async (token: string, id: string, userIds: string[]) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/add`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ user_ids: userIds
+ })
+ })
+ .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 removeUserFromGroup = async (token: string, id: string, userIds: string[]) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/remove`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({
+ user_ids: userIds
+ })
+ })
+ .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/index.ts b/src/lib/apis/index.ts
index 99b95b0629..e865e9ba0e 100644
--- a/src/lib/apis/index.ts
+++ b/src/lib/apis/index.ts
@@ -8,17 +8,26 @@ import { toast } from 'svelte-sonner';
export const getModels = async (
token: string = '',
connections: object | null = null,
- base: boolean = false
+ base: boolean = false,
+ refresh: boolean = false
) => {
+ const searchParams = new URLSearchParams();
+ if (refresh) {
+ searchParams.append('refresh', 'true');
+ }
+
let error = null;
- const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
- method: 'GET',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- ...(token && { authorization: `Bearer ${token}` })
+ const res = await fetch(
+ `${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}?${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();
@@ -328,46 +337,70 @@ export const getToolServerData = async (token: string, url: string) => {
throw error;
}
- const data = {
- openapi: res,
- info: res.info,
- specs: convertOpenApiToToolPayload(res)
- };
-
- console.log(data);
- return data;
+ console.log(res);
+ return res;
};
-export const getToolServersData = async (i18n, servers: object[]) => {
+export const getToolServersData = async (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;
- });
+ let error = null;
+
+ let toolServerToken = null;
+
+ const auth_type = server?.auth_type ?? 'bearer';
+ if (auth_type === 'bearer') {
+ toolServerToken = server?.key;
+ } else if (auth_type === 'none') {
+ // No authentication
+ } else if (auth_type === 'session') {
+ toolServerToken = localStorage.token;
+ }
+
+ let res = null;
+ const specType = server?.spec_type ?? 'url';
+
+ if (specType === 'url') {
+ res = await getToolServerData(
+ toolServerToken,
+ (server?.path ?? '').includes('://')
+ ? server?.path
+ : `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
+ ).catch((err) => {
+ error = err;
+ return null;
+ });
+ } else if ((specType === 'json' && server?.spec) ?? null) {
+ try {
+ res = JSON.parse(server?.spec);
+ } catch (e) {
+ error = 'Failed to parse JSON spec';
+ }
+ }
+
+ if (res) {
+ const { openapi, info, specs } = {
+ openapi: res,
+ info: res.info,
+ specs: convertOpenApiToToolPayload(res)
+ };
- if (data) {
- const { openapi, info, specs } = data;
return {
url: server?.url,
openapi: openapi,
info: info,
specs: specs
};
+ } else if (error) {
+ return {
+ error,
+ url: server?.url
+ };
+ } else {
+ return null;
}
})
)
@@ -456,12 +489,15 @@ export const executeToolServer = async (
...(token && { authorization: `Bearer ${token}` })
};
- let requestOptions: RequestInit = {
+ const requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
headers
};
- if (['post', 'put', 'patch'].includes(httpMethod.toLowerCase()) && operation.requestBody) {
+ if (
+ ['post', 'put', 'patch', 'delete'].includes(httpMethod.toLowerCase()) &&
+ operation.requestBody
+ ) {
requestOptions.body = JSON.stringify(bodyParams);
}
@@ -471,11 +507,25 @@ export const executeToolServer = async (
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
}
- return await res.json();
+ // make a clone of res and extract headers
+ const responseHeaders = {};
+ res.headers.forEach((value, key) => {
+ responseHeaders[key] = value;
+ });
+
+ const text = await res.text();
+ let responseData;
+
+ try {
+ responseData = JSON.parse(text);
+ } catch {
+ responseData = text;
+ }
+ return [responseData, responseHeaders];
} catch (err: any) {
error = err.message;
console.error('API Request Error:', error);
- return { error };
+ return [{ error }, null];
}
};
@@ -809,7 +859,7 @@ export const generateQueries = async (
model: string,
messages: object[],
prompt: string,
- type?: string = 'web_search'
+ type: string = 'web_search'
) => {
let error = null;
@@ -1005,7 +1055,7 @@ export const getPipelinesList = async (token: string = '') => {
throw error;
}
- let pipelines = res?.data ?? [];
+ const pipelines = res?.data ?? [];
return pipelines;
};
@@ -1148,7 +1198,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
throw error;
}
- let pipelines = res?.data ?? [];
+ const pipelines = res?.data ?? [];
return pipelines;
};
@@ -1351,6 +1401,33 @@ export const getChangelog = async () => {
return res;
};
+export const getVersion = async (token: string) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_BASE_URL}/api/version`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ })
+ .then(async (res) => {
+ if (!res.ok) throw await res.json();
+ return res.json();
+ })
+ .catch((err) => {
+ console.error(err);
+ error = err;
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
export const getVersionUpdates = async (token: string) => {
let error = null;
@@ -1587,6 +1664,7 @@ export interface ModelConfig {
}
export interface ModelMeta {
+ toolIds: never[];
description?: string;
capabilities?: object;
profile_image_url?: string;
diff --git a/src/lib/apis/models/index.ts b/src/lib/apis/models/index.ts
index 3e6e0d0c0b..d03a83e9ca 100644
--- a/src/lib/apis/models/index.ts
+++ b/src/lib/apis/models/index.ts
@@ -1,9 +1,37 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
-export const getModels = async (token: string = '') => {
+export const getModelItems = async (
+ token: string = '',
+ query,
+ viewOption,
+ selectedTag,
+ orderBy,
+ direction,
+ page
+) => {
let error = null;
- const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
+ const searchParams = new URLSearchParams();
+ if (query) {
+ searchParams.append('query', query);
+ }
+ if (viewOption) {
+ searchParams.append('view_option', viewOption);
+ }
+ if (selectedTag) {
+ searchParams.append('tag', selectedTag);
+ }
+ if (orderBy) {
+ searchParams.append('order_by', orderBy);
+ }
+ if (direction) {
+ searchParams.append('direction', direction);
+ }
+ if (page) {
+ searchParams.append('page', page.toString());
+ }
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/models/list?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@@ -31,6 +59,65 @@ export const getModels = async (token: string = '') => {
return res;
};
+export const getModelTags = async (token: string = '') => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, {
+ 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;
+ console.error(err);
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
+export const importModels = async (token: string, models: object[]) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({ models: models })
+ })
+ .then(async (res) => {
+ if (!res.ok) throw await res.json();
+ return res.json();
+ })
+ .catch((err) => {
+ error = err;
+ console.error(err);
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
export const getBaseModels = async (token: string = '') => {
let error = null;
@@ -164,17 +251,14 @@ export const toggleModelById = async (token: string, id: string) => {
export const updateModelById = async (token: string, id: string, model: object) => {
let error = null;
- const searchParams = new URLSearchParams();
- searchParams.append('id', id);
-
- const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update?${searchParams.toString()}`, {
+ const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
- body: JSON.stringify(model)
+ body: JSON.stringify({ ...model, id })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@@ -200,16 +284,14 @@ export const updateModelById = async (token: string, id: string, model: object)
export const deleteModelById = async (token: string, id: string) => {
let error = null;
- const searchParams = new URLSearchParams();
- searchParams.append('id', id);
-
- const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/delete?${searchParams.toString()}`, {
- method: 'DELETE',
+ const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/delete`, {
+ method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
- }
+ },
+ body: JSON.stringify({ id })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts
index df0be72627..61794f6766 100644
--- a/src/lib/apis/notes/index.ts
+++ b/src/lib/apis/notes/index.ts
@@ -39,7 +39,7 @@ export const createNewNote = async (token: string, note: NoteItem) => {
return res;
};
-export const getNotes = async (token: string = '') => {
+export const getNotes = async (token: string = '', raw: boolean = false) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
@@ -67,6 +67,10 @@ export const getNotes = async (token: string = '') => {
throw error;
}
+ if (raw) {
+ return res; // Return raw response if requested
+ }
+
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
@@ -87,6 +91,42 @@ export const getNotes = async (token: string = '') => {
return grouped;
};
+export const getNoteList = async (token: string = '', page: number | null = null) => {
+ let error = null;
+ const searchParams = new URLSearchParams();
+
+ if (page !== null) {
+ searchParams.append('page', `${page}`);
+ }
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ '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 getNoteById = async (token: string, id: string) => {
let error = null;
diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts
index 489055c1bb..a0f670d5e3 100644
--- a/src/lib/apis/ollama/index.ts
+++ b/src/lib/apis/ollama/index.ts
@@ -234,7 +234,7 @@ export const getOllamaModels = async (token: string = '', urlIdx: null | number
return (res?.models ?? [])
.map((model) => ({ id: model.model, name: model.name ?? model.model, ...model }))
.sort((a, b) => {
- return a.name.localeCompare(b.name);
+ return (a?.name ?? a?.id ?? '').localeCompare(b?.name ?? b?.id ?? '');
});
};
@@ -331,7 +331,7 @@ export const generateTextCompletion = async (token: string = '', model: string,
};
export const generateChatCompletion = async (token: string = '', body: object) => {
- let controller = new AbortController();
+ const controller = new AbortController();
let error = null;
const res = await fetch(`${OLLAMA_API_BASE_URL}/api/chat`, {
@@ -366,7 +366,7 @@ export const unloadModel = async (token: string, tagName: string) => {
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
- name: tagName
+ model: tagName
})
}).catch((err) => {
error = err;
@@ -419,7 +419,7 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
- name: tagName
+ model: tagName
})
}
)
diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts
index 070118a1a2..276fad145d 100644
--- a/src/lib/apis/openai/index.ts
+++ b/src/lib/apis/openai/index.ts
@@ -372,6 +372,7 @@ export const generateOpenAIChatCompletion = async (
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
+ credentials: 'include',
body: JSON.stringify(body)
})
.then(async (res) => {
@@ -379,7 +380,7 @@ export const generateOpenAIChatCompletion = async (
return res.json();
})
.catch((err) => {
- error = `${err?.detail ?? err}`;
+ error = err?.detail ?? err;
return null;
});
diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts
index 6df927fec6..5cb0f60a72 100644
--- a/src/lib/apis/retrieval/index.ts
+++ b/src/lib/apis/retrieval/index.ts
@@ -295,42 +295,6 @@ export interface SearchDocument {
filenames: string[];
}
-export const processFile = async (
- token: string,
- file_id: string,
- collection_name: string | null = null
-) => {
- let error = null;
-
- const res = await fetch(`${RETRIEVAL_API_BASE_URL}/process/file`, {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- authorization: `Bearer ${token}`
- },
- body: JSON.stringify({
- file_id: file_id,
- collection_name: collection_name ? collection_name : undefined
- })
- })
- .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 processYoutubeVideo = async (token: string, url: string) => {
let error = null;
diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts
index f2449ff3e0..ac057359a5 100644
--- a/src/lib/apis/users/index.ts
+++ b/src/lib/apis/users/index.ts
@@ -126,7 +126,7 @@ export const getUsers = async (
let error = null;
let res = null;
- let searchParams = new URLSearchParams();
+ const searchParams = new URLSearchParams();
searchParams.set('page', `${page}`);
@@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
return res;
};
+export const searchUsers = async (token: string, query: string) => {
+ let error = null;
+ let res = null;
+
+ res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`
+ }
+ })
+ .then(async (res) => {
+ if (!res.ok) throw await res.json();
+ return res.json();
+ })
+ .catch((err) => {
+ console.error(err);
+ error = err.detail;
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
export const getUserSettings = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {
@@ -403,6 +431,7 @@ export const deleteUserById = async (token: string, userId: string) => {
};
type UserUpdateForm = {
+ role: string;
profile_image_url: string;
email: string;
name: string;
@@ -442,3 +471,30 @@ export const updateUserById = async (token: string, userId: string, user: UserUp
return res;
};
+
+export const getUserGroupsById = async (token: string, userId: string) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/groups`, {
+ 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;
+};
diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts
index 1fc30ddbba..d19f10f948 100644
--- a/src/lib/apis/utils/index.ts
+++ b/src/lib/apis/utils/index.ts
@@ -179,39 +179,3 @@ export const downloadDatabase = async (token: string) => {
throw error;
}
};
-
-export const downloadLiteLLMConfig = async (token: string) => {
- let error = null;
-
- const res = await fetch(`${WEBUI_API_BASE_URL}/utils/litellm/config`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${token}`
- }
- })
- .then(async (response) => {
- if (!response.ok) {
- throw await response.json();
- }
- return response.blob();
- })
- .then((blob) => {
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'config.yaml';
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- })
- .catch((err) => {
- console.error(err);
- error = err.detail;
- return null;
- });
-
- if (error) {
- throw error;
- }
-};
diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte
index e4f02e2005..5a75774fa0 100644
--- a/src/lib/components/AddConnectionModal.svelte
+++ b/src/lib/components/AddConnectionModal.svelte
@@ -15,6 +15,9 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte';
+ import Spinner from '$lib/components/common/Spinner.svelte';
+ import XMark from '$lib/components/icons/XMark.svelte';
+ import Textarea from './common/Textarea.svelte';
export let onSubmit: Function = () => {};
export let onDelete: Function = () => {};
@@ -29,18 +32,19 @@
let url = '';
let key = '';
+ let auth_type = 'bearer';
let connectionType = 'external';
let azure = false;
$: azure =
- (url.includes('azure.com') || url.includes('cognitive.microsoft.com')) && !direct
- ? true
- : false;
+ (url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct ? true : false;
let prefixId = '';
let enable = true;
let apiVersion = '';
+ let headers = '';
+
let tags = [];
let modelId = '';
@@ -68,14 +72,32 @@
// remove trailing slash from url
url = url.replace(/\/$/, '');
+ let _headers = null;
+
+ if (headers) {
+ try {
+ _headers = JSON.parse(headers);
+ if (typeof _headers !== 'object' || Array.isArray(_headers)) {
+ _headers = null;
+ throw new Error('Headers must be a valid JSON object');
+ }
+ headers = JSON.stringify(_headers, null, 2);
+ } catch (error) {
+ toast.error($i18n.t('Headers must be a valid JSON object'));
+ return;
+ }
+ }
+
const res = await verifyOpenAIConnection(
localStorage.token,
{
url,
key,
config: {
+ auth_type,
azure: azure,
- api_version: apiVersion
+ api_version: apiVersion,
+ ...(_headers ? { headers: _headers } : {})
}
},
direct
@@ -108,7 +130,7 @@
if (!ollama && !url) {
loading = false;
- toast.error('URL is required');
+ toast.error($i18n.t('URL is required'));
return;
}
@@ -116,20 +138,33 @@
if (!apiVersion) {
loading = false;
- toast.error('API Version is required');
+ toast.error($i18n.t('API Version is required'));
return;
}
- if (!key) {
+ if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
loading = false;
- toast.error('Key is required');
+ toast.error($i18n.t('Key is required'));
return;
}
if (modelIds.length === 0) {
loading = false;
- toast.error('Deployment names are required');
+ toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
+ return;
+ }
+ }
+
+ if (headers) {
+ try {
+ const _headers = JSON.parse(headers);
+ if (typeof _headers !== 'object' || Array.isArray(_headers)) {
+ throw new Error('Headers must be a valid JSON object');
+ }
+ headers = JSON.stringify(_headers, null, 2);
+ } catch (error) {
+ toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
@@ -146,6 +181,8 @@
prefix_id: prefixId,
model_ids: modelIds,
connection_type: connectionType,
+ auth_type,
+ headers: headers ? JSON.parse(headers) : undefined,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
}
};
@@ -157,6 +194,7 @@
url = '';
key = '';
+ auth_type = 'bearer';
prefixId = '';
tags = [];
modelIds = [];
@@ -167,6 +205,11 @@
url = connection.url;
key = connection.key;
+ auth_type = connection.config.auth_type ?? 'bearer';
+ headers = connection.config?.headers
+ ? JSON.stringify(connection.config.headers, null, 2)
+ : '';
+
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
@@ -208,17 +251,7 @@
show = false;
}}
>
-
-
-
+
@@ -315,23 +348,101 @@
-
{$i18n.t('Auth')}
- {$i18n.t('Key')}
-
-
-
+
+
+
+ {$i18n.t('None')}
+ {$i18n.t('Bearer')}
+
+ {#if !ollama}
+ {$i18n.t('Session')}
+ {#if !direct}
+ {$i18n.t('OAuth')}
+ {#if azure}
+ {$i18n.t('Entra ID')}
+ {/if}
+ {/if}
+ {/if}
+
+
+
+
+ {#if auth_type === 'bearer'}
+
+ {:else if auth_type === 'none'}
+
+ {$i18n.t('No authentication')}
+
+ {:else if auth_type === 'session'}
+
+ {$i18n.t('Forwards system user session credentials to authenticate')}
+
+ {:else if auth_type === 'system_oauth'}
+
+ {$i18n.t('Forwards system user OAuth access token to authenticate')}
+
+ {:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
+
+ {$i18n.t('Uses DefaultAzureCredential to authenticate')}
+
+ {/if}
+
+
+ {#if !ollama && !direct}
+
+
+
{$i18n.t('Headers')}
+
+
+
+
+
+
+
+
+ {/if}
+
+
+ {#if !ollama && !direct}
+
+
{$i18n.t('Provider Type')}
+
+
+ {
+ azure = !azure;
+ }}
+ type="button"
+ class=" text-xs text-gray-700 dark:text-gray-300"
+ >
+ {azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
+
+
+
+ {/if}
+
{#if azure}
@@ -384,37 +518,7 @@
{/if}
-
-
-
- {$i18n.t('Tags')}
-
-
-
- {
- tags = [
- ...tags,
- {
- name: e.detail
- }
- ];
- }}
- on:delete={(e) => {
- tags = tags.filter((tag) => tag.name !== e.detail);
- }}
- />
-
-
-
-
-
-
-
+
-
-
{$i18n.t('Add a model ID')}
+
+
+
+ {$i18n.t('Tags')}
+
+
+
+ {
+ tags = [
+ ...tags,
+ {
+ name: e.detail
+ }
+ ];
+ }}
+ on:delete={(e) => {
+ tags = tags.filter((tag) => tag.name !== e.detail);
+ }}
+ />
+
+
+
+
{/if}
diff --git a/src/lib/components/AddFilesPlaceholder.svelte b/src/lib/components/AddFilesPlaceholder.svelte
index 6d72ee0e61..cb7f4f04bc 100644
--- a/src/lib/components/AddFilesPlaceholder.svelte
+++ b/src/lib/components/AddFilesPlaceholder.svelte
@@ -7,8 +7,7 @@
-
📄
-
+
{#if title}
{title}
{:else}
@@ -17,7 +16,7 @@
+ >
{#if content}
{content}
{:else}
diff --git a/src/lib/components/AddServerModal.svelte b/src/lib/components/AddServerModal.svelte
deleted file mode 100644
index 1c9ce46e24..0000000000
--- a/src/lib/components/AddServerModal.svelte
+++ /dev/null
@@ -1,391 +0,0 @@
-
-
-
-
-
-
- {#if edit}
- {$i18n.t('Edit Connection')}
- {:else}
- {$i18n.t('Add Connection')}
- {/if}
-
-
{
- show = false;
- }}
- >
-
-
-
-
-
-
-
-
-
diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte
new file mode 100644
index 0000000000..2b639b3e64
--- /dev/null
+++ b/src/lib/components/AddToolServerModal.svelte
@@ -0,0 +1,885 @@
+
+
+
+
+
+
+ {#if edit}
+ {$i18n.t('Edit Connection')}
+ {:else}
+ {$i18n.t('Add Connection')}
+ {/if}
+
+
+
+
+ {
+ inputElement?.click();
+ }}
+ >
+ {$i18n.t('Import')}
+
+
+
+ {$i18n.t('Export')}
+
+
+
{
+ show = false;
+ }}
+ >
+
+
+
+
+
+
+
+
{
+ importHandler(e);
+ }}
+ />
+
+
+
+
+
+
diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte
index 21f0f5a06f..d0d1c638a7 100644
--- a/src/lib/components/ChangelogModal.svelte
+++ b/src/lib/components/ChangelogModal.svelte
@@ -1,4 +1,6 @@
-
-
+
+
-
+
{$i18n.t("What's New in")}
{$WEBUI_NAME}
@@ -36,23 +42,16 @@
localStorage.version = $config.version;
show = false;
}}
+ aria-label={$i18n.t('Close')}
>
-
+
{$i18n.t('Close')}
-
-
+
{$i18n.t('Release Notes')}
-
+
v{WEBUI_VERSION}
@@ -60,7 +59,7 @@