Merge pull request #17070 from open-webui/dev

0.6.27
This commit is contained in:
Tim Jaeryang Baek 2025-09-09 18:34:15 +04:00 committed by GitHub
commit 918f507d8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 5263 additions and 1919 deletions

View file

@ -11,7 +11,7 @@ body:
## Important Notes ## Important Notes
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
@ -25,7 +25,9 @@ body:
label: Check Existing Issues label: Check Existing Issues
description: Confirm that youve checked for existing reports before submitting a new one. description: Confirm that youve checked for existing reports before submitting a new one.
options: 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 required: true
- label: I am using the latest version of Open WebUI. - label: I am using the latest version of Open WebUI.
required: true required: true
@ -47,7 +49,7 @@ body:
id: open-webui-version id: open-webui-version
attributes: attributes:
label: Open WebUI Version label: Open WebUI Version
description: Specify the version (e.g., v0.3.11) description: Specify the version (e.g., v0.6.26)
validations: validations:
required: true required: true
@ -63,7 +65,7 @@ body:
id: operating-system id: operating-system
attributes: attributes:
label: Operating System 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: validations:
required: true required: true
@ -126,6 +128,7 @@ body:
description: | 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. 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 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: | placeholder: |
Example (include every detail): Example (include every detail):
@ -163,5 +166,5 @@ body:
attributes: attributes:
value: | value: |
## Note ## 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! Thank you for contributing to Open WebUI!

View file

@ -12,12 +12,6 @@ updates:
interval: monthly interval: monthly
target-branch: 'dev' target-branch: 'dev'
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: 'github-actions' - package-ecosystem: 'github-actions'
directory: '/' directory: '/'
schedule: schedule:

View file

@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Check for changes in package.json - name: Check for changes in package.json
run: | run: |

View file

@ -27,7 +27,7 @@ jobs:
HF_TOKEN: ${{ secrets.HF_TOKEN }} HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
lfs: true lfs: true

View file

@ -43,7 +43,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -142,7 +142,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -244,7 +244,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -347,7 +347,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -449,7 +449,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -535,7 +535,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}' IMAGE_NAME: '${{ github.repository }}'
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: digests-main-* pattern: digests-main-*
path: /tmp/digests path: /tmp/digests
@ -589,7 +589,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}' IMAGE_NAME: '${{ github.repository }}'
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: digests-cuda-* pattern: digests-cuda-*
path: /tmp/digests path: /tmp/digests
@ -645,7 +645,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}' IMAGE_NAME: '${{ github.repository }}'
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: digests-cuda126-* pattern: digests-cuda126-*
path: /tmp/digests path: /tmp/digests
@ -701,7 +701,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}' IMAGE_NAME: '${{ github.repository }}'
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: digests-ollama-* pattern: digests-ollama-*
path: /tmp/digests path: /tmp/digests
@ -757,7 +757,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}' IMAGE_NAME: '${{ github.repository }}'
- name: Download digests - name: Download digests
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: digests-slim-* pattern: digests-slim-*
path: /tmp/digests path: /tmp/digests

View file

@ -30,7 +30,7 @@ jobs:
- 3.12.x - 3.12.x
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5

View file

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View file

@ -16,7 +16,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Git - name: Install Git

View file

@ -5,13 +5,68 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.27] - 2025-09-09
### Added
- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255)
- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5)
- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a)
- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158)
- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679)
- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166)
- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222)
- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229)
- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7)
- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9)
- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672)
- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880)
- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df)
- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae)
- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046)
- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195)
- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095)
- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b)
- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175)
- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9)
- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012)
- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061)
- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064)
- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000)
- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b)
- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085)
- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186)
- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191)
- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213)
- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225)
- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb)
- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6)
- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de)
- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6)
- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4)
- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958)
- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e)
### Changed
- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f)
- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285)
## [0.6.26] - 2025-08-28 ## [0.6.26] - 2025-08-28
### Added ### 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. - 🛂 **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. - 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility.
- 📱 **Pull-to-Refresh SupportA**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA. - 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA.
- 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows. - 📁 **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. - 🔐 **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. - 🔐 **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.

View file

@ -4,6 +4,7 @@
ARG USE_CUDA=false ARG USE_CUDA=false
ARG USE_OLLAMA=false ARG USE_OLLAMA=false
ARG USE_SLIM=false ARG USE_SLIM=false
ARG USE_PERMISSION_HARDENING=false
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default) # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
ARG USE_CUDA_VER=cu128 ARG USE_CUDA_VER=cu128
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
@ -25,6 +26,9 @@ ARG GID=0
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
ARG BUILD_HASH 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 WORKDIR /app
# to store git revision in build # to store git revision in build
@ -45,6 +49,7 @@ ARG USE_CUDA
ARG USE_OLLAMA ARG USE_OLLAMA
ARG USE_CUDA_VER ARG USE_CUDA_VER
ARG USE_SLIM ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL ARG USE_RERANKING_MODEL
ARG UID ARG UID
@ -123,7 +128,6 @@ RUN apt-get update && \
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install --no-cache-dir uv && \ RUN pip3 install --no-cache-dir uv && \
if [ "$USE_SLIM" != "true" ]; then \
if [ "$USE_CUDA" = "true" ]; then \ if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use # If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
@ -134,17 +138,17 @@ RUN pip3 install --no-cache-dir uv && \
else \ else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ 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 && \ 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 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; 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'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
fi; \ fi; \
else \
uv pip install --system -r requirements.txt --no-cache-dir; \
fi; \ fi; \
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
rm -rf /var/lib/apt/lists/*;
# Install Ollama if requested # Install Ollama if requested
RUN if [ "$USE_OLLAMA" = "true" ] && [ "$USE_SLIM" != "true" ]; then \ RUN if [ "$USE_OLLAMA" = "true" ]; then \
date +%s > /tmp/ollama_build_hash && \ date +%s > /tmp/ollama_build_hash && \
echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \ echo "Cache broken at timestamp: `cat /tmp/ollama_build_hash`" && \
curl -fsSL https://ollama.com/install.sh | sh && \ curl -fsSL https://ollama.com/install.sh | sh && \
@ -170,11 +174,13 @@ HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq
# Minimal, atomic permission hardening for OpenShift (arbitrary UID): # Minimal, atomic permission hardening for OpenShift (arbitrary UID):
# - Group 0 owns /app and /root # - Group 0 owns /app and /root
# - Directories are group-writable and have SGID so new files inherit GID 0 # - Directories are group-writable and have SGID so new files inherit GID 0
RUN set -eux; \ RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
set -eux; \
chgrp -R 0 /app /root || true; \ chgrp -R 0 /app /root || true; \
chmod -R g+rwX /app /root || true; \ chmod -R g+rwX /app /root || true; \
find /app -type d -exec chmod g+s {} + || true; \ find /app -type d -exec chmod g+s {} + || true; \
find /root -type d -exec chmod g+s {} + || true find /root -type d -exec chmod g+s {} + || true; \
fi
USER $UID:$GID USER $UID:$GID

View file

@ -313,7 +313,7 @@ JWT_EXPIRES_IN = PersistentConfig(
#################################### ####################################
ENABLE_OAUTH_PERSISTENT_CONFIG = ( ENABLE_OAUTH_PERSISTENT_CONFIG = (
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true" os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true"
) )
ENABLE_OAUTH_SIGNUP = PersistentConfig( ENABLE_OAUTH_SIGNUP = PersistentConfig(
@ -660,7 +660,7 @@ def load_oauth_providers():
if ( if (
OAUTH_CLIENT_ID.value 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 and OPENID_PROVIDER_URL.value
): ):
@ -1998,6 +1998,9 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536") os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
) )
PGVECTOR_CREATE_EXTENSION = (
os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true"
)
PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true" PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true"
PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None) PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None)
if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY: if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
@ -2229,6 +2232,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
) )
DOCLING_DO_OCR = PersistentConfig(
"DOCLING_DO_OCR",
"rag.docling_do_ocr",
os.getenv("DOCLING_DO_OCR", "True").lower() == "true",
)
DOCLING_FORCE_OCR = PersistentConfig(
"DOCLING_FORCE_OCR",
"rag.docling_force_ocr",
os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true",
)
DOCLING_OCR_ENGINE = PersistentConfig( DOCLING_OCR_ENGINE = PersistentConfig(
"DOCLING_OCR_ENGINE", "DOCLING_OCR_ENGINE",
"rag.docling_ocr_engine", "rag.docling_ocr_engine",
@ -2241,6 +2256,24 @@ DOCLING_OCR_LANG = PersistentConfig(
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"), os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
) )
DOCLING_PDF_BACKEND = PersistentConfig(
"DOCLING_PDF_BACKEND",
"rag.docling_pdf_backend",
os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"),
)
DOCLING_TABLE_MODE = PersistentConfig(
"DOCLING_TABLE_MODE",
"rag.docling_table_mode",
os.getenv("DOCLING_TABLE_MODE", "accurate"),
)
DOCLING_PIPELINE = PersistentConfig(
"DOCLING_PIPELINE",
"rag.docling_pipeline",
os.getenv("DOCLING_PIPELINE", "standard"),
)
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig( DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
"DOCLING_DO_PICTURE_DESCRIPTION", "DOCLING_DO_PICTURE_DESCRIPTION",
"rag.docling_do_picture_description", "rag.docling_do_picture_description",
@ -3097,6 +3130,12 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
"image_generation.openai.api_base_url", "image_generation.openai.api_base_url",
os.getenv("IMAGES_OPENAI_API_BASE_URL", 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 = PersistentConfig(
"IMAGES_OPENAI_API_KEY", "IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_key", "image_generation.openai.api_key",

View file

@ -465,6 +465,19 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true" os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
) )
####################################
# OAUTH Configuration
####################################
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
)
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
#################################### ####################################
# SCIM Configuration # SCIM Configuration

View file

@ -219,6 +219,15 @@ async def generate_function_chat_completion(
__task__ = metadata.get("task", None) __task__ = metadata.get("task", None)
__task_body__ = metadata.get("task_body", None) __task_body__ = metadata.get("task_body", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = { extra_params = {
"__event_emitter__": __event_emitter__, "__event_emitter__": __event_emitter__,
"__event_call__": __event_call__, "__event_call__": __event_call__,
@ -230,6 +239,7 @@ async def generate_function_chat_completion(
"__files__": files, "__files__": files,
"__user__": user.model_dump() if isinstance(user, UserModel) else {}, "__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata, "__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request, "__request__": request,
} }
extra_params["__tools__"] = await get_tools( extra_params["__tools__"] = await get_tools(

View file

@ -157,6 +157,7 @@ from open_webui.config import (
IMAGE_SIZE, IMAGE_SIZE,
IMAGE_STEPS, IMAGE_STEPS,
IMAGES_OPENAI_API_BASE_URL, IMAGES_OPENAI_API_BASE_URL,
IMAGES_OPENAI_API_VERSION,
IMAGES_OPENAI_API_KEY, IMAGES_OPENAI_API_KEY,
IMAGES_GEMINI_API_BASE_URL, IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY, IMAGES_GEMINI_API_KEY,
@ -243,8 +244,13 @@ from open_webui.config import (
EXTERNAL_DOCUMENT_LOADER_API_KEY, EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL, TIKA_SERVER_URL,
DOCLING_SERVER_URL, DOCLING_SERVER_URL,
DOCLING_DO_OCR,
DOCLING_FORCE_OCR,
DOCLING_OCR_ENGINE, DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG, DOCLING_OCR_LANG,
DOCLING_PDF_BACKEND,
DOCLING_TABLE_MODE,
DOCLING_PIPELINE,
DOCLING_DO_PICTURE_DESCRIPTION, DOCLING_DO_PICTURE_DESCRIPTION,
DOCLING_PICTURE_DESCRIPTION_MODE, DOCLING_PICTURE_DESCRIPTION_MODE,
DOCLING_PICTURE_DESCRIPTION_LOCAL, DOCLING_PICTURE_DESCRIPTION_LOCAL,
@ -591,6 +597,7 @@ app = FastAPI(
) )
oauth_manager = OAuthManager(app) oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager
app.state.instance_id = None app.state.instance_id = None
app.state.config = AppConfig( app.state.config = AppConfig(
@ -810,8 +817,13 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND
app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE
app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE 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_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
@ -1019,6 +1031,7 @@ app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL app.state.config.IMAGES_OPENAI_API_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_KEY = IMAGES_OPENAI_API_KEY
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
@ -1405,6 +1418,14 @@ async def chat_completion(
model_item = form_data.pop("model_item", {}) model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None) tasks = form_data.pop("background_tasks", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id, request.cookies.get("oauth_session_id", None)
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
metadata = {} metadata = {}
try: try:
if not model_item.get("direct", False): if not model_item.get("direct", False):
@ -1519,7 +1540,7 @@ async def chat_completion(
try: try:
event_emitter = get_event_emitter(metadata) event_emitter = get_event_emitter(metadata)
await event_emitter( await event_emitter(
{"type": "task-cancelled"}, {"type": "chat:tasks:cancel"},
) )
except Exception as e: except Exception as e:
pass pass
@ -1535,14 +1556,21 @@ async def chat_completion(
"error": {"content": str(e)}, "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: except:
pass pass
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
if ( if (
metadata.get("session_id") metadata.get("session_id")
and metadata.get("chat_id") and metadata.get("chat_id")
@ -1642,8 +1670,18 @@ async def list_tasks_by_chat_id_endpoint(
@app.get("/api/config") @app.get("/api/config")
async def get_app_config(request: Request): async def get_app_config(request: Request):
user = None 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") token = request.cookies.get("token")
if token:
try: try:
data = decode_token(token) data = decode_token(token)
except Exception as e: except Exception as e:

View file

@ -0,0 +1,52 @@
"""Add oauth_session table
Revision ID: 38d63c18f30f
Revises: 3af16a1c9fb6
Create Date: 2025-09-08 14:19:59.583921
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "38d63c18f30f"
down_revision: Union[str, None] = "3af16a1c9fb6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create oauth_session table
op.create_table(
"oauth_session",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
)
# Create indexes for better performance
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
op.create_index(
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
)
def downgrade() -> None:
# Drop indexes first
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
# Drop the table
op.drop_table("oauth_session")

View file

@ -147,6 +147,15 @@ class FilesTable:
with get_db() as db: with get_db() as db:
return [FileModel.model_validate(file) for file in db.query(File).all()] 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]: def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db: with get_db() as db:
return [ return [

View file

@ -58,6 +58,14 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel): class FolderForm(BaseModel):
name: str name: str
data: Optional[dict] = None 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") model_config = ConfigDict(extra="allow")
@ -191,7 +199,7 @@ class FolderTable:
return return
def update_folder_by_id_and_user_id( def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderForm self, id: str, user_id: str, form_data: FolderUpdateForm
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db() as db:
@ -222,8 +230,13 @@ class FolderTable:
**form_data["data"], **form_data["data"],
} }
folder.updated_at = int(time.time()) if "meta" in form_data:
folder.meta = {
**(folder.meta or {}),
**form_data["meta"],
}
folder.updated_at = int(time.time())
db.commit() db.commit()
return FolderModel.model_validate(folder) return FolderModel.model_validate(folder)

View file

@ -54,6 +54,22 @@ class FunctionModel(BaseModel):
model_config = ConfigDict(from_attributes=True) 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 # Forms
#################### ####################
@ -111,8 +127,8 @@ class FunctionsTable:
return None return None
def sync_functions( def sync_functions(
self, user_id: str, functions: list[FunctionModel] self, user_id: str, functions: list[FunctionWithValvesModel]
) -> list[FunctionModel]: ) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present. # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try: try:
with get_db() as db: with get_db() as db:
@ -166,17 +182,24 @@ class FunctionsTable:
except Exception: except Exception:
return None 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: with get_db() as db:
if active_only: if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
else:
functions = db.query(Function).all()
if include_valves:
return [ return [
FunctionModel.model_validate(function) FunctionWithValvesModel.model_validate(function)
for function in db.query(Function).filter_by(is_active=True).all() for function in functions
] ]
else: else:
return [ return [
FunctionModel.model_validate(function) FunctionModel.model_validate(function) for function in functions
for function in db.query(Function).all()
] ]
def get_functions_by_type( def get_functions_by_type(

View file

@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import FileMetadataResponse from open_webui.models.files import FileMetadataResponse
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
@ -128,11 +129,18 @@ class KnowledgeTable:
def get_knowledge_bases(self) -> list[KnowledgeUserModel]: def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
with get_db() as db: with get_db() as db:
knowledge_bases = [] all_knowledge = (
for knowledge in (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() 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( knowledge_bases.append(
KnowledgeUserModel.model_validate( KnowledgeUserModel.model_validate(
{ {
@ -143,15 +151,27 @@ class KnowledgeTable:
) )
return knowledge_bases 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( def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write"
) -> list[KnowledgeUserModel]: ) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases() knowledge_bases = self.get_knowledge_bases()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
knowledge_base knowledge_base
for knowledge_base in knowledge_bases for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id 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]: def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:

View file

@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
@ -175,9 +176,16 @@ class ModelsTable:
def get_models(self) -> list[ModelUserResponse]: def get_models(self) -> list[ModelUserResponse]:
with get_db() as db: 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 = [] models = []
for model in db.query(Model).filter(Model.base_model_id != None).all(): for model in all_models:
user = Users.get_user_by_id(model.user_id) user = users_dict.get(model.user_id)
models.append( models.append(
ModelUserResponse.model_validate( ModelUserResponse.model_validate(
{ {
@ -199,11 +207,12 @@ class ModelsTable:
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write"
) -> list[ModelUserResponse]: ) -> list[ModelUserResponse]:
models = self.get_models() models = self.get_models()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
model model
for model in models for model in models
if model.user_id == user_id if model.user_id == user_id
or has_access(user_id, permission, model.access_control) or has_access(user_id, permission, model.access_control, user_group_ids)
] ]
def get_model_by_id(self, id: str) -> Optional[ModelModel]: def get_model_by_id(self, id: str) -> Optional[ModelModel]:

View file

@ -4,6 +4,7 @@ import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db 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.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
@ -105,11 +106,12 @@ class NoteTable:
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write"
) -> list[NoteModel]: ) -> list[NoteModel]:
notes = self.get_notes() notes = self.get_notes()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
note note
for note in notes for note in notes
if note.user_id == user_id if note.user_id == user_id
or has_access(user_id, permission, note.access_control) or has_access(user_id, permission, note.access_control, user_group_ids)
] ]
def get_note_by_id(self, id: str) -> Optional[NoteModel]: def get_note_by_id(self, id: str) -> Optional[NoteModel]:

View file

@ -0,0 +1,246 @@
import time
import logging
import uuid
from typing import Optional, List
import base64
import hashlib
import json
from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# DB MODEL
####################
class OAuthSession(Base):
__tablename__ = "oauth_session"
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False)
token = Column(
Text, nullable=False
) # JSON with access_token, id_token, refresh_token
expires_at = Column(BigInteger, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
# Add indexes for better performance
__table_args__ = (
Index("idx_oauth_session_user_id", "user_id"),
Index("idx_oauth_session_expires_at", "expires_at"),
Index("idx_oauth_session_user_provider", "user_id", "provider"),
)
class OAuthSessionModel(BaseModel):
id: str
user_id: str
provider: str
token: dict
expires_at: int # timestamp in epoch
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class OAuthSessionResponse(BaseModel):
id: str
user_id: str
provider: str
expires_at: int
class OAuthSessionTable:
def __init__(self):
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
if not self.encryption_key:
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
if len(self.encryption_key) != 44:
key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
self.encryption_key = base64.urlsafe_b64encode(key_bytes)
else:
self.encryption_key = self.encryption_key.encode()
try:
self.fernet = Fernet(self.encryption_key)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
raise
def _encrypt_token(self, token) -> str:
"""Encrypt OAuth tokens for storage"""
try:
token_json = json.dumps(token)
encrypted = self.fernet.encrypt(token_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting tokens: {e}")
raise
def _decrypt_token(self, token: str):
"""Decrypt OAuth tokens from storage"""
try:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {e}")
raise
def create_session(
self,
user_id: str,
provider: str,
token: dict,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db() as db:
current_time = int(time.time())
id = str(uuid.uuid4())
result = OAuthSession(
**{
"id": id,
"user_id": user_id,
"provider": provider,
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"created_at": current_time,
"updated_at": current_time,
}
)
db.add(result)
db.commit()
db.refresh(result)
if result:
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
else:
return None
except Exception as e:
log.error(f"Error creating OAuth session: {e}")
return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db() as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db() as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
.first()
)
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db() as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = []
for session in sessions:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
return results
except Exception as e:
log.error(f"Error getting OAuth sessions by user ID: {e}")
return []
def update_session_by_id(
self, session_id: str, token: dict
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db() as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
{
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"updated_at": current_time,
}
)
db.commit()
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error updating OAuth session tokens: {e}")
return None
def delete_session_by_id(self, session_id: str) -> bool:
"""Delete an OAuth session"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
except Exception as e:
log.error(f"Error deleting OAuth session: {e}")
return False
def delete_sessions_by_user_id(self, user_id: str) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
return True
except Exception as e:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False
OAuthSessions = OAuthSessionTable()

View file

@ -2,6 +2,7 @@ import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db 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 open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -103,10 +104,16 @@ class PromptsTable:
def get_prompts(self) -> list[PromptUserResponse]: def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db: 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_ids = list(set(prompt.user_id for prompt in all_prompts))
user = Users.get_user_by_id(prompt.user_id)
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( prompts.append(
PromptUserResponse.model_validate( PromptUserResponse.model_validate(
{ {
@ -122,12 +129,13 @@ class PromptsTable:
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write"
) -> list[PromptUserResponse]: ) -> list[PromptUserResponse]:
prompts = self.get_prompts() prompts = self.get_prompts()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
prompt prompt
for prompt in prompts for prompt in prompts
if prompt.user_id == user_id 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( def update_prompt_by_command(

View file

@ -4,6 +4,8 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON from sqlalchemy import BigInteger, Column, String, Text, JSON
@ -144,9 +146,16 @@ class ToolsTable:
def get_tools(self) -> list[ToolUserModel]: def get_tools(self) -> list[ToolUserModel]:
with get_db() as db: 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 = [] tools = []
for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all(): for tool in all_tools:
user = Users.get_user_by_id(tool.user_id) user = users_dict.get(tool.user_id)
tools.append( tools.append(
ToolUserModel.model_validate( ToolUserModel.model_validate(
{ {
@ -161,12 +170,13 @@ class ToolsTable:
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write"
) -> list[ToolUserModel]: ) -> list[ToolUserModel]:
tools = self.get_tools() tools = self.get_tools()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [ return [
tool tool
for tool in tools for tool in tools
if tool.user_id == user_id 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]: def get_tool_valves_by_id(self, id: str) -> Optional[dict]:

View file

@ -1,6 +1,7 @@
import requests import requests
import logging, os import logging, os
from typing import Iterator, List, Union from typing import Iterator, List, Union
from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document from langchain_core.documents import Document
@ -37,7 +38,7 @@ class ExternalDocumentLoader(BaseLoader):
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
try: try:
headers["X-Filename"] = os.path.basename(self.file_path) headers["X-Filename"] = quote(os.path.basename(self.file_path))
except: except:
pass pass

View file

@ -148,7 +148,7 @@ class DoclingLoader:
) )
} }
params = {"image_export_mode": "placeholder", "table_mode": "accurate"} params = {"image_export_mode": "placeholder"}
if self.params: if self.params:
if self.params.get("do_picture_description"): if self.params.get("do_picture_description"):
@ -174,7 +174,15 @@ class DoclingLoader:
self.params.get("picture_description_api", {}) self.params.get("picture_description_api", {})
) )
if self.params.get("ocr_engine") and self.params.get("ocr_lang"): params["do_ocr"] = self.params.get("do_ocr")
params["force_ocr"] = self.params.get("force_ocr")
if (
self.params.get("do_ocr")
and self.params.get("ocr_engine")
and self.params.get("ocr_lang")
):
params["ocr_engine"] = self.params.get("ocr_engine") params["ocr_engine"] = self.params.get("ocr_engine")
params["ocr_lang"] = [ params["ocr_lang"] = [
lang.strip() lang.strip()
@ -182,6 +190,15 @@ class DoclingLoader:
if lang.strip() if lang.strip()
] ]
if self.params.get("pdf_backend"):
params["pdf_backend"] = self.params.get("pdf_backend")
if self.params.get("table_mode"):
params["table_mode"] = self.params.get("table_mode")
if self.params.get("pipeline"):
params["pipeline"] = self.params.get("pipeline")
endpoint = f"{self.url}/v1/convert/file" endpoint = f"{self.url}/v1/convert/file"
r = requests.post(endpoint, files=files, data=params) r = requests.post(endpoint, files=files, data=params)

View file

@ -98,10 +98,9 @@ class YoutubeLoader:
else: else:
youtube_proxies = None youtube_proxies = None
transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
try: try:
transcript_list = YouTubeTranscriptApi.list_transcripts( transcript_list = transcript_api.list(self.video_id)
self.video_id, proxies=youtube_proxies
)
except Exception as e: except Exception as e:
log.exception("Loading YouTube transcript failed") log.exception("Loading YouTube transcript failed")
return [] return []

View file

@ -128,14 +128,13 @@ def query_doc_with_hybrid_search(
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []} return {"documents": [], "metadatas": [], "distances": []}
# BM_25 required only if weight is greater than 0 log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
if hybrid_bm25_weight > 0:
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") bm25_retriever = BM25Retriever.from_texts(
bm25_retriever = BM25Retriever.from_texts( texts=collection_result.documents[0],
texts=collection_result.documents[0], metadatas=collection_result.metadatas[0],
metadatas=collection_result.metadatas[0], )
) bm25_retriever.k = k
bm25_retriever.k = k
vector_search_retriever = VectorSearchRetriever( vector_search_retriever = VectorSearchRetriever(
collection_name=collection_name, collection_name=collection_name,
@ -343,22 +342,18 @@ def query_collection_with_hybrid_search(
# Fetch collection data once per collection sequentially # Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later # Avoid fetching the same data multiple times later
collection_results = {} collection_results = {}
# Only retrieve entire collection if bm_25 calculation is required for collection_name in collection_names:
if hybrid_bm25_weight > 0: try:
for collection_name in collection_names: log.debug(
try: f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
log.debug( )
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}" collection_results[collection_name] = VECTOR_DB_CLIENT.get(
) collection_name=collection_name
collection_results[collection_name] = VECTOR_DB_CLIENT.get( )
collection_name=collection_name except Exception as e:
) log.exception(f"Failed to fetch collection {collection_name}: {e}")
except Exception as e: collection_results[collection_name] = None
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
else:
for collection_name in collection_names:
collection_results[collection_name] = []
log.info( log.info(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
) )
@ -493,17 +488,18 @@ def get_sources_from_items(
if item.get("type") == "text": if item.get("type") == "text":
# Raw Text # Raw Text
# Used during temporary chat file uploads # Used during temporary chat file uploads or web page & youtube attachements
if item.get("file"): if item.get("collection_name"):
# If item has a collection name, use it
collection_names.append(item.get("collection_name"))
elif item.get("file"):
# if item has file data, use it # if item has file data, use it
query_result = { query_result = {
"documents": [ "documents": [
[item.get("file", {}).get("data", {}).get("content")] [item.get("file", {}).get("data", {}).get("content")]
], ],
"metadatas": [ "metadatas": [[item.get("file", {}).get("meta", {})]],
[item.get("file", {}).get("data", {}).get("meta", {})]
],
} }
else: else:
# Fallback to item content # Fallback to item content

View file

@ -37,6 +37,7 @@ from open_webui.retrieval.vector.main import (
from open_webui.config import ( from open_webui.config import (
PGVECTOR_DB_URL, PGVECTOR_DB_URL,
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
PGVECTOR_CREATE_EXTENSION,
PGVECTOR_PGCRYPTO, PGVECTOR_PGCRYPTO,
PGVECTOR_PGCRYPTO_KEY, PGVECTOR_PGCRYPTO_KEY,
PGVECTOR_POOL_SIZE, PGVECTOR_POOL_SIZE,
@ -112,18 +113,19 @@ class PgvectorClient(VectorDBBase):
try: try:
# Ensure the pgvector extension is available # Ensure the pgvector extension is available
# Use a conditional check to avoid permission issues on Azure PostgreSQL # Use a conditional check to avoid permission issues on Azure PostgreSQL
self.session.execute( if PGVECTOR_CREATE_EXTENSION:
text( self.session.execute(
""" text(
DO $$ """
BEGIN DO $$
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN BEGIN
CREATE EXTENSION IF NOT EXISTS vector; IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
END IF; CREATE EXTENSION IF NOT EXISTS vector;
END $$; END IF;
""" END $$;
"""
)
) )
)
if PGVECTOR_PGCRYPTO: if PGVECTOR_PGCRYPTO:
# Ensure the pgcrypto extension is available for encryption # Ensure the pgcrypto extension is available for encryption

View file

@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get( async with session.get(
url, url,
**(self.requests_kwargs | kwargs), **(self.requests_kwargs | kwargs),
allow_redirects=False,
) as response: ) as response:
if self.raise_for_status: if self.raise_for_status:
response.raise_for_status() response.raise_for_status()

View file

@ -4,7 +4,6 @@ import logging
import os import os
import uuid import uuid
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from pydub import AudioSegment from pydub import AudioSegment
from pydub.silence import split_on_silence from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@ -15,7 +14,7 @@ import aiohttp
import aiofiles import aiofiles
import requests import requests
import mimetypes import mimetypes
from urllib.parse import quote from urllib.parse import urljoin, quote
from fastapi import ( from fastapi import (
Depends, Depends,
@ -338,7 +337,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
timeout=timeout, trust_env=True timeout=timeout, trust_env=True
) as session: ) as session:
r = await session.post( r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", url=urljoin(
request.app.state.config.TTS_OPENAI_API_BASE_URL,
"/audio/speech",
),
json=payload, json=payload,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@ -466,8 +468,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
timeout=timeout, trust_env=True timeout=timeout, trust_env=True
) as session: ) as session:
async with session.post( async with session.post(
(base_url or f"https://{region}.tts.speech.microsoft.com") urljoin(
+ "/cognitiveservices/v1", base_url or f"https://{region}.tts.speech.microsoft.com",
"/cognitiveservices/v1",
),
headers={ headers={
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY,
"Content-Type": "application/ssml+xml", "Content-Type": "application/ssml+xml",

View file

@ -19,6 +19,7 @@ from open_webui.models.auths import (
) )
from open_webui.models.users import Users, UpdateProfileForm from open_webui.models.users import Users, UpdateProfileForm
from open_webui.models.groups import Groups 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.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import ( from open_webui.env import (
@ -676,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
async def signout(request: Request, response: Response): async def signout(request: Request, response: Response):
response.delete_cookie("token") response.delete_cookie("token")
response.delete_cookie("oui-session") response.delete_cookie("oui-session")
response.delete_cookie("oauth_id_token")
if ENABLE_OAUTH_SIGNUP.value: oauth_session_id = request.cookies.get("oauth_session_id")
oauth_id_token = request.cookies.get("oauth_id_token") if oauth_session_id:
if oauth_id_token and OPENID_PROVIDER_URL.value: 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: try:
async with ClientSession(trust_env=True) as session: async with ClientSession(trust_env=True) as session:
async with session.get(OPENID_PROVIDER_URL.value) as resp: async with session.get(oauth_server_metadata_url) as r:
if resp.status == 200: if r.status == 200:
openid_data = await resp.json() openid_data = await r.json()
logout_url = openid_data.get("end_session_endpoint") logout_url = openid_data.get("end_session_endpoint")
if logout_url:
response.delete_cookie("oauth_id_token")
if logout_url:
return JSONResponse( return JSONResponse(
status_code=200, status_code=200,
content={ content={
@ -703,15 +714,14 @@ async def signout(request: Request, response: Response):
headers=response.headers, headers=response.headers,
) )
else: else:
raise HTTPException( raise Exception("Failed to fetch OpenID configuration")
status_code=resp.status,
detail="Failed to fetch OpenID configuration",
)
except Exception as e: except Exception as e:
log.error(f"OpenID signout error: {str(e)}") log.error(f"OpenID signout error: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Failed to sign out from the OpenID provider.", detail="Failed to sign out from the OpenID provider.",
headers=response.headers,
) )
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL: if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:

View file

@ -411,25 +411,28 @@ async def get_file_process_status(
MAX_FILE_PROCESSING_DURATION = 3600 * 2 MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_item): async def event_stream(file_item):
for _ in range(MAX_FILE_PROCESSING_DURATION): if file_item:
file_item = Files.get_file_by_id(file_item.id) for _ in range(MAX_FILE_PROCESSING_DURATION):
if file_item: file_item = Files.get_file_by_id(file_item.id)
data = file_item.model_dump().get("data", {}) if file_item:
status = data.get("status") data = file_item.model_dump().get("data", {})
status = data.get("status")
if status: if status:
event = {"status": status} event = {"status": status}
if status == "failed": if status == "failed":
event["error"] = data.get("error") event["error"] = data.get("error")
yield f"data: {json.dumps(event)}\n\n" yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"): if status in ("completed", "failed"):
break
else:
# Legacy
break break
else:
# Legacy
break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
return StreamingResponse( return StreamingResponse(
event_stream(file), event_stream(file),

View file

@ -10,10 +10,14 @@ import mimetypes
from open_webui.models.folders import ( from open_webui.models.folders import (
FolderForm, FolderForm,
FolderUpdateForm,
FolderModel, FolderModel,
Folders, Folders,
) )
from open_webui.models.chats import Chats 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.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -44,6 +48,31 @@ router = APIRouter()
async def get_folders(user=Depends(get_verified_user)): async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id) folders = Folders.get_folders_by_user_id(user.id)
# Verify folder data integrity
for folder in folders:
if folder.data:
if "files" in folder.data:
valid_files = []
for file in folder.data["files"]:
if file.get("type") == "file":
if Files.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
else:
valid_files.append(file)
folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data)
)
return [ return [
{ {
**folder.model_dump(), **folder.model_dump(),
@ -113,22 +142,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update") @router.post("/{id}/update")
async def update_folder_name_by_id( 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) folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder: if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name if form_data.name is not None:
) # Check if folder with same name exists
if existing_folder and existing_folder.id != id: existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
raise HTTPException( folder.parent_id, user.id, form_data.name
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
) )
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: try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data) folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
return folder return folder
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)

View file

@ -10,6 +10,7 @@ from open_webui.models.functions import (
FunctionForm, FunctionForm,
FunctionModel, FunctionModel,
FunctionResponse, FunctionResponse,
FunctionWithValvesModel,
Functions, Functions,
) )
from open_webui.utils.plugin import ( from open_webui.utils.plugin import (
@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)):
############################ ############################
@router.get("/export", response_model=list[FunctionModel]) @router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(user=Depends(get_admin_user)): async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
return Functions.get_functions() return Functions.get_functions(include_valves=include_valves)
############################ ############################
@ -132,10 +133,10 @@ async def load_function_from_url(
class SyncFunctionsForm(BaseModel): class SyncFunctionsForm(BaseModel):
functions: list[FunctionModel] = [] functions: list[FunctionWithValvesModel] = []
@router.post("/sync", response_model=list[FunctionModel]) @router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions( async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
): ):

View file

@ -48,6 +48,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": { "openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
}, },
"automatic1111": { "automatic1111": {
@ -72,6 +73,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
class OpenAIConfigForm(BaseModel): class OpenAIConfigForm(BaseModel):
OPENAI_API_BASE_URL: str OPENAI_API_BASE_URL: str
OPENAI_API_VERSION: str
OPENAI_API_KEY: str OPENAI_API_KEY: str
@ -119,6 +121,9 @@ async def update_config(
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = ( request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL form_data.openai.OPENAI_API_BASE_URL
) )
request.app.state.config.IMAGES_OPENAI_API_VERSION = (
form_data.openai.OPENAI_API_VERSION
)
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = ( request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
@ -165,6 +170,7 @@ async def update_config(
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, "prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": { "openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, "OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, "OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
}, },
"automatic1111": { "automatic1111": {
@ -544,10 +550,16 @@ async def image_generations(
), ),
} }
api_version_query_param = ""
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
api_version_query_param = (
f"?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}"
)
# Use asyncio.to_thread for the requests.post call # Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread( r = await asyncio.to_thread(
requests.post, requests.post,
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations", url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations{api_version_query_param}",
json=data, json=data,
headers=headers, headers=headers,
) )

View file

@ -1,6 +1,6 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
import logging import logging
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
@ -151,6 +151,18 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED, 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) knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge: if knowledge:
@ -285,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse]) @router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_by_id( async def update_knowledge_by_id(
request: Request,
id: str, id: str,
form_data: KnowledgeForm, form_data: KnowledgeForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@ -306,10 +319,22 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 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) knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge: if knowledge:
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] 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( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
@ -492,6 +517,7 @@ def update_file_from_knowledge_by_id(
def remove_file_from_knowledge_by_id( def remove_file_from_knowledge_by_id(
id: str, id: str,
form_data: KnowledgeFileIdForm, form_data: KnowledgeFileIdForm,
delete_file: bool = Query(True),
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id)
@ -528,18 +554,19 @@ def remove_file_from_knowledge_by_id(
log.debug(e) log.debug(e)
pass pass
try: if delete_file:
# Remove the file's collection from vector database try:
file_collection = f"file-{form_data.file_id}" # Remove the file's collection from vector database
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): file_collection = f"file-{form_data.file_id}"
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
except Exception as e: VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
log.debug("This was most likely caused by bypassing embedding processing") except Exception as e:
log.debug(e) log.debug("This was most likely caused by bypassing embedding processing")
pass log.debug(e)
pass
# Delete file from database # Delete file from database
Files.delete_file_by_id(form_data.file_id) Files.delete_file_by_id(form_data.file_id)
if knowledge: if knowledge:
data = knowledge.data or {} data = knowledge.data or {}

View file

@ -340,7 +340,10 @@ def merge_ollama_models_lists(model_lists):
return list(merged_models.values()) return list(merged_models.values())
@cached(ttl=MODELS_CACHE_TTL) @cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
)
async def get_all_models(request: Request, user: UserModel = None): async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()") log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API: if request.app.state.config.ENABLE_OLLAMA_API:

View file

@ -119,6 +119,74 @@ def openai_reasoning_model_handler(payload):
return payload return payload
def get_headers_and_cookies(
request: Request,
url,
key=None,
config=None,
metadata: Optional[dict] = None,
user: UserModel = None,
):
cookies = {}
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
token = None
auth_type = config.get("auth_type")
if auth_type == "bearer" or auth_type is None:
# Default to bearer if not specified
token = f"{key}"
elif auth_type == "none":
token = None
elif auth_type == "session":
cookies = request.cookies
token = request.state.token.credentials
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
if oauth_token:
token = f"{oauth_token.get('access_token', '')}"
if token:
headers["Authorization"] = f"Bearer {token}"
return headers, cookies
########################################## ##########################################
# #
# API routes # API routes
@ -210,34 +278,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path) return FileResponse(file_path)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx] url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
r = None r = None
try: try:
r = requests.post( r = requests.post(
url=f"{url}/audio/speech", url=f"{url}/audio/speech",
data=body, data=body,
headers={ headers=headers,
"Content-Type": "application/json", cookies=cookies,
"Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
stream=True, stream=True,
) )
@ -401,7 +458,10 @@ async def get_filtered_models(models, user):
return filtered_models return filtered_models
@cached(ttl=MODELS_CACHE_TTL) @cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()") log.info("get_all_models()")
@ -489,19 +549,9 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session: ) as session:
try: try:
headers = { headers, cookies = get_headers_and_cookies(
"Content-Type": "application/json", request, url, key, api_config, user=user
**( )
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False): if api_config.get("azure", False):
models = { models = {
@ -509,11 +559,10 @@ async def get_models(
"object": "list", "object": "list",
} }
else: else:
headers["Authorization"] = f"Bearer {key}"
async with session.get( async with session.get(
f"{url}/models", f"{url}/models",
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r: ) as r:
if r.status != 200: if r.status != 200:
@ -572,7 +621,9 @@ class ConnectionVerificationForm(BaseModel):
@router.post("/verify") @router.post("/verify")
async def verify_connection( 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 url = form_data.url
key = form_data.key key = form_data.key
@ -584,19 +635,9 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session: ) as session:
try: try:
headers = { headers, cookies = get_headers_and_cookies(
"Content-Type": "application/json", request, url, key, api_config, user=user
**( )
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False): if api_config.get("azure", False):
headers["api-key"] = key headers["api-key"] = key
@ -605,6 +646,7 @@ async def verify_connection(
async with session.get( async with session.get(
url=f"{url}/openai/models?api-version={api_version}", url=f"{url}/openai/models?api-version={api_version}",
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r: ) as r:
try: try:
@ -624,11 +666,10 @@ async def verify_connection(
return response_data return response_data
else: else:
headers["Authorization"] = f"Bearer {key}"
async with session.get( async with session.get(
f"{url}/models", f"{url}/models",
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r: ) as r:
try: try:
@ -836,32 +877,9 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"]) convert_logit_bias_input_to_json(payload["logit_bias"])
) )
headers = { headers, cookies = get_headers_and_cookies(
"Content-Type": "application/json", request, url, key, api_config, metadata, user=user
**( )
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False): if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview") api_version = api_config.get("api_version", "2023-03-15-preview")
@ -871,7 +889,6 @@ async def generate_chat_completion(
request_url = f"{request_url}/chat/completions?api-version={api_version}" request_url = f"{request_url}/chat/completions?api-version={api_version}"
else: else:
request_url = f"{url}/chat/completions" request_url = f"{url}/chat/completions"
headers["Authorization"] = f"Bearer {key}"
payload = json.dumps(payload) payload = json.dumps(payload)
@ -890,6 +907,7 @@ async def generate_chat_completion(
url=request_url, url=request_url,
data=payload, data=payload,
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) )
@ -951,31 +969,27 @@ async def embeddings(request: Request, form_data: dict, user):
models = request.app.state.OPENAI_MODELS models = request.app.state.OPENAI_MODELS
if model_id in models: if model_id in models:
idx = models[model_id]["urlIdx"] idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx] url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[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 r = None
session = None session = None
streaming = False streaming = False
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
try: try:
session = aiohttp.ClientSession(trust_env=True) session = aiohttp.ClientSession(trust_env=True)
r = await session.request( r = await session.request(
method="POST", method="POST",
url=f"{url}/embeddings", url=f"{url}/embeddings",
data=body, data=body,
headers={ headers=headers,
"Authorization": f"Bearer {key}", cookies=cookies,
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
) )
if "text/event-stream" in r.headers.get("Content-Type", ""): if "text/event-stream" in r.headers.get("Content-Type", ""):
@ -1037,19 +1051,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False streaming = False
try: try:
headers = { headers, cookies = get_headers_and_cookies(
"Content-Type": "application/json", request, url, key, api_config, user=user
**( )
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
if api_config.get("azure", False): if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview") api_version = api_config.get("api_version", "2023-03-15-preview")
@ -1062,7 +1066,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
request_url = f"{url}/{path}?api-version={api_version}" request_url = f"{url}/{path}?api-version={api_version}"
else: else:
headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}" request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True) session = aiohttp.ClientSession(trust_env=True)
@ -1071,6 +1074,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
url=request_url, url=request_url,
data=body, data=body,
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL, ssl=AIOHTTP_CLIENT_SESSION_SSL,
) )

View file

@ -426,8 +426,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "DOCLING_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_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@ -596,8 +601,13 @@ class ConfigForm(BaseModel):
TIKA_SERVER_URL: Optional[str] = None TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None DOCLING_SERVER_URL: Optional[str] = None
DOCLING_DO_OCR: Optional[bool] = None
DOCLING_FORCE_OCR: Optional[bool] = None
DOCLING_OCR_ENGINE: Optional[str] = None DOCLING_OCR_ENGINE: Optional[str] = None
DOCLING_OCR_LANG: Optional[str] = None DOCLING_OCR_LANG: Optional[str] = None
DOCLING_PDF_BACKEND: Optional[str] = None
DOCLING_TABLE_MODE: Optional[str] = None
DOCLING_PIPELINE: Optional[str] = None
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
@ -767,6 +777,16 @@ async def update_rag_config(
if form_data.DOCLING_SERVER_URL is not None if form_data.DOCLING_SERVER_URL is not None
else request.app.state.config.DOCLING_SERVER_URL else request.app.state.config.DOCLING_SERVER_URL
) )
request.app.state.config.DOCLING_DO_OCR = (
form_data.DOCLING_DO_OCR
if form_data.DOCLING_DO_OCR is not None
else request.app.state.config.DOCLING_DO_OCR
)
request.app.state.config.DOCLING_FORCE_OCR = (
form_data.DOCLING_FORCE_OCR
if form_data.DOCLING_FORCE_OCR is not None
else request.app.state.config.DOCLING_FORCE_OCR
)
request.app.state.config.DOCLING_OCR_ENGINE = ( request.app.state.config.DOCLING_OCR_ENGINE = (
form_data.DOCLING_OCR_ENGINE form_data.DOCLING_OCR_ENGINE
if form_data.DOCLING_OCR_ENGINE is not None if form_data.DOCLING_OCR_ENGINE is not None
@ -777,7 +797,21 @@ async def update_rag_config(
if form_data.DOCLING_OCR_LANG is not None if form_data.DOCLING_OCR_LANG is not None
else request.app.state.config.DOCLING_OCR_LANG else request.app.state.config.DOCLING_OCR_LANG
) )
request.app.state.config.DOCLING_PDF_BACKEND = (
form_data.DOCLING_PDF_BACKEND
if form_data.DOCLING_PDF_BACKEND is not None
else request.app.state.config.DOCLING_PDF_BACKEND
)
request.app.state.config.DOCLING_TABLE_MODE = (
form_data.DOCLING_TABLE_MODE
if form_data.DOCLING_TABLE_MODE is not None
else request.app.state.config.DOCLING_TABLE_MODE
)
request.app.state.config.DOCLING_PIPELINE = (
form_data.DOCLING_PIPELINE
if form_data.DOCLING_PIPELINE is not None
else request.app.state.config.DOCLING_PIPELINE
)
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = ( request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
form_data.DOCLING_DO_PICTURE_DESCRIPTION form_data.DOCLING_DO_PICTURE_DESCRIPTION
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
@ -1062,8 +1096,13 @@ async def update_rag_config(
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG, "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "DOCLING_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_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@ -1453,8 +1492,13 @@ def process_file(
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL, TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL, DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={ DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE, "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG, "ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION, "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@ -1945,6 +1989,8 @@ async def process_web_search(
): ):
urls = [] urls = []
result_items = []
try: try:
logging.info( logging.info(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
@ -1966,6 +2012,7 @@ async def process_web_search(
if result: if result:
for item in result: for item in result:
if item and item.link: if item and item.link:
result_items.append(item)
urls.append(item.link) urls.append(item.link)
urls = list(dict.fromkeys(urls)) urls = list(dict.fromkeys(urls))
@ -2010,12 +2057,16 @@ async def process_web_search(
urls = [ urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source") doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader ] # 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: if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return { return {
"status": True, "status": True,
"collection_name": None, "collection_name": None,
"filenames": urls, "filenames": urls,
"items": result_items,
"docs": [ "docs": [
{ {
"content": doc.page_content, "content": doc.page_content,
@ -2048,6 +2099,7 @@ async def process_web_search(
return { return {
"status": True, "status": True,
"collection_names": [collection_name], "collection_names": [collection_name],
"items": result_items,
"filenames": urls, "filenames": urls,
"loaded_count": len(docs), "loaded_count": len(docs),
} }

View file

@ -4,6 +4,7 @@ from typing import Optional
import time import time
import re import re
import aiohttp import aiohttp
from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -71,11 +72,12 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
# Admin can see all tools # Admin can see all tools
return tools return tools
else: else:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
tools = [ tools = [
tool tool
for tool in tools for tool in tools
if tool.user_id == user.id 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

View file

@ -10,6 +10,8 @@ from pydantic import BaseModel
from open_webui.models.auths import Auths 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.groups import Groups
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.users import ( from open_webui.models.users import (
@ -340,6 +342,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
) )
@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict])
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
if sessions and len(sessions) > 0:
return sessions
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################ ############################
# GetUserProfileImageById # GetUserProfileImageById
############################ ############################

View file

@ -153,9 +153,9 @@ async def stop_task(redis, task_id: str):
# Optionally check if task_id still in Redis a few moments later for feedback? # 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}"} return {"status": True, "message": f"Stop signal sent for {task_id}"}
task = tasks.pop(task_id) task = tasks.pop(task_id, None)
if not task: 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 task.cancel() # Request task cancellation
try: try:

View file

@ -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.users import Users, UserModel
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
@ -109,12 +109,15 @@ def has_access(
user_id: str, user_id: str,
type: str = "write", type: str = "write",
access_control: Optional[dict] = None, access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
) -> bool: ) -> bool:
if access_control is None: if access_control is None:
return type == "read" return type == "read"
user_groups = Groups.get_groups_by_member_id(user_id) if user_group_ids is None:
user_group_ids = [group.id for group in user_groups] 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, {}) permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", []) permitted_group_ids = permission_access.get("group_ids", [])
permitted_user_ids = permission_access.get("user_ids", []) permitted_user_ids = permission_access.get("user_ids", [])

View file

@ -261,55 +261,67 @@ def get_current_user(
return user return user
# auth by jwt token # 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: try:
user = Users.get_user_by_id(data["id"]) try:
if user is None: data = decode_token(token)
except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN, detail="Invalid token",
) )
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: if data is not None and "id" in data:
trusted_email = request.headers.get( user = Users.get_user_by_id(data["id"])
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, "" if user is None:
).lower() raise HTTPException(
if trusted_email and user.email != trusted_email: status_code=status.HTTP_401_UNAUTHORIZED,
# Delete the token cookie detail=ERROR_MESSAGES.INVALID_TOKEN,
response.delete_cookie("token") )
# Delete OAuth token if present else:
if request.cookies.get("oauth_id_token"): if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
response.delete_cookie("oauth_id_token") trusted_email = request.headers.get(
raise HTTPException( WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
status_code=status.HTTP_401_UNAUTHORIZED, ).lower()
detail="User mismatch. Please sign in again.", 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 if request.cookies.get("oauth_id_token"):
current_span = trace.get_current_span() response.delete_cookie("oauth_id_token")
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 # Delete OAuth session if present
# to prevent blocking the request if request.cookies.get("oauth_session_id"):
if background_tasks: response.delete_cookie("oauth_session_id")
background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
return user raise e
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
def get_current_user_by_api_key(api_key: str): def get_current_user_by_api_key(api_key: str):

View file

@ -369,7 +369,7 @@ async def chat_web_search_handler(
"type": "status", "type": "status",
"data": { "data": {
"action": "web_search", "action": "web_search",
"description": "Generating search query", "description": "Searching the web",
"done": False, "done": False,
}, },
} }
@ -435,8 +435,8 @@ async def chat_web_search_handler(
{ {
"type": "status", "type": "status",
"data": { "data": {
"action": "web_search", "action": "web_search_queries_generated",
"description": "Searching the web", "queries": queries,
"done": False, "done": False,
}, },
} }
@ -487,6 +487,7 @@ async def chat_web_search_handler(
"action": "web_search", "action": "web_search",
"description": "Searched {{count}} sites", "description": "Searched {{count}} sites",
"urls": results["filenames"], "urls": results["filenames"],
"items": results.get("items", []),
"done": True, "done": True,
}, },
} }
@ -529,7 +530,7 @@ async def chat_image_generation_handler(
await __event_emitter__( await __event_emitter__(
{ {
"type": "status", "type": "status",
"data": {"description": "Generating an image", "done": False}, "data": {"description": "Creating image", "done": False},
} }
) )
@ -581,7 +582,7 @@ async def chat_image_generation_handler(
await __event_emitter__( await __event_emitter__(
{ {
"type": "status", "type": "status",
"data": {"description": "Generated an image", "done": True}, "data": {"description": "Image created", "done": True},
} }
) )
@ -624,8 +625,9 @@ async def chat_image_generation_handler(
async def chat_completion_files_handler( 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]]: ) -> tuple[dict, dict[str, list]]:
__event_emitter__ = extra_params["__event_emitter__"]
sources = [] sources = []
if files := body.get("metadata", {}).get("files", None): if files := body.get("metadata", {}).get("files", None):
@ -661,6 +663,17 @@ async def chat_completion_files_handler(
if len(queries) == 0: if len(queries) == 0:
queries = [get_last_user_message(body["messages"])] queries = [get_last_user_message(body["messages"])]
await __event_emitter__(
{
"type": "status",
"data": {
"action": "queries_generated",
"queries": queries,
"done": False,
},
}
)
try: try:
# Offload get_sources_from_items to a separate thread # Offload get_sources_from_items to a separate thread
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -697,6 +710,38 @@ async def chat_completion_files_handler(
log.debug(f"rag_contexts:sources: {sources}") 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} return body, {"sources": sources}
@ -770,6 +815,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
event_emitter = get_event_emitter(metadata) event_emitter = get_event_emitter(metadata)
event_call = get_event_call(metadata) event_call = get_event_call(metadata)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = { extra_params = {
"__event_emitter__": event_emitter, "__event_emitter__": event_emitter,
"__event_call__": event_call, "__event_call__": event_call,
@ -777,6 +831,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__metadata__": metadata, "__metadata__": metadata,
"__request__": request, "__request__": request,
"__model__": model, "__model__": model,
"__oauth_token__": oauth_token,
} }
# Initialize events to store additional event to be sent to the client # Initialize events to store additional event to be sent to the client
@ -885,7 +940,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params=extra_params, extra_params=extra_params,
) )
except Exception as e: except Exception as e:
raise Exception(f"Error: {e}") raise Exception(f"{e}")
features = form_data.pop("features", None) features = form_data.pop("features", None)
if features: if features:
@ -981,7 +1036,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.exception(e) log.exception(e)
try: 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", [])) sources.extend(flags.get("sources", []))
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@ -1284,118 +1341,134 @@ async def process_chat_response(
# Non-streaming response # Non-streaming response
if not isinstance(response, StreamingResponse): if not isinstance(response, StreamingResponse):
if event_emitter: if event_emitter:
if isinstance(response, dict) or isinstance(response, JSONResponse): try:
if isinstance(response, dict) or isinstance(response, JSONResponse):
if isinstance(response, list) and len(response) == 1:
# If the response is a single-item list, unwrap it #17213
response = response[0]
if isinstance(response, JSONResponse) and isinstance( if isinstance(response, JSONResponse) and isinstance(
response.body, bytes response.body, bytes
): ):
try: try:
response_data = json.loads(response.body.decode("utf-8")) response_data = json.loads(response.body.decode("utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
response_data = {"error": {"detail": "Invalid JSON response"}} response_data = {
else: "error": {"detail": "Invalid JSON response"}
response_data = response
if "error" in response_data:
error = response_data["error"].get("detail", response_data["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
if isinstance(error, str) or isinstance(error, dict):
await event_emitter(
{
"type": "chat:message:error",
"data": {"error": {"content": error}},
},
)
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
},
)
choices = response_data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response_data["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response_data,
} }
) else:
response_data = response
title = Chats.get_chat_title_by_id(metadata["chat_id"]) if "error" in response_data:
error = response_data.get("error")
await event_emitter( if isinstance(error, dict):
{ error = error.get("detail", error)
"type": "chat:completion", else:
"data": { error = str(error)
"done": True,
"content": content,
"title": title,
},
}
)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id( Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"], metadata["chat_id"],
metadata["message_id"], metadata["message_id"],
{ {
"role": "assistant", "error": {"content": error},
"content": content, },
)
if isinstance(error, str) or isinstance(error, dict):
await event_emitter(
{
"type": "chat:message:error",
"data": {"error": {"content": error}},
}
)
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
}, },
) )
# Send a webhook notification if the user is not active choices = response_data.get("choices", [])
if not get_active_status_by_user_id(user.id): if choices and choices[0].get("message", {}).get("content"):
webhook_url = Users.get_user_webhook_url_by_id(user.id) content = response_data["choices"][0]["message"]["content"]
if webhook_url:
await post_webhook( if content:
request.app.state.WEBUI_NAME, await event_emitter(
webhook_url, {
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", "type": "chat:completion",
{ "data": response_data,
"action": "chat", }
"message": content, )
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title, "title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
}, },
) }
)
await background_tasks_handler() # Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
if events and isinstance(events, list): # Send a webhook notification if the user is not active
extra_response = {} if not get_active_status_by_user_id(user.id):
for event in events: webhook_url = Users.get_user_webhook_url_by_id(user.id)
if isinstance(event, dict): if webhook_url:
extra_response.update(event) await post_webhook(
else: request.app.state.WEBUI_NAME,
extra_response[event] = True webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
response_data = { await background_tasks_handler()
**extra_response,
**response_data,
}
if isinstance(response, dict): if events and isinstance(events, list):
response = response_data extra_response = {}
if isinstance(response, JSONResponse): for event in events:
response = JSONResponse( if isinstance(event, dict):
content=response_data, extra_response.update(event)
headers=response.headers, else:
status_code=response.status_code, 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 return response
else: else:
@ -1421,11 +1494,21 @@ async def process_chat_response(
): ):
return response return response
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = { extra_params = {
"__event_emitter__": event_emitter, "__event_emitter__": event_emitter,
"__event_call__": event_caller, "__event_call__": event_caller,
"__user__": user.model_dump() if isinstance(user, UserModel) else {}, "__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata, "__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request, "__request__": request,
"__model__": model, "__model__": model,
} }
@ -1495,7 +1578,7 @@ async def process_chat_response(
tool_result_files = result.get("files", None) tool_result_files = result.get("files", None)
break break
if tool_result: if tool_result is not None:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n' tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
else: else:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n' tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
@ -1610,7 +1693,7 @@ async def process_chat_response(
{ {
"role": "tool", "role": "tool",
"tool_call_id": result["tool_call_id"], "tool_call_id": result["tool_call_id"],
"content": result["content"], "content": result.get("content", "") or "",
} }
) )
temp_blocks = [] temp_blocks = []
@ -1958,6 +2041,10 @@ async def process_chat_response(
} }
) )
usage = data.get("usage", {}) usage = data.get("usage", {})
usage.update(
data.get("timing", {})
) # llama.cpp
if usage: if usage:
await event_emitter( await event_emitter(
{ {
@ -2331,7 +2418,7 @@ async def process_chat_response(
results.append( results.append(
{ {
"tool_call_id": tool_call_id, "tool_call_id": tool_call_id,
"content": tool_result, "content": tool_result or "",
**( **(
{"files": tool_result_files} {"files": tool_result_files}
if tool_result_files if tool_result_files
@ -2618,7 +2705,7 @@ async def process_chat_response(
await background_tasks_handler() await background_tasks_handler()
except asyncio.CancelledError: except asyncio.CancelledError:
log.warning("Task was cancelled!") log.warning("Task was cancelled!")
await event_emitter({"type": "task-cancelled"}) await event_emitter({"type": "chat:tasks:cancel"})
if not ENABLE_REALTIME_CHAT_SAVE: if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database # Save message in the database

View file

@ -4,6 +4,11 @@ import mimetypes
import sys import sys
import uuid import uuid
import json import json
from datetime import datetime, timedelta
import re
import fnmatch
import time
import aiohttp import aiohttp
from authlib.integrations.starlette_client import OAuth from authlib.integrations.starlette_client import OAuth
@ -14,8 +19,12 @@ from fastapi import (
) )
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from open_webui.models.auths import Auths 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.users import Users
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
from open_webui.config import ( from open_webui.config import (
DEFAULT_USER_ROLE, DEFAULT_USER_ROLE,
@ -46,6 +55,7 @@ from open_webui.env import (
WEBUI_NAME, WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE,
) )
from open_webui.utils.misc import parse_duration from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.auth import get_password_hash, create_token
@ -79,15 +89,235 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
"""
Check if a group name matches any blocked pattern.
Supports exact matches, shell-style wildcards (*, ?), and regex patterns.
Args:
group_name: The group name to check
groups: List of patterns to match against
Returns:
True if the group is blocked, False otherwise
"""
if not groups:
return False
for group_pattern in groups:
if not group_pattern: # Skip empty patterns
continue
# Exact match
if group_name == group_pattern:
return True
# Try as regex pattern first if it contains regex-specific characters
if any(
char in group_pattern
for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"]
):
try:
# Use the original pattern as-is for regex matching
if re.search(group_pattern, group_name):
return True
except re.error:
# If regex is invalid, fall through to wildcard check
pass
# Shell-style wildcard match (supports * and ?)
if "*" in group_pattern or "?" in group_pattern:
if fnmatch.fnmatch(group_name, group_pattern):
return True
return False
class OAuthManager: class OAuthManager:
def __init__(self, app): def __init__(self, app):
self.oauth = OAuth() self.oauth = OAuth()
self.app = app self.app = app
self._clients = {}
for _, provider_config in OAUTH_PROVIDERS.items(): for _, provider_config in OAUTH_PROVIDERS.items():
provider_config["register"](self.oauth) provider_config["register"](self.oauth)
def get_client(self, provider_name): def get_client(self, provider_name):
return self.oauth.create_client(provider_name) if provider_name not in self._clients:
self._clients[provider_name] = self.oauth.create_client(provider_name)
return self._clients[provider_name]
def get_server_metadata_url(self, provider_name):
if provider_name in self._clients:
client = self._clients[provider_name]
return (
client.server_metadata_url
if hasattr(client, "server_metadata_url")
else None
)
return None
def get_oauth_token(
self, user_id: str, session_id: str, force_refresh: bool = False
):
"""
Get a valid OAuth token for the user, automatically refreshing if needed.
Args:
user_id: The user ID
provider: Optional provider name. If None, gets the most recent session.
force_refresh: Force token refresh even if current token appears valid
Returns:
dict: OAuth token data with access_token, or None if no valid token available
"""
try:
# Get the OAuth session
session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
if not session:
log.warning(
f"No OAuth session found for user {user_id}, session {session_id}"
)
return None
if force_refresh or datetime.now() + timedelta(
minutes=5
) >= datetime.fromtimestamp(session.expires_at):
log.debug(
f"Token refresh needed for user {user_id}, provider {session.provider}"
)
refreshed_token = self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, provider {session.provider}"
)
return None
return session.token
except Exception as e:
log.error(f"Error getting OAuth token for user {user_id}: {e}")
return None
async def _refresh_token(self, session) -> dict:
"""
Refresh an OAuth token if needed, with concurrency protection.
Args:
session: The OAuth session object
Returns:
dict: Refreshed token data, or None if refresh failed
"""
try:
# Perform the actual refresh
refreshed_token = await self._perform_token_refresh(session)
if refreshed_token:
# Update the session with new token data
session = OAuthSessions.update_session_by_id(
session.id, refreshed_token
)
log.info(f"Successfully refreshed token for session {session.id}")
return session.token
else:
log.error(f"Failed to refresh token for session {session.id}")
return None
except Exception as e:
log.error(f"Error refreshing token for session {session.id}: {e}")
return None
async def _perform_token_refresh(self, session) -> dict:
"""
Perform the actual OAuth token refresh.
Args:
session: The OAuth session object
Returns:
dict: New token data, or None if refresh failed
"""
provider = session.provider
token_data = session.token
if not token_data.get("refresh_token"):
log.warning(f"No refresh token available for session {session.id}")
return None
try:
client = self.get_client(provider)
if not client:
log.error(f"No OAuth client found for provider {provider}")
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(client.gserver_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
else:
log.error(
f"Failed to fetch OpenID configuration for provider {provider}"
)
if not token_endpoint:
log.error(f"No token endpoint found for provider {provider}")
return None
# Prepare refresh request
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": token_data["refresh_token"],
"client_id": client.client_id,
}
# Add client_secret if available (some providers require it)
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
token_endpoint,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status == 200:
new_token_data = await r.json()
# Merge with existing token data (preserve refresh_token if not provided)
if "refresh_token" not in new_token_data:
new_token_data["refresh_token"] = token_data[
"refresh_token"
]
# Add timestamp for tracking
new_token_data["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if (
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = (
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
log.debug(f"Token refresh successful for provider {provider}")
return new_token_data
else:
error_text = await r.text()
log.error(
f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
)
return None
except Exception as e:
log.error(f"Exception during token refresh for provider {provider}: {e}")
return None
def get_user_role(self, user, user_data): def get_user_role(self, user, user_data):
user_count = Users.get_num_users() user_count = Users.get_num_users()
@ -238,7 +468,7 @@ class OAuthManager:
if ( if (
user_oauth_groups user_oauth_groups
and group_model.name not in 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 # Remove group from user
log.debug( log.debug(
@ -269,7 +499,7 @@ class OAuthManager:
user_oauth_groups user_oauth_groups
and group_model.name in 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 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 # Add user to group
log.debug( log.debug(
@ -354,185 +584,205 @@ class OAuthManager:
async def handle_callback(self, request, provider, response): async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS: if provider not in OAUTH_PROVIDERS:
raise HTTPException(404) raise HTTPException(404)
client = self.get_client(provider)
error_message = None
try: try:
token = await client.authorize_access_token(request) client = self.get_client(provider)
except Exception as e: try:
log.warning(f"OAuth callback error: {e}") token = await client.authorize_access_token(request)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) except Exception as e:
user_data: UserInfo = token.get("userinfo") log.warning(f"OAuth callback error: {e}")
if ( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if auth_manager_config.OAUTH_SUB_CLAIM: # Try to get userinfo from the token first, some providers include it there
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) user_data: UserInfo = token.get("userinfo")
else: if (
# Fallback to the default sub claim if not configured (not user_data)
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if not sub: # Extract the "sub" claim, using custom claim if configured
log.warning(f"OAuth callback failed, sub is missing: {user_data}") if auth_manager_config.OAUTH_SUB_CLAIM:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}" provider_sub = f"{provider}@{sub}"
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM # Email extraction
email = user_data.get(email_claim, "") email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
# We currently mandate that email addresses are provided email = user_data.get(email_claim, "")
if not email: # We currently mandate that email addresses are provided
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email if not email:
if provider == "github": # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
try: if provider == "github":
access_token = token.get("access_token") try:
headers = {"Authorization": f"Bearer {access_token}"} access_token = token.get("access_token")
async with aiohttp.ClientSession(trust_env=True) as session: headers = {"Authorization": f"Bearer {access_token}"}
async with session.get( async with aiohttp.ClientSession(trust_env=True) as session:
"https://api.github.com/user/emails", async with session.get(
headers=headers, "https://api.github.com/user/emails",
ssl=AIOHTTP_CLIENT_SESSION_SSL, headers=headers,
) as resp: ssl=AIOHTTP_CLIENT_SESSION_SSL,
if resp.ok: ) as resp:
emails = await resp.json() if resp.ok:
# use the primary email as the user's email emails = await resp.json()
primary_email = next( # use the primary email as the user's email
(e["email"] for e in emails if e.get("primary")), primary_email = next(
None, (
) e["email"]
if primary_email: for e in emails
email = primary_email if e.get("primary")
else: ),
log.warning( None,
"No primary email found in GitHub response"
) )
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( raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED 400, detail=ERROR_MESSAGES.INVALID_CRED
) )
else: except Exception as e:
log.warning("Failed to fetch GitHub email") log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
400, detail=ERROR_MESSAGES.INVALID_CRED
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
if not user:
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
else: 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 (
name = user_data.get(username_claim) "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
if not name: and email.split("@")[-1]
log.warning("Username claim is missing, using email as name") not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
name = email ):
log.warning(
role = self.get_user_role(None, user_data) f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=role,
oauth_sub=provider_sub,
) )
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if auth_manager_config.WEBHOOK_URL: # Check if the user exists
await post_webhook( user = Users.get_user_by_oauth_sub(provider_sub)
WEBUI_NAME, if not user:
auth_manager_config.WEBHOOK_URL, # If the user does not exist, check if merging is enabled
WEBHOOK_MESSAGES.USER_SIGNUP(user.name), if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
{ # Check if the user exists by email
"action": "signup", user = Users.get_user_by_email(email)
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name), if user:
"user": user.model_dump_json(exclude_none=True), # Update the user with the new oauth sub
}, Users.update_user_oauth_sub_by_id(user.id, provider_sub)
)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
else: else:
raise HTTPException( # If the user does not exist, check if signups are enabled
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
else:
picture_url = "/user.png"
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
log.warning("Username claim is missing, using email as name")
name = email
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth_sub=provider_sub,
)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
jwt_token = create_token(
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if (
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
and user.role != "admin"
):
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
) )
jwt_token = create_token( except Exception as e:
data={"id": user.id}, log.error(f"Error during OAuth process: {e}")
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), error_message = (
) e.detail
if isinstance(e, HTTPException) and e.detail
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin": else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
) )
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url) redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
@ -540,6 +790,10 @@ class OAuthManager:
redirect_base_url = redirect_base_url[:-1] redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth" 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) response = RedirectResponse(url=redirect_url, headers=response.headers)
# Set the cookie token # Set the cookie token
@ -552,13 +806,48 @@ class OAuthManager:
secure=WEBUI_AUTH_COOKIE_SECURE, secure=WEBUI_AUTH_COOKIE_SECURE,
) )
if ENABLE_OAUTH_SIGNUP.value: # Legacy cookies for compatibility with older frontend versions
oauth_id_token = token.get("id_token") if ENABLE_OAUTH_ID_TOKEN_COOKIE:
response.set_cookie( response.set_cookie(
key="oauth_id_token", key="oauth_id_token",
value=oauth_id_token, value=token.get("id_token"),
httponly=True, httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE, samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE, secure=WEBUI_AUTH_COOKIE_SECURE,
) )
try:
# Add timestamp for tracking
token["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
sessions = OAuthSessions.get_sessions_by_user_id(user.id)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id)
session = OAuthSessions.create_session(
user_id=user.id,
provider=provider,
token=token,
)
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
except Exception as e:
log.error(f"Failed to store OAuth session server-side: {e}")
return response return response

View file

@ -119,18 +119,38 @@ async def get_tools(
function_name = spec["name"] function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer") auth_type = tool_server_connection.get("auth_type", "bearer")
token = None
cookies = {}
headers = {}
if auth_type == "bearer": if auth_type == "bearer":
token = tool_server_connection.get("key", "") headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session": elif auth_type == "session":
token = request.state.token.credentials cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
def make_tool_function(function_name, token, tool_server_data): headers["Content-Type"] = "application/json"
def make_tool_function(function_name, tool_server_data, headers):
async def tool_function(**kwargs): async def tool_function(**kwargs):
return await execute_tool_server( return await execute_tool_server(
token=token,
url=tool_server_data["url"], url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name, name=function_name,
params=kwargs, params=kwargs,
server_data=tool_server_data, server_data=tool_server_data,
@ -139,7 +159,7 @@ async def get_tools(
return tool_function return tool_function
tool_function = make_tool_function( tool_function = make_tool_function(
function_name, token, tool_server_data function_name, tool_server_data, headers
) )
callable = get_async_tool_function_and_apply_extra_params( callable = get_async_tool_function_and_apply_extra_params(
@ -542,9 +562,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
return data return data
async def get_tool_servers_data( async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
servers: List[Dict[str, Any]], session_token: Optional[str] = None
) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index # Prepare list of enabled servers along with their original index
server_entries = [] server_entries = []
for idx, server in enumerate(servers): for idx, server in enumerate(servers):
@ -560,8 +578,9 @@ async def get_tool_servers_data(
if auth_type == "bearer": if auth_type == "bearer":
token = server.get("key", "") token = server.get("key", "")
elif auth_type == "session": elif auth_type == "none":
token = session_token # No authentication
pass
id = info.get("id") id = info.get("id")
if not id: if not id:
@ -610,7 +629,12 @@ async def get_tool_servers_data(
async def execute_tool_server( async def execute_tool_server(
token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any] url: str,
headers: Dict[str, str],
cookies: Dict[str, str],
name: str,
params: Dict[str, Any],
server_data: Dict[str, Any],
) -> Any: ) -> Any:
error = None error = None
try: try:
@ -671,11 +695,6 @@ async def execute_tool_server(
f"Request body expected for operation '{name}' but none found." 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( async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session: ) as session:
@ -686,6 +705,7 @@ async def execute_tool_server(
final_url, final_url,
json=body_params, json=body_params,
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response: ) as response:
if response.status >= 400: if response.status >= 400:
@ -702,6 +722,7 @@ async def execute_tool_server(
async with request_method( async with request_method(
final_url, final_url,
headers=headers, headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response: ) as response:
if response.status >= 400: if response.status >= 400:

View file

@ -29,7 +29,7 @@ pymongo
redis redis
boto3==1.40.5 boto3==1.40.5
argon2-cffi==23.1.0 argon2-cffi==25.1.0
APScheduler==3.10.4 APScheduler==3.10.4
pycrdt==0.12.25 pycrdt==0.12.25
@ -47,7 +47,7 @@ google-generativeai==0.8.5
tiktoken tiktoken
langchain==0.3.26 langchain==0.3.26
langchain-community==0.3.26 langchain-community==0.3.27
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==0.6.3 chromadb==0.6.3
@ -65,12 +65,12 @@ transformers
sentence-transformers==4.1.0 sentence-transformers==4.1.0
accelerate accelerate
colbert-ai==0.2.21 colbert-ai==0.2.21
pyarrow==20.0.0 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1 einops==0.8.1
ftfy==6.2.3 ftfy==6.2.3
pypdf==4.3.1 pypdf==6.0.0
fpdf2==2.8.2 fpdf2==2.8.2
pymdown-extensions==10.14.2 pymdown-extensions==10.14.2
docx2txt==0.8 docx2txt==0.8
@ -99,10 +99,10 @@ onnxruntime==1.20.1
faster-whisper==1.1.1 faster-whisper==1.1.1
PyJWT[crypto]==2.10.1 PyJWT[crypto]==2.10.1
authlib==1.6.1 authlib==1.6.3
black==25.1.0 black==25.1.0
youtube-transcript-api==1.1.0 youtube-transcript-api==1.2.2
pytube==15.0.0 pytube==15.0.0
pydub pydub
@ -115,7 +115,7 @@ google-auth-oauthlib
## Tests ## Tests
docker~=7.1.0 docker~=7.1.0
pytest~=8.3.5 pytest~=8.4.1
pytest-docker~=3.1.1 pytest-docker~=3.1.1
googleapis-common-protos==1.63.2 googleapis-common-protos==1.63.2

View file

@ -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_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
webui_pid=$! webui_pid=$!
echo "Waiting for webui to start..." 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 sleep 1
done done
echo "Creating admin user..." echo "Creating admin user..."
curl \ curl \
-X POST "http://localhost:8080/api/v1/auths/signup" \ -X POST "http://localhost:${PORT}/api/v1/auths/signup" \
-H "accept: application/json" \ -H "accept: application/json" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }" -d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }"

177
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.26", "version": "0.6.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.26", "version": "0.6.27",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.5.0", "@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
@ -26,7 +26,7 @@
"@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.0.7", "@tiptap/extension-highlight": "^3.3.0",
"@tiptap/extension-image": "^3.0.7", "@tiptap/extension-image": "^3.0.7",
"@tiptap/extension-link": "^3.0.7", "@tiptap/extension-link": "^3.0.7",
"@tiptap/extension-list": "^3.0.7", "@tiptap/extension-list": "^3.0.7",
@ -46,7 +46,7 @@
"codemirror-lang-hcl": "^0.1.0", "codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dompurify": "^3.2.5", "dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2", "eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
@ -66,10 +66,10 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^11.6.0", "mermaid": "^11.10.1",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",
"panzoom": "^9.4.3", "panzoom": "^9.4.3",
"pdfjs-dist": "^5.3.93", "pdfjs-dist": "^5.4.149",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0", "prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3", "prosemirror-example-setup": "^1.2.3",
@ -82,10 +82,11 @@
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1", "prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3", "pyodide": "^0.28.2",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
@ -2224,9 +2225,9 @@
} }
}, },
"node_modules/@mermaid-js/parser": { "node_modules/@mermaid-js/parser": {
"version": "0.4.0", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz",
"integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==", "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"langium": "3.3.1" "langium": "3.3.1"
@ -2238,9 +2239,9 @@
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
}, },
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz",
"integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==", "integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"workspaces": [ "workspaces": [
@ -2250,22 +2251,22 @@
"node": ">= 10" "node": ">= 10"
}, },
"optionalDependencies": { "optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.73", "@napi-rs/canvas-android-arm64": "0.1.78",
"@napi-rs/canvas-darwin-arm64": "0.1.73", "@napi-rs/canvas-darwin-arm64": "0.1.78",
"@napi-rs/canvas-darwin-x64": "0.1.73", "@napi-rs/canvas-darwin-x64": "0.1.78",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.73", "@napi-rs/canvas-linux-arm64-gnu": "0.1.78",
"@napi-rs/canvas-linux-arm64-musl": "0.1.73", "@napi-rs/canvas-linux-arm64-musl": "0.1.78",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.73", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.78",
"@napi-rs/canvas-linux-x64-gnu": "0.1.73", "@napi-rs/canvas-linux-x64-gnu": "0.1.78",
"@napi-rs/canvas-linux-x64-musl": "0.1.73", "@napi-rs/canvas-linux-x64-musl": "0.1.78",
"@napi-rs/canvas-win32-x64-msvc": "0.1.73" "@napi-rs/canvas-win32-x64-msvc": "0.1.78"
} }
}, },
"node_modules/@napi-rs/canvas-android-arm64": { "node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz",
"integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==", "integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2279,9 +2280,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-arm64": { "node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz",
"integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==", "integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2295,9 +2296,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-x64": { "node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz",
"integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==", "integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2311,9 +2312,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz",
"integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==", "integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -2327,9 +2328,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-gnu": { "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz",
"integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==", "integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2343,9 +2344,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-musl": { "node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz",
"integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==", "integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2359,9 +2360,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": { "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz",
"integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==", "integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -2375,9 +2376,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-gnu": { "node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz",
"integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==", "integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2391,9 +2392,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-musl": { "node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz",
"integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==", "integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2407,9 +2408,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-win32-x64-msvc": { "node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.73", "version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz",
"integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==", "integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -3516,16 +3517,16 @@
} }
}, },
"node_modules/@tiptap/extension-highlight": { "node_modules/@tiptap/extension-highlight": {
"version": "3.0.7", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz",
"integrity": "sha512-3oIRuXAg7l9+VPIMwHycXcqtZ7XJcC5vnLhPAQXIesYun6L9EoXmQox0225z8jpPG70N8zfl+YSd4qjsTMPaAg==", "integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.7" "@tiptap/core": "^3.3.0"
} }
}, },
"node_modules/@tiptap/extension-horizontal-rule": { "node_modules/@tiptap/extension-horizontal-rule": {
@ -6767,9 +6768,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.5", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@ -9577,14 +9578,14 @@
} }
}, },
"node_modules/mermaid": { "node_modules/mermaid": {
"version": "11.6.0", "version": "11.10.1",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz",
"integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==", "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "^7.0.4", "@braintree/sanitize-url": "^7.0.4",
"@iconify/utils": "^2.1.33", "@iconify/utils": "^2.1.33",
"@mermaid-js/parser": "^0.4.0", "@mermaid-js/parser": "^0.6.2",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"cytoscape": "^3.29.3", "cytoscape": "^3.29.3",
"cytoscape-cose-bilkent": "^4.1.0", "cytoscape-cose-bilkent": "^4.1.0",
@ -9593,11 +9594,11 @@
"d3-sankey": "^0.12.3", "d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.11", "dagre-d3-es": "7.0.11",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.4", "dompurify": "^3.2.5",
"katex": "^0.16.9", "katex": "^0.16.22",
"khroma": "^2.1.0", "khroma": "^2.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^15.0.7", "marked": "^16.0.0",
"roughjs": "^4.6.6", "roughjs": "^4.6.6",
"stylis": "^4.3.6", "stylis": "^4.3.6",
"ts-dedent": "^2.2.0", "ts-dedent": "^2.2.0",
@ -9605,15 +9606,15 @@
} }
}, },
"node_modules/mermaid/node_modules/marked": { "node_modules/mermaid/node_modules/marked": {
"version": "15.0.8", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz",
"integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==", "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 20"
} }
}, },
"node_modules/mermaid/node_modules/uuid": { "node_modules/mermaid/node_modules/uuid": {
@ -10252,15 +10253,15 @@
} }
}, },
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "5.3.93", "version": "5.4.149",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz",
"integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==", "integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=20.16.0 || >=22.3.0" "node": ">=20.16.0 || >=22.3.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@napi-rs/canvas": "^0.1.71" "@napi-rs/canvas": "^0.1.77"
} }
}, },
"node_modules/pend": { "node_modules/pend": {
@ -10905,9 +10906,9 @@
} }
}, },
"node_modules/pyodide": { "node_modules/pyodide": {
"version": "0.27.7", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz", "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz",
"integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==", "integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"ws": "^8.5.0" "ws": "^8.5.0"
@ -12502,6 +12503,26 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
} }
}, },
"node_modules/svelte-tiptap": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.0.tgz",
"integrity": "sha512-digFHOJe16RX0HIU+u8hOaCS9sIgktTpYHSF9yJ6dgxPv/JWJdYCdwoX65lcHitFhhCG7xnolJng6PJa9M9h3w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-bubble-menu": "^3.0.0",
"@tiptap/extension-floating-menu": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/svelte/node_modules/estree-walker": { "node_modules/svelte/node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.26", "version": "0.6.27",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -70,7 +70,7 @@
"@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.0.7", "@tiptap/extension-highlight": "^3.3.0",
"@tiptap/extension-image": "^3.0.7", "@tiptap/extension-image": "^3.0.7",
"@tiptap/extension-link": "^3.0.7", "@tiptap/extension-link": "^3.0.7",
"@tiptap/extension-list": "^3.0.7", "@tiptap/extension-list": "^3.0.7",
@ -90,7 +90,7 @@
"codemirror-lang-hcl": "^0.1.0", "codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2", "crc-32": "^1.2.2",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dompurify": "^3.2.5", "dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2", "eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"focus-trap": "^7.6.4", "focus-trap": "^7.6.4",
@ -110,10 +110,10 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"marked": "^9.1.0", "marked": "^9.1.0",
"mermaid": "^11.6.0", "mermaid": "^11.10.1",
"paneforge": "^0.0.6", "paneforge": "^0.0.6",
"panzoom": "^9.4.3", "panzoom": "^9.4.3",
"pdfjs-dist": "^5.3.93", "pdfjs-dist": "^5.4.149",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0", "prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3", "prosemirror-example-setup": "^1.2.3",
@ -126,10 +126,11 @@
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1", "prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3", "pyodide": "^0.28.2",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",

View file

@ -18,7 +18,7 @@ dependencies = [
"bcrypt==4.3.0", "bcrypt==4.3.0",
"argon2-cffi==23.1.0", "argon2-cffi==23.1.0",
"PyJWT[crypto]==2.10.1", "PyJWT[crypto]==2.10.1",
"authlib==1.6.1", "authlib==1.6.3",
"requests==2.32.4", "requests==2.32.4",
"aiohttp==3.12.15", "aiohttp==3.12.15",
@ -52,7 +52,7 @@ dependencies = [
"google-generativeai==0.8.5", "google-generativeai==0.8.5",
"langchain==0.3.26", "langchain==0.3.26",
"langchain-community==0.3.26", "langchain-community==0.3.27",
"fake-useragent==2.2.0", "fake-useragent==2.2.0",
"chromadb==0.6.3", "chromadb==0.6.3",
@ -72,7 +72,7 @@ dependencies = [
"einops==0.8.1", "einops==0.8.1",
"ftfy==6.2.3", "ftfy==6.2.3",
"pypdf==4.3.1", "pypdf==6.0.0",
"fpdf2==2.8.2", "fpdf2==2.8.2",
"pymdown-extensions==10.14.2", "pymdown-extensions==10.14.2",
"docx2txt==0.8", "docx2txt==0.8",

View file

@ -282,6 +282,14 @@ input[type='number'] {
outline: none; 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'] { .tippy-box[data-theme~='dark'] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl; @apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
} }

View file

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

View file

@ -1,8 +1,9 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
type FolderForm = { type FolderForm = {
name: string; name?: string;
data?: Record<string, any>; data?: Record<string, any>;
meta?: Record<string, any>;
}; };
export const createNewFolder = async (token: string, folderForm: FolderForm) => { export const createNewFolder = async (token: string, folderForm: FolderForm) => {

View file

@ -354,8 +354,19 @@ export const getToolServersData = async (servers: object[]) => {
.filter((server) => server?.config?.enable) .filter((server) => server?.config?.enable)
.map(async (server) => { .map(async (server) => {
let error = 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;
}
const data = await getToolServerData( const data = await getToolServerData(
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token, toolServerToken,
(server?.path ?? '').includes('://') (server?.path ?? '').includes('://')
? server?.path ? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}` : `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`

View file

@ -372,6 +372,7 @@ export const generateOpenAIChatCompletion = async (
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
credentials: 'include',
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
.then(async (res) => { .then(async (res) => {

View file

@ -31,6 +31,7 @@
let url = ''; let url = '';
let key = ''; let key = '';
let auth_type = 'bearer';
let connectionType = 'external'; let connectionType = 'external';
let azure = false; let azure = false;
@ -74,6 +75,7 @@
url, url,
key, key,
config: { config: {
auth_type,
azure: azure, azure: azure,
api_version: apiVersion api_version: apiVersion
} }
@ -146,6 +148,7 @@
prefix_id: prefixId, prefix_id: prefixId,
model_ids: modelIds, model_ids: modelIds,
connection_type: connectionType, connection_type: connectionType,
auth_type,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}) ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
} }
}; };
@ -157,6 +160,7 @@
url = ''; url = '';
key = ''; key = '';
auth_type = 'bearer';
prefixId = ''; prefixId = '';
tags = []; tags = [];
modelIds = []; modelIds = [];
@ -167,6 +171,8 @@
url = connection.url; url = connection.url;
key = connection.key; key = connection.key;
auth_type = connection.config.auth_type ?? 'bearer';
enable = connection.config?.enable ?? true; enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? []; tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? ''; prefixId = connection.config?.prefix_id ?? '';
@ -305,23 +311,63 @@
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div <label
class={`mb-0.5 text-xs text-gray-500 for="select-bearer-or-session"
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`} class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
> >
{$i18n.t('Key')}
</div>
<div class="flex-1"> <div class="flex gap-2">
<SensitiveInput <div class="flex-shrink-0 self-start">
inputClassName={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`} <select
bind:value={key} id="select-bearer-or-session"
placeholder={$i18n.t('API Key')} class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
required={false} bind:value={auth_type}
/> >
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !ollama}
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div> </div>
</div> </div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<label <label
for="prefix-id-input" for="prefix-id-input"
@ -349,6 +395,29 @@
</div> </div>
</div> </div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider Type')}</label
>
<div>
<button
on:click={() => {
azure = !azure;
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
</button>
</div>
</div>
{/if}
{#if azure} {#if azure}
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
@ -374,36 +443,6 @@
</div> </div>
{/if} {/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="mb-1 flex justify-between"> <div class="mb-1 flex justify-between">
<div <div
@ -489,6 +528,36 @@
</div> </div>
</div> </div>
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5"> <div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit} {#if edit}
<button <button

View file

@ -283,8 +283,14 @@
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`} class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type} bind:value={auth_type}
> >
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option> <option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option> <option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select> </select>
</div> </div>
@ -295,12 +301,24 @@
placeholder={$i18n.t('API Key')} placeholder={$i18n.t('API Key')}
required={false} required={false}
/> />
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'} {:else if auth_type === 'session'}
<div <div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
> >
{$i18n.t('Forwards system user session credentials to authenticate')} {$i18n.t('Forwards system user session credentials to authenticate')}
</div> </div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -204,7 +204,7 @@
/> />
</svg> </svg>
</div> </div>
<div class=" self-center">{$i18n.t('Tools')}</div> <div class=" self-center">{$i18n.t('External Tools')}</div>
</button> </button>
<button <button

View file

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

View file

@ -71,6 +71,7 @@
class="w-full text-sm bg-transparent outline-hidden" class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')} placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
bind:value={url} bind:value={url}
readonly={true}
/> />
</Tooltip> </Tooltip>

View file

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

View file

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

View file

@ -599,20 +599,42 @@
{/if} {/if}
{:else if config?.engine === 'openai'} {:else if config?.engine === 'openai'}
<div> <div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('OpenAI API Config')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('OpenAI API Config')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={config.openai.OPENAI_API_BASE_URL}
required
/>
</div>
</div>
</div>
<div class="flex gap-2 mb-1"> <div>
<input <div class=" mb-2 text-sm font-medium">{$i18n.t('API Key')}</div>
class="flex-1 w-full text-sm bg-transparent outline-hidden" <div class="flex w-full">
placeholder={$i18n.t('API Base URL')} <div class="flex-1 mr-2">
bind:value={config.openai.OPENAI_API_BASE_URL} <SensitiveInput
required placeholder={$i18n.t('API Key')}
/> bind:value={config.openai.OPENAI_API_KEY}
required
/>
</div>
</div>
</div>
<SensitiveInput <div>
placeholder={$i18n.t('API Key')} <div class=" mb-2 text-sm font-medium">{$i18n.t('API Version')}</div>
bind:value={config.openai.OPENAI_API_KEY} <div class="flex w-full">
/> <div class="flex-1 mr-2">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('API Version')}
bind:value={config.openai.OPENAI_API_VERSION}
/>
</div>
</div> </div>
</div> </div>
{:else if config?.engine === 'gemini'} {:else if config?.engine === 'gemini'}

View file

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

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
@ -9,7 +8,7 @@
import { config, mobile, settings, socket, user } from '$lib/stores'; import { config, mobile, settings, socket, user } from '$lib/stores';
import { import {
blobToFile, convertHeicToJpeg,
compressImage, compressImage,
extractInputVariables, extractInputVariables,
getAge, getAge,
@ -377,11 +376,7 @@
]; ];
}; };
reader.readAsDataURL( reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else { } else {
uploadFileHandler(file); uploadFileHandler(file);
} }

View file

@ -30,9 +30,10 @@
import ProfilePreview from './Message/ProfilePreview.svelte'; import ProfilePreview from './Message/ProfilePreview.svelte';
import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubble.svelte'; import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubble.svelte';
import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import ReactionPicker from './Message/ReactionPicker.svelte'; import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils'; import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
@ -74,7 +75,7 @@
<div <div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850" class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850"
> >
<ReactionPicker <EmojiPicker
onClose={() => (showButtons = false)} onClose={() => (showButtons = false)}
onSubmit={(name) => { onSubmit={(name) => {
showButtons = false; showButtons = false;
@ -91,7 +92,7 @@
<FaceSmile /> <FaceSmile />
</button> </button>
</Tooltip> </Tooltip>
</ReactionPicker> </EmojiPicker>
{#if !thread} {#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}> <Tooltip content={$i18n.t('Reply in Thread')}>
@ -275,20 +276,7 @@
onReaction(reaction.name); onReaction(reaction.name);
}} }}
> >
{#if $shortCodesToEmojis[reaction.name]} <Emoji shortCode={reaction.name} />
<img
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[
reaction.name
].toLowerCase()}.svg"
alt={reaction.name}
class=" size-4"
loading="lazy"
/>
{:else}
<div>
{reaction.name}
</div>
{/if}
{#if reaction.user_ids.length > 0} {#if reaction.user_ids.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400"> <div class="text-xs font-medium text-gray-500 dark:text-gray-400">
@ -299,7 +287,7 @@
</Tooltip> </Tooltip>
{/each} {/each}
<ReactionPicker <EmojiPicker
onSubmit={(name) => { onSubmit={(name) => {
onReaction(name); onReaction(name);
}} }}
@ -311,7 +299,7 @@
<FaceSmile /> <FaceSmile />
</div> </div>
</Tooltip> </Tooltip>
</ReactionPicker> </EmojiPicker>
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -50,7 +50,6 @@
removeAllDetails removeAllDetails
} from '$lib/utils'; } from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama';
import { import {
createNewChat, createNewChat,
getAllTags, getAllTags,
@ -63,8 +62,6 @@
} from '$lib/apis/chats'; } from '$lib/apis/chats';
import { generateOpenAIChatCompletion } from '$lib/apis/openai'; import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval'; import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { queryMemory } from '$lib/apis/memories';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users'; import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import { import {
chatCompleted, chatCompleted,
@ -75,6 +72,10 @@
getTaskIdsByChatId getTaskIdsByChatId
} from '$lib/apis'; } from '$lib/apis';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
import { uploadFile } from '$lib/apis/files';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { fade } from 'svelte/transition';
import Banner from '../common/Banner.svelte'; import Banner from '../common/Banner.svelte';
import MessageInput from '$lib/components/chat/MessageInput.svelte'; import MessageInput from '$lib/components/chat/MessageInput.svelte';
@ -85,10 +86,8 @@
import Placeholder from './Placeholder.svelte'; import Placeholder from './Placeholder.svelte';
import NotificationToast from '../NotificationToast.svelte'; import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import { fade } from 'svelte/transition';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte'; import Sidebar from '../icons/Sidebar.svelte';
import { uploadFile } from '$lib/apis/files';
export let chatIdProp = ''; export let chatIdProp = '';
@ -203,7 +202,12 @@
if (type === 'prompt') { if (type === 'prompt') {
// Handle prompt selection // Handle prompt selection
messageInput?.setText(data); messageInput?.setText(data, async () => {
if (!($settings?.insertSuggestionPrompt ?? false)) {
await tick();
submitPrompt(prompt);
}
});
} }
}; };
@ -318,6 +322,13 @@
} }
} else if (type === 'chat:completion') { } else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id); chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:tasks:cancel') {
taskIds = null;
const responseMessage = history.messages[history.currentId];
// Set all response messages to done
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
}
} else if (type === 'chat:message:delta' || type === 'message') { } else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content; message.content += data.content;
} else if (type === 'chat:message' || type === 'replace') { } else if (type === 'chat:message' || type === 'replace') {
@ -671,7 +682,7 @@
console.log(url); console.log(url);
const fileItem = { const fileItem = {
type: 'doc', type: 'text',
name: url, name: url,
collection_name: '', collection_name: '',
status: 'uploading', status: 'uploading',
@ -704,7 +715,7 @@
console.log(url); console.log(url);
const fileItem = { const fileItem = {
type: 'doc', type: 'text',
name: url, name: url,
collection_name: '', collection_name: '',
status: 'uploading', status: 'uploading',
@ -1396,10 +1407,10 @@
const submitPrompt = async (userPrompt, { _raw = false } = {}) => { const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId); console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history, history.currentId);
const _selectedModels = selectedModels.map((modelId) => const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : '' $models.map((m) => m.id).includes(modelId) ? modelId : ''
); );
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) { if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
selectedModels = _selectedModels; selectedModels = _selectedModels;
} }
@ -1413,15 +1424,6 @@
return; return;
} }
if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
return;
}
if (messages.length != 0 && messages.at(-1).error && !messages.at(-1).content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
if ( if (
files.length > 0 && files.length > 0 &&
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0 files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
@ -1431,6 +1433,7 @@
); );
return; return;
} }
if ( if (
($config?.file?.max_count ?? null) !== null && ($config?.file?.max_count ?? null) !== null &&
files.length + chatFiles.length > $config?.file?.max_count files.length + chatFiles.length > $config?.file?.max_count
@ -1443,9 +1446,25 @@
return; return;
} }
if (history?.currentId) {
const lastMessage = history.messages[history.currentId];
if (lastMessage.done != true) {
// Response not done
return;
}
if (lastMessage.error && !lastMessage.content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
}
messageInput?.setText(''); messageInput?.setText('');
prompt = ''; prompt = '';
const messages = createMessagesList(history, history.currentId);
// Reset chat input textarea // Reset chat input textarea
if (!($settings?.richTextInput ?? true)) { if (!($settings?.richTextInput ?? true)) {
const chatInputElement = document.getElementById('chat-input'); const chatInputElement = document.getElementById('chat-input');
@ -1620,6 +1639,46 @@
chats.set(await getChatList(localStorage.token, $currentChatPage)); chats.set(await getChatList(localStorage.token, $currentChatPage));
}; };
const getFeatures = () => {
let features = {};
if ($config?.features)
features = {
image_generation:
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled
: false
};
const currentModels = atSelectedModel?.id ? [atSelectedModel.id] : selectedModels;
if (
currentModels.filter(
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
).length === currentModels.length
) {
if (($settings?.webSearch ?? false) === 'always') {
features = { ...features, web_search: true };
}
}
if ($settings?.memory ?? false) {
features = { ...features, memory: true };
}
return features;
};
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => { const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
const responseMessage = _history.messages[responseMessageId]; const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId]; const userMessage = _history.messages[responseMessage.parentId];
@ -1732,25 +1791,7 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined, tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_servers: $toolServers, tool_servers: $toolServers,
features: getFeatures(),
features: {
image_generation:
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
: false,
memory: $settings?.memory ?? false
},
variables: { variables: {
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined) ...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
}, },

View file

@ -39,7 +39,11 @@
export const openPane = () => { export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) { if (parseInt(localStorage?.chatControlsSize)) {
pane.resize(parseInt(localStorage?.chatControlsSize)); const container = document.getElementById('chat-container');
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
pane.resize(size);
} else { } else {
pane.resize(minSize); pane.resize(minSize);
} }
@ -91,7 +95,7 @@
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) { for (let entry of entries) {
const width = entry.contentRect.width; const width = entry.contentRect.width;
// calculate the percentage of 200px // calculate the percentage of 350px
const percentage = (350 / width) * 100; const percentage = (350 / width) * 100;
// set the minSize to the percentage, must be an integer // set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage); minSize = Math.floor(percentage);
@ -99,6 +103,13 @@
if ($showControls) { if ($showControls) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) { if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize); pane.resize(minSize);
} else {
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
if (size < minSize) {
pane.resize(minSize);
}
} }
} }
} }
@ -207,8 +218,6 @@
bind:pane bind:pane
defaultSize={0} defaultSize={0}
onResize={(size) => { onResize={(size) => {
console.log('size', size, minSize);
if ($showControls && pane.isExpanded()) { if ($showControls && pane.isExpanded()) {
if (size < minSize) { if (size < minSize) {
pane.resize(minSize); pane.resize(minSize);
@ -217,7 +226,9 @@
if (size < minSize) { if (size < minSize) {
localStorage.chatControlsSize = 0; localStorage.chatControlsSize = 0;
} else { } else {
localStorage.chatControlsSize = size; // save the size in pixels to localStorage
const container = document.getElementById('chat-container');
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
} }
} }
}} }}

View file

@ -1,11 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as pdfjs from 'pdfjs-dist';
import * as pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs';
pdfjs.GlobalWorkerOptions.workerSrc = import.meta.url + 'pdfjs-dist/build/pdf.worker.mjs';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { marked } from 'marked'; import { marked } from 'marked';
import heic2any from 'heic2any';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@ -32,7 +27,7 @@
} from '$lib/stores'; } from '$lib/stores';
import { import {
blobToFile, convertHeicToJpeg,
compressImage, compressImage,
createMessagesList, createMessagesList,
extractContentFromFile, extractContentFromFile,
@ -106,6 +101,7 @@
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
let showInputVariablesModal = false; let showInputVariablesModal = false;
let inputVariablesModalCallback = (variableValues) => {};
let inputVariables = {}; let inputVariables = {};
let inputVariableValues = {}; let inputVariableValues = {};
@ -127,11 +123,24 @@
codeInterpreterEnabled codeInterpreterEnabled
}); });
const inputVariableHandler = async (text: string) => { const inputVariableHandler = async (text: string): Promise<string> => {
inputVariables = extractInputVariables(text); inputVariables = extractInputVariables(text);
if (Object.keys(inputVariables).length > 0) {
showInputVariablesModal = true; // No variables? return the original text immediately.
if (Object.keys(inputVariables).length === 0) {
return text;
} }
// Show modal and wait for the user's input.
showInputVariablesModal = true;
return await new Promise<string>((resolve) => {
inputVariablesModalCallback = (variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
showInputVariablesModal = false;
resolve(text);
};
});
}; };
const textVariableHandler = async (text: string) => { const textVariableHandler = async (text: string) => {
@ -249,7 +258,6 @@
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
} }
inputVariableHandler(text);
return text; return text;
}; };
@ -285,7 +293,7 @@
} }
}; };
export const setText = async (text?: string) => { export const setText = async (text?: string, cb?: (text: string) => void) => {
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
if (chatInput) { if (chatInput) {
@ -301,6 +309,10 @@
chatInput.focus(); chatInput.focus();
chatInput.dispatchEvent(new Event('input')); chatInput.dispatchEvent(new Event('input'));
} }
text = await inputVariableHandler(text);
await tick();
if (cb) await cb(text);
} }
}; };
@ -640,7 +652,7 @@
} else { } else {
// If temporary chat is enabled, we just add the file to the list without uploading it. // If temporary chat is enabled, we just add the file to the list without uploading it.
const content = await extractContentFromFile(file, pdfjsLib).catch((error) => { const content = await extractContentFromFile(file).catch((error) => {
toast.error( toast.error(
$i18n.t('Failed to extract content from the file: {{error}}', { error: error }) $i18n.t('Failed to extract content from the file: {{error}}', { error: error })
); );
@ -763,11 +775,7 @@
} }
]; ];
}; };
reader.readAsDataURL( reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
} else { } else {
uploadFileHandler(file); uploadFileHandler(file);
} }
@ -873,10 +881,7 @@
<InputVariablesModal <InputVariablesModal
bind:show={showInputVariablesModal} bind:show={showInputVariablesModal}
variables={inputVariables} variables={inputVariables}
onSave={(variableValues) => { onSave={inputVariablesModalCallback}
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
}}
/> />
{#if loaded} {#if loaded}
@ -1407,7 +1412,7 @@
command = getCommand(); command = getCommand();
}} }}
on:compositionstart={() => (isComposing = true)} on:compositionstart={() => (isComposing = true)}
oncompositionend={(e) => { on:compositionend={(e) => {
compositionEndedAt = e.timeStamp; compositionEndedAt = e.timeStamp;
isComposing = false; isComposing = false;
}} }}
@ -1778,7 +1783,7 @@
<Sparkles className="size-4" strokeWidth="1.75" /> <Sparkles className="size-4" strokeWidth="1.75" />
{/if} {/if}
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5" class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{filter?.name}</span >{filter?.name}</span
> >
</button> </button>
@ -1797,7 +1802,7 @@
> >
<GlobeAlt className="size-4" strokeWidth="1.75" /> <GlobeAlt className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5" class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Web Search')}</span >{$i18n.t('Web Search')}</span
> >
</button> </button>
@ -1816,7 +1821,7 @@
> >
<Photo className="size-4" strokeWidth="1.75" /> <Photo className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5" class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Image')}</span >{$i18n.t('Image')}</span
> >
</button> </button>
@ -1842,7 +1847,7 @@
> >
<CommandLine className="size-4" strokeWidth="1.75" /> <CommandLine className="size-4" strokeWidth="1.75" />
<span <span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5" class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Code Interpreter')}</span >{$i18n.t('Code Interpreter')}</span
> >
</button> </button>

View file

@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import CitationsModal from './CitationsModal.svelte'; import CitationsModal from '$lib/components/chat/Messages/Citations/CitationsModal.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import { mobile } from '$lib/stores';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -15,14 +11,16 @@
let showPercentage = false; let showPercentage = false;
let showRelevance = true; let showRelevance = true;
let citationModal = null;
let showCitationModal = false; let showCitationModal = false;
let selectedCitation: any = null; let selectedCitation: any = null;
let isCollapsibleOpen = false; let isCollapsibleOpen = false;
export const showSourceModal = (sourceIdx) => { export const showSourceModal = (sourceIdx) => {
if (citations[sourceIdx]) { if (citations[sourceIdx]) {
selectedCitation = citations[sourceIdx]; console.log('Showing citation modal for:', citations[sourceIdx]);
showCitationModal = true; citationModal?.showCitation(citations[sourceIdx]);
// showCitationModal = true;
} }
}; };
@ -96,127 +94,46 @@
showRelevance = calculateShowRelevance(citations); showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations); showPercentage = shouldShowPercentage(citations);
} }
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
</script> </script>
<CitationsModal <CitationsModal
bind:this={citationModal}
bind:show={showCitationModal} bind:show={showCitationModal}
citation={selectedCitation} {id}
{citations}
{showPercentage} {showPercentage}
{showRelevance} {showRelevance}
/> />
{#if citations.length > 0} {#if citations.length > 0}
<div class=" py-0.5 -mx-0.5 w-full flex gap-1 items-center flex-wrap"> {@const urlCitations = citations.filter((c) => c?.source?.name?.startsWith('http'))}
{#if citations.length <= 3} <div class=" py-1 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
<div class="flex text-xs font-medium flex-wrap"> <button
{#each citations as citation, idx} class="text-xs font-medium text-gray-600 dark:text-gray-300 px-3.5 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-1 border border-gray-50 dark:border-gray-850"
<button on:click={() => {
id={`source-${id}-${idx + 1}`} showCitationModal = true;
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96" }}
on:click={() => { >
showCitationModal = true; {#if urlCitations.length > 0}
selectedCitation = citation; <div class="flex -space-x-1 items-center">
}} {#each urlCitations.slice(0, 3) as citation, idx}
> <img
{#if citations.every((c) => c.distances !== undefined)} src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4"> alt="favicon"
{idx + 1} class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
</div> />
{/if} {/each}
<div </div>
class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition" {/if}
> <div>
{decodeString(citation.source.name)} {#if citations.length === 1}
</div> {$i18n.t('1 Source')}
</button> {:else}
{/each} {$i18n.t('{{COUNT}} Sources', {
COUNT: citations.length
})}
{/if}
</div> </div>
{:else} </button>
<Collapsible
id={`collapsible-${id}`}
bind:open={isCollapsibleOpen}
className="w-full max-w-full "
buttonClassName="w-fit max-w-full"
>
<div
class="flex w-full overflow-auto items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div
class="flex-1 flex items-center gap-1 overflow-auto scrollbar-none w-full max-w-full"
>
<span class="whitespace-nowrap hidden sm:inline shrink-0"
>{$i18n.t('References from')}</span
>
<div class="flex items-center overflow-auto scrollbar-none w-full max-w-full flex-1">
<div class="flex text-xs font-medium items-center">
{#each citations.slice(0, $mobile ? 1 : 2) as citation, idx}
<button
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-1 whitespace-nowrap shrink-0">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
{citations.length - ($mobile ? 1 : 2)}
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
<div slot="content">
<div class="flex text-xs font-medium flex-wrap">
{#each citations.slice($mobile ? 1 : 2) as citation, idx}
<button
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 3}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
</Collapsible>
{/if}
</div> </div>
{/if} {/if}

View file

@ -61,8 +61,34 @@
<Modal size="lg" bind:show> <Modal size="lg" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2"> <div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center capitalize"> <div class=" text-lg font-medium self-center">
{$i18n.t('Citation')} {#if citation?.source?.name}
{@const document = mergedDocuments?.[0]}
{#if document?.metadata?.file_id || document.source?.url?.includes('http')}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<a
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
? document.source.url
: `#`}
target="_blank"
>
{decodeString(citation?.source?.name)}
</a>
</Tooltip>
{:else}
{decodeString(citation?.source?.name)}
{/if}
{:else}
{$i18n.t('Citation')}
{/if}
</div> </div>
<button <button
class="self-center" class="self-center"
@ -76,57 +102,31 @@
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4"> <div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4">
<div <div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden" class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden gap-1"
> >
{#each mergedDocuments as document, documentIdx} {#each mergedDocuments as document, documentIdx}
<div class="flex flex-col w-full"> <div class="flex flex-col w-full gap-2">
<div class="text-sm font-medium dark:text-gray-300"> {#if document.metadata?.parameters}
{$i18n.t('Source')} <div>
</div> <div class="text-sm font-medium dark:text-gray-300 mb-1">
{#if document.source?.name}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm dark:text-gray-400 flex items-center gap-2 w-fit">
<a
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
? document.source.url
: `#`}
target="_blank"
>
{decodeString(document?.metadata?.name ?? document.source.name)}
</a>
{#if Number.isInteger(document?.metadata?.page)}
<span class="text-xs text-gray-500 dark:text-gray-400">
({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div>
</Tooltip>
{#if document.metadata?.parameters}
<div class="text-sm font-medium dark:text-gray-300 mt-2 mb-0.5">
{$i18n.t('Parameters')} {$i18n.t('Parameters')}
</div> </div>
<Textarea readonly value={JSON.stringify(document.metadata.parameters, null, 2)} <Textarea readonly value={JSON.stringify(document.metadata.parameters, null, 2)}
></Textarea> ></Textarea>
{/if} </div>
{#if showRelevance} {/if}
<div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Relevance')} <div>
</div> <div
{#if document.distance !== undefined} class=" text-sm font-medium dark:text-gray-300 flex items-center gap-2 w-fit mb-1"
>
{$i18n.t('Content')}
{#if showRelevance && document.distance !== undefined}
<Tooltip <Tooltip
className="w-fit" className="w-fit"
content={$i18n.t('Semantic distance to query')} content={$i18n.t('Relevance')}
placement="top-start" placement="top-start"
tippyOptions={{ duration: [500, 0] }} tippyOptions={{ duration: [500, 0] }}
> >
@ -141,12 +141,6 @@
{percentage.toFixed(2)}% {percentage.toFixed(2)}%
</span> </span>
{/if} {/if}
{#if typeof document?.distance === 'number'}
<span class="text-gray-500 dark:text-gray-500">
({(document?.distance ?? 0).toFixed(4)})
</span>
{/if}
{:else if typeof document?.distance === 'number'} {:else if typeof document?.distance === 'number'}
<span class="text-gray-500 dark:text-gray-500"> <span class="text-gray-500 dark:text-gray-500">
({(document?.distance ?? 0).toFixed(4)}) ({(document?.distance ?? 0).toFixed(4)})
@ -154,39 +148,30 @@
{/if} {/if}
</div> </div>
</Tooltip> </Tooltip>
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No distance available')}
</div>
{/if} {/if}
{/if}
{:else} {#if Number.isInteger(document?.metadata?.page)}
<div class="text-sm dark:text-gray-400"> <span class="text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No source available')} ({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div> </div>
{/if}
</div> {#if document.metadata?.html}
<div class="flex flex-col w-full"> <iframe
<div class=" text-sm font-medium dark:text-gray-300 mt-2"> class="w-full border-0 h-auto rounded-none"
{$i18n.t('Content')} sandbox="allow-scripts allow-forms allow-same-origin"
</div> srcdoc={document.document}
{#if document.metadata?.html} title={$i18n.t('Content')}
<iframe ></iframe>
class="w-full border-0 h-auto rounded-none" {:else}
sandbox="allow-scripts allow-forms allow-same-origin" <pre class="text-sm dark:text-gray-400 whitespace-pre-line">
srcdoc={document.document}
title={$i18n.t('Content')}
></iframe>
{:else}
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
{document.document} {document.document}
</pre> </pre>
{/if} {/if}
</div>
</div> </div>
{#if documentIdx !== mergedDocuments.length - 1}
<hr class="border-gray-100 dark:border-gray-850 my-3" />
{/if}
{/each} {/each}
</div> </div>
</div> </div>

View file

@ -0,0 +1,82 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import CitationModal from './CitationModal.svelte';
export let id = '';
export let show = false;
export let citations = [];
export let showPercentage = false;
export let showRelevance = true;
let showCitationModal = false;
let selectedCitation: any = null;
export const showCitation = (citation) => {
selectedCitation = citation;
showCitationModal = true;
};
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
</script>
<CitationModal
bind:show={showCitationModal}
citation={selectedCitation}
{showPercentage}
{showRelevance}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t('Citations')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4">
<div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden text-left text-sm gap-2"
>
{#each citations as citation, idx}
<button
id={`source-${id}-${idx + 1}`}
class="no-toggle outline-hidden flex dark:text-gray-300 bg-white dark:bg-gray-900 rounded-xl gap-1.5 items-center"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
<div class=" font-medium">
{idx + 1}.
</div>
<div
class="flex-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition text-left"
>
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
</div>
</Modal>

View file

@ -216,19 +216,19 @@
const executePythonAsWorker = async (code) => { const executePythonAsWorker = async (code) => {
let packages = [ let packages = [
code.includes('requests') ? 'requests' : null, /\bimport\s+requests\b|\bfrom\s+requests\b/.test(code) ? 'requests' : null,
code.includes('bs4') ? 'beautifulsoup4' : null, /\bimport\s+bs4\b|\bfrom\s+bs4\b/.test(code) ? 'beautifulsoup4' : null,
code.includes('numpy') ? 'numpy' : null, /\bimport\s+numpy\b|\bfrom\s+numpy\b/.test(code) ? 'numpy' : null,
code.includes('pandas') ? 'pandas' : null, /\bimport\s+pandas\b|\bfrom\s+pandas\b/.test(code) ? 'pandas' : null,
code.includes('sklearn') ? 'scikit-learn' : null, /\bimport\s+matplotlib\b|\bfrom\s+matplotlib\b/.test(code) ? 'matplotlib' : null,
code.includes('scipy') ? 'scipy' : null, /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null,
code.includes('re') ? 'regex' : null, /\bimport\s+sklearn\b|\bfrom\s+sklearn\b/.test(code) ? 'scikit-learn' : null,
code.includes('seaborn') ? 'seaborn' : null, /\bimport\s+scipy\b|\bfrom\s+scipy\b/.test(code) ? 'scipy' : null,
code.includes('sympy') ? 'sympy' : null, /\bimport\s+re\b|\bfrom\s+re\b/.test(code) ? 'regex' : null,
code.includes('tiktoken') ? 'tiktoken' : null, /\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null,
code.includes('matplotlib') ? 'matplotlib' : null, /\bimport\s+sympy\b|\bfrom\s+sympy\b/.test(code) ? 'sympy' : null,
code.includes('pytz') ? 'pytz' : null, /\bimport\s+tiktoken\b|\bfrom\s+tiktoken\b/.test(code) ? 'tiktoken' : null,
code.includes('openai') ? 'openai' : null /\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null
].filter(Boolean); ].filter(Boolean);
console.log(packages); console.log(packages);
@ -416,11 +416,11 @@
</script> </script>
<div> <div>
<div class="relative {className} flex flex-col rounded-lg" dir="ltr"> <div class="relative {className} flex flex-col rounded-xl pt-2" dir="ltr">
{#if lang === 'mermaid'} {#if lang === 'mermaid'}
{#if mermaidHtml} {#if mermaidHtml}
<SvgPanZoom <SvgPanZoom
className=" border border-gray-100 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden" className=" border border-gray-100 dark:border-gray-850 rounded-xl max-h-fit overflow-hidden"
svg={mermaidHtml} svg={mermaidHtml}
content={_token.text} content={_token.text}
/> />
@ -428,16 +428,16 @@
<pre class="mermaid">{code}</pre> <pre class="mermaid">{code}</pre>
{/if} {/if}
{:else} {:else}
<div class="text-text-300 absolute pl-4 py-1.5 text-xs font-medium dark:text-white"> <div class="text-text-300 absolute pl-4 text-xs font-medium dark:text-white">
{lang} {lang}
</div> </div>
<div <div
class="sticky {stickyButtonsClassName} mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white" class="sticky {stickyButtonsClassName} mb-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
> >
<div class="flex items-center gap-0.5 translate-y-[1px]"> <div class="flex items-center gap-0.5">
<button <button
class="flex gap-1 items-center bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={collapseCodeBlock} on:click={collapseCodeBlock}
> >
<div class=" -translate-y-[0.5px]"> <div class=" -translate-y-[0.5px]">
@ -451,7 +451,7 @@
{#if preview && ['html', 'svg'].includes(lang)} {#if preview && ['html', 'svg'].includes(lang)}
<button <button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={previewCode} on:click={previewCode}
> >
<div class=" -translate-y-[0.5px]"> <div class=" -translate-y-[0.5px]">
@ -471,7 +471,7 @@
</div> </div>
{:else if run} {:else if run}
<button <button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={async () => { on:click={async () => {
code = _code; code = _code;
await tick(); await tick();
@ -491,7 +491,7 @@
{#if save} {#if save}
<button <button
class="save-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="save-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={saveCode} on:click={saveCode}
> >
{saved ? $i18n.t('Saved') : $i18n.t('Save')} {saved ? $i18n.t('Saved') : $i18n.t('Save')}
@ -499,20 +499,20 @@
{/if} {/if}
<button <button
class="copy-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5" class="copy-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button
> >
</div> </div>
</div> </div>
<div <div
class="language-{lang} rounded-t-lg -mt-8 {editorClassName class="language-{lang} rounded-t-xl -mt-8 {editorClassName
? editorClassName ? editorClassName
: executing || stdout || stderr || result : executing || stdout || stderr || result
? '' ? ''
: 'rounded-b-lg'} overflow-hidden" : 'rounded-b-xl'} overflow-hidden"
> >
<div class=" pt-7 bg-gray-50 dark:bg-gray-850"></div> <div class=" pt-8 bg-gray-50 dark:bg-black"></div>
{#if !collapsed} {#if !collapsed}
{#if edit} {#if edit}
@ -542,7 +542,7 @@
{/if} {/if}
{:else} {:else}
<div <div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-lg! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs" class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs"
> >
<span class="text-gray-500 italic"> <span class="text-gray-500 italic">
{$i18n.t('{{COUNT}} hidden lines', { {$i18n.t('{{COUNT}} hidden lines', {
@ -556,12 +556,12 @@
{#if !collapsed} {#if !collapsed}
<div <div
id="plt-canvas-{id}" id="plt-canvas-{id}"
class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden" class="bg-gray-50 dark:bg-black dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
/> />
{#if executing || stdout || stderr || result || files} {#if executing || stdout || stderr || result || files}
<div <div
class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2" class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! py-4 px-4 flex flex-col gap-2"
> >
{#if executing} {#if executing}
<div class=" "> <div class=" ">

View file

@ -21,6 +21,10 @@
// Helper function to return only the domain from a URL // Helper function to return only the domain from a URL
function getDomain(url: string): string { function getDomain(url: string): string {
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0]; const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
if (domain.startsWith('www.')) {
return domain.slice(4);
}
return domain; return domain;
} }
@ -33,6 +37,14 @@
return title; return title;
} }
const getDisplayTitle = (title: string) => {
if (!title) return 'N/A';
if (title.length > 30) {
return title.slice(0, 15) + '...' + title.slice(-10);
}
return title;
};
$: attributes = extractAttributes(token.text); $: attributes = extractAttributes(token.text);
</script> </script>
@ -44,7 +56,11 @@
}} }}
> >
<span class="line-clamp-1"> <span class="line-clamp-1">
{attributes.title ? formattedTitle(attributes.title) : ''} {getDisplayTitle(
decodeURIComponent(attributes.title)
? formattedTitle(decodeURIComponent(attributes.title))
: ''
)}
</span> </span>
</button> </button>
{/if} {/if}

View file

@ -52,6 +52,7 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte'; import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte';
import StatusHistory from './ResponseMessage/StatusHistory.svelte';
interface MessageType { interface MessageType {
id: string; id: string;
@ -642,76 +643,11 @@
<div> <div>
<div class="chat-{message.role} w-full min-w-full markdown-prose"> <div class="chat-{message.role} w-full min-w-full markdown-prose">
<div> <div>
{#if (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length > 0} {#if model?.info?.meta?.capabilities?.status_updates ?? true}
{@const status = ( <StatusHistory
message?.statusHistory ?? [...(message?.status ? [message?.status] : [])] statusHistory={message?.statusHistory}
).at(-1)} expand={message?.content === ''}
{#if !status?.hidden} />
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.action === 'web_search' && status?.urls}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')}
{$i18n.t(status?.description, {
count: status?.urls.length
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</WebSearchResults>
{:else if status?.action === 'knowledge_search'}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
searchQuery: status.query
})}
</div>
</div>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description.includes('{{searchQuery}}')}
{$i18n.t(status?.description, {
searchQuery: status?.query
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else if status?.description === 'Searching the web'}
{$i18n.t('Searching the web...')}
{:else}
{status?.description}
{/if}
</div>
</div>
{/if}
</div>
{/if}
{/if} {/if}
{#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0} {#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0}
@ -798,7 +734,7 @@
</div> </div>
{:else} {:else}
<div class="w-full flex flex-col relative" id="response-content-container"> <div class="w-full flex flex-col relative" id="response-content-container">
{#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0} {#if message.content === '' && !message.error && ((model?.info?.meta?.capabilities?.status_updates ?? true) ? (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0 || (message?.statusHistory?.at(-1)?.hidden ?? false) : true)}
<Skeleton /> <Skeleton />
{:else if message.content && message.error !== true} {:else if message.content && message.error !== true}
<!-- always show message contents even if there's an error --> <!-- always show message contents even if there's an error -->
@ -1339,7 +1275,7 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? false)} {#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)}
{#if $settings?.regenerateMenu ?? true} {#if $settings?.regenerateMenu ?? true}
<button <button
type="button" type="button"
@ -1450,7 +1386,7 @@
{/if} {/if}
{/if} {/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? false)} {#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
{#if siblings.length > 1} {#if siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom"> <Tooltip content={$i18n.t('Delete')} placement="bottom">
<button <button

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowTurnDownRight from '$lib/components/icons/ArrowTurnDownRight.svelte';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -20,13 +19,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<Tooltip content={followUp} placement="top-start" className="line-clamp-1"> <Tooltip content={followUp} placement="top-start" className="line-clamp-1">
<div <div
class=" mr-2 py-1.5 bg-transparent text-left text-sm flex items-center gap-2 px-1.5 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer" class=" py-1.5 bg-transparent text-left text-sm flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer"
on:click={() => onClick(followUp)} on:click={() => onClick(followUp)}
title={followUp}
aria-label={followUp} aria-label={followUp}
> >
<ArrowTurnDownRight className="size-3.5" />
<div class="line-clamp-1"> <div class="line-clamp-1">
{followUp} {followUp}
</div> </div>
@ -34,7 +30,7 @@
</Tooltip> </Tooltip>
{#if idx < followUps.length - 1} {#if idx < followUps.length - 1}
<hr class="border-gray-100 dark:border-gray-850" /> <hr class="border-gray-50 dark:border-gray-850" />
{/if} {/if}
{/each} {/each}
</div> </div>

View file

@ -0,0 +1,80 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import StatusItem from './StatusHistory/StatusItem.svelte';
export let statusHistory = [];
export let expand = false;
let showHistory = true;
$: if (expand) {
showHistory = true;
} else {
showHistory = false;
}
let history = [];
let status = null;
$: if (history && history.length > 0) {
status = history.at(-1);
}
$: if (JSON.stringify(statusHistory) !== JSON.stringify(history)) {
history = statusHistory;
}
</script>
{#if history && history.length > 0}
{#if status?.hidden !== true}
<div class="text-sm flex flex-col w-full">
{#if showHistory}
<div class="flex flex-row">
{#if history.length > 1}
<div class="w-1 border-r border-gray-50 dark:border-gray-800 mt-3 -mb-2.5" />
<div class="w-full -translate-x-[7.5px]">
{#each history as status, idx}
{#if idx !== history.length - 1}
<div class="flex items-start gap-2 mb-1">
<div class="pt-3 px-1">
<span class="relative flex size-2">
<span
class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
></span>
</span>
</div>
<StatusItem {status} done={true} />
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
<button
class="w-full -translate-x-[3.5px]"
on:click={() => {
showHistory = !showHistory;
}}
>
<div class="flex items-start gap-2">
<div class="pt-3 px-1">
<span class="relative flex size-2">
{#if status?.done === false}
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 dark:bg-gray-700 opacity-75"
></span>
{/if}
<span class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
></span>
</span>
</div>
<StatusItem {status} />
</div>
</button>
</div>
{/if}
{/if}

View file

@ -0,0 +1,150 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import WebSearchResults from '../WebSearchResults.svelte';
import Search from '$lib/components/icons/Search.svelte';
import { t } from 'i18next';
export let status = null;
export let done = false;
</script>
{#if !status?.hidden}
<div class="status-description flex items-center gap-2 py-0.5 w-full text-left">
{#if status?.action === 'web_search' && (status?.urls || status?.items)}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')}
{$i18n.t(status?.description, {
count: (status?.urls || status?.items).length
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</WebSearchResults>
{:else if status?.action === 'knowledge_search'}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
searchQuery: status.query
})}
</div>
</div>
{:else if status?.action === 'web_search_queries_generated' && status?.queries}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching`)}
</div>
<div class=" flex gap-1 flex-wrap mt-2">
{#each status.queries as query, idx (query)}
<div
class="bg-gray-50 dark:bg-gray-850 flex rounded-lg py-1 px-2 items-center gap-1 text-xs"
>
<div>
<Search className="size-3" />
</div>
<span class="line-clamp-1">
{query}
</span>
</div>
{/each}
</div>
</div>
{:else if status?.action === 'queries_generated' && status?.queries}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Querying`)}
</div>
<div class=" flex gap-1 flex-wrap mt-2">
{#each status.queries as query, idx (query)}
<div
class="bg-gray-50 dark:bg-gray-850 flex rounded-lg py-1 px-2 items-center gap-1 text-xs"
>
<div>
<Search className="size-3" />
</div>
<span class="line-clamp-1">
{query}
</span>
</div>
{/each}
</div>
</div>
{:else if status?.action === 'sources_retrieved' && status?.count !== undefined}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{#if status.count === 0}
{$i18n.t('No sources found')}
{:else if status.count === 1}
{$i18n.t('Retrieved 1 source')}
{:else}
<!-- {$i18n.t('Source')} -->
<!-- {$i18n.t('No source available')} -->
<!-- {$i18n.t('No distance available')} -->
<!-- {$i18n.t('Retrieved {{count}} sources')} -->
{$i18n.t('Retrieved {{count}} sources', {
count: status.count
})}
{/if}
</div>
</div>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description?.includes('{{searchQuery}}')}
{$i18n.t(status?.description, {
searchQuery: status?.query
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else if status?.description === 'Searching the web'}
{$i18n.t('Searching the web')}
{:else}
{status?.description}
{/if}
</div>
</div>
{/if}
</div>
{/if}

View file

@ -8,27 +8,25 @@
let state = false; let state = false;
</script> </script>
<Collapsible bind:open={state} className="w-full space-y-1"> <Collapsible grow={true} className="w-full" buttonClassName="w-full" bind:open={state}>
<div <div class="flex items-center gap-2 text-gray-500 transition">
class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
>
<slot /> <slot />
{#if state} {#if state}
<ChevronUp strokeWidth="3.5" className="size-3.5 " /> <ChevronUp strokeWidth="2.5" className="size-3.5 " />
{:else} {:else}
<ChevronDown strokeWidth="3.5" className="size-3.5 " /> <ChevronDown strokeWidth="2.5" className="size-3.5 " />
{/if} {/if}
</div> </div>
<div <div
class="text-sm border border-gray-300/30 dark:border-gray-700/50 rounded-xl mb-1.5" class="text-sm border border-gray-50 dark:border-gray-850 rounded-xl my-1.5 p-2 w-full"
slot="content" slot="content"
> >
{#if status?.query} {#if status?.query}
<a <a
href="https://www.google.com/search?q={status.query}" href="https://www.google.com/search?q={status.query}"
target="_blank" target="_blank"
class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline" class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 font-normal! no-underline!"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<Search /> <Search />
@ -58,36 +56,86 @@
</a> </a>
{/if} {/if}
{#each status.urls as url, urlIdx} {#if status?.items}
<a {#each status.items as item, itemIdx}
href={url} <a
target="_blank" href={item.link}
class="flex w-full items-center p-3 px-4 {urlIdx === status.urls.length - 1 target="_blank"
? '' class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg font-normal! no-underline! mb-1"
: 'border-b border-gray-300/30 dark:border-gray-700/50'} group/item justify-between font-normal text-gray-800 dark:text-gray-300"
>
<div class=" line-clamp-1">
{url}
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
> >
<!-- --> <div class=" flex justify-center items-center gap-3">
<svg <div class="w-fit">
xmlns="http://www.w3.org/2000/svg" <img
viewBox="0 0 16 16" src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
fill="currentColor" alt="favicon"
class="size-4" class="size-3.5"
/>
</div>
<div class="w-full text-sm line-clamp-1">
{item?.title ?? item.link}
</div>
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
> >
<path <!-- -->
fill-rule="evenodd" <svg
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z" xmlns="http://www.w3.org/2000/svg"
clip-rule="evenodd" viewBox="0 0 16 16"
/> fill="currentColor"
</svg> class="size-4"
</div> >
</a> <path
{/each} fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
{:else if status?.urls}
{#each status.urls as url, urlIdx}
<a
href={url}
target="_blank"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg no-underline mb-1"
>
<div class=" flex justify-center items-center gap-3">
<div class="w-fit">
<img
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
alt="favicon"
class="size-3.5"
/>
</div>
<div class="w-full text-sm line-clamp-1">
{url}
</div>
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<!-- -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
{/if}
</div> </div>
</Collapsible> </Collapsible>

View file

@ -327,7 +327,7 @@
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}"> <div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
<div <div
class="rounded-3xl {($settings?.chatBubble ?? true) class="rounded-3xl {($settings?.chatBubble ?? true)
? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${ ? `max-w-[90%] px-4 py-1.5 bg-gray-50 dark:bg-gray-850 ${
message.files ? 'rounded-tr-lg' : '' message.files ? 'rounded-tr-lg' : ''
}` }`
: ' w-full'}" : ' w-full'}"

View file

@ -21,6 +21,8 @@
import FolderMenu from '$lib/components/layout/Sidebar/Folders/FolderMenu.svelte'; import FolderMenu from '$lib/components/layout/Sidebar/Folders/FolderMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
export let folder = null; export let folder = null;
@ -63,6 +65,25 @@
} }
}; };
const updateIconHandler = async (iconName) => {
const res = await updateFolderById(localStorage.token, folder.id, {
meta: {
icon: iconName
}
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
folder.meta = { ...folder.meta, icon: iconName };
toast.success($i18n.t('Folder updated successfully'));
selectedFolder.set(folder);
onUpdate(folder);
}
};
const deleteHandler = async () => { const deleteHandler = async () => {
const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => { const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
@ -116,9 +137,23 @@
<div class="mb-3 px-6 @md:max-w-3xl justify-between w-full flex relative group items-center"> <div class="mb-3 px-6 @md:max-w-3xl justify-between w-full flex relative group items-center">
<div class="text-center flex gap-3.5 items-center"> <div class="text-center flex gap-3.5 items-center">
<div class=" rounded-full bg-gray-50 dark:bg-gray-800 p-3 w-fit"> <EmojiPicker
<Folder className="size-4.5" strokeWidth="2" /> onClose={() => {}}
</div> onSubmit={(name) => {
console.log(name);
updateIconHandler(name);
}}
>
<button
class=" rounded-full bg-gray-50 dark:bg-gray-800 size-11 flex justify-center items-center"
>
{#if folder?.meta?.icon}
<Emoji className="size-6" shortCode={folder.meta.icon} />
{:else}
<Folder className="size-4.5" strokeWidth="2" />
{/if}
</button>
</EmojiPicker>
<div class="text-3xl"> <div class="text-3xl">
{folder.name} {folder.name}

View file

@ -72,12 +72,6 @@
autocomplete="off" autocomplete="off"
/> />
</div> </div>
<SensitiveInput
inputClassName="bg-transparent w-full"
placeholder={$i18n.t('API Key')}
bind:value={key}
/>
</div> </div>
</Tooltip> </Tooltip>

View file

@ -49,6 +49,7 @@
let largeTextAsFile = false; let largeTextAsFile = false;
let insertSuggestionPrompt = false;
let keepFollowUpPrompts = false; let keepFollowUpPrompts = false;
let insertFollowUpPrompt = false; let insertFollowUpPrompt = false;
@ -200,6 +201,7 @@
insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false; insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false;
promptAutocomplete = $settings?.promptAutocomplete ?? false; promptAutocomplete = $settings?.promptAutocomplete ?? false;
insertSuggestionPrompt = $settings?.insertSuggestionPrompt ?? false;
keepFollowUpPrompts = $settings?.keepFollowUpPrompts ?? false; keepFollowUpPrompts = $settings?.keepFollowUpPrompts ?? false;
insertFollowUpPrompt = $settings?.insertFollowUpPrompt ?? false; insertFollowUpPrompt = $settings?.insertFollowUpPrompt ?? false;
@ -697,6 +699,25 @@
</div> </div>
</div> </div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="insert-suggestion-prompt-label" class=" self-center text-xs">
{$i18n.t('Insert Suggestion Prompt to Input')}
</div>
<div class="flex items-center gap-2 p-1">
<Switch
ariaLabelledbyId="insert-suggestion-prompt-label"
tooltip={true}
bind:state={insertSuggestionPrompt}
on:change={() => {
saveSettings({ insertSuggestionPrompt });
}}
/>
</div>
</div>
</div>
<div> <div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
<div id="keep-follow-up-prompts-label" class=" self-center text-xs"> <div id="keep-follow-up-prompts-label" class=" self-center text-xs">

View file

@ -212,7 +212,7 @@
}, },
{ {
id: 'tools', id: 'tools',
title: 'Tools', title: 'External Tools',
keywords: [ keywords: [
'addconnection', 'addconnection',
'add connection', 'add connection',
@ -743,7 +743,7 @@
/> />
</svg> </svg>
</div> </div>
<div class=" self-center">{$i18n.t('Tools')}</div> <div class=" self-center">{$i18n.t('External Tools')}</div>
</button> </button>
{/if} {/if}
{:else if tabId === 'personalization'} {:else if tabId === 'personalization'}

View file

@ -46,7 +46,7 @@
{#if !dismissed} {#if !dismissed}
{#if mounted} {#if mounted}
<div <div
class="{className} top-0 left-0 right-0 p-2 px-3 flex justify-center items-center relative rounded-xl border border-gray-100 dark:border-gray-850 text-gray-800 dark:text-gary-100 bg-white dark:bg-gray-900 backdrop-blur-xl z-30" class="{className} top-0 left-0 right-0 py-0.5 flex justify-center items-center relative border border-transparent text-gray-800 dark:text-gary-100 bg-white dark:bg-gray-900 backdrop-blur-xl z-30"
transition:fade={{ delay: 100, duration: 300 }} transition:fade={{ delay: 100, duration: 300 }}
> >
<div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5"> <div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5">

View file

@ -169,7 +169,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="{buttonClassName} cursor-pointer" class="{buttonClassName} cursor-pointer"
on:pointerup={() => { on:click={(e) => {
e.stopPropagation();
}}
on:pointerup={(e) => {
if (!disabled) { if (!disabled) {
open = !open; open = !open;
} }

View file

@ -0,0 +1,20 @@
<script>
import { WEBUI_BASE_URL } from '$lib/constants';
import { shortCodesToEmojis } from '$lib/stores';
export let shortCode;
export let className = 'size-4';
</script>
{#if $shortCodesToEmojis[shortCode]}
<img
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[shortCode].toLowerCase()}.svg"
alt={shortCode}
class={className}
loading="lazy"
/>
{:else}
<div>
{shortCode}
</div>
{/if}

View file

@ -30,16 +30,18 @@
$: { $: {
if (search) { if (search) {
emojis = Object.keys(emojiShortCodes).reduce((acc, key) => { emojis = Object.keys(emojiShortCodes).reduce((acc, key) => {
if (key.includes(search)) { if (key.includes(search.toLowerCase())) {
acc[key] = emojiShortCodes[key]; acc[key] = emojiShortCodes[key];
} else { } else {
if (Array.isArray(emojiShortCodes[key])) { if (Array.isArray(emojiShortCodes[key])) {
const filtered = emojiShortCodes[key].filter((emoji) => emoji.includes(search)); const filtered = emojiShortCodes[key].filter((emoji) =>
emoji.includes(search.toLowerCase())
);
if (filtered.length) { if (filtered.length) {
acc[key] = filtered; acc[key] = filtered;
} }
} else { } else {
if (emojiShortCodes[key].includes(search)) { if (emojiShortCodes[key].includes(search.toLowerCase())) {
acc[key] = emojiShortCodes[key]; acc[key] = emojiShortCodes[key];
} }
} }

View file

@ -16,6 +16,7 @@
export let name = ''; export let name = '';
export let collapsible = true; export let collapsible = true;
export let chevron = true;
export let onAddLabel: string = ''; export let onAddLabel: string = '';
export let onAdd: null | Function = null; export let onAdd: null | Function = null;
@ -137,16 +138,18 @@
> >
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="w-full group rounded-md relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 text-gray-500 dark:text-gray-500 transition" class="w-full group rounded-lg relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 text-gray-500 dark:text-gray-500 transition"
> >
<button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium"> <button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium">
<div class="text-gray-300 dark:text-gray-600"> {#if chevron}
{#if open} <div class="text-gray-300 dark:text-gray-600 p-[1px]">
<ChevronDown className=" size-3" strokeWidth="2.5" /> {#if open}
{:else} <ChevronDown className=" size-3.5" strokeWidth="2.5" />
<ChevronRight className=" size-3" strokeWidth="2.5" /> {:else}
{/if} <ChevronRight className=" size-3.5" strokeWidth="2.5" />
</div> {/if}
</div>
{/if}
<div class="translate-y-[0.5px]"> <div class="translate-y-[0.5px]">
{name} {name}

View file

@ -9,7 +9,7 @@
export let tags = []; export let tags = [];
</script> </script>
<ul class="flex flex-row flex-wrap gap-1 line-clamp-1"> <ul class="flex flex-row flex-wrap gap-[0.3rem] line-clamp-1">
<TagList <TagList
{tags} {tags}
on:delete={(e) => { on:delete={(e) => {

View file

@ -281,7 +281,7 @@
transition={flyAndScale} transition={flyAndScale}
> >
<!-- <DropdownMenu.Item <!-- <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer dark:hover:bg-gray-800 rounded-lg"
on:click={async () => { on:click={async () => {
await showSettings.set(!$showSettings); await showSettings.set(!$showSettings);
}} }}
@ -310,7 +310,7 @@
{#if $mobile} {#if $mobile}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
id="chat-controls-button" id="chat-controls-button"
on:click={async () => { on:click={async () => {
await showControls.set(true); await showControls.set(true);
@ -324,7 +324,7 @@
{/if} {/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
id="chat-overview-button" id="chat-overview-button"
on:click={async () => { on:click={async () => {
await showControls.set(true); await showControls.set(true);
@ -337,7 +337,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
id="chat-overview-button" id="chat-overview-button"
on:click={async () => { on:click={async () => {
await showControls.set(true); await showControls.set(true);
@ -349,17 +349,17 @@
<div class="flex items-center">{$i18n.t('Artifacts')}</div> <div class="flex items-center">{$i18n.t('Artifacts')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-800 my-1" /> <hr class="border-gray-50 dark:border-gray-800 my-1" />
{#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))} {#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user.permissions?.chat?.share ?? true))}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
id="chat-share-button" id="chat-share-button"
on:click={() => { on:click={() => {
shareHandler(); shareHandler();
}} }}
> >
<Share /> <Share strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Share')}</div> <div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
@ -367,9 +367,9 @@
{#if chat?.id} {#if chat?.id}
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger <DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
> >
<Folder /> <Folder strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Move')}</div> <div class="flex items-center">{$i18n.t('Move')}</div>
</DropdownMenu.SubTrigger> </DropdownMenu.SubTrigger>
@ -380,12 +380,12 @@
> >
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder} {#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg"
on:click={() => { on:click={() => {
moveChatHandler(chat?.id, folder?.id); moveChatHandler(chat?.id, folder?.id);
}} }}
> >
<Folder /> <Folder strokeWidth="1.5" />
<div class="flex items-center">{folder?.name ?? 'Folder'}</div> <div class="flex items-center">{folder?.name ?? 'Folder'}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
@ -395,18 +395,18 @@
{/if} {/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg"
on:click={() => { on:click={() => {
archiveChatHandler(); archiveChatHandler();
}} }}
> >
<ArchiveBox className="size-4" strokeWidth="2" /> <ArchiveBox className="size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Archive')}</div> <div class="flex items-center">{$i18n.t('Archive')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger <DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -432,7 +432,7 @@
> >
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)} {#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
on:click={() => { on:click={() => {
downloadJSONExport(); downloadJSONExport();
}} }}
@ -441,7 +441,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
on:click={() => { on:click={() => {
downloadTxt(); downloadTxt();
}} }}
@ -450,7 +450,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
on:click={() => { on:click={() => {
downloadPdf(); downloadPdf();
}} }}
@ -461,7 +461,7 @@
</DropdownMenu.Sub> </DropdownMenu.Sub>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg select-none w-full"
id="chat-copy-button" id="chat-copy-button"
on:click={async () => { on:click={async () => {
const res = await copyToClipboard(await getChatAsText()).catch((e) => { const res = await copyToClipboard(await getChatAsText()).catch((e) => {
@ -478,7 +478,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
{#if !$temporaryChatEnabled} {#if !$temporaryChatEnabled}
<hr class="border-gray-100 dark:border-gray-850 my-0.5" /> <hr class="border-gray-50 dark:border-gray-800 my-1" />
<div class="flex p-1"> <div class="flex p-1">
<Tags chatId={chat.id} /> <Tags chatId={chat.id} />

View file

@ -513,7 +513,7 @@
{#if !$mobile && !$showSidebar} {#if !$mobile && !$showSidebar}
<div <div
class=" py-2 px-1.5 flex flex-col justify-between text-black dark:text-white h-full border-e border-gray-50 dark:border-gray-850 z-10" class=" py-2 px-1.5 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50 dark:hover:bg-gray-950 h-full border-e border-gray-50 dark:border-gray-850 z-10 transition-all"
id="sidebar" id="sidebar"
> >
<button <button
@ -695,7 +695,7 @@
? `ml-[4.5rem] md:ml-0 ` ? `ml-[4.5rem] md:ml-0 `
: ' transition-all duration-300 '} shrink-0 text-gray-900 dark:text-gray-200 text-sm fixed top-0 left-0 overflow-x-hidden : ' transition-all duration-300 '} shrink-0 text-gray-900 dark:text-gray-200 text-sm fixed top-0 left-0 overflow-x-hidden
" "
transition:slide={{ duration: 200, axis: 'x' }} transition:slide={{ duration: 250, axis: 'x' }}
data-state={$showSidebar} data-state={$showSidebar}
> >
<div <div
@ -847,6 +847,7 @@
<Folder <Folder
className="px-2 mt-0.5" className="px-2 mt-0.5"
name={$i18n.t('Channels')} name={$i18n.t('Channels')}
chevron={false}
dragAndDrop={false} dragAndDrop={false}
onAdd={async () => { onAdd={async () => {
if ($user?.role === 'admin') { if ($user?.role === 'admin') {
@ -873,6 +874,7 @@
<Folder <Folder
className="px-2 mt-0.5" className="px-2 mt-0.5"
name={$i18n.t('Chats')} name={$i18n.t('Chats')}
chevron={false}
onAdd={() => { onAdd={() => {
showCreateFolderModal = true; showCreateFolderModal = true;
}} }}
@ -1023,24 +1025,26 @@
{/if} {/if}
{#if folders} {#if folders}
<Folders <div class="mb-1">
{folders} <Folders
{shiftKey} {folders}
onDelete={(folderId) => { {shiftKey}
selectedFolder.set(null); onDelete={(folderId) => {
initChatList(); selectedFolder.set(null);
}} initChatList();
on:update={() => { }}
initChatList(); on:update={() => {
}} initChatList();
on:import={(e) => { }}
const { folderId, items } = e.detail; on:import={(e) => {
importChatHandler(items, false, folderId); const { folderId, items } = e.detail;
}} importChatHandler(items, false, folderId);
on:change={async () => { }}
initChatList(); on:change={async () => {
}} initChatList();
/> }}
/>
</div>
{/if} {/if}
<div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden"> <div class=" flex-1 flex flex-col overflow-y-auto scrollbar-hidden">

View file

@ -117,6 +117,65 @@
align="start" align="start"
transition={flyAndScale} transition={flyAndScale}
> >
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
>
<Share strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
>
<Download strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
transition={flyAndScale}
sideOffset={8}
>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadJSONExport();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
renameHandler();
}}
>
<Pencil strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</DropdownMenu.Item>
<hr class="border-gray-50 dark:border-gray-800 my-1" />
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => { on:click={() => {
@ -124,10 +183,10 @@
}} }}
> >
{#if pinned} {#if pinned}
<BookmarkSlash strokeWidth="2" /> <BookmarkSlash strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Unpin')}</div> <div class="flex items-center">{$i18n.t('Unpin')}</div>
{:else} {:else}
<Bookmark strokeWidth="2" /> <Bookmark strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Pin')}</div> <div class="flex items-center">{$i18n.t('Pin')}</div>
{/if} {/if}
</DropdownMenu.Item> </DropdownMenu.Item>
@ -135,7 +194,7 @@
{#if chatId} {#if chatId}
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger <DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md select-none w-full"
> >
<Folder /> <Folder />
@ -162,23 +221,13 @@
</DropdownMenu.Sub> </DropdownMenu.Sub>
{/if} {/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
renameHandler();
}}
>
<Pencil strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => { on:click={() => {
cloneChatHandler(); cloneChatHandler();
}} }}
> >
<DocumentDuplicate strokeWidth="2" /> <DocumentDuplicate strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Clone')}</div> <div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
@ -188,93 +237,19 @@
archiveChatHandler(); archiveChatHandler();
}} }}
> >
<ArchiveBox strokeWidth="2" /> <ArchiveBox strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Archive')}</div> <div class="flex items-center">{$i18n.t('Archive')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.share ?? true)}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
>
<Share />
<div class="flex items-center">{$i18n.t('Share')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
>
<Download strokeWidth="2" />
<div class="flex items-center">{$i18n.t('Download')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
transition={flyAndScale}
sideOffset={8}
>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadJSONExport();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
downloadTxt();
}}
>
<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.txt)')}</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => { on:click={() => {
deleteHandler(); deleteHandler();
}} }}
> >
<GarbageBin strokeWidth="2" /> <GarbageBin strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Delete')}</div> <div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-850 my-0.5" />
<div class="flex p-1">
<Tags
{chatId}
on:add={(e) => {
dispatch('tag', {
type: 'add',
name: e.detail.name
});
show = false;
}}
on:delete={(e) => {
dispatch('tag', {
type: 'delete',
name: e.detail.name
});
show = false;
}}
on:close={() => {
show = false;
onClose();
}}
/>
</div>
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>
</Dropdown> </Dropdown>

View file

@ -38,6 +38,7 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FolderModal from './Folders/FolderModal.svelte'; import FolderModal from './Folders/FolderModal.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Emoji from '$lib/components/common/Emoji.svelte';
export let open = false; export let open = false;
@ -426,7 +427,7 @@
<div class="w-full group"> <div class="w-full group">
<button <button
id="folder-{folderId}-button" id="folder-{folderId}-button"
class="relative w-full py-1.5 px-2 rounded-md flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id === class="relative w-full py-1.5 px-2 rounded-lg flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-500 font-medium hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
folderId folderId
? 'bg-gray-100 dark:bg-gray-900' ? 'bg-gray-100 dark:bg-gray-900'
: ''}" : ''}"
@ -444,15 +445,31 @@
}} }}
> >
<button <button
class="text-gray-300 dark:text-gray-600" class="text-gray-300 dark:text-gray-600 transition-all"
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{#if open} {#if folders[folderId]?.meta?.icon}
<ChevronDown className=" size-3" strokeWidth="2.5" /> <div class="flex group-hover:hidden transition-all">
<Emoji className="size-4" shortCode={folders[folderId].meta.icon} />
</div>
<div class="hidden group-hover:flex transition-all p-[1px]">
{#if open}
<ChevronDown className=" size-3.5" strokeWidth="2.5" />
{:else}
<ChevronRight className=" size-3.5" strokeWidth="2.5" />
{/if}
</div>
{:else} {:else}
<ChevronRight className=" size-3" strokeWidth="2.5" /> <div class="p-[1px]">
{#if open}
<ChevronDown className=" size-3.5" strokeWidth="2.5" />
{:else}
<ChevronRight className=" size-3.5" strokeWidth="2.5" />
{/if}
</div>
{/if} {/if}
</button> </button>

View file

@ -154,6 +154,7 @@
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<DropdownMenu.Item <DropdownMenu.Item
as="a" as="a"
target="_blank"
class="flex gap-2 items-center py-1.5 px-3 text-sm select-none w-full cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition" class="flex gap-2 items-center py-1.5 px-3 text-sm select-none w-full cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition"
id="chat-share-button" id="chat-share-button"
on:click={() => { on:click={() => {
@ -168,6 +169,7 @@
<!-- Releases --> <!-- Releases -->
<DropdownMenu.Item <DropdownMenu.Item
as="a" as="a"
target="_blank"
class="flex gap-2 items-center py-1.5 px-3 text-sm select-none w-full cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition" class="flex gap-2 items-center py-1.5 px-3 text-sm select-none w-full cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md transition"
id="chat-share-button" id="chat-share-button"
on:click={() => { on:click={() => {

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, tick } from 'svelte'; import { getContext, onDestroy, onMount, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
@ -26,7 +25,7 @@
import { PaneGroup, Pane, PaneResizer } from 'paneforge'; import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { compressImage, copyToClipboard, splitStream } from '$lib/utils'; import { compressImage, copyToClipboard, splitStream, convertHeicToJpeg } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files'; import { uploadFile } from '$lib/apis/files';
import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai'; import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai';
@ -545,11 +544,7 @@ ${content}
} }
}; };
reader.readAsDataURL( reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
}); });
return await uploadImagePromise; return await uploadImagePromise;
@ -978,7 +973,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
disabled={(note?.user_id !== $user?.id && $user?.role !== 'admin') || disabled={(note?.user_id !== $user?.id && $user?.role !== 'admin') ||
titleGenerating} titleGenerating}
required required
on:input={changeDebounceHandler}
on:focus={() => { on:focus={() => {
titleInputFocused = true; titleInputFocused = true;
}} }}

View file

@ -43,7 +43,7 @@
} from '$lib/constants'; } from '$lib/constants';
import { WEBUI_NAME, config, user, models, settings } from '$lib/stores'; import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai'; import { chatCompletion } from '$lib/apis/openai';
import { splitStream } from '$lib/utils'; import { splitStream } from '$lib/utils';

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