Merge pull request #19729 from open-webui/dev
Some checks are pending
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Release / release (push) Waiting to run
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
Release to PyPI / release (push) Waiting to run

0.6.42
This commit is contained in:
Tim Baek 2025-12-21 16:08:58 -05:00 committed by GitHub
commit d95f533214
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
283 changed files with 9536 additions and 4624 deletions

View file

@ -57,7 +57,8 @@ jobs:
git lfs install git lfs install
git lfs track "*.ttf" git lfs track "*.ttf"
git lfs track "*.jpg" git lfs track "*.jpg"
rm demo.gif rm demo.png
rm banner.png
git add . git add .
git commit -m "GitHub deploy: ${{ github.sha }}" git commit -m "GitHub deploy: ${{ github.sha }}"
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main

View file

@ -3,8 +3,6 @@ pnpm-lock.yaml
package-lock.json package-lock.json
yarn.lock yarn.lock
kubernetes/
# Copy of .gitignore # Copy of .gitignore
.DS_Store .DS_Store
node_modules node_modules

View file

@ -5,6 +5,110 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.42] - 2025-12-21
### Added
- 📚 Knowledge base file management was overhauled with server-side pagination loading 30 files at a time instead of loading entire collections at once, dramatically improving performance and responsiveness for large knowledge bases with hundreds or thousands of files, reducing initial load times and memory usage while adding server-side search and filtering, view options for files added by the user versus shared files, customizable sorting by name or date, and file authorship tracking with upload timestamps. [Commit](https://github.com/open-webui/open-webui/commit/94a8439105f30203ea9d729787c9c5978f5c22a2)
- ✨ Knowledge base file management was enhanced with automatic list refresh after file operations ensuring immediate UI updates, improved permission validation at the model layer, and automatic channel-file association for files uploaded with channel metadata. [Commit](https://github.com/open-webui/open-webui/commit/c15201620d03a9b60b800a34d8dc3426722c5b8b)
- 🔎 Knowledge command in chat input now uses server-side search for massive performance increases when selecting knowledge bases and files. [Commit](https://github.com/open-webui/open-webui/commit/0addc1ea461d7b4eee8fe0ca2fedd615b3988b0e)
- 🗂️ Knowledge workspace listing now uses server-side pagination loading 30 collections at a time with new search endpoints supporting query filtering and view options for created versus shared collections. [Commit](https://github.com/open-webui/open-webui/commit/ceae3d48e603f53313d5483abe94099e20e914e8)
- 📖 Knowledge workspace now displays all collections with read access including shared read-only collections, enabling users to discover and explore knowledge bases they don't own while maintaining proper access controls through visual "Read Only" badges and automatically disabled editing controls for name, description, file uploads, content editing, and deletion operations. [Commit](https://github.com/open-webui/open-webui/commit/693636d971d0e8398fa0c9ec3897686750007af5)
- 📁 Bulk website and YouTube video attachment now supports adding multiple URLs at once (newline-separated) with automatic YouTube detection and transcript retrieval, processed sequentially to prevent resource strain, and both websites and videos can now be added directly to knowledge bases through the workspace UI. [Commit](https://github.com/open-webui/open-webui/commit/7746e9f4b831f09953ad2b659b96e0fd52911031), [#6202](https://github.com/open-webui/open-webui/issues/6202), [#19587](https://github.com/open-webui/open-webui/pull/19587), [#8231](https://github.com/open-webui/open-webui/pull/8231)
- 🪟 Sidebar width is now resizable on desktop devices with persistent storage in localStorage, enforcing minimum and maximum width constraints (220px to 480px) while all layout components now reference the dynamic sidebar width via CSS variables for consistent responsive behavior. [Commit](https://github.com/open-webui/open-webui/commit/b364cf43d3e8fd3557f65f17bc285bfaca5ed368)
- 📝 Notes feature now supports server-side search and filtering with view options for notes created by the user versus notes shared with them, customizable sorting by name or date in both list and grid view modes within a redesigned interface featuring consolidated note management controls in a unified header, group-based permission sharing with read, write, and read-only access control displaying note authorship and sharing status for better collaboration, and paginated infinite scroll for improved performance with large note collections. [Commit](https://github.com/open-webui/open-webui/commit/9b24cddef6c4862bd899eb8d6332cafff54e871d)
- 👁️ Notes now support read-only access permissions, allowing users to share notes for viewing without granting edit rights, with the editor automatically becoming non-editable and appropriate UI indicators when read-only access is detected. [Commit](https://github.com/open-webui/open-webui/commit/4363df175d50e0f9729381ac2ba9b37a3c3a966d)
- 📄 Notes can now be created directly from the chat input field, allowing users to save drafted messages or content as notes without navigation or retyping. [Commit](https://github.com/open-webui/open-webui/commit/00c2b6ca405d617e3d7520953a00a36c19c790ec)
- 🪟 Sidebar folders, channels, and pinned models sections now automatically expand when creating new items or pinning models, providing immediate visual feedback for user actions. [Commit](https://github.com/open-webui/open-webui/commit/f826d3ed75213a0a1b31b50d030bfb1d5e91d199), [#19929](https://github.com/open-webui/open-webui/pull/19929)
- 📋 Chat file associations are now properly tracked in the database through a new "chat_file" table, enabling accurate file management across chats and ensuring proper cleanup of files when chats are deleted, while improving database consistency in multi-node deployments. [Commit](https://github.com/open-webui/open-webui/commit/f1bf4f20c53e6493f0eb6fa2f12cb84c2d22da52)
- 🖼️ User-uploaded images are now automatically converted from base64 to actual file storage on the server, eliminating large inline base64 strings from being stored in chat history and reducing message payload sizes while enabling better image management and sharing across multiple chats. [Commit](https://github.com/open-webui/open-webui/commit/f1bf4f20c53e6493f0eb6fa2f12cb84c2d22da52)
- 📸 Shared chats with generated or edited images now correctly display images when accessed by other users by properly linking generated images to their chat and message through the chat_file table, ensuring images remain accessible in shared chat links. [Commit](https://github.com/open-webui/open-webui/commit/446cc0ac6063402a743e949f50612376ed5a8437), [#19393](https://github.com/open-webui/open-webui/issues/19393)
- 📊 File viewer modal was significantly enhanced with native-like viewers for Excel/CSV spreadsheets rendering as interactive scrollable tables with multi-sheet navigation support, Markdown documents displaying with full typography including headers, lists, links, and tables, and source code files showing syntax highlighting, all accessible through a tabbed interface defaulting to raw text view. [#20035](https://github.com/open-webui/open-webui/pull/20035), [#2867](https://github.com/open-webui/open-webui/issues/2867)
- 📏 Chat input now displays an expand button in the top-right corner when messages exceed two lines, providing optional access to a full-screen editor for composing longer messages with enhanced workspace and visibility while temporarily disabling the main input to prevent editing conflicts. [Commit](https://github.com/open-webui/open-webui/commit/205c7111200c22da42e9b5fe1e676aec9cca6daa)
- 💬 Channel message data lazy loading was implemented, deferring attachment and file metadata retrieval until needed to improve initial message list load performance. [Commit](https://github.com/open-webui/open-webui/commit/54b7ec56d6bcd2d79addc1694b757dab18cf18c5)
- 🖼️ Channel image upload handling was optimized to process and store compressed images directly as files rather than inline data, improving memory efficiency and message load times. [Commit](https://github.com/open-webui/open-webui/commit/22f1b764a7ea1add0a896906a9ef00b4b6743adc)
- 🎥 Video file playback support was added to channel messages, enabling inline video viewing with native player controls. [Commit](https://github.com/open-webui/open-webui/commit/7b126b23d50a0bd36a350fe09dc1dbe3df105318)
- 🔐 LDAP authentication now supports user entries with multiple username attributes, correctly handling cases where the username field contains a list of values. [Commit](https://github.com/open-webui/open-webui/commit/379f888c9dc6dce21c3ef7a1fc455258aff993dc), [#19878](https://github.com/open-webui/open-webui/issues/19878)
- 👨‍👩‍👧‍👦 The "ENABLE_PUBLIC_ACTIVE_USERS_COUNT" environment variable now allows restricting active user count visibility to administrators, reducing backend load and addressing privacy concerns in large deployments. [#20027](https://github.com/open-webui/open-webui/pull/20027), [#13026](https://github.com/open-webui/open-webui/issues/13026)
- 🚀 Models page search input performance was optimized with a 300ms debounce to reduce server load and improve responsiveness. [#19832](https://github.com/open-webui/open-webui/pull/19832)
- 💨 Frontend performance was optimized by preventing unnecessary API calls for API Keys and Channels features when they are disabled in admin settings, reducing backend noise and improving overall system efficiency. [#20043](https://github.com/open-webui/open-webui/pull/20043), [#19967](https://github.com/open-webui/open-webui/issues/19967)
- 📎 Channel file association tracking was implemented, automatically linking uploaded files to their respective channels with a dedicated association table enabling better organization and future file management features within channels. [Commit](https://github.com/open-webui/open-webui/commit/2bccf8350d0915f69b8020934bb179c52e81b7b5)
- 👥 User profile previews now display group membership information for easier identification of user roles and permissions. [Commit](https://github.com/open-webui/open-webui/commit/2b1a29d44bde9fbc20ff9f0a5ded1ce8ded9d90d)
- 🌍 The "SEARXNG_LANGUAGE" environment variable now allows configuring search language for SearXNG queries, replacing the hardcoded "en-US" default with a configurable setting that defaults to "all". [#19909](https://github.com/open-webui/open-webui/pull/19909)
- ⏳ The "MINERU_API_TIMEOUT" environment variable now allows configuring request timeouts for MinerU document processing operations. [#20016](https://github.com/open-webui/open-webui/pull/20016), [#18495](https://github.com/open-webui/open-webui/issues/18495)
- 🔧 The "RAG_EXTERNAL_RERANKER_TIMEOUT" environment variable now allows configuring request timeouts for external reranker operations. [#20049](https://github.com/open-webui/open-webui/pull/20049), [#19900](https://github.com/open-webui/open-webui/issues/19900)
- 🎨 OpenAI GPT-IMAGE 1.5 model support was added for image generation and editing with automatic image size capabilities. [Commit](https://github.com/open-webui/open-webui/commit/4c2e5c93e9287479f56f780708656136849ccaee)
- 🔑 The "OAUTH_AUDIENCE" environment variable now allows OAuth providers to specify audience parameters for JWT access token generation. [#19768](https://github.com/open-webui/open-webui/pull/19768)
- ⏰ The "REDIS_SOCKET_CONNECT_TIMEOUT" environment variable now allows configuring socket connection timeouts for Redis and Sentinel connections, addressing potential failover and responsiveness issues in distributed deployments. [#19799](https://github.com/open-webui/open-webui/pull/19799), [Docs:#882](https://github.com/open-webui/docs/pull/882)
- ⏱️ The "WEB_LOADER_TIMEOUT" environment variable now allows configuring request timeouts for SafeWebBaseLoader operations. [#19804](https://github.com/open-webui/open-webui/pull/19804), [#19734](https://github.com/open-webui/open-webui/issues/19734)
- 🚀 Models API endpoint performance was optimized through batched model loading, eliminating N+1 queries and significantly reducing response times when filtering models by user permissions. [Commit](https://github.com/open-webui/open-webui/commit/0dd2cfe1f273fbacdbe90300a97c021f2e678656)
- 🔀 Custom model fallback handling was added, allowing workspace-created custom models to automatically fall back to the default chat model when their configured base model is not found; set "ENABLE_CUSTOM_MODEL_FALLBACK" to true to enable, preventing workflow disruption when base models are removed or renamed, while ensuring other requests remain unaffected. [Commit](https://github.com/open-webui/open-webui/commit/b35aeb8f46e0e278c6f4538382c2b6838e24cc5a), [#19985](https://github.com/open-webui/open-webui/pull/19985)
- 📡 A new /feedbacks/all/ids API endpoint was added to return only feedback IDs without metadata, significantly improving performance for external integrations working with large feedback collections. [Commit](https://github.com/open-webui/open-webui/commit/53c1ca64b7205d85f6de06bd69e3e265d15546b8)
- 📈 An experimental chat usage statistics endpoint (GET /api/v1/chats/stats/usage) was added with pagination support (50 chats per page) and comprehensive per-chat analytics including model usage counts, user and assistant message breakdowns, average response times calculated from message timestamps, average content lengths, and last activity timestamps; this endpoint remains experimental and not suitable for production use as it performs intensive calculations by processing entire message histories for each chat without caching. [Commit](https://github.com/open-webui/open-webui/commit/a7993f6f4e4591cd2aaa4718ece9e5623557d019)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for German, Danish, Finnish, Korean, Portuguese (Brazil), Simplified Chinese, Traditional Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- ⚡ External reranker operations were optimized to prevent event loop blocking by offloading synchronous HTTP requests to a thread pool using asyncio.to_thread(), eliminating application freezes during RAG reranking queries. [#20049](https://github.com/open-webui/open-webui/pull/20049), [#19900](https://github.com/open-webui/open-webui/issues/19900)
- 💭 Text loss in the explanation feature when using the "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE" environment variable was resolved by correcting newline handling in streaming responses. [#19829](https://github.com/open-webui/open-webui/pull/19829)
- 📚 Knowledge base batch file addition failures caused by Pydantic validation errors are now prevented by making the meta field optional in file metadata responses, allowing files without metadata to be processed correctly. [#20022](https://github.com/open-webui/open-webui/pull/20022), [#14220](https://github.com/open-webui/open-webui/issues/14220)
- 🗄️ PostgreSQL null byte insertion failures when attaching web pages or processing embedded content are now prevented by consolidating text sanitization logic across chat messages, web search results, and knowledge base documents, removing null bytes and invalid UTF-8 surrogates before database insertion. [#20072](https://github.com/open-webui/open-webui/pull/20072), [#19867](https://github.com/open-webui/open-webui/issues/19867), [#18201](https://github.com/open-webui/open-webui/issues/18201), [#15616](https://github.com/open-webui/open-webui/issues/15616)
- 🎫 MCP OAuth 2.1 token exchange failures are now fixed by removing duplicate credential passing that caused "ID1,ID1" concatenation and 401 errors from the token endpoint. [#20076](https://github.com/open-webui/open-webui/pull/20076), [#19823](https://github.com/open-webui/open-webui/issues/19823)
- 📝 Notes "Improve" action now works correctly after the streaming API change in v0.6.41 by ensuring uploaded files are fully retrieved with complete metadata before processing, restoring note improvement and summarization functionality. [Commit](https://github.com/open-webui/open-webui/commit/a3458f492c53a3b00405f59fbe1ea953fe364f18), [#20078](https://github.com/open-webui/open-webui/discussions/20078)
- 🔑 MCP OAuth 2.1 tool servers now work correctly in multi-node deployments through lazy-loading of OAuth clients from Redis-synced configuration, eliminating 404 errors when load balancers route requests to nodes that didn't process the original config update. [#20076](https://github.com/open-webui/open-webui/pull/20076), [#19902](https://github.com/open-webui/open-webui/pull/19902), [#19901](https://github.com/open-webui/open-webui/issues/19901)
- 🧩 Chat loading failures when channels permissions were disabled are now prevented through graceful error handling. [Commit](https://github.com/open-webui/open-webui/commit/5c2df97f04cce5cb7087d288f816f91a739688c1)
- 🔍 Search bar freezing and crashing issues in Models, Chat, and Archived Chat pages caused by excessively long queries exceeding server URL limits were resolved by truncating queries to 500 characters, and knowledge base layout shifting with long names was fixed by adjusting flex container properties. [#19832](https://github.com/open-webui/open-webui/pull/19832)
- 🎛️ Rate limiting errors (HTTP 429) with Brave Search free tier when generating multiple queries are now prevented through asyncio.Semaphore-based concurrency control applied globally to all search engines. [#20070](https://github.com/open-webui/open-webui/pull/20070), [#20003](https://github.com/open-webui/open-webui/issues/20003), [#14107](https://github.com/open-webui/open-webui/issues/14107), [#15134](https://github.com/open-webui/open-webui/issues/15134)
- 💥 UI crashes and white screen errors caused by null chat lists during loading or network failures were prevented by adding null safety checks to chat iteration in folder placeholders and archived chat modals. [#19898](https://github.com/open-webui/open-webui/pull/19898)
- 🧩 Chat overview tab crashes caused by undefined model references were resolved by adding proper null checks when accessing deleted or ejected models. [#19935](https://github.com/open-webui/open-webui/pull/19935)
- 🔄 MultiResponseMessages component crashes when navigating chat history after removing or changing selected models are now prevented through proper component re-initialization. [Commit](https://github.com/open-webui/open-webui/commit/870e29e3738da968c396b70532f365a3c2f71995), [#18599](https://github.com/open-webui/open-webui/issues/18599)
- 🚫 Channel API endpoint access is now correctly blocked when channels are globally disabled, preventing users with channel permissions from accessing channel data via API requests when the feature is turned off in admin settings. [#19957](https://github.com/open-webui/open-webui/pull/19957), [#19914](https://github.com/open-webui/open-webui/issues/19914)
- 👤 User list popup display in the admin panel was fixed to correctly track user identity when sorting or filtering changes the list order, preventing popups from showing incorrect user information. [Commit](https://github.com/open-webui/open-webui/commit/ae47101dc6aef2c7d8ae0d843985341fff820057), [#20046](https://github.com/open-webui/open-webui/issues/20046)
- 👥 User selection in the "Edit User Group" modal now preserves pagination position, allowing administrators to select multiple users across pages without resetting to page 1. [#19959](https://github.com/open-webui/open-webui/pull/19959)
- 📸 Model avatar images now update immediately in the admin models list through proper Cache-Control headers, eliminating the need for manual cache clearing. [#19959](https://github.com/open-webui/open-webui/pull/19959)
- 🔒 Temporary chat permission enforcement now correctly prevents users from enabling the feature through personal settings when disabled in default or group permissions. [#19785](https://github.com/open-webui/open-webui/issues/19785)
- 🎨 Image editing with reference images now correctly uses both previously generated images and newly uploaded reference images. [Commit](https://github.com/open-webui/open-webui/commit/bcd50ed8f1b7387fd700538ae0d74fc72f3c53d0)
- 🧠 Image generation and editing operations are now explicitly injected into system context, improving LLM comprehension even for weaker models so they reliably acknowledge operations instead of incorrectly claiming they cannot generate images. [Commit](https://github.com/open-webui/open-webui/commit/28b2fcab0cd036dbe646a66fe81890f288c77121)
- 📑 Source citation rendering errors when citation syntax appeared in user messages or contexts without source data were resolved. [Commit](https://github.com/open-webui/open-webui/commit/3c8f1cf8e58d52e86375634b0381374298b1b4f3)
- 📄 DOCX file parsing now works correctly in temporary chats through client-side text extraction, preventing raw data from being displayed. [Commit](https://github.com/open-webui/open-webui/commit/6993b0b40b10af8cdbe6626702cc94080fff9e22)
- 🔧 Pipeline settings save failures when valve properties contain null values are now handled correctly. [#19791](https://github.com/open-webui/open-webui/pull/19791)
- ⚙️ Model usage settings are now correctly preserved when switching between models instead of being unexpectedly cleared or reset. [#19868](https://github.com/open-webui/open-webui/pull/19868), [#19549](https://github.com/open-webui/open-webui/issues/19549)
- 🛡️ Invalid PASSWORD_VALIDATION_REGEX_PATTERN configurations no longer cause startup warnings, with automatic fallback to the default pattern when regex compilation fails. [#20058](https://github.com/open-webui/open-webui/pull/20058)
- 🎯 The DefaultFiltersSelector component in model settings now correctly displays when only global toggleable filters are present, enabling per-model default configuration. [#20066](https://github.com/open-webui/open-webui/pull/20066)
- 🎤 Audio file upload failures caused by MIME type matching issues with spacing variations and codec parameters were resolved by implementing proper MIME type parsing. [#17771](https://github.com/open-webui/open-webui/pull/17771), [#17761](https://github.com/open-webui/open-webui/issues/17761)
- ⌨️ Regenerate response keyboard shortcut now only activates when chat input is selected, preventing unintended regeneration when modals are open or other UI elements are focused. [#19875](https://github.com/open-webui/open-webui/pull/19875)
- 📋 Log truncation issues in Docker deployments during application crashes were resolved by disabling Python stdio buffering, ensuring complete diagnostic output is captured. [#19844](https://github.com/open-webui/open-webui/issues/19844)
- 🔴 Redis cluster compatibility issues with disabled KEYS command were resolved by replacing blocking KEYS operations with production-safe SCAN iterations. [#19871](https://github.com/open-webui/open-webui/pull/19871), [#15834](https://github.com/open-webui/open-webui/issues/15834)
- 🔤 File attachment container layout issues when using RTL languages were resolved by applying chat direction settings to file containers across all message types. [#19891](https://github.com/open-webui/open-webui/pull/19891), [#19742](https://github.com/open-webui/open-webui/issues/19742)
- 🔃 Ollama model list now automatically refreshes after model deletion, preventing deleted models from persisting in the UI and being inadvertently re-downloaded during subsequent pull operations. [#19912](https://github.com/open-webui/open-webui/pull/19912)
- 🌐 Ollama Cloud web search now correctly applies domain filtering to search results. [Commit](https://github.com/open-webui/open-webui/commit/d4bd938a77c22409a1643c058b937a06e07baca9)
- 📜 Tool specification serialization now preserves non-ASCII characters including Chinese text, improving LLM comprehension and tool selection accuracy by avoiding Unicode escape sequences. [#19942](https://github.com/open-webui/open-webui/pull/19942)
- 🛟 Model editor stability was improved with null safety checks for tools, functions, and file input operations, preventing crashes when stores are undefined or file objects are invalid. [#19939](https://github.com/open-webui/open-webui/pull/19939)
- 🗣️ MoA completion handling stability was improved with null safety checks for response objects, boolean casting for settings, and proper timeout type definitions. [#19921](https://github.com/open-webui/open-webui/pull/19921)
- 🎛️ Chat functionality failures caused by empty logit_bias parameter values are now prevented by properly handling empty strings in the parameter parsing middleware. [#19982](https://github.com/open-webui/open-webui/issues/19982)
- 🔏 Administrators can now delete read-only knowledge bases from deleted users, resolving permission issues that previously prevented cleanup of orphaned read-only content. [Commit](https://github.com/open-webui/open-webui/commit/59d6eb2badf46f9c2b1e879484ac33432915b575)
- 💾 Cloned prompts and tools now correctly preserve their access control settings instead of being reset to null, preventing unintended visibility changes when duplicating private or restricted items. [#19960](https://github.com/open-webui/open-webui/pull/19960), [#19360](https://github.com/open-webui/open-webui/issues/19360)
- 🎚️ Text scale adjustment buttons in Interface Settings were fixed to correctly increment and decrement the scale value. [#19699](https://github.com/open-webui/open-webui/pull/19699)
- 🎭 Group channel invite button text visibility in light theme was corrected to display properly against dark backgrounds. [#19828](https://github.com/open-webui/open-webui/issues/19828)
- 📁 The move button is now hidden when no folders exist, preventing display of non-functional controls. [#19705](https://github.com/open-webui/open-webui/pull/19705)
- 📦 Qdrant client dependency was updated to resolve startup version incompatibility warnings. [#19757](https://github.com/open-webui/open-webui/pull/19757)
- 🧮 The "ENABLE_ASYNC_EMBEDDING" environment variable is now correctly applied to embedding operations when configured exclusively via environment variables. [#19748](https://github.com/open-webui/open-webui/pull/19748)
- 🌄 The "COMFYUI_WORKFLOW_NODES" and "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES" environment variables are now correctly loaded and parsed as JSON lists, and the configuration key name was corrected from "COMFYUI_WORKFLOW" to "COMFYUI_WORKFLOW_NODES". [#19918](https://github.com/open-webui/open-webui/pull/19918), [#19886](https://github.com/open-webui/open-webui/issues/19886)
- 💫 Channel name length is now limited to 128 characters with validation to prevent display issues caused by excessively long names. [Commit](https://github.com/open-webui/open-webui/commit/f509f5542dde384d34402f6df763f49a06bea109)
- 🔐 Invalid PASSWORD_VALIDATION_REGEX_PATTERN configurations no longer cause startup warnings, with automatic fallback to the default pattern when regex compilation fails. [#20058](https://github.com/open-webui/open-webui/pull/20058)
- 🔎 Bocha search with filter list functionality now works correctly by returning results as a list instead of a dictionary wrapper, ensuring compatibility with result filtering operations. [Commit](https://github.com/open-webui/open-webui/commit/b5bd8704fe1672da839bb3be6210d7cb494797ce), [#19733](https://github.com/open-webui/open-webui/issues/19733)
### Changed
- ⚠️ This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
- 📡 WEB_SEARCH_CONCURRENT_REQUESTS default changed from 10 to 0 (unlimited) — This setting now applies to all search engines instead of only DuckDuckGo; previously users were implicitly limited to 10 concurrent queries, but now have unlimited parallel requests by default; set to 1 for sequential execution if using rate-limited APIs like Brave free tier. [#20070](https://github.com/open-webui/open-webui/pull/20070)
- 💾 SQLCipher absolute path handling was fixed to properly support absolute database paths (e.g., "/app/data.db") instead of incorrectly stripping leading slashes and converting them to relative paths; this restores functionality for Docker volume mounts and explicit absolute path configurations while maintaining backward compatibility with relative paths. [#20074](https://github.com/open-webui/open-webui/pull/20074)
- 🔌 Knowledge base file listing API was redesigned with paginated responses and new filtering parameters; the GET /knowledge/{id}/files endpoint now returns paginated results with user attribution instead of embedding all files in the knowledge object, which may require updates to custom integrations or scripts accessing knowledge base data programmatically. [Commit](https://github.com/open-webui/open-webui/commit/94a8439105f30203ea9d729787c9c5978f5c22a2)
- 🗑️ Legacy knowledge base support for deprecated document collections and tag-based collections was removed; users with pre-knowledge base documents must migrate to the current knowledge base system as legacy items will no longer appear in selectors or command menus. [Commit](https://github.com/open-webui/open-webui/commit/a934dc997ed67a036dd7975e380f8036c447d3ed)
- 🔨 Source-level log environment variables (AUDIO_LOG_LEVEL, CONFIG_LOG_LEVEL, MODELS_LOG_LEVEL, etc.) were removed as they provided limited configuration options and added significant complexity across 100+ files; the GLOBAL_LOG_LEVEL environment variable, which already took precedence over source-level settings, now serves as the exclusive logging configuration method. [#20045](https://github.com/open-webui/open-webui/pull/20045)
- 🐍 LangChain was upgraded to version 1.2.0, representing a major dependency update and significant progress toward Python 3.13 compatibility while improving RAG pipeline functionality for document loading and retrieval operations. [#19991](https://github.com/open-webui/open-webui/pull/19991)
## [0.6.41] - 2025-12-02 ## [0.6.41] - 2025-12-02
### Added ### Added

View file

@ -55,6 +55,9 @@ ARG USE_RERANKING_MODEL
ARG UID ARG UID
ARG GID ARG GID
# Python settings
ENV PYTHONUNBUFFERED=1
## Basis ## ## Basis ##
ENV ENV=prod \ ENV ENV=prod \
PORT=8080 \ PORT=8080 \

View file

@ -1,35 +0,0 @@
### Installing Both Ollama and Open WebUI Using Kustomize
For cpu-only pod
```bash
kubectl apply -f ./kubernetes/manifest/base
```
For gpu-enabled pod
```bash
kubectl apply -k ./kubernetes/manifest
```
### Installing Both Ollama and Open WebUI Using Helm
Package Helm file first
```bash
helm package ./kubernetes/helm/
```
For cpu-only pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz
```
For gpu-enabled pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
```
Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization

View file

@ -1,4 +1,4 @@
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI) Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View file

@ -10,11 +10,13 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
![Open WebUI Banner](./banner.png)
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**. **Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/) Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
![Open WebUI Demo](./demo.gif) ![Open WebUI Demo](./demo.png)
> [!TIP] > [!TIP]
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)** > **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
@ -188,14 +190,6 @@ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=
### Keeping Your Docker Installation Up-to-Date ### Keeping Your Docker Installation Up-to-Date
In case you want to update your local Docker installation to the latest version, you can do it with [Watchtower](https://containrrr.dev/watchtower/):
```bash
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui
```
In the last part of the command, replace `open-webui` with your container name if it is different.
Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating). Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating).
### Using the Dev Branch 🌙 ### Using the Dev Branch 🌙

View file

@ -629,6 +629,12 @@ OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
== "true" == "true"
) )
OAUTH_AUDIENCE = PersistentConfig(
"OAUTH_AUDIENCE",
"oauth.audience",
os.environ.get("OAUTH_AUDIENCE", ""),
)
def load_oauth_providers(): def load_oauth_providers():
OAUTH_PROVIDERS.clear() OAUTH_PROVIDERS.clear()
@ -1300,7 +1306,7 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = ( USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
os.environ.get( os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False"
).lower() ).lower()
== "true" == "true"
) )
@ -1339,8 +1345,7 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_NOTES_ALLOW_SHARING = ( USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower() os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true"
== "true"
) )
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = ( USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
@ -2518,6 +2523,12 @@ MINERU_API_URL = PersistentConfig(
os.environ.get("MINERU_API_URL", "http://localhost:8000"), os.environ.get("MINERU_API_URL", "http://localhost:8000"),
) )
MINERU_API_TIMEOUT = PersistentConfig(
"MINERU_API_TIMEOUT",
"rag.mineru_api_timeout",
os.environ.get("MINERU_API_TIMEOUT", "300"),
)
MINERU_API_KEY = PersistentConfig( MINERU_API_KEY = PersistentConfig(
"MINERU_API_KEY", "MINERU_API_KEY",
"rag.mineru_api_key", "rag.mineru_api_key",
@ -2791,6 +2802,12 @@ RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig(
os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""), os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""),
) )
RAG_EXTERNAL_RERANKER_TIMEOUT = PersistentConfig(
"RAG_EXTERNAL_RERANKER_TIMEOUT",
"rag.external_reranker_timeout",
os.environ.get("RAG_EXTERNAL_RERANKER_TIMEOUT", ""),
)
RAG_TEXT_SPLITTER = PersistentConfig( RAG_TEXT_SPLITTER = PersistentConfig(
"RAG_TEXT_SPLITTER", "RAG_TEXT_SPLITTER",
@ -2977,7 +2994,7 @@ WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
"WEB_SEARCH_CONCURRENT_REQUESTS", "WEB_SEARCH_CONCURRENT_REQUESTS",
"rag.web.search.concurrent_requests", "rag.web.search.concurrent_requests",
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")), int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "0")),
) )
@ -2994,6 +3011,12 @@ WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")), int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
) )
WEB_LOADER_TIMEOUT = PersistentConfig(
"WEB_LOADER_TIMEOUT",
"rag.web.loader.timeout",
os.getenv("WEB_LOADER_TIMEOUT", ""),
)
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
"ENABLE_WEB_LOADER_SSL_VERIFICATION", "ENABLE_WEB_LOADER_SSL_VERIFICATION",
@ -3020,6 +3043,12 @@ SEARXNG_QUERY_URL = PersistentConfig(
os.getenv("SEARXNG_QUERY_URL", ""), os.getenv("SEARXNG_QUERY_URL", ""),
) )
SEARXNG_LANGUAGE = PersistentConfig(
"SEARXNG_LANGUAGE",
"rag.web.search.searxng_language",
os.getenv("SEARXNG_LANGUAGE", "all"),
)
YACY_QUERY_URL = PersistentConfig( YACY_QUERY_URL = PersistentConfig(
"YACY_QUERY_URL", "YACY_QUERY_URL",
"rag.web.search.yacy_query_url", "rag.web.search.yacy_query_url",
@ -3450,10 +3479,16 @@ COMFYUI_WORKFLOW = PersistentConfig(
os.getenv("COMFYUI_WORKFLOW", COMFYUI_DEFAULT_WORKFLOW), os.getenv("COMFYUI_WORKFLOW", COMFYUI_DEFAULT_WORKFLOW),
) )
comfyui_workflow_nodes = os.getenv("COMFYUI_WORKFLOW_NODES", "")
try:
comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes)
except json.JSONDecodeError:
comfyui_workflow_nodes = []
COMFYUI_WORKFLOW_NODES = PersistentConfig( COMFYUI_WORKFLOW_NODES = PersistentConfig(
"COMFYUI_WORKFLOW", "COMFYUI_WORKFLOW_NODES",
"image_generation.comfyui.nodes", "image_generation.comfyui.nodes",
[], comfyui_workflow_nodes,
) )
IMAGES_OPENAI_API_BASE_URL = PersistentConfig( IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
@ -3570,10 +3605,16 @@ IMAGES_EDIT_COMFYUI_WORKFLOW = PersistentConfig(
os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""), os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""),
) )
images_edit_comfyui_workflow_nodes = os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", "")
try:
images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes)
except json.JSONDecodeError:
images_edit_comfyui_workflow_nodes = []
IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig( IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig(
"IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
"images.edit.comfyui.nodes", "images.edit.comfyui.nodes",
[], images_edit_comfyui_workflow_nodes,
) )
#################################### ####################################

View file

@ -85,32 +85,6 @@ if "cuda_error" in locals():
log.exception(cuda_error) log.exception(cuda_error)
del cuda_error del cuda_error
log_sources = [
"AUDIO",
"COMFYUI",
"CONFIG",
"DB",
"IMAGES",
"MAIN",
"MODELS",
"OLLAMA",
"OPENAI",
"RAG",
"WEBHOOK",
"SOCKET",
"OAUTH",
]
SRC_LOG_LEVELS = {}
for source in log_sources:
log_env_var = source + "_LOG_LEVEL"
SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper()
if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping():
SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL
log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}")
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI": if WEBUI_NAME != "Open WebUI":
@ -364,6 +338,11 @@ if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
except Exception: except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
# Enable public visibility of active user count (when disabled, only admins can see it)
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = (
os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true"
)
RESET_CONFIG_ON_START = ( RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
) )
@ -395,6 +374,13 @@ try:
except ValueError: except ValueError:
REDIS_SENTINEL_MAX_RETRY_COUNT = 2 REDIS_SENTINEL_MAX_RETRY_COUNT = 2
REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "")
try:
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
#################################### ####################################
# UVICORN WORKERS # UVICORN WORKERS
#################################### ####################################
@ -439,7 +425,13 @@ PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
) )
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN) try:
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
except Exception as e:
log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}")
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
BYPASS_MODEL_ACCESS_CONTROL = ( BYPASS_MODEL_ACCESS_CONTROL = (
@ -546,6 +538,10 @@ if LICENSE_PUBLIC_KEY:
# MODELS # MODELS
#################################### ####################################
ENABLE_CUSTOM_MODEL_FALLBACK = (
os.environ.get("ENABLE_CUSTOM_MODEL_FALLBACK", "False").lower() == "true"
)
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1") MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
if MODELS_CACHE_TTL == "": if MODELS_CACHE_TTL == "":
MODELS_CACHE_TTL = None MODELS_CACHE_TTL = None
@ -620,9 +616,16 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "") WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
if WEBSOCKET_REDIS_OPTIONS == "": if WEBSOCKET_REDIS_OPTIONS == "":
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") if REDIS_SOCKET_CONNECT_TIMEOUT:
WEBSOCKET_REDIS_OPTIONS = None WEBSOCKET_REDIS_OPTIONS = {
"socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT
}
else:
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
WEBSOCKET_REDIS_OPTIONS = None
else: else:
try: try:
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS) WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)

View file

@ -37,7 +37,7 @@ from open_webui.utils.plugin import (
from open_webui.utils.tools import get_tools from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.utils.misc import ( from open_webui.utils.misc import (
add_or_update_system_message, add_or_update_system_message,
@ -54,7 +54,6 @@ from open_webui.utils.payload import (
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_function_module_by_id(request: Request, pipe_id: str): def get_function_module_by_id(request: Request, pipe_id: str):

View file

@ -9,7 +9,6 @@ from open_webui.env import (
OPEN_WEBUI_DIR, OPEN_WEBUI_DIR,
DATABASE_URL, DATABASE_URL,
DATABASE_SCHEMA, DATABASE_SCHEMA,
SRC_LOG_LEVELS,
DATABASE_POOL_MAX_OVERFLOW, DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE, DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE, DATABASE_POOL_SIZE,
@ -25,7 +24,6 @@ from sqlalchemy.sql.type_api import _T
from typing_extensions import Self from typing_extensions import Self
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["DB"])
class JSONField(types.TypeDecorator): class JSONField(types.TypeDecorator):
@ -92,8 +90,6 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
# Extract database path from SQLCipher URL # Extract database path from SQLCipher URL
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "") db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3 # Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection(): def create_sqlcipher_connection():

View file

@ -2,7 +2,6 @@ import logging
import os import os
from contextvars import ContextVar from contextvars import ContextVar
from open_webui.env import SRC_LOG_LEVELS
from peewee import * from peewee import *
from peewee import InterfaceError as PeeWeeInterfaceError from peewee import InterfaceError as PeeWeeInterfaceError
from peewee import PostgresqlDatabase from peewee import PostgresqlDatabase
@ -10,7 +9,6 @@ from playhouse.db_url import connect, parse
from playhouse.shortcuts import ReconnectMixin from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["DB"])
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None} db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
db_state = ContextVar("db_state", default=db_state_default.copy()) db_state = ContextVar("db_state", default=db_state_default.copy())
@ -56,8 +54,6 @@ def register_connection(db_url):
# Parse the database path from SQLCipher URL # Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite # Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace("sqlite+sqlcipher://", "") db_path = db_url.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Use Peewee's native SqlCipherDatabase with encryption # Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password) db = SqlCipherDatabase(db_path, passphrase=database_password)

View file

@ -208,6 +208,7 @@ from open_webui.config import (
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
WEB_LOADER_ENGINE, WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS, WEB_LOADER_CONCURRENT_REQUESTS,
WEB_LOADER_TIMEOUT,
WHISPER_MODEL, WHISPER_MODEL,
WHISPER_VAD_FILTER, WHISPER_VAD_FILTER,
WHISPER_LANGUAGE, WHISPER_LANGUAGE,
@ -226,6 +227,7 @@ from open_webui.config import (
RAG_RERANKING_MODEL, RAG_RERANKING_MODEL,
RAG_EXTERNAL_RERANKER_URL, RAG_EXTERNAL_RERANKER_URL,
RAG_EXTERNAL_RERANKER_API_KEY, RAG_EXTERNAL_RERANKER_API_KEY,
RAG_EXTERNAL_RERANKER_TIMEOUT,
RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_AUTO_UPDATE,
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_ENGINE,
@ -263,6 +265,7 @@ from open_webui.config import (
MINERU_API_MODE, MINERU_API_MODE,
MINERU_API_URL, MINERU_API_URL,
MINERU_API_KEY, MINERU_API_KEY,
MINERU_API_TIMEOUT,
MINERU_PARAMS, MINERU_PARAMS,
DATALAB_MARKER_USE_LLM, DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL, EXTERNAL_DOCUMENT_LOADER_URL,
@ -297,6 +300,7 @@ from open_webui.config import (
SERPAPI_API_KEY, SERPAPI_API_KEY,
SERPAPI_ENGINE, SERPAPI_ENGINE,
SEARXNG_QUERY_URL, SEARXNG_QUERY_URL,
SEARXNG_LANGUAGE,
YACY_QUERY_URL, YACY_QUERY_URL,
YACY_USERNAME, YACY_USERNAME,
YACY_PASSWORD, YACY_PASSWORD,
@ -435,6 +439,7 @@ from open_webui.config import (
reset_config, reset_config,
) )
from open_webui.env import ( from open_webui.env import (
ENABLE_CUSTOM_MODEL_FALLBACK,
LICENSE_KEY, LICENSE_KEY,
AUDIT_EXCLUDED_PATHS, AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL, AUDIT_LOG_LEVEL,
@ -447,7 +452,6 @@ from open_webui.env import (
GLOBAL_LOG_LEVEL, GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE, MAX_BODY_LOG_SIZE,
SAFE_MODE, SAFE_MODE,
SRC_LOG_LEVELS,
VERSION, VERSION,
DEPLOYMENT_ID, DEPLOYMENT_ID,
INSTANCE_ID, INSTANCE_ID,
@ -471,6 +475,7 @@ from open_webui.env import (
EXTERNAL_PWA_MANIFEST_URL, EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_STAR_SESSIONS_MIDDLEWARE, ENABLE_STAR_SESSIONS_MIDDLEWARE,
ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
) )
@ -528,7 +533,6 @@ if SAFE_MODE:
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
class SPAStaticFiles(StaticFiles): class SPAStaticFiles(StaticFiles):
@ -878,6 +882,7 @@ app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
app.state.config.MINERU_API_MODE = MINERU_API_MODE app.state.config.MINERU_API_MODE = MINERU_API_MODE
app.state.config.MINERU_API_URL = MINERU_API_URL app.state.config.MINERU_API_URL = MINERU_API_URL
app.state.config.MINERU_API_KEY = MINERU_API_KEY app.state.config.MINERU_API_KEY = MINERU_API_KEY
app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT
app.state.config.MINERU_PARAMS = MINERU_PARAMS app.state.config.MINERU_PARAMS = MINERU_PARAMS
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
@ -895,6 +900,7 @@ app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
@ -922,6 +928,7 @@ app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
@ -934,6 +941,7 @@ 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.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.SEARXNG_LANGUAGE = SEARXNG_LANGUAGE
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME app.state.config.YACY_USERNAME = YACY_USERNAME
app.state.config.YACY_PASSWORD = YACY_PASSWORD app.state.config.YACY_PASSWORD = YACY_PASSWORD
@ -995,6 +1003,7 @@ try:
app.state.config.RAG_RERANKING_MODEL, app.state.config.RAG_RERANKING_MODEL,
app.state.config.RAG_EXTERNAL_RERANKER_URL, app.state.config.RAG_EXTERNAL_RERANKER_URL,
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
) )
else: else:
app.state.rf = None app.state.rf = None
@ -1031,6 +1040,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None else None
), ),
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
) )
app.state.RERANKING_FUNCTION = get_reranking_function( app.state.RERANKING_FUNCTION = get_reranking_function(
@ -1530,6 +1540,7 @@ async def chat_completion(
metadata = {} metadata = {}
try: try:
model_info = None
if not model_item.get("direct", False): if not model_item.get("direct", False):
if model_id not in request.app.state.MODELS: if model_id not in request.app.state.MODELS:
raise Exception("Model not found") raise Exception("Model not found")
@ -1547,7 +1558,6 @@ async def chat_completion(
raise e raise e
else: else:
model = model_item model = model_item
model_info = None
request.state.direct = True request.state.direct = True
request.state.model = model request.state.model = model
@ -1556,6 +1566,26 @@ async def chat_completion(
model_info.params.model_dump() if model_info and model_info.params else {} model_info.params.model_dump() if model_info and model_info.params else {}
) )
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
base_model_id = model_info_params.get("base_model_id")
if base_model_id not in request.app.state.MODELS:
if ENABLE_CUSTOM_MODEL_FALLBACK:
default_models = (
request.app.state.config.DEFAULT_MODELS or ""
).split(",")
fallback_model_id = (
default_models[0].strip() if default_models[0] else None
)
if fallback_model_id:
request.base_model_id = fallback_model_id
else:
raise Exception("Model not found")
else:
raise Exception("Model not found")
# Chat Params # Chat Params
stream_delta_chunk_size = form_data.get("params", {}).get( stream_delta_chunk_size = form_data.get("params", {}).get(
"stream_delta_chunk_size" "stream_delta_chunk_size"
@ -1576,6 +1606,7 @@ async def chat_completion(
"user_id": user.id, "user_id": user.id,
"chat_id": form_data.pop("chat_id", None), "chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None), "message_id": form_data.pop("id", None),
"parent_message": form_data.pop("parent_message", None),
"parent_message_id": form_data.pop("parent_id", None), "parent_message_id": form_data.pop("parent_id", None),
"session_id": form_data.pop("session_id", None), "session_id": form_data.pop("session_id", None),
"filter_ids": form_data.pop("filter_ids", []), "filter_ids": form_data.pop("filter_ids", []),
@ -1600,15 +1631,38 @@ async def chat_completion(
}, },
} }
if metadata.get("chat_id") and (user and user.role != "admin"): if metadata.get("chat_id") and user:
if not metadata["chat_id"].startswith("local:"): if not metadata["chat_id"].startswith(
"local:"
): # temporary chats are not stored
# Verify chat ownership
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id) chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None: if chat is None and user.role != "admin": # admins can access any chat
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(), detail=ERROR_MESSAGES.DEFAULT(),
) )
# Insert chat files from parent message if any
parent_message = metadata.get("parent_message", {})
parent_message_files = parent_message.get("files", [])
if parent_message_files:
try:
Chats.insert_chat_files(
metadata["chat_id"],
parent_message.get("id"),
[
file_item.get("id")
for file_item in parent_message_files
if file_item.get("type") == "file"
],
user.id,
)
except Exception as e:
log.debug(f"Error inserting chat files: {e}")
pass
request.state.metadata = metadata request.state.metadata = metadata
form_data["metadata"] = metadata form_data["metadata"] = metadata
@ -1843,6 +1897,7 @@ async def get_app_config(request: Request):
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM, "enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT, "enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK, "enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
**( **(
{ {
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
@ -2019,10 +2074,19 @@ async def get_current_usage(user=Depends(get_verified_user)):
This is an experimental endpoint and subject to change. This is an experimental endpoint and subject to change.
""" """
try: try:
# If public visibility is disabled, only allow admins to access this endpoint
if not ENABLE_PUBLIC_ACTIVE_USERS_COUNT and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Only administrators can view usage statistics.",
)
return { return {
"model_ids": get_models_in_use(), "model_ids": get_models_in_use(),
"user_count": Users.get_active_user_count(), "user_count": Users.get_active_user_count(),
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
log.error(f"Error getting usage statistics: {e}") log.error(f"Error getting usage statistics: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error") raise HTTPException(status_code=500, detail="Internal Server Error")

View file

@ -0,0 +1,54 @@
"""Add channel file table
Revision ID: 6283dc0e4d8d
Revises: 3e0e00844bb0
Create Date: 2025-12-10 15:11:39.424601
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "6283dc0e4d8d"
down_revision: Union[str, None] = "3e0e00844bb0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"channel_id",
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_channel_file_channel_id", "channel_id"),
sa.Index("ix_channel_file_file_id", "file_id"),
sa.Index("ix_channel_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"channel_id", "file_id", name="uq_channel_file_channel_file"
), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table("channel_file")

View file

@ -0,0 +1,49 @@
"""Update channel file and knowledge table
Revision ID: 81cc2ce44d79
Revises: 6283dc0e4d8d
Create Date: 2025-12-10 16:07:58.001282
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "81cc2ce44d79"
down_revision: Union[str, None] = "6283dc0e4d8d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add message_id column to channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"message_id",
sa.Text(),
sa.ForeignKey(
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
),
nullable=True,
)
)
# Add data column to knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove message_id column from channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.drop_column("message_id")
# Remove data column from knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.drop_column("data")

View file

@ -0,0 +1,57 @@
"""Add chat_file table
Revision ID: c440947495f3
Revises: 81cc2ce44d79
Create Date: 2025-12-21 20:27:41.694897
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c440947495f3"
down_revision: Union[str, None] = "81cc2ce44d79"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"chat_id",
sa.Text(),
sa.ForeignKey("chat.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_chat_file_chat_id", "chat_id"),
sa.Index("ix_chat_file_file_id", "file_id"),
sa.Index("ix_chat_file_message_id", "message_id"),
sa.Index("ix_chat_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"chat_id", "file_id", name="uq_chat_file_chat_file"
), # prevent duplicate entries
)
pass
def downgrade() -> None:
op.drop_table("chat_file")
pass

View file

@ -4,12 +4,10 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.users import UserModel, UserProfileImageResponse, Users from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text from sqlalchemy import Boolean, Column, String, Text
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# DB MODEL # DB MODEL

View file

@ -10,7 +10,18 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
String,
Text,
JSON,
UniqueConstraint,
case,
cast,
)
from sqlalchemy import or_, func, select, and_, text from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
@ -137,6 +148,41 @@ class ChannelMemberModel(BaseModel):
updated_at: Optional[int] = None # timestamp in epoch (time_ns) updated_at: Optional[int] = None # timestamp in epoch (time_ns)
class ChannelFile(Base):
__tablename__ = "channel_file"
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text, nullable=False)
channel_id = Column(
Text, ForeignKey("channel.id", ondelete="CASCADE"), nullable=False
)
message_id = Column(
Text, ForeignKey("message.id", ondelete="CASCADE"), nullable=True
)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint("channel_id", "file_id", name="uq_channel_file_channel_file"),
)
class ChannelFileModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
channel_id: str
file_id: str
user_id: str
created_at: int # timestamp in epoch (time_ns)
updated_at: int # timestamp in epoch (time_ns)
class ChannelWebhook(Base): class ChannelWebhook(Base):
__tablename__ = "channel_webhook" __tablename__ = "channel_webhook"
@ -642,6 +688,135 @@ class ChannelTable:
channel = db.query(Channel).filter(Channel.id == id).first() channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]:
with get_db() as db:
channel_files = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str
) -> list[ChannelModel]:
with get_db() as db:
# 1. Determine which channels have this file
channel_file_rows = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
channel_ids = [row.channel_id for row in channel_file_rows]
if not channel_ids:
return []
# 2. Load all channel rows that still exist
channels = (
db.query(Channel)
.filter(
Channel.id.in_(channel_ids),
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
)
.all()
)
if not channels:
return []
# Preload user's group membership
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)]
allowed_channels = []
for channel in channels:
# --- Case A: group or dm => user must be an active member ---
if channel.type in ["group", "dm"]:
membership = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == channel.id,
ChannelMember.user_id == user_id,
ChannelMember.is_active.is_(True),
)
.first()
)
if membership:
allowed_channels.append(ChannelModel.model_validate(channel))
continue
# --- Case B: standard channel => rely on ACL permissions ---
query = db.query(Channel).filter(Channel.id == channel.id)
query = self._has_permission(
db,
query,
{"user_id": user_id, "group_ids": user_group_ids},
permission="read",
)
allowed = query.first()
if allowed:
allowed_channels.append(ChannelModel.model_validate(allowed))
return allowed_channels
def get_channel_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
# Fetch the channel
channel: Channel = (
db.query(Channel)
.filter(
Channel.id == id,
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
)
.first()
)
if not channel:
return None
# If the channel is a group or dm, read access requires membership (active)
if channel.type in ["group", "dm"]:
membership = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == id,
ChannelMember.user_id == user_id,
ChannelMember.is_active.is_(True),
)
.first()
)
if membership:
return ChannelModel.model_validate(channel)
else:
return None
# For channels that are NOT group/dm, fall back to ACL-based read access
query = db.query(Channel).filter(Channel.id == id)
# Determine user groups
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
]
# Apply ACL rules
query = self._has_permission(
db,
query,
{"user_id": user_id, "group_ids": user_group_ids},
permission="read",
)
channel_allowed = query.first()
return (
ChannelModel.model_validate(channel_allowed)
if channel_allowed
else None
)
def update_channel_by_id( def update_channel_by_id(
self, id: str, form_data: ChannelForm self, id: str, form_data: ChannelForm
) -> Optional[ChannelModel]: ) -> Optional[ChannelModel]:
@ -663,6 +838,65 @@ class ChannelTable:
db.commit() db.commit()
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str
) -> Optional[ChannelFileModel]:
with get_db() as db:
channel_file = ChannelFileModel(
**{
"id": str(uuid.uuid4()),
"channel_id": channel_id,
"file_id": file_id,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
result = ChannelFile(**channel_file.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return ChannelFileModel.model_validate(result)
else:
return None
except Exception:
return None
def set_file_message_id_in_channel_by_id(
self, channel_id: str, file_id: str, message_id: str
) -> bool:
try:
with get_db() as db:
channel_file = (
db.query(ChannelFile)
.filter_by(channel_id=channel_id, file_id=file_id)
.first()
)
if not channel_file:
return False
channel_file.message_id = message_id
channel_file.updated_at = int(time.time())
db.commit()
return True
except Exception:
return False
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool:
try:
with get_db() as db:
db.query(ChannelFile).filter_by(
channel_id=channel_id, file_id=file_id
).delete()
db.commit()
return True
except Exception:
return False
def delete_channel_by_id(self, id: str): def delete_channel_by_id(self, id: str):
with get_db() as db: with get_db() as db:
db.query(Channel).filter(Channel.id == id).delete() db.query(Channel).filter(Channel.id == id).delete()

View file

@ -7,10 +7,20 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders from open_webui.models.folders import Folders
from open_webui.env import SRC_LOG_LEVELS from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, Index from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
String,
Text,
JSON,
Index,
UniqueConstraint,
)
from sqlalchemy import or_, func, select, and_, text from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import bindparam from sqlalchemy.sql.expression import bindparam
@ -20,7 +30,6 @@ from sqlalchemy.sql.expression import bindparam
#################### ####################
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Chat(Base): class Chat(Base):
@ -75,6 +84,38 @@ class ChatModel(BaseModel):
folder_id: Optional[str] = None folder_id: Optional[str] = None
class ChatFile(Base):
__tablename__ = "chat_file"
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text, nullable=False)
chat_id = Column(Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False)
message_id = Column(Text, nullable=True)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint("chat_id", "file_id", name="uq_chat_file_chat_file"),
)
class ChatFileModel(BaseModel):
id: str
user_id: str
chat_id: str
message_id: Optional[str] = None
file_id: str
created_at: int
updated_at: int
model_config = ConfigDict(from_attributes=True)
#################### ####################
# Forms # Forms
#################### ####################
@ -126,20 +167,53 @@ class ChatTitleIdResponse(BaseModel):
created_at: int created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
class ChatUsageStatsResponse(BaseModel):
id: str # chat id
models: dict = {} # models used in the chat with their usage counts
message_count: int # number of messages in the chat
history_models: dict = {} # models used in the chat history with their usage counts
history_message_count: int # number of messages in the chat history
history_user_message_count: int # number of user messages in the chat history
history_assistant_message_count: (
int # number of assistant messages in the chat history
)
average_response_time: (
float # average response time of assistant messages in seconds
)
average_user_message_content_length: (
float # average length of user message contents
)
average_assistant_message_content_length: (
float # average length of assistant message contents
)
tags: list[str] = [] # tags associated with the chat
last_message_at: int # timestamp of the last message
updated_at: int
created_at: int
model_config = ConfigDict(extra="allow")
class ChatUsageStatsListResponse(BaseModel):
items: list[ChatUsageStatsResponse]
total: int
model_config = ConfigDict(extra="allow")
class ChatTable: class ChatTable:
def _clean_null_bytes(self, obj): def _clean_null_bytes(self, obj):
""" """Recursively remove null bytes from strings in dict/list structures."""
Recursively remove actual null bytes (\x00) and unicode escape \\u0000 return sanitize_data_for_db(obj)
from strings inside dict/list structures.
Safe for JSON objects.
"""
if isinstance(obj, str):
return obj.replace("\x00", "").replace("\u0000", "")
elif isinstance(obj, dict):
return {k: self._clean_null_bytes(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [self._clean_null_bytes(v) for v in obj]
return obj
def _sanitize_chat_row(self, chat_item): def _sanitize_chat_row(self, chat_item):
""" """
@ -310,7 +384,7 @@ class ChatTable:
# Sanitize message content for null characters before upserting # Sanitize message content for null characters before upserting
if isinstance(message.get("content"), str): if isinstance(message.get("content"), str):
message["content"] = message["content"].replace("\x00", "") message["content"] = sanitize_text_for_db(message["content"])
chat = chat.chat chat = chat.chat
history = chat.get("history", {}) history = chat.get("history", {})
@ -675,14 +749,31 @@ class ChatTable:
) )
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_chats_by_user_id(
self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None
) -> ChatListResponse:
with get_db() as db: with get_db() as db:
all_chats = ( query = (
db.query(Chat) db.query(Chat)
.filter_by(user_id=user_id) .filter_by(user_id=user_id)
.order_by(Chat.updated_at.desc()) .order_by(Chat.updated_at.desc())
) )
return [ChatModel.model_validate(chat) for chat in all_chats]
total = query.count()
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
all_chats = query.all()
return ChatListResponse(
**{
"items": [ChatModel.model_validate(chat) for chat in all_chats],
"total": total,
}
)
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
with get_db() as db: with get_db() as db:
@ -713,7 +804,7 @@ class ChatTable:
""" """
Filters chats based on a search query using Python, allowing pagination using skip and limit. Filters chats based on a search query using Python, allowing pagination using skip and limit.
""" """
search_text = search_text.replace("\u0000", "").lower().strip() search_text = sanitize_text_for_db(search_text).lower().strip()
if not search_text: if not search_text:
return self.get_chat_list_by_user_id( return self.get_chat_list_by_user_id(
@ -1170,5 +1261,93 @@ class ChatTable:
except Exception: except Exception:
return False return False
def insert_chat_files(
self, chat_id: str, message_id: str, file_ids: list[str], user_id: str
) -> Optional[list[ChatFileModel]]:
if not file_ids:
return None
chat_message_file_ids = [
item.id
for item in self.get_chat_files_by_chat_id_and_message_id(
chat_id, message_id
)
]
# Remove duplicates and existing file_ids
file_ids = list(
set(
[
file_id
for file_id in file_ids
if file_id and file_id not in chat_message_file_ids
]
)
)
if not file_ids:
return None
try:
with get_db() as db:
now = int(time.time())
chat_files = [
ChatFileModel(
id=str(uuid.uuid4()),
user_id=user_id,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
created_at=now,
updated_at=now,
)
for file_id in file_ids
]
results = [
ChatFile(**chat_file.model_dump()) for chat_file in chat_files
]
db.add_all(results)
db.commit()
return chat_files
except Exception:
return None
def get_chat_files_by_chat_id_and_message_id(
self, chat_id: str, message_id: str
) -> list[ChatFileModel]:
with get_db() as db:
all_chat_files = (
db.query(ChatFile)
.filter_by(chat_id=chat_id, message_id=message_id)
.order_by(ChatFile.created_at.asc())
.all()
)
return [
ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files
]
def delete_chat_file(self, chat_id: str, file_id: str) -> bool:
try:
with get_db() as db:
db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete()
db.commit()
return True
except Exception:
return False
def get_shared_chats_by_file_id(self, file_id: str) -> list[ChatModel]:
with get_db() as db:
# Join Chat and ChatFile tables to get shared chats associated with the file_id
all_chats = (
db.query(Chat)
.join(ChatFile, Chat.id == ChatFile.chat_id)
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
.all()
)
return [ChatModel.model_validate(chat) for chat in all_chats]
Chats = ChatTable() Chats = ChatTable()

View file

@ -6,12 +6,10 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.users import User from open_webui.models.users import User
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
@ -62,6 +60,13 @@ class FeedbackResponse(BaseModel):
updated_at: int updated_at: int
class FeedbackIdResponse(BaseModel):
id: str
user_id: str
created_at: int
updated_at: int
class RatingData(BaseModel): class RatingData(BaseModel):
rating: Optional[str | int] = None rating: Optional[str | int] = None
model_id: Optional[str] = None model_id: Optional[str] = None

View file

@ -3,12 +3,10 @@ import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON from sqlalchemy import BigInteger, Column, String, Text, JSON
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# Files DB Schema # Files DB Schema
@ -83,7 +81,7 @@ class FileModelResponse(BaseModel):
class FileMetadataResponse(BaseModel): class FileMetadataResponse(BaseModel):
id: str id: str
hash: Optional[str] = None hash: Optional[str] = None
meta: dict meta: Optional[dict] = None
created_at: int # timestamp in epoch created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch updated_at: int # timestamp in epoch
@ -104,6 +102,11 @@ class FileUpdateForm(BaseModel):
meta: Optional[dict] = None meta: Optional[dict] = None
class FileListResponse(BaseModel):
items: list[FileModel]
total: int
class FilesTable: class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]: def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db: with get_db() as db:
@ -238,6 +241,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.hash = hash file.hash = hash
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
@ -249,6 +253,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data} file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception as e: except Exception as e:
@ -260,6 +265,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta} file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception: except Exception:

View file

@ -9,11 +9,9 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################

View file

@ -4,12 +4,10 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserModel from open_webui.models.users import Users, UserModel
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# Functions DB Schema # Functions DB Schema

View file

@ -5,7 +5,6 @@ from typing import Optional
import uuid import uuid
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import FileMetadataResponse from open_webui.models.files import FileMetadataResponse
@ -26,7 +25,6 @@ from sqlalchemy import (
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# UserGroup DB Schema # UserGroup DB Schema

View file

@ -5,11 +5,15 @@ from typing import Optional
import uuid import uuid
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import File, FileModel, FileMetadataResponse from open_webui.models.files import (
File,
FileModel,
FileMetadataResponse,
FileModelResponse,
)
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -21,12 +25,14 @@ from sqlalchemy import (
Text, Text,
JSON, JSON,
UniqueConstraint, UniqueConstraint,
or_,
) )
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# Knowledge DB Schema # Knowledge DB Schema
@ -126,7 +132,7 @@ class KnowledgeResponse(KnowledgeModel):
class KnowledgeUserResponse(KnowledgeUserModel): class KnowledgeUserResponse(KnowledgeUserModel):
files: Optional[list[FileMetadataResponse | dict]] = None pass
class KnowledgeForm(BaseModel): class KnowledgeForm(BaseModel):
@ -135,6 +141,20 @@ class KnowledgeForm(BaseModel):
access_control: Optional[dict] = None access_control: Optional[dict] = None
class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeListResponse(BaseModel):
items: list[KnowledgeUserModel]
total: int
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
class KnowledgeTable: class KnowledgeTable:
def insert_new_knowledge( def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm self, user_id: str, form_data: KnowledgeForm
@ -162,12 +182,13 @@ class KnowledgeTable:
except Exception: except Exception:
return None return None
def get_knowledge_bases(self) -> list[KnowledgeUserModel]: def get_knowledge_bases(
self, skip: int = 0, limit: int = 30
) -> list[KnowledgeUserModel]:
with get_db() as db: with get_db() as db:
all_knowledge = ( all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
) )
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids) if user_ids else []
@ -186,6 +207,126 @@ class KnowledgeTable:
) )
return knowledge_bases return knowledge_bases
def search_knowledge_bases(
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeListResponse:
try:
with get_db() as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Knowledge.user_id == user_id)
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
query = query.order_by(Knowledge.updated_at.desc())
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
if user
else None
),
}
)
)
return KnowledgeListResponse(items=knowledge_bases, total=total)
except Exception as e:
print(e)
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db() as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
)
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
# Apply filename search
if filter:
q = filter.get("query")
if q:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
query = query.order_by(File.updated_at.desc())
# Count before pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
rows = query.all()
items = []
for file, user in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=items, total=total)
except Exception as e:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool: def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id) knowledge = self.get_knowledge_by_id(id)
if not knowledge: if not knowledge:
@ -217,6 +358,21 @@ class KnowledgeTable:
except Exception: except Exception:
return None return None
def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
return knowledge
return None
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
try: try:
with get_db() as db: with get_db() as db:
@ -232,6 +388,88 @@ class KnowledgeTable:
except Exception: except Exception:
return [] return []
def search_files_by_id(
self,
knowledge_id: str,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
) -> KnowledgeFileListResponse:
try:
with get_db() as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(KnowledgeFile.user_id == user_id)
elif view_option == "shared":
query = query.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=files, total=total)
except Exception as e:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
try: try:
with get_db() as db: with get_db() as db:

View file

@ -9,7 +9,7 @@ from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember from open_webui.models.channels import Channels, ChannelMember
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
@ -108,11 +108,24 @@ class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None user: Optional[UserNameResponse] = None
class MessageUserSlimResponse(MessageUserResponse):
data: bool | None = None
@field_validator("data", mode="before")
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
return False
# True if ANY value in the dict is non-empty
return any(bool(val) for val in v.values())
class MessageReplyToResponse(MessageUserResponse): class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None reply_to_message: Optional[MessageUserSlimResponse] = None
class MessageWithReactionsResponse(MessageUserResponse): class MessageWithReactionsResponse(MessageUserSlimResponse):
reactions: list[Reactions] reactions: list[Reactions]

View file

@ -3,7 +3,6 @@ import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
@ -22,7 +21,6 @@ from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
@ -359,6 +357,14 @@ class ModelsTable:
except Exception: except Exception:
return None return None
def get_models_by_ids(self, ids: list[str]) -> list[ModelModel]:
try:
with get_db() as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models]
except Exception:
return []
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]: def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
with get_db() as db: with get_db() as db:
try: try:

View file

@ -7,12 +7,15 @@ from functools import lru_cache
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
#################### ####################
@ -75,7 +78,138 @@ class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
class NoteListResponse(BaseModel):
items: list[NoteUserResponse]
total: int
class NoteTable: class NoteTable:
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def insert_new_note( def insert_new_note(
self, self,
form_data: NoteForm, form_data: NoteForm,
@ -110,15 +244,107 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
) -> NoteListResponse:
with get_db() as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
cast(Note.data["content"]["md"], Text).ilike(
f"%{query_key}%"
),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
# Apply access control filtering
if "permission" in filter:
permission = filter["permission"]
else:
permission = "write"
query = self._has_permission(
db,
query,
filter,
permission=permission,
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id( def get_notes_by_user_id(
self, self,
user_id: str, user_id: str,
permission: str = "read",
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
with get_db() as db: with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id) user_group_ids = [
query = query.order_by(Note.updated_at.desc()) group.id for group in Groups.get_groups_by_member_id(user_id)
]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
if skip is not None: if skip is not None:
query = query.offset(skip) query = query.offset(skip)
@ -128,56 +354,6 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] 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]: def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db: with get_db() as db:
note = db.query(Note).filter(Note.id == id).first() note = db.query(Note).filter(Note.id == id).first()

View file

@ -9,13 +9,12 @@ import json
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Index from sqlalchemy import BigInteger, Column, String, Text, Index
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# DB MODEL # DB MODEL

View file

@ -6,12 +6,10 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################

View file

@ -6,7 +6,6 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON from sqlalchemy import BigInteger, Column, String, Text, JSON
@ -14,7 +13,6 @@ from open_webui.utils.access_control import has_access
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
#################### ####################
# Tools DB Schema # Tools DB Schema

View file

@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle from open_webui.utils.misc import throttle

View file

@ -6,10 +6,8 @@ from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalDocumentLoader(BaseLoader): class ExternalDocumentLoader(BaseLoader):

View file

@ -4,10 +4,8 @@ from typing import Iterator, List, Union
from langchain_core.document_loaders import BaseLoader from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalWebLoader(BaseLoader): class ExternalWebLoader(BaseLoader):

View file

@ -30,11 +30,10 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
known_source_ext = [ known_source_ext = [
"go", "go",
@ -144,19 +143,17 @@ class DoclingLoader:
with open(self.file_path, "rb") as f: with open(self.file_path, "rb") as f:
headers = {} headers = {}
if self.api_key: if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}" headers["X-Api-Key"] = f"Bearer {self.api_key}"
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
r = requests.post( r = requests.post(
f"{self.url}/v1/convert/file", f"{self.url}/v1/convert/file",
files=files, files={
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
},
data={ data={
"image_export_mode": "placeholder", "image_export_mode": "placeholder",
**self.params, **self.params,
@ -334,12 +331,21 @@ class Loader:
elif self.engine == "mineru" and file_ext in [ elif self.engine == "mineru" and file_ext in [
"pdf" "pdf"
]: # MinerU currently only supports PDF ]: # MinerU currently only supports PDF
mineru_timeout = self.kwargs.get("MINERU_API_TIMEOUT", 300)
if mineru_timeout:
try:
mineru_timeout = int(mineru_timeout)
except ValueError:
mineru_timeout = 300
loader = MinerULoader( loader = MinerULoader(
file_path=file_path, file_path=file_path,
api_mode=self.kwargs.get("MINERU_API_MODE", "local"), api_mode=self.kwargs.get("MINERU_API_MODE", "local"),
api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"), api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"),
api_key=self.kwargs.get("MINERU_API_KEY", ""), api_key=self.kwargs.get("MINERU_API_KEY", ""),
params=self.kwargs.get("MINERU_PARAMS", {}), params=self.kwargs.get("MINERU_PARAMS", {}),
timeout=mineru_timeout,
) )
elif ( elif (
self.engine == "mistral_ocr" self.engine == "mistral_ocr"

View file

@ -26,11 +26,13 @@ class MinerULoader:
api_url: str = "http://localhost:8000", api_url: str = "http://localhost:8000",
api_key: str = "", api_key: str = "",
params: dict = None, params: dict = None,
timeout: Optional[int] = 300,
): ):
self.file_path = file_path self.file_path = file_path
self.api_mode = api_mode.lower() self.api_mode = api_mode.lower()
self.api_url = api_url.rstrip("/") self.api_url = api_url.rstrip("/")
self.api_key = api_key self.api_key = api_key
self.timeout = timeout
# Parse params dict with defaults # Parse params dict with defaults
self.params = params or {} self.params = params or {}
@ -101,7 +103,7 @@ class MinerULoader:
f"{self.api_url}/file_parse", f"{self.api_url}/file_parse",
data=form_data, data=form_data,
files=files, files=files,
timeout=300, # 5 minute timeout for large documents timeout=self.timeout,
) )
response.raise_for_status() response.raise_for_status()
@ -300,7 +302,7 @@ class MinerULoader:
response = requests.put( response = requests.put(
upload_url, upload_url,
data=f, data=f,
timeout=300, # 5 minute timeout for large files timeout=self.timeout,
) )
response.raise_for_status() response.raise_for_status()
except FileNotFoundError: except FileNotFoundError:

View file

@ -9,11 +9,10 @@ from typing import List, Dict, Any
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MistralLoader: class MistralLoader:

View file

@ -4,10 +4,8 @@ from typing import Iterator, List, Literal, Union
from langchain_core.document_loaders import BaseLoader from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class TavilyLoader(BaseLoader): class TavilyLoader(BaseLoader):

View file

@ -4,10 +4,8 @@ from xml.etree.ElementTree import ParseError
from typing import Any, Dict, Generator, List, Optional, Sequence, Union from typing import Any, Dict, Generator, List, Optional, Sequence, Union
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
ALLOWED_SCHEMES = {"http", "https"} ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_NETLOCS = { ALLOWED_NETLOCS = {

View file

@ -5,12 +5,10 @@ import numpy as np
from colbert.infra import ColBERTConfig from colbert.infra import ColBERTConfig
from colbert.modeling.checkpoint import Checkpoint from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT(BaseReranker): class ColBERT(BaseReranker):

View file

@ -4,13 +4,12 @@ from typing import Optional, List, Tuple
from urllib.parse import quote from urllib.parse import quote
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.retrieval.models.base_reranker import BaseReranker from open_webui.retrieval.models.base_reranker import BaseReranker
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalReranker(BaseReranker): class ExternalReranker(BaseReranker):
@ -19,10 +18,12 @@ class ExternalReranker(BaseReranker):
api_key: str, api_key: str,
url: str = "http://localhost:8080/v1/rerank", url: str = "http://localhost:8080/v1/rerank",
model: str = "reranker", model: str = "reranker",
timeout: Optional[int] = None,
): ):
self.api_key = api_key self.api_key = api_key
self.url = url self.url = url
self.model = model self.model = model
self.timeout = timeout
def predict( def predict(
self, sentences: List[Tuple[str, str]], user=None self, sentences: List[Tuple[str, str]], user=None
@ -53,6 +54,7 @@ class ExternalReranker(BaseReranker):
f"{self.url}", f"{self.url}",
headers=headers, headers=headers,
json=payload, json=payload,
timeout=self.timeout,
) )
r.raise_for_status() r.raise_for_status()

View file

@ -12,7 +12,10 @@ import re
from urllib.parse import quote from urllib.parse import quote
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever from langchain_classic.retrievers import (
ContextualCompressionRetriever,
EnsembleRetriever,
)
from langchain_community.retrievers import BM25Retriever from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document from langchain_core.documents import Document
@ -37,7 +40,6 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS,
OFFLINE_MODE, OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_FORWARD_USER_INFO_HEADERS,
) )
@ -48,7 +50,6 @@ from open_webui.config import (
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
from typing import Any from typing import Any
@ -1281,7 +1282,7 @@ class RerankCompressor(BaseDocumentCompressor):
scores = None scores = None
if reranking: if reranking:
scores = self.reranking_function(query, documents) scores = await asyncio.to_thread(self.reranking_function, query, documents)
else: else:
from sentence_transformers import util from sentence_transformers import util

View file

@ -24,10 +24,8 @@ from open_webui.config import (
CHROMA_CLIENT_AUTH_PROVIDER, CHROMA_CLIENT_AUTH_PROVIDER,
CHROMA_CLIENT_AUTH_CREDENTIALS, CHROMA_CLIENT_AUTH_CREDENTIALS,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ChromaClient(VectorDBBase): class ChromaClient(VectorDBBase):

View file

@ -25,10 +25,8 @@ from open_webui.config import (
MILVUS_DISKANN_MAX_DEGREE, MILVUS_DISKANN_MAX_DEGREE,
MILVUS_DISKANN_SEARCH_LIST_SIZE, MILVUS_DISKANN_SEARCH_LIST_SIZE,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MilvusClient(VectorDBBase): class MilvusClient(VectorDBBase):

View file

@ -12,7 +12,6 @@ from open_webui.config import (
MILVUS_HNSW_EFCONSTRUCTION, MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST, MILVUS_IVF_FLAT_NLIST,
) )
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
GetResult, GetResult,
SearchResult, SearchResult,
@ -29,7 +28,6 @@ from pymilvus import (
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
RESOURCE_ID_FIELD = "resource_id" RESOURCE_ID_FIELD = "resource_id"

View file

@ -55,10 +55,8 @@ from open_webui.config import (
ORACLE_DB_POOL_MAX, ORACLE_DB_POOL_MAX,
ORACLE_DB_POOL_INCREMENT, ORACLE_DB_POOL_INCREMENT,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class Oracle23aiClient(VectorDBBase): class Oracle23aiClient(VectorDBBase):

View file

@ -51,7 +51,6 @@ from open_webui.config import (
PGVECTOR_USE_HALFVEC, PGVECTOR_USE_HALFVEC,
) )
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
USE_HALFVEC = PGVECTOR_USE_HALFVEC USE_HALFVEC = PGVECTOR_USE_HALFVEC
@ -61,7 +60,6 @@ VECTOR_OPCLASS = "halfvec_cosine_ops" if USE_HALFVEC else "vector_cosine_ops"
Base = declarative_base() Base = declarative_base()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def pgcrypto_encrypt(val, key): def pgcrypto_encrypt(val, key):

View file

@ -31,7 +31,6 @@ from open_webui.config import (
PINECONE_METRIC, PINECONE_METRIC,
PINECONE_CLOUD, PINECONE_CLOUD,
) )
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import process_metadata from open_webui.retrieval.vector.utils import process_metadata
@ -39,7 +38,6 @@ NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class PineconeClient(VectorDBBase): class PineconeClient(VectorDBBase):

View file

@ -22,12 +22,10 @@ from open_webui.config import (
QDRANT_TIMEOUT, QDRANT_TIMEOUT,
QDRANT_HNSW_M, QDRANT_HNSW_M,
) )
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 999999999 NO_LIMIT = 999999999
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase): class QdrantClient(VectorDBBase):

View file

@ -13,7 +13,6 @@ from open_webui.config import (
QDRANT_TIMEOUT, QDRANT_TIMEOUT,
QDRANT_HNSW_M, QDRANT_HNSW_M,
) )
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
GetResult, GetResult,
SearchResult, SearchResult,
@ -30,7 +29,6 @@ TENANT_ID_FIELD = "tenant_id"
DEFAULT_DIMENSION = 384 DEFAULT_DIMENSION = 384
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def _tenant_filter(tenant_id: str) -> models.FieldCondition: def _tenant_filter(tenant_id: str) -> models.FieldCondition:

View file

@ -6,13 +6,11 @@ from open_webui.retrieval.vector.main import (
SearchResult, SearchResult,
) )
from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION
from open_webui.env import SRC_LOG_LEVELS
from typing import List, Optional, Dict, Any, Union from typing import List, Optional, Dict, Any, Union
import logging import logging
import boto3 import boto3
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class S3VectorClient(VectorDBBase): class S3VectorClient(VectorDBBase):

View file

@ -1,10 +1,8 @@
import logging import logging
from typing import Optional from typing import Optional
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
""" """
Azure AI Search integration for Open WebUI. Azure AI Search integration for Open WebUI.

View file

@ -4,11 +4,9 @@ from pprint import pprint
from typing import Optional from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
import argparse import argparse
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
""" """
Documentation: https://docs.microsoft.com/en-us/bing/search-apis/bing-web-search/overview Documentation: https://docs.microsoft.com/en-us/bing/search-apis/bing-web-search/overview
""" """

View file

@ -4,20 +4,18 @@ from typing import Optional
import requests import requests
import json import json
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def _parse_response(response): def _parse_response(response):
result = {} results = []
if "data" in response: if "data" in response:
data = response["data"] data = response["data"]
if "webPages" in data: if "webPages" in data:
webPages = data["webPages"] webPages = data["webPages"]
if "value" in webPages: if "value" in webPages:
result["webpage"] = [ results = [
{ {
"id": item.get("id", ""), "id": item.get("id", ""),
"name": item.get("name", ""), "name": item.get("name", ""),
@ -31,7 +29,7 @@ def _parse_response(response):
} }
for item in webPages["value"] for item in webPages["value"]
] ]
return result return results
def search_bocha( def search_bocha(
@ -53,7 +51,7 @@ def search_bocha(
response = requests.post(url, headers=headers, data=payload, timeout=5) response = requests.post(url, headers=headers, data=payload, timeout=5)
response.raise_for_status() response.raise_for_status()
results = _parse_response(response.json()) results = _parse_response(response.json())
print(results)
if filter_list: if filter_list:
results = get_filtered_results(results, filter_list) results = get_filtered_results(results, filter_list)
@ -61,5 +59,5 @@ def search_bocha(
SearchResult( SearchResult(
link=result["url"], title=result.get("name"), snippet=result.get("summary") link=result["url"], title=result.get("name"), snippet=result.get("summary")
) )
for result in results.get("webpage", [])[:count] for result in results[:count]
] ]

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_brave( def search_brave(

View file

@ -4,10 +4,8 @@ from typing import Optional
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from ddgs import DDGS from ddgs import DDGS
from ddgs.exceptions import RatelimitException from ddgs.exceptions import RatelimitException
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_duckduckgo( def search_duckduckgo(

View file

@ -3,11 +3,9 @@ from dataclasses import dataclass
from typing import Optional from typing import Optional
import requests import requests
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.web.main import SearchResult from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
EXA_API_BASE = "https://api.exa.ai" EXA_API_BASE = "https://api.exa.ai"

View file

@ -5,14 +5,12 @@ import requests
from fastapi import Request from fastapi import Request
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_external( def search_external(

View file

@ -2,11 +2,9 @@ import logging
from typing import Optional, List from typing import Optional, List
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_firecrawl( def search_firecrawl(

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_google_pse( def search_google_pse(

View file

@ -2,11 +2,9 @@ import logging
import requests import requests
from open_webui.retrieval.web.main import SearchResult from open_webui.retrieval.web.main import SearchResult
from open_webui.env import SRC_LOG_LEVELS
from yarl import URL from yarl import URL
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]: def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]:

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_kagi( def search_kagi(

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_mojeek( def search_mojeek(

View file

@ -3,11 +3,9 @@ from dataclasses import dataclass
from typing import Optional from typing import Optional
import requests import requests
from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_ollama_cloud( def search_ollama_cloud(
@ -38,6 +36,9 @@ def search_ollama_cloud(
results = data.get("results", []) results = data.get("results", [])
log.info(f"Found {len(results)} results") log.info(f"Found {len(results)} results")
if filter_list:
results = get_filtered_results(results, filter_list)
return [ return [
SearchResult( SearchResult(
link=result.get("url", ""), link=result.get("url", ""),

View file

@ -3,7 +3,6 @@ from typing import Optional, Literal
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
MODELS = Literal[ MODELS = Literal[
"sonar", "sonar",
@ -16,7 +15,6 @@ SEARCH_CONTEXT_USAGE_LEVELS = Literal["low", "medium", "high"]
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_perplexity( def search_perplexity(

View file

@ -4,11 +4,9 @@ import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_perplexity_search( def search_perplexity_search(

View file

@ -4,10 +4,8 @@ from urllib.parse import urlencode
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_searchapi( def search_searchapi(

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_searxng( def search_searxng(
@ -27,7 +25,7 @@ def search_searxng(
count (int): The maximum number of results to retrieve from the search. count (int): The maximum number of results to retrieve from the search.
Keyword Args: Keyword Args:
language (str): Language filter for the search results; e.g., "en-US". Defaults to an empty string. language (str): Language filter for the search results; e.g., "all", "en-US", "es". Defaults to "all".
safesearch (int): Safe search filter for safer web results; 0 = off, 1 = moderate, 2 = strict. Defaults to 1 (moderate). safesearch (int): Safe search filter for safer web results; 0 = off, 1 = moderate, 2 = strict. Defaults to 1 (moderate).
time_range (str): Time range for filtering results by date; e.g., "2023-04-05..today" or "all-time". Defaults to ''. time_range (str): Time range for filtering results by date; e.g., "2023-04-05..today" or "all-time". Defaults to ''.
categories: (Optional[list[str]]): Specific categories within which the search should be performed, defaulting to an empty string if not provided. categories: (Optional[list[str]]): Specific categories within which the search should be performed, defaulting to an empty string if not provided.
@ -40,7 +38,7 @@ def search_searxng(
""" """
# Default values for optional parameters are provided as empty strings or None when not specified. # Default values for optional parameters are provided as empty strings or None when not specified.
language = kwargs.get("language", "en-US") language = kwargs.get("language", "all")
safesearch = kwargs.get("safesearch", "1") safesearch = kwargs.get("safesearch", "1")
time_range = kwargs.get("time_range", "") time_range = kwargs.get("time_range", "")
categories = "".join(kwargs.get("categories", [])) categories = "".join(kwargs.get("categories", []))

View file

@ -4,10 +4,8 @@ from urllib.parse import urlencode
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_serpapi( def search_serpapi(

View file

@ -4,10 +4,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_serper( def search_serper(

View file

@ -4,10 +4,8 @@ from urllib.parse import urlencode
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_serply( def search_serply(

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_serpstack( def search_serpstack(

View file

@ -4,10 +4,8 @@ from typing import Optional, List
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_sougou( def search_sougou(

View file

@ -3,10 +3,8 @@ from typing import Optional
import requests import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_tavily( def search_tavily(

View file

@ -33,6 +33,7 @@ from open_webui.config import (
PLAYWRIGHT_WS_URL, PLAYWRIGHT_WS_URL,
PLAYWRIGHT_TIMEOUT, PLAYWRIGHT_TIMEOUT,
WEB_LOADER_ENGINE, WEB_LOADER_ENGINE,
WEB_LOADER_TIMEOUT,
FIRECRAWL_API_BASE_URL, FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
TAVILY_API_KEY, TAVILY_API_KEY,
@ -41,11 +42,9 @@ from open_webui.config import (
EXTERNAL_WEB_LOADER_API_KEY, EXTERNAL_WEB_LOADER_API_KEY,
WEB_FETCH_FILTER_LIST, WEB_FETCH_FILTER_LIST,
) )
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.misc import is_string_allowed from open_webui.utils.misc import is_string_allowed
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def resolve_hostname(hostname): def resolve_hostname(hostname):
@ -674,6 +673,20 @@ def get_web_loader(
if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web": if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web":
WebLoaderClass = SafeWebBaseLoader WebLoaderClass = SafeWebBaseLoader
request_kwargs = {}
if WEB_LOADER_TIMEOUT.value:
try:
timeout_value = float(WEB_LOADER_TIMEOUT.value)
except ValueError:
timeout_value = None
if timeout_value:
request_kwargs["timeout"] = timeout_value
if request_kwargs:
web_loader_args["requests_kwargs"] = request_kwargs
if WEB_LOADER_ENGINE.value == "playwright": if WEB_LOADER_ENGINE.value == "playwright":
WebLoaderClass = SafePlaywrightURLLoader WebLoaderClass = SafePlaywrightURLLoader
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value

View file

@ -4,10 +4,8 @@ from typing import Optional
import requests import requests
from requests.auth import HTTPDigestAuth from requests.auth import HTTPDigestAuth
from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_yacy( def search_yacy(

View file

@ -33,6 +33,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.config import ( from open_webui.config import (
@ -48,7 +49,6 @@ from open_webui.env import (
ENV, ENV,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT,
SRC_LOG_LEVELS,
DEVICE_TYPE, DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_FORWARD_USER_INFO_HEADERS,
) )
@ -63,7 +63,6 @@ AZURE_MAX_FILE_SIZE_MB = 200
AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["AUDIO"])
SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech"
SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@ -1155,17 +1154,9 @@ def transcription(
stt_supported_content_types = getattr( stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
) ) or ["audio/*", "video/webm"]
if not any( if not strict_match_mime_type(stt_supported_content_types, file.content_type):
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,

View file

@ -37,7 +37,6 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL, WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP, ENABLE_INITIAL_ADMIN_SIGNUP,
SRC_LOG_LEVELS,
) )
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse from fastapi.responses import RedirectResponse, Response, JSONResponse
@ -81,7 +80,6 @@ from ldap3.utils.conv import escape_filter_chars
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
signin_rate_limiter = RateLimiter( signin_rate_limiter = RateLimiter(
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3 redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
@ -288,13 +286,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
f"{LDAP_ATTRIBUTE_FOR_MAIL}", f"{LDAP_ATTRIBUTE_FOR_MAIL}",
"cn", "cn",
] ]
if ENABLE_LDAP_GROUP_MANAGEMENT: if ENABLE_LDAP_GROUP_MANAGEMENT:
search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}") search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}")
log.info( log.info(
f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes" f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes"
) )
log.info(f"LDAP search attributes: {search_attributes}") log.info(f"LDAP search attributes: {search_attributes}")
search_success = connection_app.search( search_success = connection_app.search(
@ -302,15 +298,22 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})", search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=search_attributes, attributes=search_attributes,
) )
if not search_success or not connection_app.entries: if not search_success or not connection_app.entries:
raise HTTPException(400, detail="User not found in the LDAP server") raise HTTPException(400, detail="User not found in the LDAP server")
entry = connection_app.entries[0] entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() entry_username = entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"].value
email = entry[ email = entry[
f"{LDAP_ATTRIBUTE_FOR_MAIL}" f"{LDAP_ATTRIBUTE_FOR_MAIL}"
].value # retrieve the Attribute value ].value # retrieve the Attribute value
username_list = [] # list of usernames from LDAP attribute
if isinstance(entry_username, list):
username_list = [str(name).lower() for name in entry_username]
else:
username_list = [str(entry_username).lower()]
# TODO: support multiple emails if LDAP returns a list
if not email: if not email:
raise HTTPException(400, "User does not have a valid email address.") raise HTTPException(400, "User does not have a valid email address.")
elif isinstance(email, str): elif isinstance(email, str):
@ -320,13 +323,13 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
else: else:
email = str(email).lower() email = str(email).lower()
cn = str(entry["cn"]) cn = str(entry["cn"]) # common name
user_dn = entry.entry_dn user_dn = entry.entry_dn # user distinguished name
user_groups = [] user_groups = []
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry: if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS] group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
log.info(f"LDAP raw group DNs for user {username}: {group_dns}") log.info(f"LDAP raw group DNs for user {username_list}: {group_dns}")
if group_dns: if group_dns:
log.info(f"LDAP group_dns original: {group_dns}") log.info(f"LDAP group_dns original: {group_dns}")
@ -377,16 +380,16 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
) )
log.info( log.info(
f"LDAP groups for user {username}: {user_groups} (total: {len(user_groups)})" f"LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})"
) )
else: else:
log.info(f"No groups found for user {username}") log.info(f"No groups found for user {username_list}")
elif ENABLE_LDAP_GROUP_MANAGEMENT: elif ENABLE_LDAP_GROUP_MANAGEMENT:
log.warning( log.warning(
f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry" f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry"
) )
if username == form_data.user.lower(): if username_list and form_data.user.lower() in username_list:
connection_user = Connection( connection_user = Connection(
server, server,
user_dn, user_dn,

View file

@ -5,7 +5,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel from pydantic import BaseModel
from pydantic import field_validator
from open_webui.socket.main import ( from open_webui.socket.main import (
emit_to_users, emit_to_users,
@ -39,9 +39,10 @@ from open_webui.models.messages import (
) )
from open_webui.utils.files import get_image_base64_from_file_id
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.models import ( from open_webui.utils.models import (
@ -62,10 +63,24 @@ from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions from open_webui.utils.channels import extract_mentions, replace_mentions
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
############################
# Channels Enabled Dependency
############################
def check_channels_access(request: Request):
"""Dependency to ensure channels are globally enabled."""
if not request.app.state.config.ENABLE_CHANNELS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Channels are not enabled",
)
############################ ############################
# GetChatList # GetChatList
############################ ############################
@ -80,7 +95,11 @@ class ChannelListItemResponse(ChannelModel):
@router.get("/", response_model=list[ChannelListItemResponse]) @router.get("/", response_model=list[ChannelListItemResponse])
async def get_channels(request: Request, user=Depends(get_verified_user)): async def get_channels(
request: Request,
user=Depends(get_verified_user),
):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -132,7 +151,11 @@ async def get_channels(request: Request, user=Depends(get_verified_user)):
@router.get("/list", response_model=list[ChannelModel]) @router.get("/list", response_model=list[ChannelModel])
async def get_all_channels(user=Depends(get_verified_user)): async def get_all_channels(
request: Request,
user=Depends(get_verified_user),
):
check_channels_access(request)
if user.role == "admin": if user.role == "admin":
return Channels.get_channels() return Channels.get_channels()
return Channels.get_channels_by_user_id(user.id) return Channels.get_channels_by_user_id(user.id)
@ -145,8 +168,11 @@ async def get_all_channels(user=Depends(get_verified_user)):
@router.get("/users/{user_id}", response_model=Optional[ChannelModel]) @router.get("/users/{user_id}", response_model=Optional[ChannelModel])
async def get_dm_channel_by_user_id( async def get_dm_channel_by_user_id(
request: Request, user_id: str, user=Depends(get_verified_user) request: Request,
user_id: str,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -214,8 +240,11 @@ async def get_dm_channel_by_user_id(
@router.post("/create", response_model=Optional[ChannelModel]) @router.post("/create", response_model=Optional[ChannelModel])
async def create_new_channel( async def create_new_channel(
request: Request, form_data: CreateChannelForm, user=Depends(get_verified_user) request: Request,
form_data: CreateChannelForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -294,7 +323,12 @@ class ChannelFullResponse(ChannelResponse):
@router.get("/{id}", response_model=Optional[ChannelFullResponse]) @router.get("/{id}", response_model=Optional[ChannelFullResponse])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)): async def get_channel_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -381,6 +415,7 @@ PAGE_ITEM_COUNT = 30
@router.get("/{id}/members", response_model=UserListResponse) @router.get("/{id}/members", response_model=UserListResponse)
async def get_channel_members_by_id( async def get_channel_members_by_id(
request: Request,
id: str, id: str,
query: Optional[str] = None, query: Optional[str] = None,
order_by: Optional[str] = None, order_by: Optional[str] = None,
@ -388,6 +423,7 @@ async def get_channel_members_by_id(
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
@ -470,10 +506,12 @@ class UpdateActiveMemberForm(BaseModel):
@router.post("/{id}/members/active", response_model=bool) @router.post("/{id}/members/active", response_model=bool)
async def update_is_active_member_by_id_and_user_id( async def update_is_active_member_by_id_and_user_id(
request: Request,
id: str, id: str,
form_data: UpdateActiveMemberForm, form_data: UpdateActiveMemberForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -506,6 +544,7 @@ async def add_members_by_id(
form_data: UpdateMembersForm, form_data: UpdateMembersForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -554,6 +593,7 @@ async def remove_members_by_id(
form_data: RemoveMembersForm, form_data: RemoveMembersForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -591,8 +631,12 @@ async def remove_members_by_id(
@router.post("/{id}/update", response_model=Optional[ChannelModel]) @router.post("/{id}/update", response_model=Optional[ChannelModel])
async def update_channel_by_id( async def update_channel_by_id(
request: Request, id: str, form_data: ChannelForm, user=Depends(get_verified_user) request: Request,
id: str,
form_data: ChannelForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -629,8 +673,11 @@ async def update_channel_by_id(
@router.delete("/{id}/delete", response_model=bool) @router.delete("/{id}/delete", response_model=bool)
async def delete_channel_by_id( async def delete_channel_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request,
id: str,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
): ):
@ -666,13 +713,27 @@ async def delete_channel_by_id(
class MessageUserResponse(MessageResponse): class MessageUserResponse(MessageResponse):
pass data: bool | None = None
@field_validator("data", mode="before")
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
return False
# True if ANY value in the dict is non-empty
return any(bool(val) for val in v.values())
@router.get("/{id}/messages", response_model=list[MessageUserResponse]) @router.get("/{id}/messages", response_model=list[MessageUserResponse])
async def get_channel_messages( async def get_channel_messages(
id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user) request: Request,
id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -734,8 +795,12 @@ PAGE_ITEM_COUNT_PINNED = 20
@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) @router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse])
async def get_pinned_channel_messages( async def get_pinned_channel_messages(
id: str, page: int = 1, user=Depends(get_verified_user) request: Request,
id: str,
page: int = 1,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -906,6 +971,10 @@ async def model_response_handler(request, channel, message, user):
for file in thread_message_files: for file in thread_message_files:
if file.get("type", "") == "image": if file.get("type", "") == "image":
images.append(file.get("url", "")) images.append(file.get("url", ""))
elif file.get("content_type", "").startswith("image/"):
image = get_image_base64_from_file_id(file.get("id", ""))
if image:
images.append(image)
thread_history_string = "\n\n".join(thread_history) thread_history_string = "\n\n".join(thread_history)
system_message = { system_message = {
@ -1075,9 +1144,19 @@ async def post_new_message(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
try: try:
message, channel = await new_message_handler(request, id, form_data, user) message, channel = await new_message_handler(request, id, form_data, user)
try:
if files := message.data.get("files", []):
for file in files:
Channels.set_file_message_id_in_channel_by_id(
channel.id, file.get("id", ""), message.id
)
except Exception as e:
log.debug(e)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
async def background_handler(): async def background_handler():
@ -1108,10 +1187,14 @@ async def post_new_message(
############################ ############################
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse]) @router.get("/{id}/messages/{message_id}", response_model=Optional[MessageResponse])
async def get_channel_message( async def get_channel_message(
id: str, message_id: str, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1142,7 +1225,7 @@ async def get_channel_message(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
) )
return MessageUserResponse( return MessageResponse(
**{ **{
**message.model_dump(), **message.model_dump(),
"user": UserNameResponse( "user": UserNameResponse(
@ -1152,6 +1235,52 @@ async def get_channel_message(
) )
############################
# GetChannelMessageData
############################
@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict])
async def get_channel_message_data(
request: Request,
id: str,
message_id: str,
user=Depends(get_verified_user),
):
check_channels_access(request)
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return message.data
############################ ############################
# PinChannelMessage # PinChannelMessage
############################ ############################
@ -1165,8 +1294,13 @@ class PinMessageForm(BaseModel):
"/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse]
) )
async def pin_channel_message( async def pin_channel_message(
id: str, message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
form_data: PinMessageForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1224,12 +1358,14 @@ async def pin_channel_message(
"/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse] "/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse]
) )
async def get_channel_thread_messages( async def get_channel_thread_messages(
request: Request,
id: str, id: str,
message_id: str, message_id: str,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1282,8 +1418,13 @@ async def get_channel_thread_messages(
"/{id}/messages/{message_id}/update", response_model=Optional[MessageModel] "/{id}/messages/{message_id}/update", response_model=Optional[MessageModel]
) )
async def update_message_by_id( async def update_message_by_id(
id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
form_data: MessageForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1357,8 +1498,13 @@ class ReactionForm(BaseModel):
@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool) @router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
async def add_reaction_to_message( async def add_reaction_to_message(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
form_data: ReactionForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1426,8 +1572,13 @@ async def add_reaction_to_message(
@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool) @router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
async def remove_reaction_by_id_and_user_id_and_name( async def remove_reaction_by_id_and_user_id_and_name(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
form_data: ReactionForm,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(
@ -1498,8 +1649,12 @@ async def remove_reaction_by_id_and_user_id_and_name(
@router.delete("/{id}/messages/{message_id}/delete", response_model=bool) @router.delete("/{id}/messages/{message_id}/delete", response_model=bool)
async def delete_message_by_id( async def delete_message_by_id(
id: str, message_id: str, user=Depends(get_verified_user) request: Request,
id: str,
message_id: str,
user=Depends(get_verified_user),
): ):
check_channels_access(request)
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
raise HTTPException( raise HTTPException(

View file

@ -3,10 +3,12 @@ import logging
from typing import Optional from typing import Optional
from open_webui.utils.misc import get_message_list
from open_webui.socket.main import get_event_emitter from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import ( from open_webui.models.chats import (
ChatForm, ChatForm,
ChatImportForm, ChatImportForm,
ChatUsageStatsListResponse,
ChatsImportForm, ChatsImportForm,
ChatResponse, ChatResponse,
Chats, Chats,
@ -17,7 +19,6 @@ from open_webui.models.folders import Folders
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
@ -26,7 +27,6 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
@ -66,6 +66,132 @@ def get_session_user_chat_list(
) )
############################
# GetChatUsageStats
# EXPERIMENTAL: may be removed in future releases
############################
@router.get("/stats/usage", response_model=ChatUsageStatsListResponse)
def get_session_user_chat_usage_stats(
items_per_page: Optional[int] = 50,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
try:
limit = items_per_page
skip = (page - 1) * limit
result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit)
chats = result.items
total = result.total
chat_stats = []
for chat in chats:
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
if messages_map and message_id:
try:
history_models = {}
history_message_count = len(messages_map)
history_user_messages = []
history_assistant_messages = []
for message in messages_map.values():
if message.get("role", "") == "user":
history_user_messages.append(message)
elif message.get("role", "") == "assistant":
history_assistant_messages.append(message)
model = message.get("model", None)
if model:
if model not in history_models:
history_models[model] = 0
history_models[model] += 1
average_user_message_content_length = (
sum(
len(message.get("content", ""))
for message in history_user_messages
)
/ len(history_user_messages)
if len(history_user_messages) > 0
else 0
)
average_assistant_message_content_length = (
sum(
len(message.get("content", ""))
for message in history_assistant_messages
)
/ len(history_assistant_messages)
if len(history_assistant_messages) > 0
else 0
)
response_times = []
for message in history_assistant_messages:
user_message_id = message.get("parentId", None)
if user_message_id and user_message_id in messages_map:
user_message = messages_map[user_message_id]
response_time = message.get(
"timestamp", 0
) - user_message.get("timestamp", 0)
response_times.append(response_time)
average_response_time = (
sum(response_times) / len(response_times)
if len(response_times) > 0
else 0
)
message_list = get_message_list(messages_map, message_id)
message_count = len(message_list)
models = {}
for message in reversed(message_list):
if message.get("role") == "assistant":
model = message.get("model", None)
if model:
if model not in models:
models[model] = 0
models[model] += 1
annotation = message.get("annotation", {})
chat_stats.append(
{
"id": chat.id,
"models": models,
"message_count": message_count,
"history_models": history_models,
"history_message_count": history_message_count,
"history_user_message_count": len(history_user_messages),
"history_assistant_message_count": len(
history_assistant_messages
),
"average_response_time": average_response_time,
"average_user_message_content_length": average_user_message_content_length,
"average_assistant_message_content_length": average_assistant_message_content_length,
"tags": chat.meta.get("tags", []),
"last_message_at": message_list[-1].get("timestamp", None),
"updated_at": chat.updated_at,
"created_at": chat.created_at,
}
)
except Exception as e:
pass
return ChatUsageStatsListResponse(items=chat_stats, total=total)
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# DeleteAllChats # DeleteAllChats
############################ ############################

View file

@ -18,7 +18,6 @@ from open_webui.utils.tools import (
from open_webui.utils.mcp.client import MCPClient from open_webui.utils.mcp.client import MCPClient
from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.oauth import ( from open_webui.utils.oauth import (
get_discovery_urls, get_discovery_urls,
@ -32,7 +31,6 @@ from mcp.shared.auth import OAuthMetadata
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
############################ ############################

View file

@ -4,6 +4,7 @@ from pydantic import BaseModel
from open_webui.models.users import Users, UserModel from open_webui.models.users import Users, UserModel
from open_webui.models.feedbacks import ( from open_webui.models.feedbacks import (
FeedbackIdResponse,
FeedbackModel, FeedbackModel,
FeedbackResponse, FeedbackResponse,
FeedbackForm, FeedbackForm,
@ -64,6 +65,12 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
return feedbacks return feedbacks
@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse])
async def get_all_feedback_ids(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return feedbacks
@router.delete("/feedbacks/all") @router.delete("/feedbacks/all")
async def delete_all_feedbacks(user=Depends(get_admin_user)): async def delete_all_feedbacks(user=Depends(get_admin_user)):
success = Feedbacks.delete_all_feedbacks() success = Feedbacks.delete_all_feedbacks()
@ -71,7 +78,7 @@ async def delete_all_feedbacks(user=Depends(get_admin_user)):
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) @router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
async def get_all_feedbacks(user=Depends(get_admin_user)): async def export_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks() feedbacks = Feedbacks.get_all_feedbacks()
return feedbacks return feedbacks

View file

@ -24,9 +24,9 @@ from fastapi import (
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.channels import Channels
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.models.files import ( from open_webui.models.files import (
FileForm, FileForm,
@ -34,11 +34,11 @@ from open_webui.models.files import (
FileModelResponse, FileModelResponse,
Files, Files,
) )
from open_webui.models.chats import Chats
from open_webui.models.knowledge import Knowledges from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
from open_webui.routers.retrieval import ProcessFileForm, process_file from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe from open_webui.routers.audio import transcribe
@ -47,11 +47,10 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel from pydantic import BaseModel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
@ -73,9 +72,9 @@ def has_access_to_file(
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id) knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
for knowledge_base in knowledge_bases: for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access( if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids user.id, access_type, knowledge_base.access_control, user_group_ids
@ -91,6 +90,17 @@ def has_access_to_file(
if knowledge_base.id == knowledge_base_id: if knowledge_base.id == knowledge_base_id:
return True return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id)
if access_type == "read" and channels:
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id)
if chats:
return True
return False return False
@ -104,17 +114,9 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
if file.content_type: if file.content_type:
stt_supported_content_types = getattr( stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
) ) or ["audio/*", "video/webm"]
if any( if strict_match_mime_type(stt_supported_content_types, file.content_type):
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
file_path = Storage.get_file(file_path) file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata, user) result = transcribe(request, file_path, file_metadata, user)
@ -138,6 +140,7 @@ 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" 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) process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e: except Exception as e:
log.error(f"Error processing file: {file_item.id}") log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id( Files.update_file_data_by_id(
@ -179,7 +182,7 @@ def upload_file_handler(
user=Depends(get_verified_user), user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None, background_tasks: Optional[BackgroundTasks] = None,
): ):
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type} {process}")
if isinstance(metadata, str): if isinstance(metadata, str):
try: try:
@ -247,6 +250,13 @@ def upload_file_handler(
), ),
) )
if "channel_id" in file_metadata:
channel = Channels.get_channel_by_id_and_user_id(
file_metadata["channel_id"], user.id
)
if channel:
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id)
if process: if process:
if background_tasks and process_in_background: if background_tasks and process_in_background:
background_tasks.add_task( background_tasks.add_task(

View file

@ -21,7 +21,6 @@ from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@ -34,7 +33,6 @@ from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()

View file

@ -23,12 +23,10 @@ from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()

View file

@ -17,11 +17,9 @@ from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()

View file

@ -16,7 +16,9 @@ from fastapi.responses import FileResponse
from open_webui.config import CACHE_DIR from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats
from open_webui.routers.files import upload_file_handler, get_file_content_by_id from open_webui.routers.files import upload_file_handler, get_file_content_by_id
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
@ -31,7 +33,6 @@ from open_webui.utils.images.comfyui import (
from pydantic import BaseModel from pydantic import BaseModel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["IMAGES"])
IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations"
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
@ -196,12 +197,12 @@ async def update_config(
set_image_model(request, form_data.IMAGE_GENERATION_MODEL) set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if ( if (
form_data.IMAGE_SIZE == "auto" form_data.IMAGE_SIZE == "auto"
and form_data.IMAGE_GENERATION_MODEL != "gpt-image-1" and not form_data.IMAGE_GENERATION_MODEL.startswith("gpt-image")
): ):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=ERROR_MESSAGES.INCORRECT_FORMAT( detail=ERROR_MESSAGES.INCORRECT_FORMAT(
" (auto is only allowed with gpt-image-1)." " (auto is only allowed with gpt-image models)."
), ),
) )
@ -380,6 +381,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
{"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-2", "name": "DALL·E 2"},
{"id": "dall-e-3", "name": "DALL·E 3"}, {"id": "dall-e-3", "name": "DALL·E 3"},
{"id": "gpt-image-1", "name": "GPT-IMAGE 1"}, {"id": "gpt-image-1", "name": "GPT-IMAGE 1"},
{"id": "gpt-image-1.5", "name": "GPT-IMAGE 1.5"},
] ]
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini":
return [ return [
@ -510,14 +512,29 @@ def upload_image(request, image_data, content_type, metadata, user):
process=False, process=False,
user=user, user=user,
) )
if file_item and file_item.id:
# If chat_id and message_id are provided in metadata, link the file to the chat message
chat_id = metadata.get("chat_id")
message_id = metadata.get("message_id")
if chat_id and message_id:
Chats.insert_chat_files(
chat_id=chat_id,
message_id=message_id,
file_ids=[file_item.id],
user_id=user.id,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url return file_item, url
@router.post("/generations") @router.post("/generations")
async def image_generations( async def image_generations(
request: Request, request: Request,
form_data: CreateImageForm, form_data: CreateImageForm,
metadata: Optional[dict] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
# if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
@ -535,6 +552,9 @@ async def image_generations(
size = form_data.size size = form_data.size
width, height = tuple(map(int, size.split("x"))) width, height = tuple(map(int, size.split("x")))
metadata = metadata or {}
model = get_image_model(request) model = get_image_model(request)
r = None r = None
@ -564,7 +584,9 @@ async def image_generations(
), ),
**( **(
{} {}
if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL.startswith(
"gpt-image"
)
else {"response_format": "b64_json"} else {"response_format": "b64_json"}
), ),
**( **(
@ -593,7 +615,9 @@ async def image_generations(
else: else:
image_data, content_type = get_image_data(image["b64_json"]) image_data, content_type = get_image_data(image["b64_json"])
url = upload_image(request, image_data, content_type, data, user) _, url = upload_image(
request, image_data, content_type, {**data, **metadata}, user
)
images.append({"url": url}) images.append({"url": url})
return images return images
@ -643,7 +667,9 @@ async def image_generations(
image_data, content_type = get_image_data( image_data, content_type = get_image_data(
image["bytesBase64Encoded"] image["bytesBase64Encoded"]
) )
url = upload_image(request, image_data, content_type, data, user) _, url = upload_image(
request, image_data, content_type, {**data, **metadata}, user
)
images.append({"url": url}) images.append({"url": url})
elif model.endswith(":generateContent"): elif model.endswith(":generateContent"):
for image in res["candidates"]: for image in res["candidates"]:
@ -652,8 +678,12 @@ async def image_generations(
image_data, content_type = get_image_data( image_data, content_type = get_image_data(
part["inlineData"]["data"] part["inlineData"]["data"]
) )
url = upload_image( _, url = upload_image(
request, image_data, content_type, data, user request,
image_data,
content_type,
{**data, **metadata},
user,
) )
images.append({"url": url}) images.append({"url": url})
@ -703,11 +733,11 @@ async def image_generations(
} }
image_data, content_type = get_image_data(image["url"], headers) image_data, content_type = get_image_data(image["url"], headers)
url = upload_image( _, url = upload_image(
request, request,
image_data, image_data,
content_type, content_type,
form_data.model_dump(exclude_none=True), {**form_data.model_dump(exclude_none=True), **metadata},
user, user,
) )
images.append({"url": url}) images.append({"url": url})
@ -750,11 +780,11 @@ async def image_generations(
for image in res["images"]: for image in res["images"]:
image_data, content_type = get_image_data(image) image_data, content_type = get_image_data(image)
url = upload_image( _, url = upload_image(
request, request,
image_data, image_data,
content_type, content_type,
{**data, "info": res["info"]}, {**data, "info": res["info"], **metadata},
user, user,
) )
images.append({"url": url}) images.append({"url": url})
@ -781,10 +811,13 @@ class EditImageForm(BaseModel):
async def image_edits( async def image_edits(
request: Request, request: Request,
form_data: EditImageForm, form_data: EditImageForm,
metadata: Optional[dict] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
size = None size = None
width, height = None, None width, height = None, None
metadata = metadata or {}
if ( if (
request.app.state.config.IMAGE_EDIT_SIZE request.app.state.config.IMAGE_EDIT_SIZE
and "x" in request.app.state.config.IMAGE_EDIT_SIZE and "x" in request.app.state.config.IMAGE_EDIT_SIZE
@ -867,7 +900,7 @@ async def image_edits(
**({"size": size} if size else {}), **({"size": size} if size else {}),
**( **(
{} {}
if "gpt-image-1" in request.app.state.config.IMAGE_EDIT_MODEL if request.app.state.config.IMAGE_EDIT_MODEL.startswith("gpt-image")
else {"response_format": "b64_json"} else {"response_format": "b64_json"}
), ),
} }
@ -902,7 +935,9 @@ async def image_edits(
else: else:
image_data, content_type = get_image_data(image["b64_json"]) image_data, content_type = get_image_data(image["b64_json"])
url = upload_image(request, image_data, content_type, data, user) _, url = upload_image(
request, image_data, content_type, {**data, **metadata}, user
)
images.append({"url": url}) images.append({"url": url})
return images return images
@ -955,8 +990,12 @@ async def image_edits(
image_data, content_type = get_image_data( image_data, content_type = get_image_data(
part["inlineData"]["data"] part["inlineData"]["data"]
) )
url = upload_image( _, url = upload_image(
request, image_data, content_type, data, user request,
image_data,
content_type,
{**data, **metadata},
user,
) )
images.append({"url": url}) images.append({"url": url})
@ -1033,11 +1072,11 @@ async def image_edits(
} }
image_data, content_type = get_image_data(image_url, headers) image_data, content_type = get_image_data(image_url, headers)
url = upload_image( _, url = upload_image(
request, request,
image_data, image_data,
content_type, content_type,
form_data.model_dump(exclude_none=True), {**form_data.model_dump(exclude_none=True), **metadata},
user, user,
) )
images.append({"url": url}) images.append({"url": url})

View file

@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
import logging import logging
from open_webui.models.groups import Groups
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
KnowledgeFileListResponse,
Knowledges, Knowledges,
KnowledgeForm, KnowledgeForm,
KnowledgeResponse, KnowledgeResponse,
@ -25,13 +27,11 @@ from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.models.models import Models, ModelForm from open_webui.models.models import Models, ModelForm
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
@ -39,41 +39,115 @@ router = APIRouter()
# getKnowledgeBases # getKnowledgeBases
############################ ############################
PAGE_ITEM_COUNT = 30
@router.get("/", response_model=list[KnowledgeUserResponse])
async def get_knowledge(user=Depends(get_verified_user)):
# Return knowledge bases with read access
knowledge_bases = []
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
return [
KnowledgeUserResponse(
**knowledge_base.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
)
for knowledge_base in knowledge_bases
]
@router.get("/list", response_model=list[KnowledgeUserResponse]) class KnowledgeAccessResponse(KnowledgeUserResponse):
async def get_knowledge_list(user=Depends(get_verified_user)): write_access: Optional[bool] = False
# Return knowledge bases with write access
knowledge_bases = []
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
return [
KnowledgeUserResponse( class KnowledgeAccessListResponse(BaseModel):
**knowledge_base.model_dump(), items: list[KnowledgeAccessResponse]
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), total: int
)
for knowledge_base in knowledge_bases
] @router.get("/", response_model=KnowledgeAccessListResponse)
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
),
)
for knowledge_base in result.items
],
total=result.total,
)
@router.get("/search", response_model=KnowledgeAccessListResponse)
async def search_knowledge_bases(
query: Optional[str] = None,
view_option: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
),
)
for knowledge_base in result.items
],
total=result.total,
)
@router.get("/search/files", response_model=KnowledgeFileListResponse)
async def search_knowledge_files(
query: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit)
############################ ############################
@ -185,7 +259,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
class KnowledgeFilesResponse(KnowledgeResponse): class KnowledgeFilesResponse(KnowledgeResponse):
files: list[FileMetadataResponse] files: Optional[list[FileMetadataResponse]] = None
write_access: Optional[bool] = False
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
@ -201,7 +276,10 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), write_access=(
user.id == knowledge.user_id
or has_access(user.id, "write", knowledge.access_control)
),
) )
else: else:
raise HTTPException( raise HTTPException(
@ -264,6 +342,59 @@ async def update_knowledge_by_id(
) )
############################
# GetKnowledgeFilesById
############################
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
async def get_knowledge_files_by_id(
id: str,
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
page = max(page, 1)
limit = 30
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Knowledges.search_files_by_id(
id, user.id, filter=filter, skip=skip, limit=limit
)
############################ ############################
# AddFileToKnowledge # AddFileToKnowledge
############################ ############################
@ -309,11 +440,6 @@ def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED, detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
) )
# Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
)
# Add content to the vector database # Add content to the vector database
try: try:
process_file( process_file(
@ -321,6 +447,11 @@ def add_file_to_knowledge_by_id(
ProcessFileForm(file_id=form_data.file_id, collection_name=id), ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user, user=user,
) )
# Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
)
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
raise HTTPException( raise HTTPException(

View file

@ -7,11 +7,9 @@ from typing import Optional
from open_webui.models.memories import Memories, MemoryModel from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()

View file

@ -291,12 +291,15 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/model/profile/image") @router.get("/model/profile/image")
async def get_model_profile_image(id: str, user=Depends(get_verified_user)): async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id) model = Models.get_model_by_id(id)
# Cache-control headers to prevent stale cached images
cache_headers = {"Cache-Control": "no-cache, must-revalidate"}
if model: if model:
if model.meta.profile_image_url: if model.meta.profile_image_url:
if model.meta.profile_image_url.startswith("http"): if model.meta.profile_image_url.startswith("http"):
return Response( return Response(
status_code=status.HTTP_302_FOUND, status_code=status.HTTP_302_FOUND,
headers={"Location": model.meta.profile_image_url}, headers={"Location": model.meta.profile_image_url, **cache_headers},
) )
elif model.meta.profile_image_url.startswith("data:image"): elif model.meta.profile_image_url.startswith("data:image"):
try: try:
@ -307,14 +310,17 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
return StreamingResponse( return StreamingResponse(
image_buffer, image_buffer,
media_type="image/png", media_type="image/png",
headers={"Content-Disposition": "inline; filename=image.png"}, headers={
"Content-Disposition": "inline; filename=image.png",
**cache_headers,
},
) )
except Exception as e: except Exception as e:
pass pass
return FileResponse(f"{STATIC_DIR}/favicon.png") return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers)
else: else:
return FileResponse(f"{STATIC_DIR}/favicon.png") return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers)
############################ ############################

View file

@ -8,20 +8,28 @@ from pydantic import BaseModel
from open_webui.socket.main import sio from open_webui.socket.main import sio
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse from open_webui.models.notes import (
NoteListResponse,
Notes,
NoteModel,
NoteForm,
NoteUserResponse,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
)
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
@ -30,39 +38,17 @@ router = APIRouter()
############################ ############################
@router.get("/", response_model=list[NoteUserResponse]) class NoteItemResponse(BaseModel):
async def get_notes(request: Request, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_permission(user.id, "write")
]
return notes
class NoteTitleIdResponse(BaseModel):
id: str id: str
title: str title: str
data: Optional[dict]
updated_at: int updated_at: int
created_at: int created_at: int
user: Optional[UserResponse] = None
@router.get("/list", response_model=list[NoteTitleIdResponse]) @router.get("/", response_model=list[NoteItemResponse])
async def get_note_list( async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user) request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
@ -80,15 +66,64 @@ async def get_note_list(
skip = (page - 1) * limit skip = (page - 1) * limit
notes = [ notes = [
NoteTitleIdResponse(**note.model_dump()) NoteUserResponse(
for note in Notes.get_notes_by_permission( **{
user.id, "write", skip=skip, limit=limit **note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
) )
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit)
] ]
return notes return notes
@router.get("/search", response_model=NoteListResponse)
async def search_notes(
request: Request,
query: Optional[str] = None,
view_option: Optional[str] = None,
permission: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
limit = None
skip = None
if page is not None:
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if permission:
filter["permission"] = permission
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit)
############################ ############################
# CreateNewNote # CreateNewNote
############################ ############################
@ -98,7 +133,6 @@ async def get_note_list(
async def create_new_note( async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user) request: Request, form_data: NoteForm, user=Depends(get_verified_user)
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
): ):
@ -122,7 +156,11 @@ async def create_new_note(
############################ ############################
@router.get("/{id}", response_model=Optional[NoteModel]) class NoteResponse(NoteModel):
write_access: bool = False
@router.get("/{id}", response_model=Optional[NoteResponse])
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
@ -146,7 +184,15 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
return note write_access = (
user.role == "admin"
or (user.id == note.user_id)
or has_access(
user.id, type="write", access_control=note.access_control, strict=False
)
)
return NoteResponse(**note.model_dump(), write_access=write_access)
############################ ############################

View file

@ -58,7 +58,6 @@ from open_webui.config import (
) )
from open_webui.env import ( from open_webui.env import (
ENV, ENV,
SRC_LOG_LEVELS,
MODELS_CACHE_TTL, MODELS_CACHE_TTL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT,
@ -68,7 +67,6 @@ from open_webui.env import (
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OLLAMA"])
########################################## ##########################################
@ -1280,7 +1278,12 @@ async def generate_chat_completion(
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
payload["model"] = model_info.base_model_id base_model_id = (
request.base_model_id
if hasattr(request, "base_model_id")
else model_info.base_model_id
) # Use request's base_model_id if available
payload["model"] = base_model_id
params = model_info.params.model_dump() params = model_info.params.model_dump()

View file

@ -35,7 +35,6 @@ from open_webui.env import (
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.payload import ( from open_webui.utils.payload import (
@ -53,7 +52,6 @@ from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OPENAI"])
########################################## ##########################################
@ -814,8 +812,13 @@ async def generate_chat_completion(
# Check model info and override the payload # Check model info and override the payload
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
payload["model"] = model_info.base_model_id base_model_id = (
model_id = model_info.base_model_id request.base_model_id
if hasattr(request, "base_model_id")
else model_info.base_model_id
) # Use request's base_model_id if available
payload["model"] = base_model_id
model_id = base_model_id
params = model_info.params.model_dump() params = model_info.params.model_dump()
@ -891,10 +894,11 @@ async def generate_chat_completion(
del payload["max_tokens"] del payload["max_tokens"]
# Convert the modified body back to JSON # Convert the modified body back to JSON
if "logit_bias" in payload: if "logit_bias" in payload and payload["logit_bias"]:
payload["logit_bias"] = json.loads( logit_bias = convert_logit_bias_input_to_json(payload["logit_bias"])
convert_logit_bias_input_to_json(payload["logit_bias"])
) if logit_bias:
payload["logit_bias"] = json.loads(logit_bias)
headers, cookies = await get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, metadata, user=user request, url, key, api_config, metadata, user=user

View file

@ -18,7 +18,7 @@ from pydantic import BaseModel
from starlette.responses import FileResponse from starlette.responses import FileResponse
from typing import Optional from typing import Optional
from open_webui.env import SRC_LOG_LEVELS, AIOHTTP_CLIENT_SESSION_SSL from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.config import CACHE_DIR from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@ -28,7 +28,6 @@ from open_webui.routers.openai import get_all_models_responses
from open_webui.utils.auth import get_admin_user from open_webui.utils.auth import get_admin_user
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
################################## ##################################

View file

@ -14,6 +14,7 @@ from typing import Iterator, List, Optional, Sequence, Union
from fastapi import ( from fastapi import (
Depends, Depends,
FastAPI, FastAPI,
Query,
File, File,
Form, Form,
HTTPException, HTTPException,
@ -28,8 +29,11 @@ from pydantic import BaseModel
import tiktoken import tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter from langchain_text_splitters import (
from langchain_text_splitters import MarkdownHeaderTextSplitter RecursiveCharacterTextSplitter,
TokenTextSplitter,
MarkdownHeaderTextSplitter,
)
from langchain_core.documents import Document from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files from open_webui.models.files import FileModel, FileUpdateForm, Files
@ -84,6 +88,7 @@ from open_webui.retrieval.utils import (
from open_webui.retrieval.vector.utils import filter_metadata from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.utils.misc import ( from open_webui.utils.misc import (
calculate_sha256_string, calculate_sha256_string,
sanitize_text_for_db,
) )
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
@ -99,7 +104,6 @@ from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX, RAG_EMBEDDING_QUERY_PREFIX,
) )
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS,
DEVICE_TYPE, DEVICE_TYPE,
DOCKER, DOCKER,
SENTENCE_TRANSFORMERS_BACKEND, SENTENCE_TRANSFORMERS_BACKEND,
@ -111,7 +115,6 @@ from open_webui.env import (
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
########################################## ##########################################
# #
@ -148,9 +151,14 @@ def get_rf(
reranking_model: Optional[str] = None, reranking_model: Optional[str] = None,
external_reranker_url: str = "", external_reranker_url: str = "",
external_reranker_api_key: str = "", external_reranker_api_key: str = "",
external_reranker_timeout: str = "",
auto_update: bool = RAG_RERANKING_MODEL_AUTO_UPDATE, auto_update: bool = RAG_RERANKING_MODEL_AUTO_UPDATE,
): ):
rf = None rf = None
# Convert timeout string to int or None (system default)
timeout_value = (
int(external_reranker_timeout) if external_reranker_timeout else None
)
if reranking_model: if reranking_model:
if any(model in reranking_model for model in ["jinaai/jina-colbert-v2"]): if any(model in reranking_model for model in ["jinaai/jina-colbert-v2"]):
try: try:
@ -173,6 +181,7 @@ def get_rf(
url=external_reranker_url, url=external_reranker_url,
api_key=external_reranker_api_key, api_key=external_reranker_api_key,
model=reranking_model, model=reranking_model,
timeout=timeout_value,
) )
except Exception as e: except Exception as e:
log.error(f"ExternalReranking: {e}") log.error(f"ExternalReranking: {e}")
@ -475,12 +484,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
"MINERU_API_URL": request.app.state.config.MINERU_API_URL, "MINERU_API_URL": request.app.state.config.MINERU_API_URL,
"MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
"MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT,
"MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
# Reranking settings # Reranking settings
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
"RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
"RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
"RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
# Chunking settings # Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
@ -507,6 +518,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "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, "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, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"SEARXNG_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
"YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
@ -536,6 +548,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
@ -565,6 +578,7 @@ class WebConfig(BaseModel):
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
SEARXNG_QUERY_URL: Optional[str] = None SEARXNG_QUERY_URL: Optional[str] = None
SEARXNG_LANGUAGE: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None YACY_USERNAME: Optional[str] = None
YACY_PASSWORD: Optional[str] = None YACY_PASSWORD: Optional[str] = None
@ -594,6 +608,7 @@ class WebConfig(BaseModel):
SOUGOU_API_SID: Optional[str] = None SOUGOU_API_SID: Optional[str] = None
SOUGOU_API_SK: Optional[str] = None SOUGOU_API_SK: Optional[str] = None
WEB_LOADER_ENGINE: Optional[str] = None WEB_LOADER_ENGINE: Optional[str] = None
WEB_LOADER_TIMEOUT: Optional[str] = None
ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
PLAYWRIGHT_WS_URL: Optional[str] = None PLAYWRIGHT_WS_URL: Optional[str] = None
PLAYWRIGHT_TIMEOUT: Optional[int] = None PLAYWRIGHT_TIMEOUT: Optional[int] = None
@ -656,6 +671,7 @@ class ConfigForm(BaseModel):
MINERU_API_MODE: Optional[str] = None MINERU_API_MODE: Optional[str] = None
MINERU_API_URL: Optional[str] = None MINERU_API_URL: Optional[str] = None
MINERU_API_KEY: Optional[str] = None MINERU_API_KEY: Optional[str] = None
MINERU_API_TIMEOUT: Optional[str] = None
MINERU_PARAMS: Optional[dict] = None MINERU_PARAMS: Optional[dict] = None
# Reranking settings # Reranking settings
@ -663,6 +679,7 @@ class ConfigForm(BaseModel):
RAG_RERANKING_ENGINE: Optional[str] = None RAG_RERANKING_ENGINE: Optional[str] = None
RAG_EXTERNAL_RERANKER_URL: Optional[str] = None RAG_EXTERNAL_RERANKER_URL: Optional[str] = None
RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None
RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None
# Chunking settings # Chunking settings
TEXT_SPLITTER: Optional[str] = None TEXT_SPLITTER: Optional[str] = None
@ -877,6 +894,11 @@ async def update_rag_config(
if form_data.MINERU_API_KEY is not None if form_data.MINERU_API_KEY is not None
else request.app.state.config.MINERU_API_KEY else request.app.state.config.MINERU_API_KEY
) )
request.app.state.config.MINERU_API_TIMEOUT = (
form_data.MINERU_API_TIMEOUT
if form_data.MINERU_API_TIMEOUT is not None
else request.app.state.config.MINERU_API_TIMEOUT
)
request.app.state.config.MINERU_PARAMS = ( request.app.state.config.MINERU_PARAMS = (
form_data.MINERU_PARAMS form_data.MINERU_PARAMS
if form_data.MINERU_PARAMS is not None if form_data.MINERU_PARAMS is not None
@ -914,6 +936,12 @@ async def update_rag_config(
else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY else request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY
) )
request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = (
form_data.RAG_EXTERNAL_RERANKER_TIMEOUT
if form_data.RAG_EXTERNAL_RERANKER_TIMEOUT is not None
else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT
)
log.info( log.info(
f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}" f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}"
) )
@ -934,6 +962,7 @@ async def update_rag_config(
request.app.state.config.RAG_RERANKING_MODEL, request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.config.RAG_EXTERNAL_RERANKER_URL, request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
) )
request.app.state.RERANKING_FUNCTION = get_reranking_function( request.app.state.RERANKING_FUNCTION = get_reranking_function(
@ -1024,6 +1053,7 @@ async def update_rag_config(
form_data.web.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.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
request.app.state.config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
@ -1071,6 +1101,8 @@ async def update_rag_config(
# Web loader settings # Web loader settings
request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE
request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT
request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ( request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = (
form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION
) )
@ -1145,12 +1177,14 @@ async def update_rag_config(
"MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE,
"MINERU_API_URL": request.app.state.config.MINERU_API_URL, "MINERU_API_URL": request.app.state.config.MINERU_API_URL,
"MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY,
"MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT,
"MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS,
# Reranking settings # Reranking settings
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE,
"RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL,
"RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
"RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
# Chunking settings # Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
@ -1177,6 +1211,7 @@ async def update_rag_config(
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "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, "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, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"SEARXNG_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
"YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
@ -1206,6 +1241,7 @@ async def update_rag_config(
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
@ -1346,7 +1382,7 @@ def save_docs_to_vector_db(
if len(docs) == 0: if len(docs) == 0:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
texts = [doc.page_content for doc in docs] texts = [sanitize_text_for_db(doc.page_content) for doc in docs]
metadatas = [ metadatas = [
{ {
**doc.metadata, **doc.metadata,
@ -1401,6 +1437,7 @@ def save_docs_to_vector_db(
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None else None
), ),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
) )
# Run async embedding in sync context # Run async embedding in sync context
@ -1557,6 +1594,7 @@ def process_file(
MINERU_API_MODE=request.app.state.config.MINERU_API_MODE, MINERU_API_MODE=request.app.state.config.MINERU_API_MODE,
MINERU_API_URL=request.app.state.config.MINERU_API_URL, MINERU_API_URL=request.app.state.config.MINERU_API_URL,
MINERU_API_KEY=request.app.state.config.MINERU_API_KEY, MINERU_API_KEY=request.app.state.config.MINERU_API_KEY,
MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT,
MINERU_PARAMS=request.app.state.config.MINERU_PARAMS, MINERU_PARAMS=request.app.state.config.MINERU_PARAMS,
) )
docs = loader.load( docs = loader.load(
@ -1715,44 +1753,53 @@ async def process_text(
@router.post("/process/youtube") @router.post("/process/youtube")
@router.post("/process/web") @router.post("/process/web")
async def process_web( async def process_web(
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user) request: Request,
form_data: ProcessUrlForm,
process: bool = Query(True, description="Whether to process and save the content"),
user=Depends(get_verified_user),
): ):
try: try:
collection_name = form_data.collection_name
if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63]
content, docs = await run_in_threadpool( content, docs = await run_in_threadpool(
get_content_from_url, request, form_data.url get_content_from_url, request, form_data.url
) )
log.debug(f"text_content: {content}") log.debug(f"text_content: {content}")
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: if process:
await run_in_threadpool( collection_name = form_data.collection_name
save_docs_to_vector_db, if not collection_name:
request, collection_name = calculate_sha256_string(form_data.url)[:63]
docs,
collection_name,
overwrite=True,
user=user,
)
else:
collection_name = None
return { if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
"status": True, await run_in_threadpool(
"collection_name": collection_name, save_docs_to_vector_db,
"filename": form_data.url, request,
"file": { docs,
"data": { collection_name,
"content": content, overwrite=True,
user=user,
)
else:
collection_name = None
return {
"status": True,
"collection_name": collection_name,
"filename": form_data.url,
"file": {
"data": {
"content": content,
},
"meta": {
"name": form_data.url,
"source": form_data.url,
},
}, },
"meta": { }
"name": form_data.url, else:
"source": form_data.url, return {
}, "status": True,
}, "content": content,
} }
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
raise HTTPException( raise HTTPException(
@ -1809,11 +1856,13 @@ def search_web(
raise Exception("No PERPLEXITY_API_KEY found in environment variables") raise Exception("No PERPLEXITY_API_KEY found in environment variables")
elif engine == "searxng": elif engine == "searxng":
if request.app.state.config.SEARXNG_QUERY_URL: if request.app.state.config.SEARXNG_QUERY_URL:
searxng_kwargs = {"language": request.app.state.config.SEARXNG_LANGUAGE}
return search_searxng( return search_searxng(
request.app.state.config.SEARXNG_QUERY_URL, request.app.state.config.SEARXNG_QUERY_URL,
query, query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
**searxng_kwargs,
) )
else: else:
raise Exception("No SEARXNG_QUERY_URL found in environment variables") raise Exception("No SEARXNG_QUERY_URL found in environment variables")
@ -2066,16 +2115,38 @@ async def process_web_search(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
) )
search_tasks = [ # Use semaphore to limit concurrent requests based on WEB_SEARCH_CONCURRENT_REQUESTS
run_in_threadpool( # 0 or None = unlimited (previous behavior), positive number = limited concurrency
search_web, # Set to 1 for sequential execution (rate-limited APIs like Brave free tier)
request, concurrent_limit = request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS
request.app.state.config.WEB_SEARCH_ENGINE,
query, if concurrent_limit:
user, # Limited concurrency with semaphore
) semaphore = asyncio.Semaphore(concurrent_limit)
for query in form_data.queries
] async def search_with_limit(query):
async with semaphore:
return await run_in_threadpool(
search_web,
request,
request.app.state.config.WEB_SEARCH_ENGINE,
query,
user,
)
search_tasks = [search_with_limit(query) for query in form_data.queries]
else:
# Unlimited parallel execution (previous behavior)
search_tasks = [
run_in_threadpool(
search_web,
request,
request.app.state.config.WEB_SEARCH_ENGINE,
query,
user,
)
for query in form_data.queries
]
search_results = await asyncio.gather(*search_tasks) search_results = await asyncio.gather(*search_tasks)

View file

@ -24,10 +24,8 @@ from open_webui.utils.auth import (
get_verified_user, get_verified_user,
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()

View file

@ -35,11 +35,9 @@ from open_webui.config import (
DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE, DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE,
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
) )
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()

View file

@ -27,13 +27,11 @@ 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.utils.access_control import has_access, has_permission
from open_webui.utils.tools import get_tool_servers from open_webui.utils.tools import get_tool_servers
from open_webui.env import SRC_LOG_LEVELS
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()

View file

@ -28,7 +28,7 @@ from open_webui.models.users import (
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR from open_webui.env import STATIC_DIR
from open_webui.utils.auth import ( from open_webui.utils.auth import (
@ -41,7 +41,6 @@ from open_webui.utils.access_control import get_permissions, has_permission
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter() router = APIRouter()
@ -391,6 +390,7 @@ async def update_user_info_by_session_user(
class UserActiveResponse(UserStatus): class UserActiveResponse(UserStatus):
name: str name: str
profile_image_url: Optional[str] = None profile_image_url: Optional[str] = None
groups: Optional[list] = []
is_active: bool is_active: bool
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@ -412,11 +412,12 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
) )
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id)
if user: if user:
groups = Groups.get_groups_by_member_id(user_id)
return UserActiveResponse( return UserActiveResponse(
**{ **{
**user.model_dump(), **user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id), "is_active": Users.is_user_active(user_id),
} }
) )

View file

@ -14,11 +14,9 @@ from open_webui.utils.misc import get_gravatar_url
from open_webui.utils.pdf_generator import PDFGenerator from open_webui.utils.pdf_generator import PDFGenerator
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()

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