mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
Merge branch 'open-webui:main' into universal_file_deletion
This commit is contained in:
commit
30095f5cb5
326 changed files with 15979 additions and 6767 deletions
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -73,4 +73,4 @@
|
|||
|
||||
### Contributor License Agreement
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
|
|
|||
4
.github/workflows/build-release.yml
vendored
4
.github/workflows/build-release.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
@ -61,7 +61,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build workflow
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
|
|
|
|||
2
.github/workflows/format-backend.yaml
vendored
2
.github/workflows/format-backend.yaml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '${{ matrix.python-version }}'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/format-build-frontend.yaml
vendored
4
.github/workflows/format-build-frontend.yaml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ jobs:
|
|||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/release-pypi.yml
vendored
4
.github/workflows/release-pypi.yml
vendored
|
|
@ -21,10 +21,10 @@ jobs:
|
|||
fetch-depth: 0
|
||||
- name: Install Git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Build
|
||||
|
|
|
|||
140
CHANGELOG.md
140
CHANGELOG.md
|
|
@ -5,6 +5,146 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.31] - 2025-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
|
||||
- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
|
||||
- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
|
||||
- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
|
||||
- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
|
||||
- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
|
||||
- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
|
||||
- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
|
||||
- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
|
||||
- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
|
||||
- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
|
||||
- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
|
||||
- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
|
||||
- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
|
||||
- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
|
||||
- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
|
||||
- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
|
||||
- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
|
||||
- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
|
||||
- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
|
||||
- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
|
||||
- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
|
||||
- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
|
||||
- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
|
||||
- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
|
||||
- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
|
||||
- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
|
||||
- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
|
||||
- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
|
||||
- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
|
||||
- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
|
||||
- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
|
||||
- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
|
||||
- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
|
||||
- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
|
||||
- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
|
||||
- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
|
||||
|
||||
### Changed
|
||||
|
||||
- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
|
||||
- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
|
||||
|
||||
## [0.6.30] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
|
||||
|
||||
## [0.6.29] - 2025-09-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1)
|
||||
- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145)
|
||||
- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054)
|
||||
- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418)
|
||||
- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748)
|
||||
- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2)
|
||||
- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402)
|
||||
- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690)
|
||||
- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308)
|
||||
- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697)
|
||||
- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477)
|
||||
- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479)
|
||||
- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498)
|
||||
- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499)
|
||||
- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502)
|
||||
- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503)
|
||||
- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572)
|
||||
- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50)
|
||||
- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e)
|
||||
- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275)
|
||||
- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62)
|
||||
- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5)
|
||||
- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad)
|
||||
- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c)
|
||||
- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685)
|
||||
- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533)
|
||||
- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356)
|
||||
- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd)
|
||||
- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f)
|
||||
- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12)
|
||||
- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f)
|
||||
- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0)
|
||||
- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66)
|
||||
- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396)
|
||||
- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c)
|
||||
- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4)
|
||||
- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8)
|
||||
- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab)
|
||||
- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9)
|
||||
- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa)
|
||||
- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423)
|
||||
- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3)
|
||||
|
||||
### Changed
|
||||
|
||||
- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696)
|
||||
- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5)
|
||||
- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956)
|
||||
- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161)
|
||||
|
||||
## [0.6.28] - 2025-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- 🔍 The "@" command for model selection now supports real-time search and filtering, improving usability and aligning its behavior with other input commands. [#17307](https://github.com/open-webui/open-webui/issues/17307), [Commit](https://github.com/open-webui/open-webui/commit/f2a09c71499489ee71599af4a179e7518aaf658b)
|
||||
- 🛠️ External tool server data handling is now more robust, automatically attempting to parse specifications as JSON before falling back to YAML, regardless of the URL extension. [Commit](https://github.com/open-webui/open-webui/commit/774c0056bde88ed4831422efa81506488e3d6641)
|
||||
- 🎯 The "Title" field is now automatically focused when creating a new chat folder, streamlining the folder creation process. [#17315](https://github.com/open-webui/open-webui/issues/17315), [Commit](https://github.com/open-webui/open-webui/commit/c51a651a2d5e2a27546416666812e9b92205562d)
|
||||
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
|
||||
- 🌐 Brazilian Portuguese and Simplified Chinese translations were expanded and refined.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔊 A regression affecting Text-to-Speech for local providers using the OpenAI engine was fixed by reverting a URL joining change. [#17316](https://github.com/open-webui/open-webui/issues/17316), [Commit](https://github.com/open-webui/open-webui/commit/8339f59cdfc63f2d58c8e26933d1bf1438479d75)
|
||||
- 🪧 A regression was fixed where the input modal for prompts with placeholders would not open, causing the raw prompt text to be pasted into the chat input field instead. [#17325](https://github.com/open-webui/open-webui/issues/17325), [Commit](https://github.com/open-webui/open-webui/commit/d5cb65527eaa4831459a4c7dbf187daa9c0525ae)
|
||||
- 🔑 An issue was resolved where modified connection keys in the OpenAIConnection component did not take effect. [#17324](https://github.com/open-webui/open-webui/pull/17324)
|
||||
|
||||
## [0.6.27] - 2025-09-09
|
||||
|
||||
### Added
|
||||
|
|
|
|||
11
LICENSE_NOTICE
Normal file
11
LICENSE_NOTICE
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Open WebUI Multi-License Notice
|
||||
|
||||
This repository contains code governed by multiple licenses based on the date and origin of contribution:
|
||||
|
||||
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
|
||||
|
||||
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
|
||||
|
||||
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
|
||||
|
||||
For details on which commits are covered by which license, refer to LICENSE_HISTORY.
|
||||
|
|
@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
|
|||
|
||||
## License 📜
|
||||
|
||||
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
|
||||
This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
|
||||
|
||||
## Support 💬
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export CORS_ALLOW_ORIGIN="http://localhost:5173"
|
||||
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
|
|
|
|||
|
|
@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
|
|||
|
||||
|
||||
class AppConfig:
|
||||
_state: dict[str, PersistentConfig]
|
||||
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
|
||||
_redis_key_prefix: str
|
||||
|
||||
_state: dict[str, PersistentConfig]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis_url: Optional[str] = None,
|
||||
|
|
@ -233,9 +234,8 @@ class AppConfig:
|
|||
redis_cluster: Optional[bool] = False,
|
||||
redis_key_prefix: str = "open-webui",
|
||||
):
|
||||
super().__setattr__("_state", {})
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
if redis_url:
|
||||
super().__setattr__("_redis_key_prefix", redis_key_prefix)
|
||||
super().__setattr__(
|
||||
"_redis",
|
||||
get_redis_connection(
|
||||
|
|
@ -246,6 +246,8 @@ class AppConfig:
|
|||
),
|
||||
)
|
||||
|
||||
super().__setattr__("_state", {})
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if isinstance(value, PersistentConfig):
|
||||
self._state[key] = value
|
||||
|
|
@ -513,6 +515,30 @@ OAUTH_GROUPS_CLAIM = PersistentConfig(
|
|||
os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
|
||||
)
|
||||
|
||||
FEISHU_CLIENT_ID = PersistentConfig(
|
||||
"FEISHU_CLIENT_ID",
|
||||
"oauth.feishu.client_id",
|
||||
os.environ.get("FEISHU_CLIENT_ID", ""),
|
||||
)
|
||||
|
||||
FEISHU_CLIENT_SECRET = PersistentConfig(
|
||||
"FEISHU_CLIENT_SECRET",
|
||||
"oauth.feishu.client_secret",
|
||||
os.environ.get("FEISHU_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
FEISHU_OAUTH_SCOPE = PersistentConfig(
|
||||
"FEISHU_OAUTH_SCOPE",
|
||||
"oauth.feishu.scope",
|
||||
os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"),
|
||||
)
|
||||
|
||||
FEISHU_REDIRECT_URI = PersistentConfig(
|
||||
"FEISHU_REDIRECT_URI",
|
||||
"oauth.feishu.redirect_uri",
|
||||
os.environ.get("FEISHU_REDIRECT_URI", ""),
|
||||
)
|
||||
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
|
||||
"ENABLE_OAUTH_ROLE_MANAGEMENT",
|
||||
"oauth.enable_role_mapping",
|
||||
|
|
@ -705,6 +731,33 @@ def load_oauth_providers():
|
|||
"register": oidc_oauth_register,
|
||||
}
|
||||
|
||||
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
|
||||
|
||||
def feishu_oauth_register(client: OAuth):
|
||||
client.register(
|
||||
name="feishu",
|
||||
client_id=FEISHU_CLIENT_ID.value,
|
||||
client_secret=FEISHU_CLIENT_SECRET.value,
|
||||
access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize",
|
||||
api_base_url="https://open.feishu.cn/open-apis",
|
||||
userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
client_kwargs={
|
||||
"scope": FEISHU_OAUTH_SCOPE.value,
|
||||
**(
|
||||
{"timeout": int(OAUTH_TIMEOUT.value)}
|
||||
if OAUTH_TIMEOUT.value
|
||||
else {}
|
||||
),
|
||||
},
|
||||
redirect_uri=FEISHU_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["feishu"] = {
|
||||
"register": feishu_oauth_register,
|
||||
"sub_claim": "user_id",
|
||||
}
|
||||
|
||||
configured_providers = []
|
||||
if GOOGLE_CLIENT_ID.value:
|
||||
configured_providers.append("Google")
|
||||
|
|
@ -712,6 +765,8 @@ def load_oauth_providers():
|
|||
configured_providers.append("Microsoft")
|
||||
if GITHUB_CLIENT_ID.value:
|
||||
configured_providers.append("GitHub")
|
||||
if FEISHU_CLIENT_ID.value:
|
||||
configured_providers.append("Feishu")
|
||||
|
||||
if configured_providers and not OPENID_PROVIDER_URL.value:
|
||||
provider_list = ", ".join(configured_providers)
|
||||
|
|
@ -2116,10 +2171,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
|
|||
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
|
||||
)
|
||||
|
||||
ONEDRIVE_CLIENT_ID = PersistentConfig(
|
||||
"ONEDRIVE_CLIENT_ID",
|
||||
"onedrive.client_id",
|
||||
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
|
||||
|
||||
ENABLE_ONEDRIVE_PERSONAL = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
|
||||
)
|
||||
ENABLE_ONEDRIVE_BUSINESS = (
|
||||
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
|
||||
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
|
||||
)
|
||||
|
||||
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
|
||||
|
|
@ -2702,6 +2767,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
|
|||
)
|
||||
|
||||
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
|
||||
"rag.web.search.ollama_cloud_api_key",
|
||||
os.getenv("OLLAMA_CLOUD_API_KEY", ""),
|
||||
)
|
||||
|
||||
SEARXNG_QUERY_URL = PersistentConfig(
|
||||
"SEARXNG_QUERY_URL",
|
||||
"rag.web.search.searxng_query_url",
|
||||
|
|
|
|||
|
|
@ -474,6 +474,10 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE = (
|
|||
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
|
||||
)
|
||||
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
|
@ -547,16 +551,16 @@ else:
|
|||
|
||||
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
|
||||
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
|
||||
)
|
||||
|
||||
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
else:
|
||||
try:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
|
||||
except Exception:
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
|
||||
|
||||
|
||||
####################################
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from fastapi import (
|
|||
from starlette.responses import Response, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
|
|
@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
|
|||
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
|
||||
|
||||
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
|
||||
Valves = function_module.Valves
|
||||
valves = Functions.get_function_valves_by_id(pipe_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
if valves:
|
||||
try:
|
||||
function_module.valves = Valves(
|
||||
**{k: v for k, v in valves.items() if v is not None}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"Error loading valves for function {pipe_id}: {e}")
|
||||
raise e
|
||||
else:
|
||||
function_module.valves = Valves()
|
||||
|
||||
return function_module
|
||||
|
||||
|
||||
|
|
@ -70,65 +83,69 @@ async def get_function_models(request):
|
|||
pipe_models = []
|
||||
|
||||
for pipe in pipes:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
try:
|
||||
function_module = get_function_module_by_id(request, pipe.id)
|
||||
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
sub_pipes = []
|
||||
|
||||
# Handle pipes being a list, sync function, or async function
|
||||
try:
|
||||
if callable(function_module.pipes):
|
||||
if asyncio.iscoroutinefunction(function_module.pipes):
|
||||
sub_pipes = await function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
# Check if function is a manifold
|
||||
if hasattr(function_module, "pipes"):
|
||||
sub_pipes = []
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||
)
|
||||
# Handle pipes being a list, sync function, or async function
|
||||
try:
|
||||
if callable(function_module.pipes):
|
||||
if asyncio.iscoroutinefunction(function_module.pipes):
|
||||
sub_pipes = await function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes()
|
||||
else:
|
||||
sub_pipes = function_module.pipes
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
sub_pipes = []
|
||||
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
sub_pipe_name = p["name"]
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
|
||||
)
|
||||
|
||||
if hasattr(function_module, "name"):
|
||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||
for p in sub_pipes:
|
||||
sub_pipe_id = f'{pipe.id}.{p["id"]}'
|
||||
sub_pipe_name = p["name"]
|
||||
|
||||
pipe_flag = {"type": pipe.type}
|
||||
if hasattr(function_module, "name"):
|
||||
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
|
||||
|
||||
pipe_flag = {"type": pipe.type}
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": sub_pipe_id,
|
||||
"name": sub_pipe_name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pipe_flag = {"type": "pipe"}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": sub_pipe_id,
|
||||
"name": sub_pipe_name,
|
||||
"id": pipe.id,
|
||||
"name": pipe.name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
else:
|
||||
pipe_flag = {"type": "pipe"}
|
||||
|
||||
log.debug(
|
||||
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
|
||||
)
|
||||
|
||||
pipe_models.append(
|
||||
{
|
||||
"id": pipe.id,
|
||||
"name": pipe.name,
|
||||
"object": "model",
|
||||
"created": pipe.created_at,
|
||||
"owned_by": "openai",
|
||||
"pipe": pipe_flag,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
continue
|
||||
|
||||
return pipe_models
|
||||
|
||||
|
|
@ -221,10 +238,11 @@ async def generate_function_chat_completion(
|
|||
|
||||
oauth_token = None
|
||||
try:
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||
from starlette.responses import Response, StreamingResponse
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from starsessions import (
|
||||
SessionMiddleware as StarSessionsMiddleware,
|
||||
SessionAutoloadMiddleware,
|
||||
)
|
||||
from starsessions.stores.redis import RedisStore
|
||||
|
||||
from open_webui.utils import logger
|
||||
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
|
||||
|
|
@ -111,9 +116,6 @@ from open_webui.config import (
|
|||
OLLAMA_API_CONFIGS,
|
||||
# OpenAI
|
||||
ENABLE_OPENAI_API,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
OPENAI_API_BASE_URLS,
|
||||
OPENAI_API_KEYS,
|
||||
OPENAI_API_CONFIGS,
|
||||
|
|
@ -273,6 +275,7 @@ from open_webui.config import (
|
|||
WEB_SEARCH_CONCURRENT_REQUESTS,
|
||||
WEB_SEARCH_TRUST_ENV,
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
JINA_API_KEY,
|
||||
SEARCHAPI_API_KEY,
|
||||
SEARCHAPI_ENGINE,
|
||||
|
|
@ -304,14 +307,17 @@ from open_webui.config import (
|
|||
GOOGLE_PSE_ENGINE_ID,
|
||||
GOOGLE_DRIVE_CLIENT_ID,
|
||||
GOOGLE_DRIVE_API_KEY,
|
||||
ONEDRIVE_CLIENT_ID,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
ONEDRIVE_SHAREPOINT_URL,
|
||||
ONEDRIVE_SHAREPOINT_TENANT_ID,
|
||||
ENABLE_ONEDRIVE_PERSONAL,
|
||||
ENABLE_ONEDRIVE_BUSINESS,
|
||||
ENABLE_RAG_HYBRID_SEARCH,
|
||||
ENABLE_RAG_LOCAL_WEB_FETCH,
|
||||
ENABLE_WEB_LOADER_SSL_VERIFICATION,
|
||||
ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
ENABLE_ONEDRIVE_INTEGRATION,
|
||||
UPLOAD_DIR,
|
||||
EXTERNAL_WEB_SEARCH_URL,
|
||||
EXTERNAL_WEB_SEARCH_API_KEY,
|
||||
|
|
@ -449,6 +455,7 @@ from open_webui.utils.models import (
|
|||
get_all_models,
|
||||
get_all_base_models,
|
||||
check_model_access,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.chat import (
|
||||
generate_chat_completion as chat_completion_handler,
|
||||
|
|
@ -467,7 +474,12 @@ from open_webui.utils.auth import (
|
|||
get_verified_user,
|
||||
)
|
||||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.oauth import OAuthManager
|
||||
from open_webui.utils.oauth import (
|
||||
OAuthManager,
|
||||
OAuthClientManager,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from open_webui.utils.security_headers import SecurityHeadersMiddleware
|
||||
from open_webui.utils.redis import get_redis_connection
|
||||
|
||||
|
|
@ -597,9 +609,14 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# For Open WebUI OIDC/OAuth2
|
||||
oauth_manager = OAuthManager(app)
|
||||
app.state.oauth_manager = oauth_manager
|
||||
|
||||
# For Integrations
|
||||
oauth_client_manager = OAuthClientManager(app)
|
||||
app.state.oauth_client_manager = oauth_client_manager
|
||||
|
||||
app.state.instance_id = None
|
||||
app.state.config = AppConfig(
|
||||
redis_url=REDIS_URL,
|
||||
|
|
@ -883,6 +900,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
|
|||
|
||||
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
|
||||
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
|
||||
|
||||
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
|
||||
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
|
||||
app.state.config.YACY_USERNAME = YACY_USERNAME
|
||||
|
|
@ -1293,33 +1312,6 @@ if audit_level != AuditLevel.NONE:
|
|||
async def get_models(
|
||||
request: Request, refresh: bool = False, user=Depends(get_verified_user)
|
||||
):
|
||||
def get_filtered_models(models, user):
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
if has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
):
|
||||
filtered_models.append(model)
|
||||
continue
|
||||
|
||||
model_info = Models.get_model_by_id(model["id"])
|
||||
if model_info:
|
||||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id, type="read", access_control=model_info.access_control
|
||||
)
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
|
||||
all_models = await get_all_models(request, refresh=refresh, user=user)
|
||||
|
||||
models = []
|
||||
|
|
@ -1355,12 +1347,7 @@ async def get_models(
|
|||
)
|
||||
)
|
||||
|
||||
# Filter out models that the user does not have access to
|
||||
if (
|
||||
user.role == "user"
|
||||
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
) and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
models = get_filtered_models(models, user)
|
||||
models = get_filtered_models(models, user)
|
||||
|
||||
log.debug(
|
||||
f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
|
||||
|
|
@ -1420,14 +1407,6 @@ async def chat_completion(
|
|||
model_item = form_data.pop("model_item", {})
|
||||
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 = {}
|
||||
try:
|
||||
if not model_item.get("direct", False):
|
||||
|
|
@ -1572,6 +1551,14 @@ async def chat_completion(
|
|||
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if mcp_clients := metadata.get("mcp_clients"):
|
||||
for client in mcp_clients:
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
log.debug(f"Error cleaning up: {e}")
|
||||
pass
|
||||
|
||||
if (
|
||||
metadata.get("session_id")
|
||||
|
|
@ -1740,6 +1727,14 @@ async def get_app_config(request: Request):
|
|||
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
|
||||
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
**(
|
||||
{
|
||||
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
|
||||
"enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
|
||||
}
|
||||
if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
|
||||
else {}
|
||||
),
|
||||
}
|
||||
if user is not None
|
||||
else {}
|
||||
|
|
@ -1777,7 +1772,8 @@ async def get_app_config(request: Request):
|
|||
"api_key": GOOGLE_DRIVE_API_KEY.value,
|
||||
},
|
||||
"onedrive": {
|
||||
"client_id": ONEDRIVE_CLIENT_ID.value,
|
||||
"client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
|
||||
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
|
||||
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
|
||||
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
|
||||
},
|
||||
|
|
@ -1897,14 +1893,78 @@ async def get_current_usage(user=Depends(get_verified_user)):
|
|||
# OAuth Login & Callback
|
||||
############################
|
||||
|
||||
|
||||
# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if tool_server_connection.get("type", "openapi") == "mcp":
|
||||
server_id = tool_server_connection.get("info", {}).get("id")
|
||||
auth_type = tool_server_connection.get("auth_type", "none")
|
||||
if server_id and auth_type == "oauth_2.1":
|
||||
oauth_client_info = tool_server_connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
app.state.oauth_client_manager.add_client(
|
||||
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
|
||||
)
|
||||
|
||||
|
||||
# SessionMiddleware is used by authlib for oauth
|
||||
if len(OAUTH_PROVIDERS) > 0:
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=WEBUI_SECRET_KEY,
|
||||
session_cookie="oui-session",
|
||||
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
try:
|
||||
if REDIS_URL:
|
||||
redis_session_store = RedisStore(
|
||||
url=REDIS_URL,
|
||||
prefix=(
|
||||
f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"
|
||||
),
|
||||
)
|
||||
|
||||
app.add_middleware(SessionAutoloadMiddleware)
|
||||
app.add_middleware(
|
||||
StarSessionsMiddleware,
|
||||
store=redis_session_store,
|
||||
cookie_name="oui-session",
|
||||
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
log.info("Using Redis for session")
|
||||
else:
|
||||
raise ValueError("No Redis URL provided")
|
||||
except Exception as e:
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=WEBUI_SECRET_KEY,
|
||||
session_cookie="oui-session",
|
||||
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
https_only=WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/authorize")
|
||||
async def oauth_client_authorize(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
|
||||
|
||||
|
||||
@app.get("/oauth/clients/{client_id}/callback")
|
||||
async def oauth_client_callback(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return await oauth_client_manager.handle_callback(
|
||||
request,
|
||||
client_id=client_id,
|
||||
user_id=user.id if user else None,
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1919,8 +1979,9 @@ async def oauth_login(provider: str, request: Request):
|
|||
# - This is considered insecure in general, as OAuth providers do not always verify email addresses
|
||||
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
|
||||
# - Email addresses are considered unique, so we fail registration if the email address is already taken
|
||||
@app.get("/oauth/{provider}/callback")
|
||||
async def oauth_callback(provider: str, request: Request, response: Response):
|
||||
@app.get("/oauth/{provider}/login/callback")
|
||||
@app.get("/oauth/{provider}/callback") # Legacy endpoint
|
||||
async def oauth_login_callback(provider: str, request: Request, response: Response):
|
||||
return await oauth_manager.handle_callback(request, provider, response)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
|
|||
####################
|
||||
|
||||
|
||||
class ChannelResponse(ChannelModel):
|
||||
write_access: bool = False
|
||||
|
||||
|
||||
class ChannelForm(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ class ChatTable:
|
|||
|
||||
return chat.chat.get("title", "New Chat")
|
||||
|
||||
def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
|
@ -492,11 +492,16 @@ class ChatTable:
|
|||
self,
|
||||
user_id: str,
|
||||
include_archived: bool = False,
|
||||
include_folders: bool = False,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
if not include_folders:
|
||||
query = query.filter_by(folder_id=None)
|
||||
|
||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
|
||||
if not include_archived:
|
||||
|
|
@ -943,6 +948,16 @@ class ChatTable:
|
|||
|
||||
return count
|
||||
|
||||
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
|
||||
query = query.filter_by(folder_id=folder_id)
|
||||
count = query.count()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
return count
|
||||
|
||||
def delete_tag_by_id_and_user_id_and_tag_name(
|
||||
self, id: str, user_id: str, tag_name: str
|
||||
) -> bool:
|
||||
|
|
|
|||
|
|
@ -130,6 +130,17 @@ class FilesTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
file = db.query(File).filter_by(id=id, user_id=user_id).first()
|
||||
if file:
|
||||
return FileModel.model_validate(file)
|
||||
else:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class Function(Base):
|
|||
class FunctionMeta(BaseModel):
|
||||
description: Optional[str] = None
|
||||
manifest: Optional[dict] = {}
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class FunctionModel(BaseModel):
|
||||
|
|
@ -260,6 +261,29 @@ class FunctionsTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def update_function_metadata_by_id(
|
||||
self, id: str, metadata: dict
|
||||
) -> Optional[FunctionModel]:
|
||||
with get_db() as db:
|
||||
try:
|
||||
function = db.get(Function, id)
|
||||
|
||||
if function:
|
||||
if function.meta:
|
||||
function.meta = {**function.meta, **metadata}
|
||||
else:
|
||||
function.meta = metadata
|
||||
|
||||
function.updated_at = int(time.time())
|
||||
db.commit()
|
||||
db.refresh(function)
|
||||
return self.get_function_by_id(id)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function metadata by id {id}: {e}")
|
||||
return None
|
||||
|
||||
def get_user_valves_by_id_and_user_id(
|
||||
self, id: str, user_id: str
|
||||
) -> Optional[dict]:
|
||||
|
|
|
|||
|
|
@ -201,8 +201,14 @@ class MessageTable:
|
|||
with get_db() as db:
|
||||
message = db.get(Message, id)
|
||||
message.content = form_data.content
|
||||
message.data = form_data.data
|
||||
message.meta = form_data.meta
|
||||
message.data = {
|
||||
**(message.data if message.data else {}),
|
||||
**(form_data.data if form_data.data else {}),
|
||||
}
|
||||
message.meta = {
|
||||
**(message.meta if message.meta else {}),
|
||||
**(form_data.meta if form_data.meta else {}),
|
||||
}
|
||||
message.updated_at = int(time.time_ns())
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
|
|
@ -97,22 +98,85 @@ class NoteTable:
|
|||
db.commit()
|
||||
return note
|
||||
|
||||
def get_notes(self) -> list[NoteModel]:
|
||||
def get_notes(
|
||||
self, skip: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
|
||||
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_user_id(
|
||||
self, user_id: str, permission: str = "write"
|
||||
self,
|
||||
user_id: str,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
notes = self.get_notes()
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
|
||||
return [
|
||||
note
|
||||
for note in notes
|
||||
if note.user_id == user_id
|
||||
or has_access(user_id, permission, note.access_control, user_group_ids)
|
||||
]
|
||||
with get_db() as db:
|
||||
query = db.query(Note).filter(Note.user_id == user_id)
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_notes_by_permission(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "write",
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
|
||||
# Order newest-first. We stream to keep memory usage low.
|
||||
query = (
|
||||
db.query(Note)
|
||||
.order_by(Note.updated_at.desc())
|
||||
.execution_options(stream_results=True)
|
||||
.yield_per(256)
|
||||
)
|
||||
|
||||
results: list[NoteModel] = []
|
||||
n_skipped = 0
|
||||
|
||||
for note in query:
|
||||
# Fast-pass #1: owner
|
||||
if note.user_id == user_id:
|
||||
permitted = True
|
||||
# Fast-pass #2: public/open
|
||||
elif note.access_control is None:
|
||||
# Technically this should mean public access for both read and write, but we'll only do read for now
|
||||
# We might want to change this behavior later
|
||||
permitted = permission == "read"
|
||||
else:
|
||||
permitted = has_access(
|
||||
user_id, permission, note.access_control, user_group_ids
|
||||
)
|
||||
|
||||
if not permitted:
|
||||
continue
|
||||
|
||||
# Apply skip AFTER permission filtering so it counts only accessible notes
|
||||
if skip and n_skipped < skip:
|
||||
n_skipped += 1
|
||||
continue
|
||||
|
||||
results.append(NoteModel.model_validate(note))
|
||||
if limit is not None and len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
|
|
|
|||
|
|
@ -176,6 +176,26 @@ class OAuthSessionTable:
|
|||
log.error(f"Error getting OAuth session by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_session_by_provider_and_user_id(
|
||||
self, provider: str, user_id: str
|
||||
) -> Optional[OAuthSessionModel]:
|
||||
"""Get OAuth session by provider and user ID"""
|
||||
try:
|
||||
with get_db() as db:
|
||||
session = (
|
||||
db.query(OAuthSession)
|
||||
.filter_by(provider=provider, user_id=user_id)
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth session by provider and user ID: {e}")
|
||||
return None
|
||||
|
||||
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
|
||||
"""Get all OAuth sessions for a user"""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ class ToolResponse(BaseModel):
|
|||
class ToolUserResponse(ToolResponse):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class ToolForm(BaseModel):
|
||||
id: str
|
||||
|
|
|
|||
|
|
@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel):
|
|||
role: str
|
||||
|
||||
|
||||
class UserIdNameResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class UserInfoListResponse(BaseModel):
|
||||
users: list[UserInfoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserIdNameListResponse(BaseModel):
|
||||
users: list[UserIdNameResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
|
@ -210,7 +220,7 @@ class UsersTable:
|
|||
filter: Optional[dict] = None,
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> UserListResponse:
|
||||
) -> dict:
|
||||
with get_db() as db:
|
||||
query = db.query(User)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
|||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.notes import Notes
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
|
||||
from open_webui.env import (
|
||||
|
|
@ -124,7 +127,13 @@ def query_doc_with_hybrid_search(
|
|||
hybrid_bm25_weight: float,
|
||||
) -> dict:
|
||||
try:
|
||||
if not collection_result.documents[0]:
|
||||
if (
|
||||
not collection_result
|
||||
or not hasattr(collection_result, "documents")
|
||||
or not collection_result.documents
|
||||
or len(collection_result.documents) == 0
|
||||
or not collection_result.documents[0]
|
||||
):
|
||||
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
|
||||
return {"documents": [], "metadatas": [], "distances": []}
|
||||
|
||||
|
|
@ -432,13 +441,14 @@ def get_embedding_function(
|
|||
if isinstance(query, list):
|
||||
embeddings = []
|
||||
for i in range(0, len(query), embedding_batch_size):
|
||||
embeddings.extend(
|
||||
func(
|
||||
query[i : i + embedding_batch_size],
|
||||
prefix=prefix,
|
||||
user=user,
|
||||
)
|
||||
batch_embeddings = func(
|
||||
query[i : i + embedding_batch_size],
|
||||
prefix=prefix,
|
||||
user=user,
|
||||
)
|
||||
|
||||
if isinstance(batch_embeddings, list):
|
||||
embeddings.extend(batch_embeddings)
|
||||
return embeddings
|
||||
else:
|
||||
return func(query, prefix, user)
|
||||
|
|
@ -490,25 +500,37 @@ def get_sources_from_items(
|
|||
# Raw Text
|
||||
# Used during temporary chat file uploads or web page & youtube attachements
|
||||
|
||||
if item.get("collection_name"):
|
||||
# If item has a collection name, use it
|
||||
collection_names.append(item.get("collection_name"))
|
||||
elif item.get("file"):
|
||||
# if item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [[item.get("file", {}).get("meta", {})]],
|
||||
}
|
||||
else:
|
||||
# Fallback to item content
|
||||
query_result = {
|
||||
"documents": [[item.get("content")]],
|
||||
"metadatas": [
|
||||
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||
],
|
||||
}
|
||||
if item.get("context") == "full":
|
||||
if item.get("file"):
|
||||
# if item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [[item.get("file", {}).get("meta", {})]],
|
||||
}
|
||||
|
||||
if query_result is None:
|
||||
# Fallback
|
||||
if item.get("collection_name"):
|
||||
# If item has a collection name, use it
|
||||
collection_names.append(item.get("collection_name"))
|
||||
elif item.get("file"):
|
||||
# If item has file data, use it
|
||||
query_result = {
|
||||
"documents": [
|
||||
[item.get("file", {}).get("data", {}).get("content")]
|
||||
],
|
||||
"metadatas": [[item.get("file", {}).get("meta", {})]],
|
||||
}
|
||||
else:
|
||||
# Fallback to item content
|
||||
query_result = {
|
||||
"documents": [[item.get("content")]],
|
||||
"metadatas": [
|
||||
[{"file_id": item.get("id"), "name": item.get("name")}]
|
||||
],
|
||||
}
|
||||
|
||||
elif item.get("type") == "note":
|
||||
# Note Attached
|
||||
|
|
@ -525,6 +547,30 @@ def get_sources_from_items(
|
|||
"metadatas": [[{"file_id": note.id, "name": note.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "chat":
|
||||
# Chat Attached
|
||||
chat = Chats.get_chat_by_id(item.get("id"))
|
||||
|
||||
if chat and (user.role == "admin" or chat.user_id == user.id):
|
||||
messages_map = chat.chat.get("history", {}).get("messages", {})
|
||||
message_id = chat.chat.get("history", {}).get("currentId")
|
||||
|
||||
if messages_map and message_id:
|
||||
# Reconstruct the message list in order
|
||||
message_list = get_message_list(messages_map, message_id)
|
||||
message_history = "\n".join(
|
||||
[
|
||||
f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n"
|
||||
for m in message_list
|
||||
]
|
||||
)
|
||||
|
||||
# User has access to the chat
|
||||
query_result = {
|
||||
"documents": [[message_history]],
|
||||
"metadatas": [[{"file_id": chat.id, "name": chat.title}]],
|
||||
}
|
||||
|
||||
elif item.get("type") == "file":
|
||||
if (
|
||||
item.get("context") == "full"
|
||||
|
|
@ -581,6 +627,7 @@ def get_sources_from_items(
|
|||
|
||||
if knowledge_base and (
|
||||
user.role == "admin"
|
||||
or knowledge_base.user_id == user.id
|
||||
or has_access(user.id, "read", knowledge_base.access_control)
|
||||
):
|
||||
|
||||
|
|
|
|||
51
backend/open_webui/retrieval/web/ollama.py
Normal file
51
backend/open_webui/retrieval/web/ollama.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_ollama_cloud(
|
||||
url: str,
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Ollama Search API key
|
||||
query (str): The query to search for
|
||||
count (int): Number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results by
|
||||
"""
|
||||
log.info(f"Searching with Ollama for query: {query}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"query": query, "max_results": count}
|
||||
|
||||
try:
|
||||
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
log.info(f"Found {len(results)} results")
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result.get("url", ""),
|
||||
title=result.get("title", ""),
|
||||
snippet=result.get("content", ""),
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
except Exception as e:
|
||||
log.error(f"Error searching Ollama: {e}")
|
||||
return []
|
||||
64
backend/open_webui/retrieval/web/perplexity_search.py
Normal file
64
backend/open_webui/retrieval/web/perplexity_search.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import logging
|
||||
from typing import Optional, Literal
|
||||
import requests
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["RAG"])
|
||||
|
||||
|
||||
def search_perplexity_search(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Perplexity API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A Perplexity API key
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
filter_list (Optional[list[str]]): List of domains to filter results
|
||||
|
||||
"""
|
||||
|
||||
# Handle PersistentConfig object
|
||||
if hasattr(api_key, "__str__"):
|
||||
api_key = str(api_key)
|
||||
|
||||
try:
|
||||
url = "https://api.perplexity.ai/search"
|
||||
|
||||
# Create payload for the API call
|
||||
payload = {
|
||||
"query": query,
|
||||
"max_results": count,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Make the API request
|
||||
response = requests.request("POST", url, json=payload, headers=headers)
|
||||
# Parse the JSON response
|
||||
json_response = response.json()
|
||||
|
||||
# Extract citations from the response
|
||||
results = json_response.get("results", [])
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["url"], title=result["title"], snippet=result["snippet"]
|
||||
)
|
||||
for result in results
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error searching with Perplexity Search API: {e}")
|
||||
return []
|
||||
|
|
@ -337,10 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
r = await session.post(
|
||||
url=urljoin(
|
||||
request.app.state.config.TTS_OPENAI_API_BASE_URL,
|
||||
"/audio/speech",
|
||||
),
|
||||
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -468,10 +465,8 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
timeout=timeout, trust_env=True
|
||||
) as session:
|
||||
async with session.post(
|
||||
urljoin(
|
||||
base_url or f"https://{region}.tts.speech.microsoft.com",
|
||||
"/cognitiveservices/v1",
|
||||
),
|
||||
(base_url or f"https://{region}.tts.speech.microsoft.com")
|
||||
+ "/cognitiveservices/v1",
|
||||
headers={
|
||||
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY,
|
||||
"Content-Type": "application/ssml+xml",
|
||||
|
|
@ -555,7 +550,7 @@ def transcription_handler(request, file_path, metadata):
|
|||
metadata = metadata or {}
|
||||
|
||||
languages = [
|
||||
metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE,
|
||||
metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
|
||||
None, # Always fallback to None in case transcription fails
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ from pydantic import BaseModel
|
|||
from open_webui.socket.main import sio, get_user_ids_from_room
|
||||
from open_webui.models.users import Users, UserNameResponse
|
||||
|
||||
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.channels import (
|
||||
Channels,
|
||||
ChannelModel,
|
||||
ChannelForm,
|
||||
ChannelResponse,
|
||||
)
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
|
|
@ -24,9 +30,17 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
|
||||
from open_webui.utils.models import (
|
||||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, get_users_with_access
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from open_webui.utils.channels import extract_mentions, replace_mentions
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MODELS"])
|
||||
|
|
@ -72,7 +86,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
|
|||
############################
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=Optional[ChannelModel])
|
||||
@router.get("/{id}", response_model=Optional[ChannelResponse])
|
||||
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
||||
channel = Channels.get_channel_by_id(id)
|
||||
if not channel:
|
||||
|
|
@ -87,7 +101,16 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
|
|||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
return ChannelModel(**channel.model_dump())
|
||||
write_access = has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
|
||||
return ChannelResponse(
|
||||
**{
|
||||
**channel.model_dump(),
|
||||
"write_access": write_access or user.role == "admin",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -200,14 +223,11 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
|
|||
users = get_users_with_access("read", channel.access_control)
|
||||
|
||||
for user in users:
|
||||
if user.id in active_user_ids:
|
||||
continue
|
||||
else:
|
||||
if user.id not in active_user_ids:
|
||||
if user.settings:
|
||||
webhook_url = user.settings.ui.get("notifications", {}).get(
|
||||
"webhook_url", None
|
||||
)
|
||||
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
name,
|
||||
|
|
@ -221,14 +241,155 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
|
|||
},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
|
||||
async def post_new_message(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: MessageForm,
|
||||
background_tasks: BackgroundTasks,
|
||||
user=Depends(get_verified_user),
|
||||
|
||||
async def model_response_handler(request, channel, message, user):
|
||||
MODELS = {
|
||||
model["id"]: model
|
||||
for model in get_filtered_models(await get_all_models(request, user=user), user)
|
||||
}
|
||||
|
||||
mentions = extract_mentions(message.content)
|
||||
message_content = replace_mentions(message.content)
|
||||
|
||||
# check if any of the mentions are models
|
||||
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
|
||||
if not model_mentions:
|
||||
return False
|
||||
|
||||
for mention in model_mentions:
|
||||
model_id = mention["id"]
|
||||
model = MODELS.get(model_id, None)
|
||||
|
||||
if model:
|
||||
try:
|
||||
# reverse to get in chronological order
|
||||
thread_messages = Messages.get_messages_by_parent_id(
|
||||
channel.id,
|
||||
message.parent_id if message.parent_id else message.id,
|
||||
)[::-1]
|
||||
|
||||
response_message, channel = await new_message_handler(
|
||||
request,
|
||||
channel.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"parent_id": (
|
||||
message.parent_id if message.parent_id else message.id
|
||||
),
|
||||
"content": f"",
|
||||
"data": {},
|
||||
"meta": {
|
||||
"model_id": model_id,
|
||||
"model_name": model.get("name", model_id),
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
|
||||
thread_history = []
|
||||
images = []
|
||||
message_users = {}
|
||||
|
||||
for thread_message in thread_messages:
|
||||
message_user = None
|
||||
if thread_message.user_id not in message_users:
|
||||
message_user = Users.get_user_by_id(thread_message.user_id)
|
||||
message_users[thread_message.user_id] = message_user
|
||||
else:
|
||||
message_user = message_users[thread_message.user_id]
|
||||
|
||||
if thread_message.meta and thread_message.meta.get(
|
||||
"model_id", None
|
||||
):
|
||||
# If the message was sent by a model, use the model name
|
||||
message_model_id = thread_message.meta.get("model_id", None)
|
||||
message_model = MODELS.get(message_model_id, None)
|
||||
username = (
|
||||
message_model.get("name", message_model_id)
|
||||
if message_model
|
||||
else message_model_id
|
||||
)
|
||||
else:
|
||||
username = message_user.name if message_user else "Unknown"
|
||||
|
||||
thread_history.append(
|
||||
f"{username}: {replace_mentions(thread_message.content)}"
|
||||
)
|
||||
|
||||
thread_message_files = thread_message.data.get("files", [])
|
||||
for file in thread_message_files:
|
||||
if file.get("type", "") == "image":
|
||||
images.append(file.get("url", ""))
|
||||
|
||||
system_message = {
|
||||
"role": "system",
|
||||
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
|
||||
+ (
|
||||
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
|
||||
if thread_history
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
content = f"{user.name if user else 'User'}: {message_content}"
|
||||
if images:
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
},
|
||||
*[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": image,
|
||||
},
|
||||
}
|
||||
for image in images
|
||||
],
|
||||
]
|
||||
|
||||
form_data = {
|
||||
"model": model_id,
|
||||
"messages": [
|
||||
system_message,
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
form_data=form_data,
|
||||
user=user,
|
||||
)
|
||||
|
||||
if res:
|
||||
await update_message_by_id(
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
"content": res["choices"][0]["message"]["content"],
|
||||
"meta": {
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.info(e)
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def new_message_handler(
|
||||
request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user)
|
||||
):
|
||||
channel = Channels.get_channel_by_id(id)
|
||||
if not channel:
|
||||
|
|
@ -237,7 +398,7 @@ async def post_new_message(
|
|||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -302,11 +463,30 @@ async def post_new_message(
|
|||
},
|
||||
to=f"channel:{channel.id}",
|
||||
)
|
||||
return MessageModel(**message.model_dump()), channel
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||
|
||||
background_tasks.add_task(
|
||||
send_notification,
|
||||
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
|
||||
async def post_new_message(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: MessageForm,
|
||||
background_tasks: BackgroundTasks,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
|
||||
try:
|
||||
message, channel = await new_message_handler(request, id, form_data, user)
|
||||
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
|
||||
|
||||
async def background_handler():
|
||||
await model_response_handler(request, channel, message, user)
|
||||
await send_notification(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBUI_URL,
|
||||
channel,
|
||||
|
|
@ -314,7 +494,12 @@ async def post_new_message(
|
|||
active_user_ids,
|
||||
)
|
||||
|
||||
return MessageModel(**message.model_dump())
|
||||
background_tasks.add_task(background_handler)
|
||||
|
||||
return message
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
|
|
@ -509,7 +694,7 @@ async def add_reaction_to_message(
|
|||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -575,7 +760,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
|||
)
|
||||
|
||||
if user.role != "admin" and not has_access(
|
||||
user.id, type="read", access_control=channel.access_control
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
@ -657,7 +842,9 @@ async def delete_message_by_id(
|
|||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not has_access(user.id, type="read", access_control=channel.access_control)
|
||||
and not has_access(
|
||||
user.id, type="write", access_control=channel.access_control, strict=False
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ router = APIRouter()
|
|||
@router.get("/", response_model=list[ChatTitleIdResponse])
|
||||
@router.get("/list", response_model=list[ChatTitleIdResponse])
|
||||
def get_session_user_chat_list(
|
||||
user=Depends(get_verified_user), page: Optional[int] = None
|
||||
user=Depends(get_verified_user),
|
||||
page: Optional[int] = None,
|
||||
include_folders: Optional[bool] = False,
|
||||
):
|
||||
try:
|
||||
if page is not None:
|
||||
|
|
@ -45,10 +47,12 @@ def get_session_user_chat_list(
|
|||
skip = (page - 1) * limit
|
||||
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, skip=skip, limit=limit
|
||||
user.id, include_folders=include_folders, skip=skip, limit=limit
|
||||
)
|
||||
else:
|
||||
return Chats.get_chat_title_id_list_by_user_id(user.id)
|
||||
return Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id, include_folders=include_folders
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(
|
||||
|
|
@ -166,7 +170,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)
|
|||
|
||||
|
||||
@router.get("/search", response_model=list[ChatTitleIdResponse])
|
||||
async def search_user_chats(
|
||||
def search_user_chats(
|
||||
text: str, page: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if page is None:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import logging
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
import aiohttp
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -12,10 +14,24 @@ from open_webui.utils.tools import (
|
|||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
)
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
|
||||
from open_webui.utils.oauth import (
|
||||
get_discovery_urls,
|
||||
get_oauth_client_info_with_dynamic_client_registration,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
)
|
||||
from mcp.shared.auth import OAuthMetadata
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||
|
||||
|
||||
############################
|
||||
# ImportConfig
|
||||
|
|
@ -79,6 +95,43 @@ async def set_connections_config(
|
|||
}
|
||||
|
||||
|
||||
class OAuthClientRegistrationForm(BaseModel):
|
||||
url: str
|
||||
client_id: str
|
||||
client_name: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/oauth/clients/register")
|
||||
async def register_oauth_client(
|
||||
request: Request,
|
||||
form_data: OAuthClientRegistrationForm,
|
||||
type: Optional[str] = None,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
try:
|
||||
oauth_client_id = form_data.client_id
|
||||
if type:
|
||||
oauth_client_id = f"{type}:{form_data.client_id}"
|
||||
|
||||
oauth_client_info = (
|
||||
await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_client_info": encrypt_data(
|
||||
oauth_client_info.model_dump(mode="json")
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to register OAuth client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to register OAuth client",
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# ToolServers Config
|
||||
############################
|
||||
|
|
@ -87,6 +140,7 @@ async def set_connections_config(
|
|||
class ToolServerConnection(BaseModel):
|
||||
url: str
|
||||
path: str
|
||||
type: Optional[str] = "openapi" # openapi, mcp
|
||||
auth_type: Optional[str]
|
||||
key: Optional[str]
|
||||
config: Optional[dict]
|
||||
|
|
@ -114,8 +168,29 @@ async def set_tool_servers_config(
|
|||
request.app.state.config.TOOL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
|
||||
]
|
||||
|
||||
await set_tool_servers(request)
|
||||
|
||||
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
server_type = connection.get("type", "openapi")
|
||||
if server_type == "mcp":
|
||||
server_id = connection.get("info", {}).get("id")
|
||||
auth_type = connection.get("auth_type", "none")
|
||||
if auth_type == "oauth_2.1" and server_id:
|
||||
try:
|
||||
oauth_client_info = connection.get("info", {}).get(
|
||||
"oauth_client_info", ""
|
||||
)
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
|
||||
await request.app.state.oauth_client_manager.add_client(
|
||||
f"{server_type}:{server_id}",
|
||||
OAuthClientInformationFull(**oauth_client_info),
|
||||
)
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
|
||||
}
|
||||
|
|
@ -129,19 +204,105 @@ async def verify_tool_servers_config(
|
|||
Verify the connection to the tool server.
|
||||
"""
|
||||
try:
|
||||
if form_data.type == "mcp":
|
||||
if form_data.auth_type == "oauth_2.1":
|
||||
discovery_urls = get_discovery_urls(form_data.url)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
discovery_urls[0]
|
||||
) as oauth_server_metadata_response:
|
||||
if oauth_server_metadata_response.status != 200:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
|
||||
)
|
||||
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
try:
|
||||
oauth_server_metadata = OAuthMetadata.model_validate(
|
||||
await oauth_server_metadata_response.json()
|
||||
)
|
||||
return {
|
||||
"status": True,
|
||||
"oauth_server_metadata": oauth_server_metadata.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
log.info(
|
||||
f"Failed to parse OAuth 2.1 discovery document: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}",
|
||||
)
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
client = MCPClient()
|
||||
headers = None
|
||||
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if token:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
specs = await client.list_tool_specs()
|
||||
return {
|
||||
"status": True,
|
||||
"specs": specs,
|
||||
}
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to create MCP client: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to create MCP client",
|
||||
)
|
||||
finally:
|
||||
if client:
|
||||
await client.disconnect()
|
||||
else: # openapi
|
||||
token = None
|
||||
if form_data.auth_type == "bearer":
|
||||
token = form_data.key
|
||||
elif form_data.auth_type == "session":
|
||||
token = request.state.token.credentials
|
||||
elif form_data.auth_type == "system_oauth":
|
||||
try:
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(token, url)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to connect to the tool server: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to connect to the tool server: {str(e)}",
|
||||
detail=f"Failed to connect to the tool server",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -120,11 +120,6 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
|
|||
f"File type {file.content_type} is not provided, but trying to process anyway"
|
||||
)
|
||||
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
|
||||
|
||||
Files.update_file_data_by_id(
|
||||
file_item.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error processing file: {file_item.id}")
|
||||
Files.update_file_data_by_id(
|
||||
|
|
|
|||
|
|
@ -262,15 +262,15 @@ async def update_folder_is_expanded_by_id(
|
|||
async def delete_folder_by_id(
|
||||
request: Request, id: str, user=Depends(get_verified_user)
|
||||
):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
|
||||
chat_delete_permission = has_permission(
|
||||
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
|
||||
)
|
||||
if user.role != "admin" and not chat_delete_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
|
||||
if folder:
|
||||
|
|
|
|||
|
|
@ -148,6 +148,18 @@ async def sync_functions(
|
|||
content=function.content,
|
||||
)
|
||||
|
||||
if hasattr(function_module, "Valves") and function.valves:
|
||||
Valves = function_module.Valves
|
||||
try:
|
||||
Valves(
|
||||
**{k: v for k, v in function.valves.items() if v is not None}
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(
|
||||
f"Error validating valves for function {function.id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return Functions.sync_functions(user.id, form_data.functions)
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to load a function: {e}")
|
||||
|
|
@ -192,6 +204,9 @@ async def create_new_function(
|
|||
function_cache_dir = CACHE_DIR / "functions" / form_data.id
|
||||
function_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if function_type == "filter" and getattr(function_module, "toggle", None):
|
||||
Functions.update_function_metadata_by_id(id, {"toggle": True})
|
||||
|
||||
if function:
|
||||
return function
|
||||
else:
|
||||
|
|
@ -308,6 +323,9 @@ async def update_function_by_id(
|
|||
|
||||
function = Functions.update_function_by_id(id, updated)
|
||||
|
||||
if function_type == "filter" and getattr(function_module, "toggle", None):
|
||||
Functions.update_function_metadata_by_id(id, {"toggle": True})
|
||||
|
||||
if function:
|
||||
return function
|
||||
else:
|
||||
|
|
@ -413,8 +431,10 @@ async def update_function_valves_by_id(
|
|||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Functions.update_function_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Functions.update_function_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function values by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -496,10 +516,11 @@ async def update_function_user_valves_by_id(
|
|||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Functions.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Error updating function user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -514,6 +514,7 @@ async def image_generations(
|
|||
size = form_data.size
|
||||
|
||||
width, height = tuple(map(int, size.split("x")))
|
||||
model = get_image_model(request)
|
||||
|
||||
r = None
|
||||
try:
|
||||
|
|
@ -531,11 +532,7 @@ async def image_generations(
|
|||
headers["X-OpenWebUI-User-Role"] = user.role
|
||||
|
||||
data = {
|
||||
"model": (
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL
|
||||
if request.app.state.config.IMAGE_GENERATION_MODEL != ""
|
||||
else "dall-e-2"
|
||||
),
|
||||
"model": model,
|
||||
"prompt": form_data.prompt,
|
||||
"n": form_data.n,
|
||||
"size": (
|
||||
|
|
@ -584,7 +581,6 @@ async def image_generations(
|
|||
headers["Content-Type"] = "application/json"
|
||||
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
|
||||
|
||||
model = get_image_model(request)
|
||||
data = {
|
||||
"instances": {"prompt": form_data.prompt},
|
||||
"parameters": {
|
||||
|
|
@ -640,7 +636,7 @@ async def image_generations(
|
|||
}
|
||||
)
|
||||
res = await comfyui_generate_image(
|
||||
request.app.state.config.IMAGE_GENERATION_MODEL,
|
||||
model,
|
||||
form_data,
|
||||
user.id,
|
||||
request.app.state.config.COMFYUI_BASE_URL,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
from typing import Optional
|
||||
import io
|
||||
import base64
|
||||
|
||||
from open_webui.models.models import (
|
||||
ModelForm,
|
||||
|
|
@ -10,12 +12,13 @@ from open_webui.models.models import (
|
|||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -129,6 +132,39 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
|
|||
)
|
||||
|
||||
|
||||
###########################
|
||||
# GetModelById
|
||||
###########################
|
||||
|
||||
|
||||
@router.get("/model/profile/image")
|
||||
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
|
||||
model = Models.get_model_by_id(id)
|
||||
if model:
|
||||
if model.meta.profile_image_url:
|
||||
if model.meta.profile_image_url.startswith("http"):
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={"Location": model.meta.profile_image_url},
|
||||
)
|
||||
elif model.meta.profile_image_url.startswith("data:image"):
|
||||
try:
|
||||
header, base64_data = model.meta.profile_image_url.split(",", 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type="image/png",
|
||||
headers={"Content-Disposition": "inline; filename=image.png"},
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
return FileResponse(f"{STATIC_DIR}/favicon.png")
|
||||
else:
|
||||
return FileResponse(f"{STATIC_DIR}/favicon.png")
|
||||
|
||||
|
||||
############################
|
||||
# ToggleModelById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
|
|||
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
|
||||
}
|
||||
)
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
for note in Notes.get_notes_by_permission(user.id, "write")
|
||||
]
|
||||
|
||||
return notes
|
||||
|
|
@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel):
|
|||
|
||||
|
||||
@router.get("/list", response_model=list[NoteTitleIdResponse])
|
||||
async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
async def get_note_list(
|
||||
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
|
|
@ -72,9 +73,17 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
|
|||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
limit = None
|
||||
skip = None
|
||||
if page is not None:
|
||||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
notes = [
|
||||
NoteTitleIdResponse(**note.model_dump())
|
||||
for note in Notes.get_notes_by_user_id(user.id, "write")
|
||||
for note in Notes.get_notes_by_permission(
|
||||
user.id, "write", skip=skip, limit=limit
|
||||
)
|
||||
]
|
||||
|
||||
return notes
|
||||
|
|
|
|||
|
|
@ -1694,25 +1694,27 @@ async def download_file_stream(
|
|||
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
|
||||
|
||||
if done:
|
||||
file.seek(0)
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
file.seek(0)
|
||||
file.close()
|
||||
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
response = requests.post(url, data=file)
|
||||
with open(file_path, "rb") as file:
|
||||
chunk_size = 1024 * 1024 * 2
|
||||
hashed = calculate_sha256(file, chunk_size)
|
||||
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
|
||||
with requests.Session() as session:
|
||||
response = session.post(url, data=file, timeout=30)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
if response.ok:
|
||||
res = {
|
||||
"done": done,
|
||||
"blob": f"sha256:{hashed}",
|
||||
"name": file_name,
|
||||
}
|
||||
os.remove(file_path)
|
||||
|
||||
yield f"data: {json.dumps(res)}\n\n"
|
||||
else:
|
||||
raise "Ollama: Could not create blob, Please try again."
|
||||
|
||||
|
||||
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from aiocache import cached
|
|||
import requests
|
||||
from urllib.parse import quote
|
||||
|
||||
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, APIRouter
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
|
|
@ -119,7 +121,7 @@ def openai_reasoning_model_handler(payload):
|
|||
return payload
|
||||
|
||||
|
||||
def get_headers_and_cookies(
|
||||
async def get_headers_and_cookies(
|
||||
request: Request,
|
||||
url,
|
||||
key=None,
|
||||
|
|
@ -171,22 +173,41 @@ def get_headers_and_cookies(
|
|||
|
||||
oauth_token = None
|
||||
try:
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
if oauth_token:
|
||||
token = f"{oauth_token.get('access_token', '')}"
|
||||
|
||||
elif auth_type in ("azure_ad", "microsoft_entra_id"):
|
||||
token = get_microsoft_entra_id_access_token()
|
||||
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
return headers, cookies
|
||||
|
||||
|
||||
def get_microsoft_entra_id_access_token():
|
||||
"""
|
||||
Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
|
||||
Returns the token string or None if authentication fails.
|
||||
"""
|
||||
try:
|
||||
token_provider = get_bearer_token_provider(
|
||||
DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
|
||||
)
|
||||
return token_provider()
|
||||
except Exception as e:
|
||||
log.error(f"Error getting Microsoft Entra ID access token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
##########################################
|
||||
#
|
||||
# API routes
|
||||
|
|
@ -284,7 +305,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
|||
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
|
|
@ -549,7 +570,7 @@ async def get_models(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
|
|
@ -635,14 +656,17 @@ async def verify_connection(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
|
||||
) as session:
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
headers["api-key"] = key
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
|
||||
async with session.get(
|
||||
url=f"{url}/openai/models?api-version={api_version}",
|
||||
headers=headers,
|
||||
|
|
@ -877,14 +901,19 @@ async def generate_chat_completion(
|
|||
convert_logit_bias_input_to_json(payload["logit_bias"])
|
||||
)
|
||||
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, metadata, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
request_url, payload = convert_to_azure_payload(url, payload, api_version)
|
||||
headers["api-key"] = key
|
||||
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
request_url = f"{request_url}/chat/completions?api-version={api_version}"
|
||||
else:
|
||||
|
|
@ -981,7 +1010,9 @@ async def embeddings(request: Request, form_data: dict, user):
|
|||
session = None
|
||||
streaming = False
|
||||
|
||||
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
try:
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
r = await session.request(
|
||||
|
|
@ -1051,13 +1082,18 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
|||
streaming = False
|
||||
|
||||
try:
|
||||
headers, cookies = get_headers_and_cookies(
|
||||
headers, cookies = await get_headers_and_cookies(
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
|
||||
if api_config.get("azure", False):
|
||||
api_version = api_config.get("api_version", "2023-03-15-preview")
|
||||
headers["api-key"] = key
|
||||
|
||||
# Only set api-key header if not using Azure Entra ID authentication
|
||||
auth_type = api_config.get("auth_type", "bearer")
|
||||
if auth_type not in ("azure_ad", "microsoft_entra_id"):
|
||||
headers["api-key"] = key
|
||||
|
||||
headers["api-version"] = api_version
|
||||
|
||||
payload = json.loads(body)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
|||
# Web search engines
|
||||
from open_webui.retrieval.web.main import SearchResult
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.retrieval.web.ollama import search_ollama_cloud
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.brave import search_brave
|
||||
from open_webui.retrieval.web.kagi import search_kagi
|
||||
from open_webui.retrieval.web.mojeek import search_mojeek
|
||||
|
|
@ -469,6 +471,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
|||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
|
|
@ -525,6 +528,7 @@ class WebConfig(BaseModel):
|
|||
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
|
||||
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
|
||||
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
|
||||
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
SEARXNG_QUERY_URL: Optional[str] = None
|
||||
YACY_QUERY_URL: Optional[str] = None
|
||||
YACY_USERNAME: Optional[str] = None
|
||||
|
|
@ -988,6 +992,9 @@ async def update_rag_config(
|
|||
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
|
||||
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
|
||||
)
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
|
||||
form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
|
||||
)
|
||||
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
|
||||
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
|
||||
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
|
||||
|
|
@ -1139,6 +1146,7 @@ async def update_rag_config(
|
|||
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
|
||||
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
|
||||
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
|
||||
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
|
||||
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
|
||||
|
|
@ -1334,7 +1342,7 @@ def save_docs_to_vector_db(
|
|||
)
|
||||
return True
|
||||
|
||||
log.info(f"adding to collection {collection_name}")
|
||||
log.info(f"generating embeddings for {collection_name}")
|
||||
embedding_function = get_embedding_function(
|
||||
request.app.state.config.RAG_EMBEDDING_ENGINE,
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL,
|
||||
|
|
@ -1370,6 +1378,7 @@ def save_docs_to_vector_db(
|
|||
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
|
||||
user=user,
|
||||
)
|
||||
log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
|
||||
|
||||
items = [
|
||||
{
|
||||
|
|
@ -1381,11 +1390,13 @@ def save_docs_to_vector_db(
|
|||
for idx, text in enumerate(texts)
|
||||
]
|
||||
|
||||
log.info(f"adding to collection {collection_name}")
|
||||
VECTOR_DB_CLIENT.insert(
|
||||
collection_name=collection_name,
|
||||
items=items,
|
||||
)
|
||||
|
||||
log.info(f"added {len(items)} items to collection {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -1404,203 +1415,228 @@ def process_file(
|
|||
form_data: ProcessFileForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
if user.role == "admin":
|
||||
file = Files.get_file_by_id(form_data.file_id)
|
||||
else:
|
||||
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
|
||||
|
||||
collection_name = form_data.collection_name
|
||||
if file:
|
||||
try:
|
||||
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
collection_name = form_data.collection_name
|
||||
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
if collection_name is None:
|
||||
collection_name = f"file-{file.id}"
|
||||
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}")
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
try:
|
||||
# /files/{file_id}/data/content/update
|
||||
VECTOR_DB_CLIENT.delete_collection(
|
||||
collection_name=f"file-{file.id}"
|
||||
)
|
||||
except:
|
||||
# Audio file upload pipeline
|
||||
pass
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=form_data.content.replace("<br/>", "\n"),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
)
|
||||
]
|
||||
|
||||
text_content = form_data.content
|
||||
elif form_data.collection_name:
|
||||
# Check if the file has already been processed and save the content
|
||||
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
result = VECTOR_DB_CLIENT.query(
|
||||
collection_name=f"file-{file.id}", filter={"file_id": file.id}
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**doc.metadata,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"content": text_content},
|
||||
)
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
|
||||
if result is not None and len(result.ids[0]) > 0:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=result.documents[0][idx],
|
||||
metadata=result.metadatas[0][idx],
|
||||
)
|
||||
for idx, id in enumerate(result.ids[0])
|
||||
]
|
||||
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
Files.update_file_data_by_id(file.id, {"status": "completed"})
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
text_content = file.data.get("content", "")
|
||||
else:
|
||||
# Process the file and save the content
|
||||
# Usage: /files/
|
||||
file_path = file.path
|
||||
if file_path:
|
||||
file_path = Storage.get_file(file_path)
|
||||
loader = Loader(
|
||||
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
|
||||
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
|
||||
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
|
||||
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
|
||||
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
|
||||
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
|
||||
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
|
||||
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
|
||||
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
|
||||
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
|
||||
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
|
||||
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
|
||||
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
|
||||
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
|
||||
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
|
||||
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
|
||||
DOCLING_PARAMS={
|
||||
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
|
||||
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
|
||||
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
|
||||
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
|
||||
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
|
||||
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
|
||||
"pipeline": request.app.state.config.DOCLING_PIPELINE,
|
||||
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
|
||||
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
|
||||
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
|
||||
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
|
||||
},
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
|
||||
)
|
||||
docs = loader.load(
|
||||
file.filename, file.meta.get("content_type"), file_path
|
||||
)
|
||||
|
||||
docs = [
|
||||
Document(
|
||||
page_content=doc.page_content,
|
||||
metadata={
|
||||
**doc.metadata,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
else:
|
||||
docs = [
|
||||
Document(
|
||||
page_content=file.data.get("content", ""),
|
||||
metadata={
|
||||
**file.meta,
|
||||
"name": file.filename,
|
||||
"created_by": file.user_id,
|
||||
"file_id": file.id,
|
||||
"source": file.filename,
|
||||
},
|
||||
)
|
||||
]
|
||||
text_content = " ".join([doc.page_content for doc in docs])
|
||||
log.info(f"added {len(docs)} items to collection {collection_name}")
|
||||
|
||||
log.debug(f"text_content: {text_content}")
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed", "content": text_content},
|
||||
)
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
hash = calculate_sha256_string(text_content)
|
||||
Files.update_file_hash_by_id(file.id, hash)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "completed"},
|
||||
)
|
||||
|
||||
if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
|
||||
try:
|
||||
result = save_docs_to_vector_db(
|
||||
request,
|
||||
docs=docs,
|
||||
collection_name=collection_name,
|
||||
metadata={
|
||||
"file_id": file.id,
|
||||
"name": file.filename,
|
||||
"hash": hash,
|
||||
},
|
||||
add=(True if form_data.collection_name else False),
|
||||
user=user,
|
||||
)
|
||||
|
||||
if result:
|
||||
Files.update_file_metadata_by_id(
|
||||
file.id,
|
||||
{
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
else:
|
||||
raise Exception("Error saving document to vector database")
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": collection_name,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
return {
|
||||
"status": True,
|
||||
"collection_name": None,
|
||||
"filename": file.filename,
|
||||
"content": text_content,
|
||||
}
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
Files.update_file_data_by_id(
|
||||
file.id,
|
||||
{"status": "failed"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
if "No pandoc was found" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
class ProcessTextForm(BaseModel):
|
||||
|
|
@ -1758,7 +1794,25 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
|
|||
"""
|
||||
|
||||
# TODO: add playwright to search the web
|
||||
if engine == "searxng":
|
||||
if engine == "ollama_cloud":
|
||||
return search_ollama_cloud(
|
||||
"https://ollama.com",
|
||||
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
elif engine == "perplexity_search":
|
||||
if request.app.state.config.PERPLEXITY_API_KEY:
|
||||
return search_perplexity_search(
|
||||
request.app.state.config.PERPLEXITY_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception("No PERPLEXITY_API_KEY found in environment variables")
|
||||
elif engine == "searxng":
|
||||
if request.app.state.config.SEARXNG_QUERY_URL:
|
||||
return search_searxng(
|
||||
request.app.state.config.SEARXNG_QUERY_URL,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from pydantic import BaseModel, HttpUrl
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.tools import (
|
||||
ToolForm,
|
||||
ToolModel,
|
||||
|
|
@ -41,8 +42,17 @@ router = APIRouter()
|
|||
|
||||
@router.get("/", response_model=list[ToolUserResponse])
|
||||
async def get_tools(request: Request, user=Depends(get_verified_user)):
|
||||
tools = Tools.get_tools()
|
||||
tools = [
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
"has_user_valves": "class UserValves(BaseModel):" in tool.content,
|
||||
}
|
||||
)
|
||||
for tool in Tools.get_tools()
|
||||
]
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
for server in await get_tool_servers(request):
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
|
|
@ -68,6 +78,50 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
|
|||
)
|
||||
)
|
||||
|
||||
# MCP Tool Servers
|
||||
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if server.get("type", "openapi") == "mcp":
|
||||
server_id = server.get("info", {}).get("id")
|
||||
auth_type = server.get("auth_type", "none")
|
||||
|
||||
session_token = None
|
||||
if auth_type == "oauth_2.1":
|
||||
splits = server_id.split(":")
|
||||
server_id = splits[-1] if len(splits) > 1 else server_id
|
||||
|
||||
session_token = (
|
||||
await request.app.state.oauth_client_manager.get_oauth_token(
|
||||
user.id, f"mcp:{server_id}"
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
"id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
|
||||
"name": server.get("info", {}).get("name", "MCP Tool Server"),
|
||||
"meta": {
|
||||
"description": server.get("info", {}).get(
|
||||
"description", ""
|
||||
),
|
||||
},
|
||||
"access_control": server.get("config", {}).get(
|
||||
"access_control", None
|
||||
),
|
||||
"updated_at": int(time.time()),
|
||||
"created_at": int(time.time()),
|
||||
**(
|
||||
{
|
||||
"authenticated": session_token is not None,
|
||||
}
|
||||
if auth_type == "oauth_2.1"
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
# Admin can see all tools
|
||||
return tools
|
||||
|
|
@ -462,8 +516,9 @@ async def update_tools_valves_by_id(
|
|||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
valves = Valves(**form_data)
|
||||
Tools.update_tool_valves_by_id(id, valves.model_dump())
|
||||
return valves.model_dump()
|
||||
valves_dict = valves.model_dump(exclude_unset=True)
|
||||
Tools.update_tool_valves_by_id(id, valves_dict)
|
||||
return valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update tool valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
|
@ -538,10 +593,11 @@ async def update_tools_user_valves_by_id(
|
|||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Tools.update_user_valves_by_id_and_user_id(
|
||||
id, user.id, user_valves.model_dump()
|
||||
id, user.id, user_valves_dict
|
||||
)
|
||||
return user_valves.model_dump()
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to update user valves by id {id}: {e}")
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from open_webui.models.users import (
|
|||
UserModel,
|
||||
UserListResponse,
|
||||
UserInfoListResponse,
|
||||
UserIdNameListResponse,
|
||||
UserRoleUpdateForm,
|
||||
Users,
|
||||
UserSettings,
|
||||
|
|
@ -100,6 +101,23 @@ async def get_all_users(
|
|||
return Users.get_users()
|
||||
|
||||
|
||||
@router.get("/search", response_model=UserIdNameListResponse)
|
||||
async def search_users(
|
||||
query: Optional[str] = None,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
limit = PAGE_ITEM_COUNT
|
||||
|
||||
page = 1 # Always return the first page for search
|
||||
skip = (page - 1) * limit
|
||||
|
||||
filter = {}
|
||||
if query:
|
||||
filter["query"] = query
|
||||
|
||||
return Users.get_users(filter=filter, skip=skip, limit=limit)
|
||||
|
||||
|
||||
############################
|
||||
# User Groups
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -110,9 +110,13 @@ def has_access(
|
|||
type: str = "write",
|
||||
access_control: Optional[dict] = None,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
strict: bool = True,
|
||||
) -> bool:
|
||||
if access_control is None:
|
||||
return type == "read"
|
||||
if strict:
|
||||
return type == "read"
|
||||
else:
|
||||
return True
|
||||
|
||||
if user_group_ids is None:
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
|
|
@ -130,9 +134,10 @@ def has_access(
|
|||
# Get all users with access to a resource
|
||||
def get_users_with_access(
|
||||
type: str = "write", access_control: Optional[dict] = None
|
||||
) -> List[UserModel]:
|
||||
) -> list[UserModel]:
|
||||
if access_control is None:
|
||||
return Users.get_users()
|
||||
result = Users.get_users()
|
||||
return result.get("users", [])
|
||||
|
||||
permission_access = access_control.get(type, {})
|
||||
permitted_group_ids = permission_access.get("group_ids", [])
|
||||
|
|
|
|||
31
backend/open_webui/utils/channels.py
Normal file
31
backend/open_webui/utils/channels.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import re
|
||||
|
||||
|
||||
def extract_mentions(message: str, triggerChar: str = "@"):
|
||||
# Escape triggerChar in case it's a regex special character
|
||||
triggerChar = re.escape(triggerChar)
|
||||
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
|
||||
|
||||
matches = re.findall(pattern, message)
|
||||
return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
|
||||
|
||||
|
||||
def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
|
||||
"""
|
||||
Replace mentions in the message with either their label (after the pipe `|`)
|
||||
or their id if no label exists.
|
||||
|
||||
Example:
|
||||
"<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True)
|
||||
"<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
|
||||
"""
|
||||
# Escape triggerChar
|
||||
triggerChar = re.escape(triggerChar)
|
||||
|
||||
def replacer(match):
|
||||
id_type, id_value, label = match.groups()
|
||||
return label if use_label and label else id_value
|
||||
|
||||
# Regex captures: idType, id, optional label
|
||||
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
|
||||
return re.sub(pattern, replacer, message)
|
||||
97
backend/open_webui/utils/files.py
Normal file
97
backend/open_webui/utils/files.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from open_webui.routers.images import (
|
||||
load_b64_image_data,
|
||||
upload_image,
|
||||
)
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
|
||||
from open_webui.routers.files import upload_file_handler
|
||||
|
||||
import mimetypes
|
||||
import base64
|
||||
import io
|
||||
|
||||
|
||||
def get_image_url_from_base64(request, base64_image_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_image_string:
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = load_b64_image_data(base64_image_string)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return image_url
|
||||
return None
|
||||
|
||||
|
||||
def load_b64_audio_data(b64_str):
|
||||
try:
|
||||
if "," in b64_str:
|
||||
header, b64_data = b64_str.split(",", 1)
|
||||
else:
|
||||
b64_data = b64_str
|
||||
header = "data:audio/wav;base64"
|
||||
audio_data = base64.b64decode(b64_data)
|
||||
content_type = (
|
||||
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
|
||||
)
|
||||
return audio_data, content_type
|
||||
except Exception as e:
|
||||
print(f"Error decoding base64 audio data: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def upload_audio(request, audio_data, content_type, metadata, user):
|
||||
audio_format = mimetypes.guess_extension(content_type)
|
||||
file = UploadFile(
|
||||
file=io.BytesIO(audio_data),
|
||||
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
|
||||
headers={
|
||||
"content-type": content_type,
|
||||
},
|
||||
)
|
||||
file_item = upload_file_handler(
|
||||
request,
|
||||
file=file,
|
||||
metadata=metadata,
|
||||
process=False,
|
||||
user=user,
|
||||
)
|
||||
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
|
||||
return url
|
||||
|
||||
|
||||
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
|
||||
if "data:audio/wav;base64" in base64_audio_string:
|
||||
audio_url = ""
|
||||
# Extract base64 audio data from the line
|
||||
audio_data, content_type = load_b64_audio_data(base64_audio_string)
|
||||
if audio_data is not None:
|
||||
audio_url = upload_audio(
|
||||
request,
|
||||
audio_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
return audio_url
|
||||
return None
|
||||
|
||||
|
||||
def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
||||
if "data:image/png;base64" in base64_file_string:
|
||||
return get_image_url_from_base64(request, base64_file_string, metadata, user)
|
||||
elif "data:audio/wav;base64" in base64_file_string:
|
||||
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
||||
return None
|
||||
|
|
@ -127,8 +127,10 @@ async def process_filter_functions(
|
|||
raise e
|
||||
|
||||
# Handle file cleanup for inlet
|
||||
if skip_files and "files" in form_data.get("metadata", {}):
|
||||
del form_data["files"]
|
||||
del form_data["metadata"]["files"]
|
||||
if skip_files:
|
||||
if "files" in form_data.get("metadata", {}):
|
||||
del form_data["metadata"]["files"]
|
||||
if "files" in form_data:
|
||||
del form_data["files"]
|
||||
|
||||
return form_data, {}
|
||||
|
|
|
|||
110
backend/open_webui/utils/mcp/client.py
Normal file
110
backend/open_webui/utils/mcp/client.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import asyncio
|
||||
from typing import Optional
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
self.session: Optional[ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
async def connect(self, url: str, headers: Optional[dict] = None):
|
||||
try:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
|
||||
transport = await self.exit_stack.enter_async_context(self._streams_context)
|
||||
read_stream, write_stream, _ = transport
|
||||
|
||||
self._session_context = ClientSession(
|
||||
read_stream, write_stream
|
||||
) # pylint: disable=W0201
|
||||
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
self._session_context
|
||||
)
|
||||
await self.session.initialize()
|
||||
except Exception as e:
|
||||
await self.disconnect()
|
||||
raise e
|
||||
|
||||
async def list_tool_specs(self) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_tools()
|
||||
tools = result.tools
|
||||
|
||||
tool_specs = []
|
||||
for tool in tools:
|
||||
name = tool.name
|
||||
description = tool.description
|
||||
|
||||
inputSchema = tool.inputSchema
|
||||
|
||||
# TODO: handle outputSchema if needed
|
||||
outputSchema = getattr(tool, "outputSchema", None)
|
||||
|
||||
tool_specs.append(
|
||||
{"name": name, "description": description, "parameters": inputSchema}
|
||||
)
|
||||
|
||||
return tool_specs
|
||||
|
||||
async def call_tool(
|
||||
self, function_name: str, function_args: dict
|
||||
) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.call_tool(function_name, function_args)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP tool call.")
|
||||
|
||||
result_dict = result.model_dump(mode="json")
|
||||
result_content = result_dict.get("content", {})
|
||||
|
||||
if result.isError:
|
||||
raise Exception(result_content)
|
||||
else:
|
||||
return result_content
|
||||
|
||||
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.list_resources(cursor=cursor)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP list_resources call.")
|
||||
|
||||
result_dict = result.model_dump()
|
||||
resources = result_dict.get("resources", [])
|
||||
|
||||
return resources
|
||||
|
||||
async def read_resource(self, uri: str) -> Optional[dict]:
|
||||
if not self.session:
|
||||
raise RuntimeError("MCP client is not connected.")
|
||||
|
||||
result = await self.session.read_resource(uri)
|
||||
if not result:
|
||||
raise Exception("No result returned from MCP read_resource call.")
|
||||
result_dict = result.model_dump()
|
||||
|
||||
return result_dict
|
||||
|
||||
async def disconnect(self):
|
||||
# Clean up and close the session
|
||||
await self.exit_stack.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.exit_stack.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
|
||||
await self.disconnect()
|
||||
|
|
@ -20,9 +20,11 @@ from concurrent.futures import ThreadPoolExecutor
|
|||
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from starlette.responses import Response, StreamingResponse, JSONResponse
|
||||
|
||||
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.users import Users
|
||||
|
|
@ -52,6 +54,11 @@ from open_webui.routers.pipelines import (
|
|||
from open_webui.routers.memories import query_memory, QueryMemoryForm
|
||||
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from open_webui.utils.files import (
|
||||
get_audio_url_from_base64,
|
||||
get_file_url_from_base64,
|
||||
get_image_url_from_base64,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
|
|
@ -86,6 +93,7 @@ from open_webui.utils.filter import (
|
|||
)
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
from open_webui.utils.payload import apply_system_prompt_to_body
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
|
||||
|
||||
from open_webui.config import (
|
||||
|
|
@ -144,12 +152,14 @@ async def chat_completion_tools_handler(
|
|||
|
||||
def get_tools_function_calling_payload(messages, task_model_id, content):
|
||||
user_message = get_last_user_message(messages)
|
||||
history = "\n".join(
|
||||
|
||||
recent_messages = messages[-4:] if len(messages) > 4 else messages
|
||||
chat_history = "\n".join(
|
||||
f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
|
||||
for message in messages[::-1][:4]
|
||||
for message in recent_messages
|
||||
)
|
||||
|
||||
prompt = f"History:\n{history}\nQuery: {user_message}"
|
||||
prompt = f"History:\n{chat_history}\nQuery: {user_message}"
|
||||
|
||||
return {
|
||||
"model": task_model_id,
|
||||
|
|
@ -631,48 +641,53 @@ async def chat_completion_files_handler(
|
|||
sources = []
|
||||
|
||||
if files := body.get("metadata", {}).get("files", None):
|
||||
# Check if all files are in full context mode
|
||||
all_full_context = all(item.get("context") == "full" for item in files)
|
||||
|
||||
queries = []
|
||||
try:
|
||||
queries_response = await generate_queries(
|
||||
request,
|
||||
{
|
||||
"model": body["model"],
|
||||
"messages": body["messages"],
|
||||
"type": "retrieval",
|
||||
},
|
||||
user,
|
||||
)
|
||||
queries_response = queries_response["choices"][0]["message"]["content"]
|
||||
|
||||
if not all_full_context:
|
||||
try:
|
||||
bracket_start = queries_response.find("{")
|
||||
bracket_end = queries_response.rfind("}") + 1
|
||||
queries_response = await generate_queries(
|
||||
request,
|
||||
{
|
||||
"model": body["model"],
|
||||
"messages": body["messages"],
|
||||
"type": "retrieval",
|
||||
},
|
||||
user,
|
||||
)
|
||||
queries_response = queries_response["choices"][0]["message"]["content"]
|
||||
|
||||
if bracket_start == -1 or bracket_end == -1:
|
||||
raise Exception("No JSON object found in the response")
|
||||
try:
|
||||
bracket_start = queries_response.find("{")
|
||||
bracket_end = queries_response.rfind("}") + 1
|
||||
|
||||
queries_response = queries_response[bracket_start:bracket_end]
|
||||
queries_response = json.loads(queries_response)
|
||||
except Exception as e:
|
||||
queries_response = {"queries": [queries_response]}
|
||||
if bracket_start == -1 or bracket_end == -1:
|
||||
raise Exception("No JSON object found in the response")
|
||||
|
||||
queries = queries_response.get("queries", [])
|
||||
except:
|
||||
pass
|
||||
queries_response = queries_response[bracket_start:bracket_end]
|
||||
queries_response = json.loads(queries_response)
|
||||
except Exception as e:
|
||||
queries_response = {"queries": [queries_response]}
|
||||
|
||||
queries = queries_response.get("queries", [])
|
||||
except:
|
||||
pass
|
||||
|
||||
if len(queries) == 0:
|
||||
queries = [get_last_user_message(body["messages"])]
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "queries_generated",
|
||||
"queries": queries,
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
if not all_full_context:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"action": "queries_generated",
|
||||
"queries": queries,
|
||||
"done": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# Offload get_sources_from_items to a separate thread
|
||||
|
|
@ -701,7 +716,8 @@ async def chat_completion_files_handler(
|
|||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
|
||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
full_context=request.app.state.config.RAG_FULL_CONTEXT,
|
||||
full_context=all_full_context
|
||||
or request.app.state.config.RAG_FULL_CONTEXT,
|
||||
user=user,
|
||||
),
|
||||
)
|
||||
|
|
@ -817,10 +833,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
|
||||
oauth_token = None
|
||||
try:
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
|
|
@ -986,14 +1003,107 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# Server side tools
|
||||
tool_ids = metadata.get("tool_ids", None)
|
||||
# Client side tools
|
||||
tool_servers = metadata.get("tool_servers", None)
|
||||
direct_tool_servers = metadata.get("tool_servers", None)
|
||||
|
||||
log.debug(f"{tool_ids=}")
|
||||
log.debug(f"{tool_servers=}")
|
||||
log.debug(f"{direct_tool_servers=}")
|
||||
|
||||
tools_dict = {}
|
||||
|
||||
mcp_clients = []
|
||||
mcp_tools_dict = {}
|
||||
|
||||
if tool_ids:
|
||||
for tool_id in tool_ids:
|
||||
if tool_id.startswith("server:mcp:"):
|
||||
try:
|
||||
server_id = tool_id[len("server:mcp:") :]
|
||||
|
||||
mcp_server_connection = None
|
||||
for (
|
||||
server_connection
|
||||
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
|
||||
if (
|
||||
server_connection.get("type", "") == "mcp"
|
||||
and server_connection.get("info", {}).get("id") == server_id
|
||||
):
|
||||
mcp_server_connection = server_connection
|
||||
break
|
||||
|
||||
if not mcp_server_connection:
|
||||
log.error(f"MCP server with id {server_id} not found")
|
||||
continue
|
||||
|
||||
auth_type = mcp_server_connection.get("auth_type", "")
|
||||
|
||||
headers = {}
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {mcp_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
elif auth_type == "oauth_2.1":
|
||||
try:
|
||||
splits = server_id.split(":")
|
||||
server_id = splits[-1] if len(splits) > 1 else server_id
|
||||
|
||||
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
|
||||
user.id, f"mcp:{server_id}"
|
||||
)
|
||||
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
oauth_token = None
|
||||
|
||||
mcp_client = MCPClient()
|
||||
await mcp_client.connect(
|
||||
url=mcp_server_connection.get("url", ""),
|
||||
headers=headers if headers else None,
|
||||
)
|
||||
|
||||
tool_specs = await mcp_client.list_tool_specs()
|
||||
for tool_spec in tool_specs:
|
||||
|
||||
def make_tool_function(function_name):
|
||||
async def tool_function(**kwargs):
|
||||
return await mcp_client.call_tool(
|
||||
function_name,
|
||||
function_args=kwargs,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(tool_spec["name"])
|
||||
|
||||
mcp_tools_dict[tool_spec["name"]] = {
|
||||
"spec": tool_spec,
|
||||
"callable": tool_function,
|
||||
"type": "mcp",
|
||||
"client": mcp_client,
|
||||
"direct": False,
|
||||
}
|
||||
|
||||
mcp_clients.append(mcp_client)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
continue
|
||||
|
||||
tools_dict = await get_tools(
|
||||
request,
|
||||
tool_ids,
|
||||
|
|
@ -1005,9 +1115,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
"__files__": metadata.get("files", []),
|
||||
},
|
||||
)
|
||||
if mcp_tools_dict:
|
||||
tools_dict = {**tools_dict, **mcp_tools_dict}
|
||||
|
||||
if tool_servers:
|
||||
for tool_server in tool_servers:
|
||||
if direct_tool_servers:
|
||||
for tool_server in direct_tool_servers:
|
||||
tool_specs = tool_server.pop("specs", [])
|
||||
|
||||
for tool in tool_specs:
|
||||
|
|
@ -1017,6 +1129,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
"server": tool_server,
|
||||
}
|
||||
|
||||
if mcp_clients:
|
||||
metadata["mcp_clients"] = mcp_clients
|
||||
|
||||
if tools_dict:
|
||||
if metadata.get("params", {}).get("function_calling") == "native":
|
||||
# If the function calling is native, then call the tools function calling handler
|
||||
|
|
@ -1025,6 +1140,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
{"type": "function", "function": tool.get("spec", {})}
|
||||
for tool in tools_dict.values()
|
||||
]
|
||||
|
||||
else:
|
||||
# If the function calling is not native, then call the tools function calling handler
|
||||
try:
|
||||
|
|
@ -1078,26 +1194,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
raise Exception("No user message found")
|
||||
|
||||
if context_string != "":
|
||||
# Workaround for Ollama 2.0+ system prompt issue
|
||||
# TODO: replace with add_or_update_system_message
|
||||
if model.get("owned_by") == "ollama":
|
||||
form_data["messages"] = prepend_to_first_user_message_content(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
context_string,
|
||||
prompt,
|
||||
),
|
||||
form_data["messages"],
|
||||
)
|
||||
else:
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
context_string,
|
||||
prompt,
|
||||
),
|
||||
form_data["messages"],
|
||||
)
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
context_string,
|
||||
prompt,
|
||||
),
|
||||
form_data["messages"],
|
||||
append=False,
|
||||
)
|
||||
|
||||
# If there are citations, add them to the data_items
|
||||
sources = [
|
||||
|
|
@ -1130,11 +1235,11 @@ async def process_chat_response(
|
|||
request, response, form_data, user, metadata, model, events, tasks
|
||||
):
|
||||
async def background_tasks_handler():
|
||||
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
|
||||
message = message_map.get(metadata["message_id"]) if message_map else None
|
||||
messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
|
||||
message = messages_map.get(metadata["message_id"]) if messages_map else None
|
||||
|
||||
if message:
|
||||
message_list = get_message_list(message_map, metadata["message_id"])
|
||||
message_list = get_message_list(messages_map, metadata["message_id"])
|
||||
|
||||
# Remove details tags and files from the messages.
|
||||
# as get_message_list creates a new list, it does not affect
|
||||
|
|
@ -1496,10 +1601,11 @@ async def process_chat_response(
|
|||
|
||||
oauth_token = None
|
||||
try:
|
||||
oauth_token = request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
if request.cookies.get("oauth_session_id", None):
|
||||
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
|
||||
user.id,
|
||||
request.cookies.get("oauth_session_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token: {e}")
|
||||
|
||||
|
|
@ -1579,7 +1685,8 @@ async def process_chat_response(
|
|||
break
|
||||
|
||||
if tool_result is not None:
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
tool_result_embeds = result.get("embeds", "")
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
|
||||
else:
|
||||
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
|
||||
|
||||
|
|
@ -2029,6 +2136,20 @@ async def process_chat_response(
|
|||
)
|
||||
else:
|
||||
choices = data.get("choices", [])
|
||||
|
||||
# 17421
|
||||
usage = data.get("usage", {}) or {}
|
||||
usage.update(data.get("timings", {})) # llama.cpp
|
||||
if usage:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"usage": usage,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if not choices:
|
||||
error = data.get("error", {})
|
||||
if error:
|
||||
|
|
@ -2040,20 +2161,6 @@ async def process_chat_response(
|
|||
},
|
||||
}
|
||||
)
|
||||
usage = data.get("usage", {})
|
||||
usage.update(
|
||||
data.get("timing", {})
|
||||
) # llama.cpp
|
||||
|
||||
if usage:
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": {
|
||||
"usage": usage,
|
||||
},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
delta = choices[0].get("delta", {})
|
||||
|
|
@ -2326,6 +2433,8 @@ async def process_chat_response(
|
|||
results = []
|
||||
|
||||
for tool_call in response_tool_calls:
|
||||
|
||||
print("tool_call", tool_call)
|
||||
tool_call_id = tool_call.get("id", "")
|
||||
tool_name = tool_call.get("function", {}).get("name", "")
|
||||
tool_args = tool_call.get("function", {}).get("arguments", "{}")
|
||||
|
|
@ -2400,14 +2509,145 @@ async def process_chat_response(
|
|||
except Exception as e:
|
||||
tool_result = str(e)
|
||||
|
||||
tool_result_embeds = []
|
||||
if isinstance(tool_result, HTMLResponse):
|
||||
content_disposition = tool_result.headers.get(
|
||||
"Content-Disposition", ""
|
||||
)
|
||||
if "inline" in content_disposition:
|
||||
content = tool_result.body.decode("utf-8")
|
||||
tool_result_embeds.append(content)
|
||||
|
||||
if 200 <= tool_result.status_code < 300:
|
||||
tool_result = {
|
||||
"status": "success",
|
||||
"code": "ui_component",
|
||||
"message": "Embedded UI result is active and visible to the user.",
|
||||
}
|
||||
elif 400 <= tool_result.status_code < 500:
|
||||
tool_result = {
|
||||
"status": "error",
|
||||
"code": "ui_component",
|
||||
"message": f"Client error {tool_result.status_code} from embedded UI result.",
|
||||
}
|
||||
elif 500 <= tool_result.status_code < 600:
|
||||
tool_result = {
|
||||
"status": "error",
|
||||
"code": "ui_component",
|
||||
"message": f"Server error {tool_result.status_code} from embedded UI result.",
|
||||
}
|
||||
else:
|
||||
tool_result = {
|
||||
"status": "error",
|
||||
"code": "ui_component",
|
||||
"message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
|
||||
}
|
||||
else:
|
||||
tool_result = tool_result.body.decode("utf-8")
|
||||
|
||||
elif (
|
||||
tool.get("type") == "external"
|
||||
and isinstance(tool_result, tuple)
|
||||
) or (
|
||||
tool.get("direct", True)
|
||||
and isinstance(tool_result, list)
|
||||
and len(tool_result) == 2
|
||||
):
|
||||
tool_result, tool_response_headers = tool_result
|
||||
|
||||
if tool_response_headers:
|
||||
content_disposition = tool_response_headers.get(
|
||||
"Content-Disposition",
|
||||
tool_response_headers.get(
|
||||
"content-disposition", ""
|
||||
),
|
||||
)
|
||||
|
||||
if "inline" in content_disposition:
|
||||
content_type = tool_response_headers.get(
|
||||
"Content-Type",
|
||||
tool_response_headers.get("content-type", ""),
|
||||
)
|
||||
location = tool_response_headers.get(
|
||||
"Location",
|
||||
tool_response_headers.get("location", ""),
|
||||
)
|
||||
|
||||
if "text/html" in content_type:
|
||||
# Display as iframe embed
|
||||
tool_result_embeds.append(tool_result)
|
||||
tool_result = {
|
||||
"status": "success",
|
||||
"code": "ui_component",
|
||||
"message": "Embedded UI result is active and visible to the user.",
|
||||
}
|
||||
elif location:
|
||||
tool_result_embeds.append(location)
|
||||
tool_result = {
|
||||
"status": "success",
|
||||
"code": "ui_component",
|
||||
"message": "Embedded UI result is active and visible to the user.",
|
||||
}
|
||||
|
||||
tool_result_files = []
|
||||
if isinstance(tool_result, list):
|
||||
for item in tool_result:
|
||||
# check if string
|
||||
if isinstance(item, str) and item.startswith("data:"):
|
||||
tool_result_files.append(item)
|
||||
tool_result_files.append(
|
||||
{
|
||||
"type": "data",
|
||||
"content": item,
|
||||
}
|
||||
)
|
||||
tool_result.remove(item)
|
||||
|
||||
if tool.get("type") == "mcp":
|
||||
if isinstance(item, dict):
|
||||
if (
|
||||
item.get("type") == "image"
|
||||
or item.get("type") == "audio"
|
||||
):
|
||||
file_url = get_file_url_from_base64(
|
||||
request,
|
||||
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
|
||||
{
|
||||
"chat_id": metadata.get(
|
||||
"chat_id", None
|
||||
),
|
||||
"message_id": metadata.get(
|
||||
"message_id", None
|
||||
),
|
||||
"session_id": metadata.get(
|
||||
"session_id", None
|
||||
),
|
||||
"result": item,
|
||||
},
|
||||
user,
|
||||
)
|
||||
|
||||
tool_result_files.append(
|
||||
{
|
||||
"type": item.get("type", "data"),
|
||||
"url": file_url,
|
||||
}
|
||||
)
|
||||
tool_result.remove(item)
|
||||
|
||||
if tool_result_files:
|
||||
if not isinstance(tool_result, list):
|
||||
tool_result = [
|
||||
tool_result,
|
||||
]
|
||||
|
||||
for file in tool_result_files:
|
||||
tool_result.append(
|
||||
{
|
||||
"type": file.get("type", "data"),
|
||||
"content": "Result is being displayed as a file.",
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(tool_result, dict) or isinstance(
|
||||
tool_result, list
|
||||
):
|
||||
|
|
@ -2424,6 +2664,11 @@ async def process_chat_response(
|
|||
if tool_result_files
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"embeds": tool_result_embeds}
|
||||
if tool_result_embeds
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -2569,23 +2814,18 @@ async def process_chat_response(
|
|||
if isinstance(stdout, str):
|
||||
stdoutLines = stdout.split("\n")
|
||||
for idx, line in enumerate(stdoutLines):
|
||||
|
||||
if "data:image/png;base64" in line:
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = (
|
||||
load_b64_image_data(line)
|
||||
image_url = get_image_url_from_base64(
|
||||
request,
|
||||
line,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
if image_url:
|
||||
stdoutLines[idx] = (
|
||||
f""
|
||||
)
|
||||
stdoutLines[idx] = (
|
||||
f""
|
||||
)
|
||||
|
||||
output["stdout"] = "\n".join(stdoutLines)
|
||||
|
||||
|
|
@ -2595,19 +2835,12 @@ async def process_chat_response(
|
|||
resultLines = result.split("\n")
|
||||
for idx, line in enumerate(resultLines):
|
||||
if "data:image/png;base64" in line:
|
||||
image_url = ""
|
||||
# Extract base64 image data from the line
|
||||
image_data, content_type = (
|
||||
load_b64_image_data(line)
|
||||
image_url = get_image_url_from_base64(
|
||||
request,
|
||||
line,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
if image_data is not None:
|
||||
image_url = upload_image(
|
||||
request,
|
||||
image_data,
|
||||
content_type,
|
||||
metadata,
|
||||
user,
|
||||
)
|
||||
resultLines[idx] = (
|
||||
f""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ def deep_update(d, u):
|
|||
return d
|
||||
|
||||
|
||||
def get_message_list(messages, message_id):
|
||||
def get_message_list(messages_map, message_id):
|
||||
"""
|
||||
Reconstructs a list of messages in order up to the specified message_id.
|
||||
|
||||
|
|
@ -36,11 +36,11 @@ def get_message_list(messages, message_id):
|
|||
"""
|
||||
|
||||
# Handle case where messages is None
|
||||
if not messages:
|
||||
if not messages_map:
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
|
||||
# Find the message by its id
|
||||
current_message = messages.get(message_id)
|
||||
current_message = messages_map.get(message_id)
|
||||
|
||||
if not current_message:
|
||||
return [] # Return empty list instead of None to prevent iteration errors
|
||||
|
|
@ -53,7 +53,7 @@ def get_message_list(messages, message_id):
|
|||
0, current_message
|
||||
) # Insert the message at the beginning of the list
|
||||
parent_id = current_message.get("parentId") # Use .get() for safety
|
||||
current_message = messages.get(parent_id) if parent_id else None
|
||||
current_message = messages_map.get(parent_id) if parent_id else None
|
||||
|
||||
return message_list
|
||||
|
||||
|
|
@ -120,19 +120,20 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
|
|||
return get_system_message(messages), remove_system_message(messages)
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
break
|
||||
return messages
|
||||
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
|
||||
if isinstance(message["content"], list):
|
||||
for item in message["content"]:
|
||||
if item["type"] == "text":
|
||||
if append:
|
||||
item["text"] = f"{item['text']}\n{content}"
|
||||
else:
|
||||
item["text"] = f"{content}\n{item['text']}"
|
||||
else:
|
||||
if append:
|
||||
message["content"] = f"{message['content']}\n{content}"
|
||||
else:
|
||||
message["content"] = f"{content}\n{message['content']}"
|
||||
return message
|
||||
|
||||
|
||||
def add_or_update_system_message(
|
||||
|
|
@ -148,10 +149,7 @@ def add_or_update_system_message(
|
|||
"""
|
||||
|
||||
if messages and messages[0].get("role") == "system":
|
||||
if append:
|
||||
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
|
||||
else:
|
||||
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
|
||||
messages[0] = update_message_content(messages[0], content, append)
|
||||
else:
|
||||
# Insert at the beginning
|
||||
messages.insert(0, {"role": "system", "content": content})
|
||||
|
|
@ -159,7 +157,7 @@ def add_or_update_system_message(
|
|||
return messages
|
||||
|
||||
|
||||
def add_or_update_user_message(content: str, messages: list[dict]):
|
||||
def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
|
||||
"""
|
||||
Adds a new user message at the end of the messages list
|
||||
or updates the existing user message at the end.
|
||||
|
|
@ -170,7 +168,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
|||
"""
|
||||
|
||||
if messages and messages[-1].get("role") == "user":
|
||||
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
|
||||
messages[-1] = update_message_content(messages[-1], content, append)
|
||||
else:
|
||||
# Insert at the end
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
|
@ -178,6 +176,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
|
|||
return messages
|
||||
|
||||
|
||||
def prepend_to_first_user_message_content(
|
||||
content: str, messages: list[dict]
|
||||
) -> list[dict]:
|
||||
for message in messages:
|
||||
if message["role"] == "user":
|
||||
message = update_message_content(message, content, append=False)
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
def append_or_update_assistant_message(content: str, messages: list[dict]):
|
||||
"""
|
||||
Adds a new assistant message at the end of the messages list
|
||||
|
|
|
|||
|
|
@ -22,10 +22,11 @@ from open_webui.utils.access_control import has_access
|
|||
|
||||
|
||||
from open_webui.config import (
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
)
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.users import UserModel
|
||||
|
||||
|
||||
|
|
@ -332,3 +333,40 @@ def check_model_access(user, model):
|
|||
)
|
||||
):
|
||||
raise Exception("Model not found")
|
||||
|
||||
|
||||
def get_filtered_models(models, user):
|
||||
# Filter out models that the user does not have access to
|
||||
if (
|
||||
user.role == "user"
|
||||
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
) and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
filtered_models = []
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
if has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model.get("info", {})
|
||||
.get("meta", {})
|
||||
.get("access_control", {}),
|
||||
):
|
||||
filtered_models.append(model)
|
||||
continue
|
||||
|
||||
model_info = Models.get_model_by_id(model["id"])
|
||||
if model_info:
|
||||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model_info.user_id
|
||||
or has_access(
|
||||
user.id,
|
||||
type="read",
|
||||
access_control=model_info.access_control,
|
||||
)
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
else:
|
||||
return models
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import sys
|
||||
import urllib
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -9,6 +11,9 @@ from datetime import datetime, timedelta
|
|||
import re
|
||||
import fnmatch
|
||||
import time
|
||||
import secrets
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
import aiohttp
|
||||
from authlib.integrations.starlette_client import OAuth
|
||||
|
|
@ -18,6 +23,7 @@ from fastapi import (
|
|||
status,
|
||||
)
|
||||
from starlette.responses import RedirectResponse
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from open_webui.models.auths import Auths
|
||||
|
|
@ -56,11 +62,27 @@ from open_webui.env import (
|
|||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE,
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
|
||||
)
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.auth import get_password_hash, create_token
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
)
|
||||
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadata):
|
||||
issuer: Optional[str] = None # URL of the OAuth server that issued this client
|
||||
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
client_id_issued_at: int | None = None
|
||||
client_secret_expires_at: int | None = None
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -89,6 +111,42 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
|||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
|
||||
|
||||
FERNET = None
|
||||
|
||||
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
|
||||
key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest()
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes)
|
||||
else:
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()
|
||||
|
||||
try:
|
||||
FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY)
|
||||
except Exception as e:
|
||||
log.error(f"Error initializing Fernet with provided key: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def encrypt_data(data) -> str:
|
||||
"""Encrypt data for storage"""
|
||||
try:
|
||||
data_json = json.dumps(data)
|
||||
encrypted = FERNET.encrypt(data_json.encode()).decode()
|
||||
return encrypted
|
||||
except Exception as e:
|
||||
log.error(f"Error encrypting data: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def decrypt_data(data: str):
|
||||
"""Decrypt data from storage"""
|
||||
try:
|
||||
decrypted = FERNET.decrypt(data.encode()).decode()
|
||||
return json.loads(decrypted)
|
||||
except Exception as e:
|
||||
log.error(f"Error decrypting data: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
|
||||
"""
|
||||
Check if a group name matches any blocked pattern.
|
||||
|
|
@ -133,6 +191,412 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
|
||||
parsed = urllib.parse.urlparse(server_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return parsed, base_url
|
||||
|
||||
|
||||
def get_discovery_urls(server_url) -> list[str]:
|
||||
urls = []
|
||||
parsed, base_url = get_parsed_and_base_url(server_url)
|
||||
|
||||
urls.append(
|
||||
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server")
|
||||
)
|
||||
urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"))
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
|
||||
# This is not currently supported.
|
||||
async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
request,
|
||||
client_id: str,
|
||||
oauth_server_url: str,
|
||||
oauth_server_key: Optional[str] = None,
|
||||
) -> OAuthClientInformationFull:
|
||||
try:
|
||||
oauth_server_metadata = None
|
||||
oauth_server_metadata_url = None
|
||||
|
||||
redirect_base_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
|
||||
oauth_client_metadata = OAuthClientMetadata(
|
||||
client_name="Open WebUI",
|
||||
redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
token_endpoint_auth_method="client_secret_post",
|
||||
)
|
||||
|
||||
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
|
||||
discovery_urls = get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as oauth_server_metadata_response:
|
||||
if oauth_server_metadata_response.status == 200:
|
||||
try:
|
||||
oauth_server_metadata = OAuthMetadata.model_validate(
|
||||
await oauth_server_metadata_response.json()
|
||||
)
|
||||
oauth_server_metadata_url = url
|
||||
if (
|
||||
oauth_client_metadata.scope is None
|
||||
and oauth_server_metadata.scopes_supported is not None
|
||||
):
|
||||
oauth_client_metadata.scope = " ".join(
|
||||
oauth_server_metadata.scopes_supported
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Error parsing OAuth metadata from {url}: {e}")
|
||||
continue
|
||||
|
||||
registration_url = None
|
||||
if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
|
||||
registration_url = str(oauth_server_metadata.registration_endpoint)
|
||||
else:
|
||||
_, base_url = get_parsed_and_base_url(oauth_server_url)
|
||||
registration_url = urllib.parse.urljoin(base_url, "/register")
|
||||
|
||||
registration_data = oauth_client_metadata.model_dump(
|
||||
exclude_none=True,
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
)
|
||||
|
||||
# Perform dynamic client registration and return client info
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as oauth_client_registration_response:
|
||||
try:
|
||||
registration_response_json = (
|
||||
await oauth_client_registration_response.json()
|
||||
)
|
||||
oauth_client_info = OAuthClientInformationFull.model_validate(
|
||||
{
|
||||
**registration_response_json,
|
||||
**{"issuer": oauth_server_metadata_url},
|
||||
}
|
||||
)
|
||||
log.info(
|
||||
f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}"
|
||||
)
|
||||
return oauth_client_info
|
||||
except Exception as e:
|
||||
error_text = None
|
||||
try:
|
||||
error_text = await oauth_client_registration_response.text()
|
||||
log.error(
|
||||
f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}"
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
log.error(f"Error parsing client registration response: {e}")
|
||||
raise Exception(
|
||||
f"Dynamic client registration failed: {error_text}"
|
||||
if error_text
|
||||
else "Error parsing client registration response"
|
||||
)
|
||||
raise Exception("Dynamic client registration failed")
|
||||
except Exception as e:
|
||||
log.error(f"Exception during dynamic client registration: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
class OAuthClientManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
self.app = app
|
||||
self.clients = {}
|
||||
|
||||
def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
|
||||
self.clients[client_id] = {
|
||||
"client": self.oauth.register(
|
||||
name=client_id,
|
||||
client_id=oauth_client_info.client_id,
|
||||
client_secret=oauth_client_info.client_secret,
|
||||
client_kwargs=(
|
||||
{"scope": oauth_client_info.scope}
|
||||
if oauth_client_info.scope
|
||||
else {}
|
||||
),
|
||||
server_metadata_url=(
|
||||
oauth_client_info.issuer if oauth_client_info.issuer else None
|
||||
),
|
||||
),
|
||||
"client_info": oauth_client_info,
|
||||
}
|
||||
return self.clients[client_id]
|
||||
|
||||
def remove_client(self, client_id):
|
||||
if client_id in self.clients:
|
||||
del self.clients[client_id]
|
||||
log.info(f"Removed OAuth client {client_id}")
|
||||
return True
|
||||
|
||||
def get_client(self, client_id):
|
||||
client = self.clients.get(client_id)
|
||||
return client["client"] if client else None
|
||||
|
||||
def get_client_info(self, client_id):
|
||||
client = self.clients.get(client_id)
|
||||
return client["client_info"] if client else None
|
||||
|
||||
def get_server_metadata_url(self, client_id):
|
||||
if client_id in self.clients:
|
||||
client = self.clients[client_id]
|
||||
return (
|
||||
client.server_metadata_url
|
||||
if hasattr(client, "server_metadata_url")
|
||||
else None
|
||||
)
|
||||
return None
|
||||
|
||||
async def get_oauth_token(
|
||||
self, user_id: str, client_id: str, force_refresh: bool = False
|
||||
):
|
||||
"""
|
||||
Get a valid OAuth token for the user, automatically refreshing if needed.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
client_id: The OAuth client ID (provider)
|
||||
force_refresh: Force token refresh even if current token appears valid
|
||||
|
||||
Returns:
|
||||
dict: OAuth token data with access_token, or None if no valid token available
|
||||
"""
|
||||
try:
|
||||
# Get the OAuth session
|
||||
session = OAuthSessions.get_session_by_provider_and_user_id(
|
||||
client_id, user_id
|
||||
)
|
||||
if not session:
|
||||
log.warning(
|
||||
f"No OAuth session found for user {user_id}, client_id {client_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
if force_refresh or datetime.now() + timedelta(
|
||||
minutes=5
|
||||
) >= datetime.fromtimestamp(session.expires_at):
|
||||
log.debug(
|
||||
f"Token refresh needed for user {user_id}, client_id {session.provider}"
|
||||
)
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
return refreshed_token
|
||||
else:
|
||||
log.warning(
|
||||
f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}"
|
||||
)
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
return None
|
||||
return session.token
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error getting OAuth token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def _refresh_token(self, session) -> dict:
|
||||
"""
|
||||
Refresh an OAuth token if needed, with concurrency protection.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: Refreshed token data, or None if refresh failed
|
||||
"""
|
||||
try:
|
||||
# Perform the actual refresh
|
||||
refreshed_token = await self._perform_token_refresh(session)
|
||||
|
||||
if refreshed_token:
|
||||
# Update the session with new token data
|
||||
session = OAuthSessions.update_session_by_id(
|
||||
session.id, refreshed_token
|
||||
)
|
||||
log.info(f"Successfully refreshed token for session {session.id}")
|
||||
return session.token
|
||||
else:
|
||||
log.error(f"Failed to refresh token for session {session.id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error refreshing token for session {session.id}: {e}")
|
||||
return None
|
||||
|
||||
async def _perform_token_refresh(self, session) -> dict:
|
||||
"""
|
||||
Perform the actual OAuth token refresh.
|
||||
|
||||
Args:
|
||||
session: The OAuth session object
|
||||
|
||||
Returns:
|
||||
dict: New token data, or None if refresh failed
|
||||
"""
|
||||
client_id = session.provider
|
||||
token_data = session.token
|
||||
|
||||
if not token_data.get("refresh_token"):
|
||||
log.warning(f"No refresh token available for session {session.id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
client = self.get_client(client_id)
|
||||
if not client:
|
||||
log.error(f"No OAuth client found for provider {client_id}")
|
||||
return None
|
||||
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(
|
||||
self.get_server_metadata_url(client_id)
|
||||
) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get("token_endpoint")
|
||||
else:
|
||||
log.error(
|
||||
f"Failed to fetch OpenID configuration for client_id {client_id}"
|
||||
)
|
||||
if not token_endpoint:
|
||||
log.error(f"No token endpoint found for client_id {client_id}")
|
||||
return None
|
||||
|
||||
# Prepare refresh request
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"client_id": client.client_id,
|
||||
}
|
||||
if hasattr(client, "client_secret") and client.client_secret:
|
||||
refresh_data["client_secret"] = client.client_secret
|
||||
|
||||
# Make refresh request
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.post(
|
||||
token_endpoint,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status == 200:
|
||||
new_token_data = await r.json()
|
||||
|
||||
# Merge with existing token data (preserve refresh_token if not provided)
|
||||
if "refresh_token" not in new_token_data:
|
||||
new_token_data["refresh_token"] = token_data[
|
||||
"refresh_token"
|
||||
]
|
||||
|
||||
# Add timestamp for tracking
|
||||
new_token_data["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if (
|
||||
"expires_in" in new_token_data
|
||||
and "expires_at" not in new_token_data
|
||||
):
|
||||
new_token_data["expires_at"] = int(
|
||||
datetime.now().timestamp()
|
||||
+ new_token_data["expires_in"]
|
||||
)
|
||||
|
||||
log.debug(f"Token refresh successful for client_id {client_id}")
|
||||
return new_token_data
|
||||
else:
|
||||
error_text = await r.text()
|
||||
log.error(
|
||||
f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Exception during token refresh for client_id {client_id}: {e}")
|
||||
return None
|
||||
|
||||
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
|
||||
client = self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
client_info = self.get_client_info(client_id)
|
||||
if client_info is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
redirect_uri = (
|
||||
client_info.redirect_uris[0] if client_info.redirect_uris else None
|
||||
)
|
||||
return await client.authorize_redirect(request, str(redirect_uri))
|
||||
|
||||
async def handle_callback(self, request, client_id: str, user_id: str, response):
|
||||
client = self.get_client(client_id)
|
||||
if client is None:
|
||||
raise HTTPException(404)
|
||||
|
||||
error_message = None
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
if token:
|
||||
try:
|
||||
# Add timestamp for tracking
|
||||
token["issued_at"] = datetime.now().timestamp()
|
||||
|
||||
# Calculate expires_at if we have expires_in
|
||||
if "expires_in" in token and "expires_at" not in token:
|
||||
token["expires_at"] = (
|
||||
datetime.now().timestamp() + token["expires_in"]
|
||||
)
|
||||
|
||||
# Clean up any existing sessions for this user/client_id first
|
||||
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
|
||||
for session in sessions:
|
||||
if session.provider == client_id:
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
|
||||
session = OAuthSessions.create_session(
|
||||
user_id=user_id,
|
||||
provider=client_id,
|
||||
token=token,
|
||||
)
|
||||
log.info(
|
||||
f"Stored OAuth session server-side for user {user_id}, client_id {client_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = "Failed to store OAuth session server-side"
|
||||
log.error(f"Failed to store OAuth session server-side: {e}")
|
||||
else:
|
||||
error_message = "Failed to obtain OAuth token"
|
||||
log.warning(error_message)
|
||||
except Exception as e:
|
||||
error_message = "OAuth callback error"
|
||||
log.warning(f"OAuth callback error: {e}")
|
||||
|
||||
redirect_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
|
||||
if error_message:
|
||||
log.debug(error_message)
|
||||
redirect_url = f"{redirect_url}/?error={error_message}"
|
||||
return RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
|
||||
response = RedirectResponse(url=redirect_url, headers=response.headers)
|
||||
return response
|
||||
|
||||
|
||||
class OAuthManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
|
|
@ -157,7 +621,7 @@ class OAuthManager:
|
|||
)
|
||||
return None
|
||||
|
||||
def get_oauth_token(
|
||||
async def get_oauth_token(
|
||||
self, user_id: str, session_id: str, force_refresh: bool = False
|
||||
):
|
||||
"""
|
||||
|
|
@ -186,13 +650,15 @@ class OAuthManager:
|
|||
log.debug(
|
||||
f"Token refresh needed for user {user_id}, provider {session.provider}"
|
||||
)
|
||||
refreshed_token = self._refresh_token(session)
|
||||
refreshed_token = await self._refresh_token(session)
|
||||
if refreshed_token:
|
||||
return refreshed_token
|
||||
else:
|
||||
log.warning(
|
||||
f"Token refresh failed for user {user_id}, provider {session.provider}"
|
||||
f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}"
|
||||
)
|
||||
OAuthSessions.delete_session_by_id(session.id)
|
||||
|
||||
return None
|
||||
return session.token
|
||||
|
||||
|
|
@ -252,9 +718,10 @@ class OAuthManager:
|
|||
log.error(f"No OAuth client found for provider {provider}")
|
||||
return None
|
||||
|
||||
server_metadata_url = self.get_server_metadata_url(provider)
|
||||
token_endpoint = None
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.get(client.gserver_metadata_url) as r:
|
||||
async with session_http.get(server_metadata_url) as r:
|
||||
if r.status == 200:
|
||||
openid_data = await r.json()
|
||||
token_endpoint = openid_data.get("token_endpoint")
|
||||
|
|
@ -301,7 +768,7 @@ class OAuthManager:
|
|||
"expires_in" in new_token_data
|
||||
and "expires_at" not in new_token_data
|
||||
):
|
||||
new_token_data["expires_at"] = (
|
||||
new_token_data["expires_at"] = int(
|
||||
datetime.now().timestamp()
|
||||
+ new_token_data["expires_in"]
|
||||
)
|
||||
|
|
@ -574,7 +1041,7 @@ class OAuthManager:
|
|||
raise HTTPException(404)
|
||||
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
|
||||
redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for(
|
||||
"oauth_callback", provider=provider
|
||||
"oauth_login_callback", provider=provider
|
||||
)
|
||||
client = self.get_client(provider)
|
||||
if client is None:
|
||||
|
|
@ -602,6 +1069,12 @@ class OAuthManager:
|
|||
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
|
||||
):
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
if (
|
||||
provider == "feishu"
|
||||
and isinstance(user_data, dict)
|
||||
and "data" in user_data
|
||||
):
|
||||
user_data = user_data["data"]
|
||||
if not user_data:
|
||||
log.warning(f"OAuth callback failed, user data is missing: {token}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
|
@ -785,9 +1258,9 @@ class OAuthManager:
|
|||
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
|
||||
)
|
||||
|
||||
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
if redirect_base_url.endswith("/"):
|
||||
redirect_base_url = redirect_base_url[:-1]
|
||||
redirect_base_url = (
|
||||
str(request.app.state.config.WEBUI_URL or request.base_url)
|
||||
).rstrip("/")
|
||||
redirect_url = f"{redirect_base_url}/auth"
|
||||
|
||||
if error_message:
|
||||
|
|
|
|||
|
|
@ -163,20 +163,27 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
|||
@app.middleware("http")
|
||||
async def _metrics_middleware(request: Request, call_next):
|
||||
start_time = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
||||
|
||||
# Route template e.g. "/items/{item_id}" instead of real path.
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", request.url.path)
|
||||
status_code = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = getattr(response, "status_code", 500)
|
||||
return response
|
||||
except Exception:
|
||||
status_code = 500
|
||||
raise
|
||||
finally:
|
||||
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
||||
|
||||
attrs: Dict[str, str | int] = {
|
||||
"http.method": request.method,
|
||||
"http.route": route_path,
|
||||
"http.status_code": response.status_code,
|
||||
}
|
||||
# Route template e.g. "/items/{item_id}" instead of real path.
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", request.url.path)
|
||||
|
||||
request_counter.add(1, attrs)
|
||||
duration_histogram.record(elapsed_ms, attrs)
|
||||
attrs: Dict[str, str | int] = {
|
||||
"http.method": request.method,
|
||||
"http.route": route_path,
|
||||
"http.status_code": status_code,
|
||||
}
|
||||
|
||||
return response
|
||||
request_counter.add(1, attrs)
|
||||
duration_histogram.record(elapsed_ms, attrs)
|
||||
|
|
|
|||
|
|
@ -96,92 +96,117 @@ async def get_tools(
|
|||
for tool_id in tool_ids:
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
|
||||
if tool_id.startswith("server:"):
|
||||
server_id = tool_id.split(":")[1]
|
||||
splits = tool_id.split(":")
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
if len(splits) == 2:
|
||||
type = "openapi"
|
||||
server_id = splits[1]
|
||||
elif len(splits) == 3:
|
||||
type = splits[1]
|
||||
server_id = splits[2]
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
server_id_splits = server_id.split("|")
|
||||
if len(server_id_splits) == 2:
|
||||
server_id = server_id_splits[0]
|
||||
function_names = server_id_splits[1].split(",")
|
||||
|
||||
if type == "openapi":
|
||||
|
||||
tool_server_data = None
|
||||
for server in await get_tool_servers(request):
|
||||
if server["id"] == server_id:
|
||||
tool_server_data = server
|
||||
break
|
||||
|
||||
if tool_server_data is None:
|
||||
log.warning(f"Tool server data not found for {server_id}")
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[
|
||||
tool_server_idx
|
||||
]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
# Misc info
|
||||
"type": "external",
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
tool_server_idx = tool_server_data.get("idx", 0)
|
||||
tool_server_connection = (
|
||||
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
|
||||
)
|
||||
|
||||
specs = tool_server_data.get("specs", [])
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
auth_type = tool_server_connection.get("auth_type", "bearer")
|
||||
|
||||
cookies = {}
|
||||
headers = {}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {tool_server_connection.get('key', '')}"
|
||||
)
|
||||
elif auth_type == "none":
|
||||
# No authentication
|
||||
pass
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {request.state.token.credentials}"
|
||||
)
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {oauth_token.get('access_token', '')}"
|
||||
)
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
def make_tool_function(function_name, tool_server_data, headers):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=tool_server_data["url"],
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
name=function_name,
|
||||
params=kwargs,
|
||||
server_data=tool_server_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(
|
||||
function_name, tool_server_data, headers
|
||||
)
|
||||
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
tool_function,
|
||||
{},
|
||||
)
|
||||
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
# Handle function name collisions
|
||||
while function_name in tools_dict:
|
||||
log.warning(
|
||||
f"Tool {function_name} already exists in another tools!"
|
||||
)
|
||||
# Prepend server ID to function name
|
||||
function_name = f"{server_id}_{function_name}"
|
||||
|
||||
tools_dict[function_name] = tool_dict
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
|
|
@ -538,12 +563,23 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
|
|||
error_body = await response.json()
|
||||
raise Exception(error_body)
|
||||
|
||||
text_content = None
|
||||
|
||||
# Check if URL ends with .yaml or .yml to determine format
|
||||
if url.lower().endswith((".yaml", ".yml")):
|
||||
text_content = await response.text()
|
||||
res = yaml.safe_load(text_content)
|
||||
else:
|
||||
res = await response.json()
|
||||
text_content = await response.text()
|
||||
|
||||
try:
|
||||
res = json.loads(text_content)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
res = yaml.safe_load(text_content)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
except Exception as err:
|
||||
log.exception(f"Could not fetch tool server spec from {url}")
|
||||
if isinstance(err, dict) and "detail" in err:
|
||||
|
|
@ -566,7 +602,10 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
|
|||
# Prepare list of enabled servers along with their original index
|
||||
server_entries = []
|
||||
for idx, server in enumerate(servers):
|
||||
if server.get("config", {}).get("enable"):
|
||||
if (
|
||||
server.get("config", {}).get("enable")
|
||||
and server.get("type", "openapi") == "openapi"
|
||||
):
|
||||
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
|
||||
openapi_path = server.get("path", "openapi.json")
|
||||
full_url = get_tool_server_url(server.get("url"), openapi_path)
|
||||
|
|
@ -635,7 +674,7 @@ async def execute_tool_server(
|
|||
name: str,
|
||||
params: Dict[str, Any],
|
||||
server_data: Dict[str, Any],
|
||||
) -> Any:
|
||||
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
||||
error = None
|
||||
try:
|
||||
openapi = server_data.get("openapi", {})
|
||||
|
|
@ -707,6 +746,7 @@ async def execute_tool_server(
|
|||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
|
|
@ -717,13 +757,15 @@ async def execute_tool_server(
|
|||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
else:
|
||||
async with request_method(
|
||||
final_url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
|
|
@ -734,12 +776,13 @@ async def execute_tool_server(
|
|||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
return response_data
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
|
||||
except Exception as err:
|
||||
error = str(err)
|
||||
log.exception(f"API Request Error: {error}")
|
||||
return {"error": error}
|
||||
return ({"error": error}, None)
|
||||
|
||||
|
||||
def get_tool_server_url(url: Optional[str], path: str) -> str:
|
||||
|
|
|
|||
|
|
@ -2,72 +2,79 @@ fastapi==0.115.7
|
|||
uvicorn[standard]==0.35.0
|
||||
pydantic==2.11.7
|
||||
python-multipart==0.0.20
|
||||
itsdangerous==2.2.0
|
||||
|
||||
python-socketio==5.13.0
|
||||
python-jose==3.4.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
cryptography
|
||||
bcrypt==4.3.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.3
|
||||
|
||||
requests==2.32.4
|
||||
requests==2.32.5
|
||||
aiohttp==3.12.15
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
starlette-compress==1.6.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.38
|
||||
alembic==1.14.0
|
||||
peewee==3.18.1
|
||||
peewee-migrate==1.12.2
|
||||
psycopg2-binary==2.9.9
|
||||
pgvector==0.4.0
|
||||
PyMySQL==1.1.1
|
||||
bcrypt==4.3.0
|
||||
|
||||
pymongo
|
||||
redis
|
||||
boto3==1.40.5
|
||||
|
||||
argon2-cffi==25.1.0
|
||||
APScheduler==3.10.4
|
||||
|
||||
pycrdt==0.12.25
|
||||
redis
|
||||
|
||||
pymongo
|
||||
|
||||
psycopg2-binary==2.9.10
|
||||
pgvector==0.4.1
|
||||
|
||||
PyMySQL==1.1.1
|
||||
boto3==1.40.5
|
||||
|
||||
APScheduler==3.10.4
|
||||
RestrictedPython==8.0
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.8.1
|
||||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
mcp==1.14.1
|
||||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.32.0
|
||||
google-genai==1.38.0
|
||||
google-generativeai==0.8.5
|
||||
tiktoken
|
||||
|
||||
langchain==0.3.26
|
||||
langchain-community==0.3.27
|
||||
langchain==0.3.27
|
||||
langchain-community==0.3.29
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==0.6.3
|
||||
posthog==5.4.0
|
||||
chromadb==1.0.20
|
||||
opensearch-py==2.8.0
|
||||
|
||||
pymilvus==2.5.0
|
||||
qdrant-client==1.14.3
|
||||
opensearch-py==2.8.0
|
||||
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
|
||||
elasticsearch==9.0.1
|
||||
elasticsearch==9.1.0
|
||||
pinecone==6.0.2
|
||||
oracledb==3.2.0
|
||||
|
||||
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
|
||||
transformers
|
||||
sentence-transformers==4.1.0
|
||||
sentence-transformers==5.1.1
|
||||
accelerate
|
||||
colbert-ai==0.2.21
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.1
|
||||
|
||||
colbert-ai==0.2.21
|
||||
|
||||
ftfy==6.2.3
|
||||
pypdf==6.0.0
|
||||
|
|
@ -95,11 +102,8 @@ rapidocr-onnxruntime==1.4.4
|
|||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.20.1
|
||||
|
||||
faster-whisper==1.1.1
|
||||
|
||||
PyJWT[crypto]==2.10.1
|
||||
authlib==1.6.3
|
||||
|
||||
black==25.1.0
|
||||
youtube-transcript-api==1.2.2
|
||||
|
|
@ -118,10 +122,10 @@ docker~=7.1.0
|
|||
pytest~=8.4.1
|
||||
pytest-docker~=3.1.1
|
||||
|
||||
googleapis-common-protos==1.63.2
|
||||
googleapis-common-protos==1.70.0
|
||||
google-cloud-storage==2.19.0
|
||||
|
||||
azure-identity==1.23.0
|
||||
azure-identity==1.25.0
|
||||
azure-storage-blob==12.24.1
|
||||
|
||||
|
||||
|
|
|
|||
98
package-lock.json
generated
98
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.27",
|
||||
"version": "0.6.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.6.27",
|
||||
"version": "0.6.31",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
|
|
@ -37,7 +37,9 @@
|
|||
"@tiptap/extensions": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
|
|
@ -86,7 +88,6 @@
|
|||
"socket.io-client": "^4.2.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"svelte-tiptap": "^3.0.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
|
|
@ -3383,9 +3384,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-collaboration": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
|
||||
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
|
||||
"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
|
@ -3393,8 +3394,8 @@
|
|||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3",
|
||||
"yjs": "^13"
|
||||
}
|
||||
|
|
@ -3413,9 +3414,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-drag-handle": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
|
||||
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
|
||||
"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13"
|
||||
|
|
@ -3425,10 +3426,10 @@
|
|||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-collaboration": "^3.0.7",
|
||||
"@tiptap/extension-node-range": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/extension-collaboration": "^3.4.5",
|
||||
"@tiptap/extension-node-range": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5",
|
||||
"@tiptap/y-tiptap": "^3.0.0-beta.3"
|
||||
}
|
||||
},
|
||||
|
|
@ -3642,9 +3643,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-node-range": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
|
||||
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
|
||||
"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
|
@ -3652,8 +3653,8 @@
|
|||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7"
|
||||
"@tiptap/core": "^3.4.5",
|
||||
"@tiptap/pm": "^3.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
|
|
@ -3856,18 +3857,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tiptap/suggestion": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz",
|
||||
"integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
|
||||
"integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.9",
|
||||
"@tiptap/pm": "^3.0.9"
|
||||
"@tiptap/core": "^3.4.2",
|
||||
"@tiptap/pm": "^3.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/y-tiptap": {
|
||||
|
|
@ -4570,6 +4570,21 @@
|
|||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
|
||||
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
|
||||
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@webreflection/fetch": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
|
||||
|
|
@ -4673,6 +4688,15 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
|
||||
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/amator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
|
||||
|
|
@ -12503,26 +12527,6 @@
|
|||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.6.27",
|
||||
"version": "0.6.31",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
"@tiptap/extension-floating-menu": "^2.26.1",
|
||||
"@tiptap/extension-highlight": "^3.3.0",
|
||||
|
|
@ -81,7 +81,9 @@
|
|||
"@tiptap/extensions": "^3.0.7",
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.5.0",
|
||||
|
|
@ -130,7 +132,6 @@
|
|||
"socket.io-client": "^4.2.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"svelte-tiptap": "^3.0.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.2.0",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
|
|
|
|||
|
|
@ -10,23 +10,25 @@ dependencies = [
|
|||
"uvicorn[standard]==0.35.0",
|
||||
"pydantic==2.11.7",
|
||||
"python-multipart==0.0.20",
|
||||
"itsdangerous==2.2.0",
|
||||
|
||||
"python-socketio==5.13.0",
|
||||
"python-jose==3.4.0",
|
||||
"passlib[bcrypt]==1.7.4",
|
||||
"cryptography",
|
||||
"bcrypt==4.3.0",
|
||||
"argon2-cffi==23.1.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.10.1",
|
||||
"authlib==1.6.3",
|
||||
|
||||
"requests==2.32.4",
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.12.15",
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"starlette-compress==1.6.0",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
"starsessions[redis]==2.2.1",
|
||||
|
||||
"sqlalchemy==2.0.38",
|
||||
"alembic==1.14.0",
|
||||
|
|
@ -46,28 +48,23 @@ dependencies = [
|
|||
"asgiref==3.8.1",
|
||||
|
||||
"tiktoken",
|
||||
"mcp==1.14.1",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.32.0",
|
||||
"google-genai==1.38.0",
|
||||
"google-generativeai==0.8.5",
|
||||
|
||||
"langchain==0.3.26",
|
||||
"langchain-community==0.3.27",
|
||||
"langchain==0.3.27",
|
||||
"langchain-community==0.3.29",
|
||||
|
||||
"fake-useragent==2.2.0",
|
||||
"chromadb==0.6.3",
|
||||
"pymilvus==2.5.0",
|
||||
"qdrant-client==1.14.3",
|
||||
"chromadb==1.0.20",
|
||||
"opensearch-py==2.8.0",
|
||||
"playwright==1.49.1",
|
||||
"elasticsearch==9.0.1",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.2.0",
|
||||
|
||||
|
||||
"transformers",
|
||||
"sentence-transformers==4.1.0",
|
||||
"sentence-transformers==5.1.1",
|
||||
"accelerate",
|
||||
"colbert-ai==0.2.21",
|
||||
"pyarrow==20.0.0",
|
||||
"einops==0.8.1",
|
||||
|
||||
|
|
@ -112,10 +109,10 @@ dependencies = [
|
|||
|
||||
|
||||
|
||||
"googleapis-common-protos==1.63.2",
|
||||
"googleapis-common-protos==1.70.0",
|
||||
"google-cloud-storage==2.19.0",
|
||||
|
||||
"azure-identity==1.20.0",
|
||||
"azure-identity==1.25.0",
|
||||
"azure-storage-blob==12.24.1",
|
||||
|
||||
"ldap3==2.9.1",
|
||||
|
|
@ -124,7 +121,6 @@ dependencies = [
|
|||
"tencentcloud-sdk-python==3.0.1336",
|
||||
|
||||
"oracledb>=3.2.0",
|
||||
"posthog==5.4.0",
|
||||
|
||||
]
|
||||
readme = "README.md"
|
||||
|
|
@ -142,8 +138,8 @@ classifiers = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
postgres = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pgvector==0.4.0",
|
||||
"psycopg2-binary==2.9.10",
|
||||
"pgvector==0.4.1",
|
||||
]
|
||||
|
||||
all = [
|
||||
|
|
@ -155,6 +151,15 @@ all = [
|
|||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.1.1",
|
||||
"playwright==1.49.1",
|
||||
"elasticsearch==9.1.0",
|
||||
|
||||
"qdrant-client==1.14.3",
|
||||
"pymilvus==2.5.0",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.2.0",
|
||||
|
||||
"colbert-ai==0.2.21",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
147
src/app.css
147
src/app.css
|
|
@ -70,23 +70,23 @@ textarea::placeholder {
|
|||
}
|
||||
|
||||
.input-prose {
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.input-prose-sm {
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-sm {
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-xs {
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
|
|
@ -116,7 +116,7 @@ li p {
|
|||
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
background-color: rgba(215, 215, 215, 0.8);
|
||||
background-color: rgba(215, 215, 215, 0.6);
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
border-radius: 9999px;
|
||||
border-width: 1px;
|
||||
|
|
@ -124,12 +124,12 @@ li p {
|
|||
|
||||
/* Dark theme scrollbar styles */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
|
||||
background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
|
||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 0.6rem;
|
||||
height: 0.4rem;
|
||||
width: 0.4rem;
|
||||
}
|
||||
|
||||
|
|
@ -409,17 +409,33 @@ input[type='number'] {
|
|||
}
|
||||
}
|
||||
|
||||
.tiptap .mention {
|
||||
.mention {
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
padding: 0.1rem 0.3rem;
|
||||
@apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20;
|
||||
@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
|
||||
}
|
||||
|
||||
.tiptap .mention::after {
|
||||
.mention::after {
|
||||
content: '\200B';
|
||||
}
|
||||
|
||||
.tiptap .suggestion {
|
||||
border-radius: 0.4rem;
|
||||
box-decoration-break: clone;
|
||||
padding: 0.1rem 0.3rem;
|
||||
@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
|
||||
}
|
||||
|
||||
.tiptap .suggestion::after {
|
||||
content: '\200B';
|
||||
}
|
||||
|
||||
.tiptap .suggestion.is-empty::after {
|
||||
content: '\00A0';
|
||||
border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
|
||||
}
|
||||
|
||||
.input-prose .tiptap ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
|
|
@ -645,3 +661,112 @@ body {
|
|||
background: #171717;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Position the handle relative to each LI */
|
||||
.pm-li--with-handle {
|
||||
position: relative;
|
||||
margin-left: 12px; /* make space for the handle */
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* The drag handle itself */
|
||||
.pm-list-drag-handle {
|
||||
position: absolute;
|
||||
left: -36px; /* pull into the left gutter */
|
||||
top: 1px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
opacity: 0.35;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
|
||||
left: -16px; /* pull into the left gutter more to avoid the checkbox */
|
||||
}
|
||||
|
||||
.pm-list-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pm-li--with-handle:hover > .pm-list-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.pm-list-drag-handle:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:root {
|
||||
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
|
||||
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
|
||||
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* BEFORE/AFTER lines */
|
||||
.pm-li-drop-before::before,
|
||||
.pm-li-drop-after::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--pm-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pm-li-drop-before::before {
|
||||
top: -2px;
|
||||
}
|
||||
.pm-li-drop-after::after {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
background: var(--pm-fill-target);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-outdent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 3px;
|
||||
background: color-mix(in oklab, Highlight 35%, transparent);
|
||||
}
|
||||
|
||||
.pm-li--with-handle:has(.pm-li-drop-before),
|
||||
.pm-li--with-handle:has(.pm-li-drop-after),
|
||||
.pm-li--with-handle:has(.pm-li-drop-into),
|
||||
.pm-li--with-handle:has(.pm-li-drop-outdent) {
|
||||
background: var(--pm-fill-ancestor);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pm-li-drop-before,
|
||||
.pm-li-drop-after,
|
||||
.pm-li-drop-into,
|
||||
.pm-li-drop-outdent {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
|
|
|||
11
src/app.html
11
src/app.html
|
|
@ -23,8 +23,6 @@
|
|||
href="/static/apple-touch-icon.png"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
|
||||
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/manifest.json"
|
||||
|
|
@ -37,14 +35,7 @@
|
|||
/>
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="description" content="Open WebUI" />
|
||||
<link
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
title="Open WebUI"
|
||||
href="/opensearch.xml"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
|
||||
<script src="/static/loader.js" defer crossorigin="use-credentials"></script>
|
||||
<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,11 @@ export const importChat = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getChatList = async (token: string = '', page: number | null = null) => {
|
||||
export const getChatList = async (
|
||||
token: string = '',
|
||||
page: number | null = null,
|
||||
include_folders: boolean = false
|
||||
) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
|
|
@ -85,6 +89,10 @@ export const getChatList = async (token: string = '', page: number | null = null
|
|||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
if (include_folders) {
|
||||
searchParams.append('include_folders', 'true');
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import type { Banner } from '$lib/types';
|
||||
|
||||
export const importConfig = async (token: string, config) => {
|
||||
|
|
@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
|
|||
return res;
|
||||
};
|
||||
|
||||
type RegisterOAuthClientForm = {
|
||||
url: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
token: string,
|
||||
formData: RegisterOAuthClientForm,
|
||||
type: null | string = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const searchParams = type ? `?type=${type}` : '';
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
|
||||
const oauthClientId = type ? `${type}:${clientId}` : clientId;
|
||||
return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
|
||||
};
|
||||
|
||||
export const getCodeExecutionConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -493,18 +493,25 @@ export const executeToolServer = async (
|
|||
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
|
||||
}
|
||||
|
||||
let responseData;
|
||||
try {
|
||||
responseData = await res.json();
|
||||
} catch (err) {
|
||||
responseData = await res.text();
|
||||
}
|
||||
// make a clone of res and extract headers
|
||||
const responseHeaders = {};
|
||||
res.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
|
||||
return responseData;
|
||||
const text = await res.text();
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = JSON.parse(text);
|
||||
} catch {
|
||||
responseData = text;
|
||||
}
|
||||
return [responseData, responseHeaders];
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
console.error('API Request Error:', error);
|
||||
return { error };
|
||||
return [{ error }, null];
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
|
|||
return grouped;
|
||||
};
|
||||
|
||||
export const getNoteList = async (token: string = '') => {
|
||||
export const getNoteList = async (token: string = '', page: number | null = null) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
|
||||
if (page !== null) {
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
|
|
|||
|
|
@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const searchUsers = async (token: string, query: string) => {
|
||||
let error = null;
|
||||
let res = null;
|
||||
|
||||
res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getUserSettings = async (token: string) => {
|
||||
let error = null;
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
|
||||
loading = false;
|
||||
|
||||
toast.error($i18n.t('Key is required'));
|
||||
|
|
@ -331,6 +331,9 @@
|
|||
<option value="session">{$i18n.t('Session')}</option>
|
||||
{#if !direct}
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if azure}
|
||||
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
|
|
@ -361,6 +364,12 @@
|
|||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -443,7 +452,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
|
|
@ -499,8 +508,6 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
|
|
@ -528,9 +535,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
</script>
|
||||
|
||||
<div class="px-3">
|
||||
<div class="text-center text-6xl mb-3">📄</div>
|
||||
<div class="text-center dark:text-white text-xl font-semibold z-50">
|
||||
<div class="text-center dark:text-white text-2xl font-medium z-50">
|
||||
{#if title}
|
||||
{title}
|
||||
{:else}
|
||||
|
|
@ -17,7 +16,7 @@
|
|||
</div>
|
||||
|
||||
<slot
|
||||
><div class="px-2 mt-2 text-center text-sm dark:text-gray-200 w-full">
|
||||
><div class="px-2 mt-2 text-center text-gray-700 dark:text-gray-200 w-full">
|
||||
{#if content}
|
||||
{content}
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tags from './common/Tags.svelte';
|
||||
import { getToolServerData } from '$lib/apis';
|
||||
import { verifyToolServerConnection } from '$lib/apis/configs';
|
||||
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
|
||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
|
@ -30,6 +30,8 @@
|
|||
let url = '';
|
||||
let path = 'openapi.json';
|
||||
|
||||
let type = 'openapi'; // 'openapi', 'mcp'
|
||||
|
||||
let auth_type = 'bearer';
|
||||
let key = '';
|
||||
|
||||
|
|
@ -39,10 +41,47 @@
|
|||
let name = '';
|
||||
let description = '';
|
||||
|
||||
let enable = true;
|
||||
let oauthClientInfo = null;
|
||||
|
||||
let enable = true;
|
||||
let loading = false;
|
||||
|
||||
const registerOAuthClientHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === '') {
|
||||
toast.error($i18n.t('Please enter a valid ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await registerOAuthClient(
|
||||
localStorage.token,
|
||||
{
|
||||
url: url,
|
||||
client_id: id
|
||||
},
|
||||
'mcp'
|
||||
).catch((err) => {
|
||||
toast.error($i18n.t('Registration failed'));
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.warning(
|
||||
$i18n.t(
|
||||
'Please save the connection to persist the OAuth client information and do not change the ID'
|
||||
)
|
||||
);
|
||||
toast.success($i18n.t('Registration successful'));
|
||||
|
||||
console.debug('Registration successful', res);
|
||||
oauthClientInfo = res?.oauth_client_info ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
|
|
@ -70,6 +109,7 @@
|
|||
const res = await verifyToolServerConnection(localStorage.token, {
|
||||
url,
|
||||
path,
|
||||
type,
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
|
|
@ -97,10 +137,22 @@
|
|||
|
||||
// remove trailing slash from url
|
||||
url = url.replace(/\/$/, '');
|
||||
if (id.includes(':') || id.includes('|')) {
|
||||
toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
|
||||
toast.error($i18n.t('Please register the OAuth client'));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = {
|
||||
url,
|
||||
path,
|
||||
type,
|
||||
auth_type,
|
||||
key,
|
||||
config: {
|
||||
|
|
@ -110,7 +162,8 @@
|
|||
info: {
|
||||
id: id,
|
||||
name: name,
|
||||
description: description
|
||||
description: description,
|
||||
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -119,14 +172,18 @@
|
|||
loading = false;
|
||||
show = false;
|
||||
|
||||
// reset form
|
||||
url = '';
|
||||
path = 'openapi.json';
|
||||
type = 'openapi';
|
||||
|
||||
key = '';
|
||||
auth_type = 'bearer';
|
||||
|
||||
id = '';
|
||||
name = '';
|
||||
description = '';
|
||||
oauthClientInfo = null;
|
||||
|
||||
enable = true;
|
||||
accessControl = null;
|
||||
|
|
@ -137,12 +194,14 @@
|
|||
url = connection.url;
|
||||
path = connection?.path ?? 'openapi.json';
|
||||
|
||||
type = connection?.type ?? 'openapi';
|
||||
auth_type = connection?.auth_type ?? 'bearer';
|
||||
key = connection?.key ?? '';
|
||||
|
||||
id = connection.info?.id ?? '';
|
||||
name = connection.info?.name ?? '';
|
||||
description = connection.info?.description ?? '';
|
||||
oauthClientInfo = connection.info?.oauth_client_info ?? null;
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
accessControl = connection.config?.access_control ?? null;
|
||||
|
|
@ -189,6 +248,31 @@
|
|||
}}
|
||||
>
|
||||
<div class="px-1">
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mb-1.5">
|
||||
<div class="flex w-full justify-between items-center">
|
||||
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
|
||||
|
||||
<div class="">
|
||||
<button
|
||||
on:click={() => {
|
||||
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
{$i18n.t('OpenAPI')}
|
||||
{:else if type === 'mcp'}
|
||||
{$i18n.t('MCP')}
|
||||
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
|
|
@ -243,38 +327,85 @@
|
|||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Auth')}</label
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Auth')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if auth_type === 'oauth_2.1'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col justify-end items-center shrink-0">
|
||||
<Tooltip
|
||||
content={oauthClientInfo
|
||||
? $i18n.t('Register Again')
|
||||
: $i18n.t('Register Client')}
|
||||
>
|
||||
<button
|
||||
class=" text-xs underline dark:text-gray-500 dark:hover:text-gray-200 text-gray-700 hover:text-gray-900 transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
registerOAuthClientHandler();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Register Client')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{#if !oauthClientInfo}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
|
||||
>
|
||||
{$i18n.t('Not Registered')}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
|
||||
>
|
||||
{$i18n.t('Registered')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
|
|
@ -290,6 +421,9 @@
|
|||
|
||||
{#if !direct}
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if type === 'mcp'}
|
||||
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -319,6 +453,12 @@
|
|||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'oauth_2.1'}
|
||||
<div
|
||||
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -334,9 +474,12 @@
|
|||
for="enter-id"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
|
||||
>{$i18n.t('Optional')}</span
|
||||
>
|
||||
|
||||
{#if type !== 'mcp'}
|
||||
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
|
||||
>{$i18n.t('Optional')}</span
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
|
|
@ -347,6 +490,7 @@
|
|||
bind:value={id}
|
||||
placeholder={$i18n.t('Enter ID')}
|
||||
autocomplete="off"
|
||||
required={type === 'mcp'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -396,13 +540,32 @@
|
|||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if type === 'mcp'}
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{$i18n.t('Warning')}:
|
||||
</span>
|
||||
{$i18n.t(
|
||||
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
|
||||
)}
|
||||
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.openwebui.com/features/mcp"
|
||||
target="_blank">{$i18n.t('Read more →')}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { Confetti } from 'svelte-confetti';
|
||||
|
||||
|
|
@ -17,16 +19,19 @@
|
|||
|
||||
let changelog = null;
|
||||
|
||||
onMount(async () => {
|
||||
const res = await getChangelog();
|
||||
changelog = res;
|
||||
});
|
||||
const init = async () => {
|
||||
changelog = await getChangelog();
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="lg">
|
||||
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
|
||||
<Modal bind:show size="xl">
|
||||
<div class="px-6 pt-5 dark:text-white text-black">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="text-xl font-semibold">
|
||||
<div class="text-xl font-medium">
|
||||
{$i18n.t("What's New in")}
|
||||
{$WEBUI_NAME}
|
||||
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
|
||||
|
|
@ -46,7 +51,7 @@
|
|||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
|
||||
<div class="text-sm dark:text-gray-200">
|
||||
v{WEBUI_VERSION}
|
||||
</div>
|
||||
|
|
@ -54,7 +59,7 @@
|
|||
</div>
|
||||
|
||||
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
|
||||
<div class=" overflow-y-scroll max-h-96 scrollbar-hidden">
|
||||
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
|
||||
<div class="mb-3">
|
||||
{#if changelog}
|
||||
{#each Object.keys(changelog) as version}
|
||||
|
|
@ -63,31 +68,28 @@
|
|||
v{version} - {changelog[version].date}
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-2" />
|
||||
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
|
||||
|
||||
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
|
||||
<div class="">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="font-semibold uppercase text-xs {section === 'added'
|
||||
? 'text-white bg-blue-600'
|
||||
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
|
||||
: section === 'fixed'
|
||||
? 'text-white bg-green-600'
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-200'
|
||||
: section === 'changed'
|
||||
? 'text-white bg-yellow-600'
|
||||
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
|
||||
: section === 'removed'
|
||||
? 'text-white bg-red-600'
|
||||
: ''} w-fit px-3 rounded-full my-2.5"
|
||||
? 'bg-red-500/20 text-red-700 dark:text-red-200'
|
||||
: ''} w-fit rounded-xl px-2 my-2.5"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
|
||||
<div class="my-2.5 px-1.5">
|
||||
{#each Object.keys(changelog[version][section]) as item}
|
||||
<div class="text-sm mb-2">
|
||||
<div class="font-semibold uppercase">
|
||||
{changelog[version][section][item].title}
|
||||
</div>
|
||||
<div class="mb-2 mt-1">{changelog[version][section][item].content}</div>
|
||||
<div class="my-2.5 px-1.5 markdown-prose-sm !list-none !w-full !max-w-none">
|
||||
{#each changelog[version][section] as entry}
|
||||
<div class="my-2">
|
||||
{@html DOMPurify.sanitize(entry?.raw)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,43 @@
|
|||
export let title: string = 'HI';
|
||||
export let content: string;
|
||||
|
||||
let startX = 0,
|
||||
startY = 0;
|
||||
let moved = false;
|
||||
const DRAG_THRESHOLD_PX = 6;
|
||||
|
||||
const clickHandler = () => {
|
||||
onClick();
|
||||
dispatch('closeToast');
|
||||
};
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
moved = false;
|
||||
// Ensure we continue to get events even if the toast moves under the pointer.
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (moved) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (dx * dx + dy * dy > DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
// Release capture if taken
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
||||
|
||||
// Only treat as a click if there wasn't a drag
|
||||
if (!moved) {
|
||||
clickHandler();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!navigator.userActivation.hasBeenActive) {
|
||||
return;
|
||||
|
|
@ -31,24 +68,33 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-850 rounded-xl px-3.5 py-3.5"
|
||||
on:click={() => {
|
||||
onClick();
|
||||
dispatch('closeToast');
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
|
||||
on:dragstart|preventDefault
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointermove={onPointerMove}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={() => (moved = true)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
clickHandler();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 self-top -translate-y-0.5">
|
||||
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
|
||||
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if title}
|
||||
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{title}</div>
|
||||
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
|
||||
{/if}
|
||||
|
||||
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
|
||||
{@html DOMPurify.sanitize(marked(content))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
id="users-tabs-container"
|
||||
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
>
|
||||
<button
|
||||
id="leaderboard"
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
|
||||
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
|
||||
{#if selectedTab === 'leaderboard'}
|
||||
<Leaderboard {feedbacks} />
|
||||
{:else if selectedTab === 'feedbacks'}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Download from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
|
||||
let show = false;
|
||||
</script>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[150px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
class="w-full max-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="start"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
|
||||
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
|
||||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
|
|
@ -169,7 +169,7 @@
|
|||
|
||||
<FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
|
||||
|
||||
<div class="mt-0.5 mb-2 gap-1 flex flex-row justify-between">
|
||||
<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
|
||||
<div class="flex md:self-center text-lg font-medium px-0.5">
|
||||
{$i18n.t('Feedback History')}
|
||||
|
||||
|
|
@ -187,31 +187,25 @@
|
|||
exportHandler();
|
||||
}}
|
||||
>
|
||||
<ArrowDownTray className="size-3" />
|
||||
<Download className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
|
||||
>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
{#if (feedbacks ?? []).length === 0}
|
||||
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
|
||||
{$i18n.t('No feedbacks found')}
|
||||
</div>
|
||||
{:else}
|
||||
<table
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
|
||||
>
|
||||
<thead
|
||||
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
|
||||
>
|
||||
<tr class="">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none w-3"
|
||||
class="px-2.5 py-2 cursor-pointer select-none w-3"
|
||||
on:click={() => setSortKey('user')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -234,7 +228,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 pr-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('model_id')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -257,7 +251,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
|
||||
on:click={() => setSortKey('rating')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -280,7 +274,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 text-right cursor-pointer select-none w-0"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
|
||||
on:click={() => setSortKey('updated_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -301,7 +295,7 @@
|
|||
</div>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
<script lang="ts">
|
||||
import * as ort from 'onnxruntime-web';
|
||||
import { env, AutoModel, AutoTokenizer } from '@huggingface/transformers';
|
||||
|
||||
env.backends.onnx.wasm.wasmPaths = '/wasm/';
|
||||
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { models } from '$lib/stores';
|
||||
|
||||
|
|
@ -237,6 +232,11 @@
|
|||
//////////////////////
|
||||
|
||||
const loadEmbeddingModel = async () => {
|
||||
const { env, AutoModel, AutoTokenizer } = await import('@huggingface/transformers');
|
||||
if (env.backends.onnx.wasm) {
|
||||
env.backends.onnx.wasm.wasmPaths = '/wasm/';
|
||||
}
|
||||
|
||||
// Check if the tokenizer and model are already loaded and stored in the window object
|
||||
if (!window.tokenizer) {
|
||||
window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
|
||||
|
|
@ -337,7 +337,7 @@
|
|||
/>
|
||||
|
||||
<div
|
||||
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
|
||||
<div class=" gap-1">
|
||||
|
|
@ -370,9 +370,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
|
||||
>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm">
|
||||
{#if loadingLeaderboard}
|
||||
<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
|
||||
<div class="m-auto">
|
||||
|
|
@ -386,17 +384,15 @@
|
|||
</div>
|
||||
{:else}
|
||||
<table
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full {loadingLeaderboard
|
||||
? 'opacity-20'
|
||||
: ''}"
|
||||
>
|
||||
<thead
|
||||
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
|
||||
>
|
||||
<tr class="">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none w-3"
|
||||
class="px-2.5 py-2 cursor-pointer select-none w-3"
|
||||
on:click={() => setSortKey('rating')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -418,7 +414,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -440,7 +436,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
|
||||
on:click={() => setSortKey('rating')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -462,7 +458,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
|
||||
on:click={() => setSortKey('won')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -484,7 +480,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
|
||||
on:click={() => setSortKey('lost')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
toggleGlobalById
|
||||
} from '$lib/apis/functions';
|
||||
|
||||
import ArrowDownTray from '../icons/ArrowDownTray.svelte';
|
||||
import Download from '../icons/Download.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import ConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import { getModels } from '$lib/apis';
|
||||
|
|
@ -222,7 +222,7 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col mt-1.5 mb-0.5">
|
||||
<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex md:self-center text-xl items-center font-medium px-0.5">
|
||||
{$i18n.t('Functions')}
|
||||
|
|
@ -317,7 +317,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<div class="mb-5 px-[16px]">
|
||||
{#each filteredItems as func (func.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||
|
|
@ -330,14 +330,14 @@
|
|||
<div class=" flex-1 self-center pl-1">
|
||||
<div class=" font-semibold flex items-center gap-1.5">
|
||||
<div
|
||||
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{func.type}
|
||||
</div>
|
||||
|
||||
{#if func?.meta?.manifest?.version}
|
||||
<div
|
||||
class="text-xs font-bold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
v{func?.meta?.manifest?.version ?? ''}
|
||||
</div>
|
||||
|
|
@ -482,7 +482,7 @@
|
|||
)}
|
||||
</div> -->
|
||||
|
||||
<div class=" flex justify-end w-full mb-2">
|
||||
<div class=" flex justify-end w-full mb-2 px-[16px]">
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
id="documents-import-input"
|
||||
|
|
@ -562,7 +562,7 @@
|
|||
</div>
|
||||
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<div class=" my-16">
|
||||
<div class=" my-16 px-[16px]">
|
||||
<div class=" text-xl font-medium mb-1 line-clamp-1">
|
||||
{$i18n.t('Made by Open WebUI Community')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
import Github from '$lib/components/icons/Github.svelte';
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||
class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="start"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
|
||||
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
class="w-full max-w-[180px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="start"
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
>
|
||||
{#if ['filter', 'action'].includes(func.type)}
|
||||
<div
|
||||
class="flex gap-2 justify-between items-center px-3 py-2 text-sm font-medium cursor-pointerrounded-md"
|
||||
class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<GlobeAlt />
|
||||
|
|
@ -63,11 +63,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-1" />
|
||||
<hr class="border-gray-50 dark:border-gray-850 my-1" />
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
editHandler();
|
||||
}}
|
||||
|
|
@ -91,7 +91,7 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
}}
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
cloneHandler();
|
||||
}}
|
||||
|
|
@ -112,20 +112,20 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
exportHandler();
|
||||
}}
|
||||
>
|
||||
<ArrowDownTray />
|
||||
<Download />
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Export')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-1" />
|
||||
<hr class="border-gray-50 dark:border-gray-850 my-1" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
deleteHandler();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
id="admin-settings-tabs-container"
|
||||
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
>
|
||||
<button
|
||||
id="general"
|
||||
|
|
@ -433,7 +433,9 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden">
|
||||
<div
|
||||
class="flex-1 mt-3 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
|
||||
>
|
||||
{#if selectedTab === 'general'}
|
||||
<General
|
||||
saveHandler={async () => {
|
||||
|
|
|
|||
|
|
@ -261,8 +261,8 @@
|
|||
<div class="flex flex-col gap-1.5 mt-1.5">
|
||||
{#each OPENAI_API_BASE_URLS as url, idx}
|
||||
<OpenAIConnection
|
||||
{url}
|
||||
key={OPENAI_API_KEYS[idx]}
|
||||
bind:url={OPENAI_API_BASE_URLS[idx]}
|
||||
bind:key={OPENAI_API_KEYS[idx]}
|
||||
bind:config={OPENAI_API_CONFIGS[idx]}
|
||||
pipeline={pipelineUrls[url] ? true : false}
|
||||
onSubmit={() => {
|
||||
|
|
@ -326,7 +326,7 @@
|
|||
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
|
||||
{#each OLLAMA_BASE_URLS as url, idx}
|
||||
<OllamaConnection
|
||||
{url}
|
||||
bind:url={OLLAMA_BASE_URLS[idx]}
|
||||
bind:config={OLLAMA_API_CONFIGS[idx]}
|
||||
{idx}
|
||||
onSubmit={() => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
import ManageOllamaModal from './ManageOllamaModal.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
|
||||
export let onDelete = () => {};
|
||||
export let onSubmit = () => {};
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownTray />
|
||||
<Download />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@
|
|||
</div>
|
||||
</button>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-850 my-1" />
|
||||
<hr class="border-gray-50 dark:border-gray-850 my-1" />
|
||||
|
||||
{#if $config?.features.enable_admin_export ?? true}
|
||||
<div class=" flex w-full justify-between">
|
||||
|
|
@ -480,14 +480,4 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
|
||||
</div> -->
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1143,7 +1143,7 @@
|
|||
<div class=" mb-2.5 py-0.5 w-full justify-between">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5'
|
||||
'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
|
||||
)}
|
||||
placement="top-start"
|
||||
className="inline-tooltip"
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@
|
|||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2 -mx-2">
|
||||
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
|
||||
<AccessControl bind:accessControl />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import ManageModelsModal from './Models/ManageModelsModal.svelte';
|
||||
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
|
|
@ -265,7 +265,7 @@
|
|||
showManageModal = true;
|
||||
}}
|
||||
>
|
||||
<ArrowDownTray />
|
||||
<Download />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
import Share from '$lib/components/icons/Share.svelte';
|
||||
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||||
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
|
||||
|
||||
import { config } from '$lib/stores';
|
||||
|
|
@ -45,14 +45,14 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[170px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
sideOffset={-2}
|
||||
side="bottom"
|
||||
align="start"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
hideHandler();
|
||||
}}
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
copyLinkHandler();
|
||||
}}
|
||||
|
|
@ -115,12 +115,12 @@
|
|||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
exportHandler();
|
||||
}}
|
||||
>
|
||||
<ArrowDownTray />
|
||||
<Download />
|
||||
|
||||
<div class="flex items-center">{$i18n.t('Export')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
|
||||
|
||||
import AddServerModal from '$lib/components/AddServerModal.svelte';
|
||||
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
|
||||
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
|
||||
|
||||
export let saveSettings: Function;
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between text-sm"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
export let saveHandler: Function;
|
||||
|
||||
let webSearchEngines = [
|
||||
'ollama_cloud',
|
||||
'perplexity_search',
|
||||
'searxng',
|
||||
'yacy',
|
||||
'google_pse',
|
||||
|
|
@ -130,7 +132,41 @@
|
|||
</div>
|
||||
|
||||
{#if webConfig.WEB_SEARCH_ENGINE !== ''}
|
||||
{#if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
|
||||
{#if webConfig.WEB_SEARCH_ENGINE === 'ollama_cloud'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Ollama Cloud API Key')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Ollama Cloud API Key')}
|
||||
bind:value={webConfig.OLLAMA_CLOUD_WEB_SEARCH_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'perplexity_search'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Perplexity API Key')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Perplexity API Key')}
|
||||
bind:value={webConfig.PERPLEXITY_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
id="users-tabs-container"
|
||||
class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
>
|
||||
<button
|
||||
id="overview"
|
||||
|
|
@ -111,7 +111,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
|
||||
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
|
||||
{#if selectedTab === 'overview'}
|
||||
<UserList />
|
||||
{:else if selectedTab === 'groups'}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
|
||||
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-semibold">
|
||||
<div class="w-full basis-3/5">{$i18n.t('Group')}</div>
|
||||
|
||||
<div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex md:self-center text-lg font-medium px-0.5">
|
||||
<div class="flex-shrink-0">
|
||||
|
|
@ -219,19 +219,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
|
||||
>
|
||||
<table
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
|
||||
>
|
||||
<thead
|
||||
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
|
||||
>
|
||||
<tr class="">
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('role')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -254,7 +248,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('name')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -277,7 +271,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('email')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -301,7 +295,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('last_active_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -324,7 +318,7 @@
|
|||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('created_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -347,7 +341,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-1.5 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
on:click={() => setSortKey('oauth_sub')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -369,7 +363,7 @@
|
|||
</div>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="px-3 py-2 text-right" />
|
||||
<th scope="col" class="px-2.5 py-2 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="">
|
||||
|
|
@ -508,11 +502,11 @@
|
|||
> [!NOTE]
|
||||
> # **Hey there! 👋**
|
||||
>
|
||||
> It looks like you have over 50 users — that usually falls under organizational usage.
|
||||
> It looks like you have over 50 users, that usually falls under organizational usage.
|
||||
>
|
||||
> Open WebUI is proudly open source and completely free, with no hidden limits — and we'd love to keep it that way. 🌱
|
||||
> Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱
|
||||
>
|
||||
> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more — all at a fraction of what it would cost to build and maintain internally.
|
||||
> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally.
|
||||
>
|
||||
> Your support helps us stay independent and continue building great tools for everyone. 💛
|
||||
>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@
|
|||
let searchDebounceTimeout;
|
||||
|
||||
const searchHandler = async () => {
|
||||
if (!show) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchDebounceTimeout) {
|
||||
clearTimeout(searchDebounceTimeout);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import Drawer from '../common/Drawer.svelte';
|
||||
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
|
||||
import Thread from './Thread.svelte';
|
||||
import i18n from '$lib/i18n';
|
||||
|
||||
export let id = '';
|
||||
|
||||
|
|
@ -250,6 +251,12 @@
|
|||
<MessageInput
|
||||
id="root"
|
||||
{typingUsers}
|
||||
userSuggestions={true}
|
||||
channelSuggestions={true}
|
||||
disabled={!channel?.write_access}
|
||||
placeholder={!channel?.write_access
|
||||
? $i18n.t('You do not have permission to send messages in this channel.')
|
||||
: $i18n.t('Type here...')}
|
||||
{onChange}
|
||||
onSubmit={submitHandler}
|
||||
{scrollToBottom}
|
||||
|
|
@ -279,11 +286,12 @@
|
|||
{/if}
|
||||
{:else if threadId !== null}
|
||||
<PaneResizer
|
||||
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
id="controls-resizer"
|
||||
>
|
||||
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
|
||||
<EllipsisVertical className="size-4 invisible group-hover:visible" />
|
||||
</div>
|
||||
<div
|
||||
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
/>
|
||||
</PaneResizer>
|
||||
|
||||
<Pane defaultSize={50} minSize={30} class="h-full w-full">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,8 @@
|
|||
import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
|
||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||
import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
|
||||
import Camera from '$lib/components/icons/Camera.svelte';
|
||||
import Clip from '$lib/components/icons/Clip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -44,34 +46,32 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu.Content
|
||||
class="w-full max-w-[200px] rounded-xl px-1 py-1 border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
sideOffset={15}
|
||||
alignOffset={-8}
|
||||
side="top"
|
||||
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
|
||||
sideOffset={4}
|
||||
alignOffset={-6}
|
||||
side="bottom"
|
||||
align="start"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
{#if !$mobile}
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
screenCaptureHandler();
|
||||
}}
|
||||
>
|
||||
<CameraSolid />
|
||||
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
|
||||
on:click={() => {
|
||||
uploadFilesHandler();
|
||||
}}
|
||||
>
|
||||
<DocumentArrowUpSolid />
|
||||
<Clip />
|
||||
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
|
||||
on:click={() => {
|
||||
screenCaptureHandler();
|
||||
}}
|
||||
>
|
||||
<Camera />
|
||||
<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
205
src/lib/components/channel/MessageInput/MentionList.svelte
Normal file
205
src/lib/components/channel/MessageInput/MentionList.svelte
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { channels, models, user } from '$lib/stores';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Hashtag from '$lib/components/icons/Hashtag.svelte';
|
||||
import Lock from '$lib/components/icons/Lock.svelte';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { searchUsers } from '$lib/apis/users';
|
||||
|
||||
export let query = '';
|
||||
|
||||
export let command: (payload: { id: string; label: string }) => void;
|
||||
export let selectedIndex = 0;
|
||||
|
||||
export let label = '';
|
||||
export let triggerChar = '@';
|
||||
|
||||
export let modelSuggestions = false;
|
||||
export let userSuggestions = false;
|
||||
export let channelSuggestions = false;
|
||||
|
||||
let _models = [];
|
||||
let _users = [];
|
||||
let _channels = [];
|
||||
|
||||
$: filteredItems = [..._users, ..._models, ..._channels].filter(
|
||||
(u) =>
|
||||
u.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
u.id.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const getUserList = async () => {
|
||||
const res = await searchUsers(localStorage.token, query).catch((error) => {
|
||||
console.error('Error searching users:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
|
||||
a.label.localeCompare(b.label)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (query !== null && userSuggestions) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
const select = (index: number) => {
|
||||
const item = filteredItems[index];
|
||||
if (!item) return;
|
||||
|
||||
// Add the "U:", "M:" or "C:" prefix to the id
|
||||
// and also append the label after a pipe |
|
||||
// so that the mention renderer can show the label
|
||||
if (item)
|
||||
command({
|
||||
id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : 'C'}:${item.id}|${item.label}`,
|
||||
label: item.label
|
||||
});
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||
const item = document.querySelector(`[data-selected="true"]`);
|
||||
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
|
||||
const item = document.querySelector(`[data-selected="true"]`);
|
||||
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
select(selectedIndex);
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
// tell tiptap we handled it (it will close)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// This method will be called from the suggestion renderer
|
||||
// @ts-ignore
|
||||
export function _onKeyDown(event: KeyboardEvent) {
|
||||
return onKeyDown(event);
|
||||
}
|
||||
|
||||
const keydownListener = (e) => {
|
||||
// required to prevent the default enter behavior
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
select(selectedIndex);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('keydown', keydownListener);
|
||||
if (channelSuggestions) {
|
||||
// Add a dummy channel item
|
||||
_channels = [
|
||||
...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
|
||||
];
|
||||
} else {
|
||||
if (userSuggestions) {
|
||||
await getUserList();
|
||||
}
|
||||
|
||||
if (modelSuggestions) {
|
||||
_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', keydownListener);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if filteredItems.length}
|
||||
<div
|
||||
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
|
||||
id="suggestions-container"
|
||||
>
|
||||
<div class="overflow-y-auto scrollbar-thin max-h-60">
|
||||
{#each filteredItems as item, i}
|
||||
{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
|
||||
<div class="px-2 text-xs text-gray-500 py-1">
|
||||
{#if item?.type === 'user'}
|
||||
{$i18n.t('Users')}
|
||||
{:else if item?.type === 'model'}
|
||||
{$i18n.t('Models')}
|
||||
{:else if item?.type === 'channel'}
|
||||
{$i18n.t('Channels')}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={item?.id} placement="top-start">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => select(i)}
|
||||
on:mousemove={() => {
|
||||
selectedIndex = i;
|
||||
}}
|
||||
class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
|
||||
selectedIndex
|
||||
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
|
||||
: ''}"
|
||||
data-selected={i === selectedIndex}
|
||||
>
|
||||
{#if item.type === 'channel'}
|
||||
<div class=" size-4 justify-center flex items-center mr-0.5">
|
||||
{#if item?.data?.access_control === null}
|
||||
<Hashtag className="size-3" strokeWidth="2.5" />
|
||||
{:else}
|
||||
<Lock className="size-[15px]" strokeWidth="2" />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if item.type === 'model'}
|
||||
<img
|
||||
src={item?.data?.info?.meta?.profile_image_url ??
|
||||
`${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
alt={item?.data?.name ?? item.id}
|
||||
class="rounded-full size-5 items-center mr-2"
|
||||
/>
|
||||
{:else if item.type === 'user'}
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${item.id}/profile/image`}
|
||||
alt={item?.label ?? item.id}
|
||||
class="rounded-full size-5 items-center mr-2"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="truncate flex-1 pr-2">
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-xs text-gray-500">
|
||||
{#if item.type === 'user'}
|
||||
{$i18n.t('User')}
|
||||
{:else if item.type === 'model'}
|
||||
{$i18n.t('Model')}
|
||||
{:else if item.type === 'channel'}
|
||||
{$i18n.t('Channel')}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -63,11 +63,7 @@
|
|||
</div>
|
||||
</Loader>
|
||||
{:else if !thread}
|
||||
<div
|
||||
class="px-5
|
||||
|
||||
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
|
||||
>
|
||||
<div class="px-5 max-w-full mx-auto">
|
||||
{#if channel}
|
||||
<div class="flex flex-col gap-1.5 pb-5 pt-10">
|
||||
<div class="text-2xl font-medium capitalize">{channel.name}</div>
|
||||
|
|
@ -98,8 +94,10 @@
|
|||
<Message
|
||||
{message}
|
||||
{thread}
|
||||
disabled={!channel?.write_access}
|
||||
showUserProfile={messageIdx === 0 ||
|
||||
messageList.at(messageIdx - 1)?.user_id !== message.user_id}
|
||||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
|
||||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id}
|
||||
onDelete={() => {
|
||||
messages = messages.filter((m) => m.id !== message.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
|
||||
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||
|
|
@ -34,10 +34,13 @@
|
|||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||
import { formatDate } from '$lib/utils';
|
||||
import Emoji from '$lib/components/common/Emoji.svelte';
|
||||
import { t } from 'i18next';
|
||||
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
|
||||
|
||||
export let message;
|
||||
export let showUserProfile = true;
|
||||
export let thread = false;
|
||||
export let disabled = false;
|
||||
|
||||
export let onDelete: Function = () => {};
|
||||
export let onEdit: Function = () => {};
|
||||
|
|
@ -64,11 +67,9 @@
|
|||
<div
|
||||
class="flex flex-col justify-between px-5 {showUserProfile
|
||||
? 'pt-1.5 pb-0.5'
|
||||
: ''} w-full {($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
|
||||
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
|
||||
>
|
||||
{#if !edit}
|
||||
{#if !edit && !disabled}
|
||||
<div
|
||||
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
|
||||
>
|
||||
|
|
@ -138,19 +139,22 @@
|
|||
id="message-{message.id}"
|
||||
dir={$settings.chatDirection}
|
||||
>
|
||||
<div
|
||||
class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
|
||||
>
|
||||
<div class={`shrink-0 mr-3 w-9`}>
|
||||
{#if showUserProfile}
|
||||
<ProfilePreview user={message.user}>
|
||||
<ProfileImage
|
||||
src={message.user?.profile_image_url ??
|
||||
($i18n.language === 'dg-DG'
|
||||
? `${WEBUI_BASE_URL}/doge.png`
|
||||
: `${WEBUI_BASE_URL}/static/favicon.png`)}
|
||||
className={'size-8 translate-y-1 ml-0.5'}
|
||||
{#if message?.meta?.model_id}
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
|
||||
alt={message.meta.model_name ?? message.meta.model_id}
|
||||
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
|
||||
/>
|
||||
</ProfilePreview>
|
||||
{:else}
|
||||
<ProfilePreview user={message.user}>
|
||||
<ProfileImage
|
||||
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
className={'size-8 translate-y-1 ml-0.5'}
|
||||
/>
|
||||
</ProfilePreview>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
|
||||
|
||||
|
|
@ -170,7 +174,11 @@
|
|||
{#if showUserProfile}
|
||||
<Name>
|
||||
<div class=" self-end text-base shrink-0 font-medium truncate">
|
||||
{message?.user?.name}
|
||||
{#if message?.meta?.model_id}
|
||||
{message?.meta?.model_name ?? message?.meta?.model_id}
|
||||
{:else}
|
||||
{message?.user?.name}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.created_at}
|
||||
|
|
@ -178,7 +186,12 @@
|
|||
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
|
||||
>
|
||||
<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
|
||||
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
|
||||
<span class="line-clamp-1">
|
||||
{$i18n.t(formatDate(message.created_at / 1000000), {
|
||||
LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
|
||||
LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
|
||||
})}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -198,7 +211,7 @@
|
|||
name={file.name}
|
||||
type={file.type}
|
||||
size={file?.size}
|
||||
colorClassName="bg-white dark:bg-gray-850 "
|
||||
small={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -228,7 +241,7 @@
|
|||
<div class="flex space-x-1.5">
|
||||
<button
|
||||
id="close-edit-message-button"
|
||||
class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
|
||||
class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
|
||||
on:click={() => {
|
||||
edit = false;
|
||||
editedContent = null;
|
||||
|
|
@ -239,7 +252,7 @@
|
|||
|
||||
<button
|
||||
id="confirm-edit-message-button"
|
||||
class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
|
||||
class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
|
||||
on:click={async () => {
|
||||
onEdit(editedContent);
|
||||
edit = false;
|
||||
|
|
@ -253,12 +266,16 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class=" min-w-full markdown-prose">
|
||||
<Markdown
|
||||
id={message.id}
|
||||
content={message.content}
|
||||
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
|
||||
>(edited)</span
|
||||
>{/if}
|
||||
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
|
||||
<Skeleton />
|
||||
{:else}
|
||||
<Markdown
|
||||
id={message.id}
|
||||
content={message.content}
|
||||
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
class="text-gray-500 text-[10px]">({$i18n.t('edited')})</span
|
||||
>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if (message?.reactions ?? []).length > 0}
|
||||
|
|
|
|||
|
|
@ -1,101 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { getUserActiveStatusById } from '$lib/apis/users';
|
||||
|
||||
export let side = 'right';
|
||||
export let align = 'top';
|
||||
import UserStatus from './UserStatus.svelte';
|
||||
import UserStatusLinkPreview from './UserStatusLinkPreview.svelte';
|
||||
|
||||
export let user = null;
|
||||
let show = false;
|
||||
|
||||
let active = false;
|
||||
|
||||
const getActiveStatus = async () => {
|
||||
const res = await getUserActiveStatusById(localStorage.token, user.id).catch((error) => {
|
||||
console.error('Error fetching user active status:', error);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
active = res.active;
|
||||
} else {
|
||||
active = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
getActiveStatus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root
|
||||
bind:open={show}
|
||||
closeFocus={false}
|
||||
onOpenChange={(state) => {}}
|
||||
typeahead={false}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
<LinkPreview.Root openDelay={0} closeDelay={0}>
|
||||
<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
|
||||
<slot />
|
||||
</DropdownMenu.Trigger>
|
||||
</LinkPreview.Trigger>
|
||||
|
||||
<slot name="content">
|
||||
<DropdownMenu.Content
|
||||
class="max-w-full w-[240px] rounded-lg z-9999 bg-white dark:bg-black dark:text-white shadow-lg"
|
||||
sideOffset={8}
|
||||
{side}
|
||||
{align}
|
||||
transition={flyAndScale}
|
||||
>
|
||||
{#if user}
|
||||
<div class=" flex flex-col gap-2 w-full rounded-lg">
|
||||
<div class="py-8 relative bg-gray-900 rounded-t-lg">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
|
||||
alt="profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class=" flex flex-col pt-4 pb-2.5 px-4">
|
||||
<div class=" -mb-1">
|
||||
<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
|
||||
</div>
|
||||
|
||||
<div class=" flex items-center gap-2">
|
||||
{#if active}
|
||||
<div>
|
||||
<span class="relative flex size-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
/>
|
||||
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class=" -translate-y-[1px]">
|
||||
<span class="text-xs"> {$i18n.t('Active')} </span>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<span class="relative flex size-2">
|
||||
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class=" -translate-y-[1px]">
|
||||
<span class="text-xs"> {$i18n.t('Away')} </span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</slot>
|
||||
</DropdownMenu.Root>
|
||||
<UserStatusLinkPreview id={user?.id} side="right" align="center" sideOffset={8} />
|
||||
</LinkPreview.Root>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
export let user = null;
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<div class=" flex gap-3.5 w-full py-3 px-3 items-center">
|
||||
<div class=" items-center flex shrink-0">
|
||||
<img
|
||||
crossorigin="anonymous"
|
||||
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
||||
class=" size-12 object-cover rounded-xl"
|
||||
alt="profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class=" flex flex-col w-full flex-1">
|
||||
<div class="mb-0.5 font-medium line-clamp-1 pr-2">
|
||||
{user.name}
|
||||
</div>
|
||||
|
||||
<div class=" flex items-center gap-2">
|
||||
{#if user?.active}
|
||||
<div>
|
||||
<span class="relative flex size-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||
/>
|
||||
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-xs"> {$i18n.t('Active')} </span>
|
||||
{:else}
|
||||
<div>
|
||||
<span class="relative flex size-2">
|
||||
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="text-xs"> {$i18n.t('Away')} </span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
import { getUserById } from '$lib/apis/users';
|
||||
|
||||
import UserStatus from './UserStatus.svelte';
|
||||
|
||||
export let id = null;
|
||||
|
||||
export let side = 'top';
|
||||
export let align = 'start';
|
||||
export let sideOffset = 6;
|
||||
|
||||
let user = null;
|
||||
|
||||
onMount(async () => {
|
||||
if (id) {
|
||||
user = await getUserById(localStorage.token, id).catch((error) => {
|
||||
console.error('Error fetching user by ID:', error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<LinkPreview.Content
|
||||
class="w-full max-w-[260px] rounded-2xl border border-gray-100 dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
|
||||
{side}
|
||||
{align}
|
||||
{sideOffset}
|
||||
>
|
||||
<UserStatus {user} />
|
||||
</LinkPreview.Content>
|
||||
{/if}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
import Messages from './Messages.svelte';
|
||||
import { onDestroy, onMount, tick, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -159,7 +160,7 @@
|
|||
|
||||
{#if channel}
|
||||
<div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850">
|
||||
<div class="flex items-center justify-between px-3.5 pt-3">
|
||||
<div class="sticky top-0 flex items-center justify-between px-3.5 py-3">
|
||||
<div class=" font-medium text-lg">{$i18n.t('Thread')}</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -174,32 +175,50 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" max-h-full w-full overflow-y-auto pt-3" bind:this={messagesContainerElement}>
|
||||
<Messages
|
||||
id={threadId}
|
||||
{channel}
|
||||
{messages}
|
||||
{top}
|
||||
thread={true}
|
||||
onLoad={async () => {
|
||||
const newMessages = await getChannelThreadMessages(
|
||||
localStorage.token,
|
||||
channel.id,
|
||||
threadId,
|
||||
messages.length
|
||||
);
|
||||
<div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
|
||||
{#if messages !== null}
|
||||
<Messages
|
||||
id={threadId}
|
||||
{channel}
|
||||
{messages}
|
||||
{top}
|
||||
thread={true}
|
||||
onLoad={async () => {
|
||||
const newMessages = await getChannelThreadMessages(
|
||||
localStorage.token,
|
||||
channel.id,
|
||||
threadId,
|
||||
messages.length
|
||||
);
|
||||
|
||||
messages = [...messages, ...newMessages];
|
||||
messages = [...messages, ...newMessages];
|
||||
|
||||
if (newMessages.length < 50) {
|
||||
top = true;
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
if (newMessages.length < 50) {
|
||||
top = true;
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full flex justify-center pt-5 pb-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" pb-[1rem] px-2.5">
|
||||
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
|
||||
<div class=" pb-[1rem] px-2.5 w-full">
|
||||
<MessageInput
|
||||
id={threadId}
|
||||
disabled={!channel?.write_access}
|
||||
placeholder={!channel?.write_access
|
||||
? $i18n.t('You do not have permission to send messages in this thread.')
|
||||
: $i18n.t('Reply to thread...')}
|
||||
typingUsersClassName="from-gray-50 dark:from-gray-850"
|
||||
{typingUsers}
|
||||
userSuggestions={true}
|
||||
channelSuggestions={true}
|
||||
{onChange}
|
||||
onSubmit={submitHandler}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import SvgPanZoom from '../common/SVGPanZoom.svelte';
|
||||
import ArrowLeft from '../icons/ArrowLeft.svelte';
|
||||
import ArrowDownTray from '../icons/ArrowDownTray.svelte';
|
||||
import Download from '../icons/Download.svelte';
|
||||
|
||||
export let overlay = false;
|
||||
export let history;
|
||||
|
|
@ -205,7 +205,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
class=" w-full h-full relative flex flex-col bg-gray-50 dark:bg-gray-850"
|
||||
class=" w-full h-full relative flex flex-col bg-white dark:bg-gray-850"
|
||||
id="artifacts-container"
|
||||
>
|
||||
<div class="w-full h-full flex flex-col flex-1 relative">
|
||||
|
|
@ -213,15 +213,6 @@
|
|||
<div
|
||||
class="pointer-events-auto z-20 flex justify-between items-center p-2.5 font-primar text-gray-900 dark:text-white"
|
||||
>
|
||||
<button
|
||||
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
|
||||
on:click={() => {
|
||||
showArtifacts.set(false);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-3.5 text-gray-900 dark:text-white" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 flex items-center justify-between pr-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
|
|
@ -294,7 +285,7 @@
|
|||
class=" bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md p-0.5"
|
||||
on:click={downloadArtifact}
|
||||
>
|
||||
<ArrowDownTray className="size-3.5" />
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import mermaid from 'mermaid';
|
||||
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
|
||||
|
||||
import { getContext, onDestroy, onMount, tick } from 'svelte';
|
||||
|
|
@ -37,6 +36,7 @@
|
|||
showArtifacts,
|
||||
tools,
|
||||
toolServers,
|
||||
functions,
|
||||
selectedFolder,
|
||||
pinnedChats
|
||||
} from '$lib/stores';
|
||||
|
|
@ -88,6 +88,8 @@
|
|||
import Spinner from '../common/Spinner.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Sidebar from '../icons/Sidebar.svelte';
|
||||
import { getFunctions } from '$lib/apis/functions';
|
||||
import Image from '../common/Image.svelte';
|
||||
|
||||
export let chatIdProp = '';
|
||||
|
||||
|
|
@ -236,33 +238,58 @@
|
|||
};
|
||||
|
||||
const resetInput = () => {
|
||||
console.debug('resetInput');
|
||||
setToolIds();
|
||||
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
|
||||
setDefaults();
|
||||
};
|
||||
|
||||
const setToolIds = async () => {
|
||||
const setDefaults = async () => {
|
||||
if (!$tools) {
|
||||
tools.set(await getTools(localStorage.token));
|
||||
}
|
||||
|
||||
if (!$functions) {
|
||||
functions.set(await getFunctions(localStorage.token));
|
||||
}
|
||||
if (selectedModels.length !== 1 && !atSelectedModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
|
||||
if (model && model?.info?.meta?.toolIds) {
|
||||
selectedToolIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
} else {
|
||||
selectedToolIds = [];
|
||||
if (model) {
|
||||
// Set Default Tools
|
||||
if (model?.info?.meta?.toolIds) {
|
||||
selectedToolIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
// Set Default Filters (Toggleable only)
|
||||
if (model?.info?.meta?.defaultFilterIds) {
|
||||
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
|
||||
model?.filters?.find((f) => f.id === id)
|
||||
);
|
||||
}
|
||||
|
||||
// Set Default Features
|
||||
if (model?.info?.meta?.defaultFeatureIds) {
|
||||
if (model.info?.meta?.capabilities?.['image_generation']) {
|
||||
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
|
||||
}
|
||||
|
||||
if (model.info?.meta?.capabilities?.['web_search']) {
|
||||
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
|
||||
}
|
||||
|
||||
if (model.info?.meta?.capabilities?.['code_interpreter']) {
|
||||
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -438,6 +465,15 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'action:submit') {
|
||||
console.debug(event.data.text);
|
||||
|
||||
if (prompt !== '') {
|
||||
await tick();
|
||||
submitPrompt(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace with your iframe's origin
|
||||
if (event.data.type === 'input:prompt') {
|
||||
console.debug(event.data.text);
|
||||
|
|
@ -450,15 +486,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (event.data.type === 'action:submit') {
|
||||
console.debug(event.data.text);
|
||||
|
||||
if (prompt !== '') {
|
||||
await tick();
|
||||
submitPrompt(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data.type === 'input:prompt:submit') {
|
||||
console.debug(event.data.text);
|
||||
|
||||
|
|
@ -1464,19 +1491,13 @@
|
|||
prompt = '';
|
||||
|
||||
const messages = createMessagesList(history, history.currentId);
|
||||
|
||||
// Reset chat input textarea
|
||||
if (!($settings?.richTextInput ?? true)) {
|
||||
const chatInputElement = document.getElementById('chat-input');
|
||||
|
||||
if (chatInputElement) {
|
||||
await tick();
|
||||
chatInputElement.style.height = '';
|
||||
}
|
||||
}
|
||||
|
||||
const _files = JSON.parse(JSON.stringify(files));
|
||||
chatFiles.push(..._files.filter((item) => ['doc', 'file', 'collection'].includes(item.type)));
|
||||
|
||||
chatFiles.push(
|
||||
..._files.filter((item) =>
|
||||
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
|
||||
)
|
||||
);
|
||||
chatFiles = chatFiles.filter(
|
||||
// Remove duplicates
|
||||
(item, index, array) =>
|
||||
|
|
@ -1667,7 +1688,7 @@
|
|||
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
|
||||
).length === currentModels.length
|
||||
) {
|
||||
if (($settings?.webSearch ?? false) === 'always') {
|
||||
if ($config?.features?.enable_web_search && ($settings?.webSearch ?? false) === 'always') {
|
||||
features = { ...features, web_search: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1696,7 +1717,7 @@
|
|||
let files = JSON.parse(JSON.stringify(chatFiles));
|
||||
files.push(
|
||||
...(userMessage?.files ?? []).filter((item) =>
|
||||
['doc', 'text', 'file', 'note', 'collection'].includes(item.type)
|
||||
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
|
||||
)
|
||||
);
|
||||
// Remove duplicates
|
||||
|
|
@ -1769,6 +1790,23 @@
|
|||
}))
|
||||
.filter((message) => message?.role === 'user' || message?.content?.trim());
|
||||
|
||||
const toolIds = [];
|
||||
const toolServerIds = [];
|
||||
|
||||
for (const toolId of selectedToolIds) {
|
||||
if (toolId.startsWith('direct_server:')) {
|
||||
let serverId = toolId.replace('direct_server:', '');
|
||||
// Check if serverId is a number
|
||||
if (!isNaN(parseInt(serverId))) {
|
||||
toolServerIds.push(parseInt(serverId));
|
||||
} else {
|
||||
toolServerIds.push(serverId);
|
||||
}
|
||||
} else {
|
||||
toolIds.push(toolId);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await generateOpenAIChatCompletion(
|
||||
localStorage.token,
|
||||
{
|
||||
|
|
@ -1789,8 +1827,10 @@
|
|||
files: (files?.length ?? 0) > 0 ? files : undefined,
|
||||
|
||||
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
||||
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
|
||||
tool_servers: $toolServers,
|
||||
tool_ids: toolIds.length > 0 ? toolIds : undefined,
|
||||
tool_servers: ($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
features: getFeatures(),
|
||||
variables: {
|
||||
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
|
||||
|
|
@ -2225,7 +2265,18 @@
|
|||
>
|
||||
{#if !loading}
|
||||
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
|
||||
{#if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
|
||||
{#if $selectedFolder && $selectedFolder?.meta?.background_image_url}
|
||||
<div
|
||||
class="absolute {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
|
||||
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style="background-image: url({$selectedFolder?.meta?.background_image_url}) "
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
|
||||
/>
|
||||
{:else if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
|
||||
<div
|
||||
class="absolute {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
|
||||
|
|
@ -2240,7 +2291,7 @@
|
|||
{/if}
|
||||
|
||||
<PaneGroup direction="horizontal" class="w-full h-full">
|
||||
<Pane defaultSize={50} class="h-full flex relative max-w-full flex-col">
|
||||
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
|
||||
<Navbar
|
||||
bind:this={navbarElement}
|
||||
chat={{
|
||||
|
|
@ -2259,7 +2310,6 @@
|
|||
bind:selectedModels
|
||||
shareEnabled={!!history.currentId}
|
||||
{initNewChat}
|
||||
showBanners={!showCommands}
|
||||
archiveChatHandler={() => {}}
|
||||
{moveChatHandler}
|
||||
onSaveTempChat={async () => {
|
||||
|
|
@ -2379,11 +2429,7 @@
|
|||
if (e.detail || files.length > 0) {
|
||||
await tick();
|
||||
|
||||
submitPrompt(
|
||||
($settings?.richTextInput ?? true)
|
||||
? e.detail.replaceAll('\n\n', '\n')
|
||||
: e.detail
|
||||
);
|
||||
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -2432,11 +2478,7 @@
|
|||
clearDraft();
|
||||
if (e.detail || files.length > 0) {
|
||||
await tick();
|
||||
submitPrompt(
|
||||
($settings?.richTextInput ?? true)
|
||||
? e.detail.replaceAll('\n\n', '\n')
|
||||
: e.detail
|
||||
);
|
||||
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue