diff --git a/.github/workflows/deploy-to-hf-spaces.yml b/.github/workflows/deploy-to-hf-spaces.yml index a30046af895..cda9f1eb4be 100644 --- a/.github/workflows/deploy-to-hf-spaces.yml +++ b/.github/workflows/deploy-to-hf-spaces.yml @@ -57,7 +57,8 @@ jobs: git lfs install git lfs track "*.ttf" git lfs track "*.jpg" - rm demo.gif + rm demo.png + rm banner.png git add . git commit -m "GitHub deploy: ${{ github.sha }}" git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main diff --git a/.prettierignore b/.prettierignore index 82c49125724..83bbde598b4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,8 +3,6 @@ pnpm-lock.yaml package-lock.json yarn.lock -kubernetes/ - # Copy of .gitignore .DS_Store node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index d0730963ece..572ac402571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,253 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.2] - 2026-01-10 + +### Fixed + +- ⚡ Users no longer experience database connection timeouts under high concurrency due to connections being held during LLM calls, telemetry collection, and file status streaming. [#20545](https://github.com/open-webui/open-webui/pull/20545), [#20542](https://github.com/open-webui/open-webui/pull/20542), [#20547](https://github.com/open-webui/open-webui/pull/20547) +- 📝 Users can now create and save prompts in the workspace prompts editor without encountering errors. [Commit](https://github.com/open-webui/open-webui/commit/ab99d3b1129cffbc13cf7de5aa897692e3f8662e) +- 🎙️ Users can now use local Whisper for speech-to-text when STT_ENGINE is left empty (the default for local mode). [#20534](https://github.com/open-webui/open-webui/pull/20534) +- 📊 The Evaluations page now loads faster by eliminating duplicate API calls to the leaderboard and feedbacks endpoints. [Commit](https://github.com/open-webui/open-webui/commit/2dd09223f2aac301a4d5c17fb667d974c34f3ff1) +- 🌐 Fixed missing Settings tab i18n label keys. [#20526](https://github.com/open-webui/open-webui/pull/20526) + +## [0.7.1] - 2026-01-09 + +### Fixed + +- ⚡ **Improved reliability for low-spec and SQLite deployments.** Fixed page timeouts by disabling database session sharing by default, improving stability for resource-constrained environments. Users can re-enable via 'DATABASE_ENABLE_SESSION_SHARING=true' if needed. [#20520](https://github.com/open-webui/open-webui/issues/20520) + +## [0.7.0] - 2026-01-09 + +### Added + +- 🤖 **Native Function Calling with Built-in Tools.** Users can now ask models to perform multi-step tasks that combine web research, knowledge base queries, note-taking, and image generation in a single conversation—for example, "research the latest on X, save key findings to a note, and generate an infographic." Requires models with native function calling support and function calling mode set to "Native" in Chat Controls. [#19397](https://github.com/open-webui/open-webui/issues/19397), [Commit](https://github.com/open-webui/open-webui/commit/5c1d52231a3997a17381c48639bd7e339262cf7c) +- 🧠 Users can now ask the model to find relevant context from their notes, past chats, and channel messages—for example, "what did I discuss about project X last week?" or "find the conversation where I brainstormed ideas for Y." [Commit](https://github.com/open-webui/open-webui/commit/646835d76744ad9b2e67ede0407a61d62e969aab) +- 📚 Users can now ask the model to search their knowledge bases and retrieve documents without manually attaching files—for example, "find the section about authentication in our API docs" or "what do our internal guidelines say about X?" [Commit](https://github.com/open-webui/open-webui/commit/c8622adcb01f3091b17ca50f8c8e2f20c7b9cd2a) +- 💭 Users with models that support interleaved thinking now get more refined results from multi-step workflows, as the model can analyze each tool's output before deciding what to do next. +- 🔍 When models invoke web search, search results appear as clickable citations in real-time for full source verification. [Commit](https://github.com/open-webui/open-webui/commit/2789f6a24d8405c30cd48ae460071f6a4f2c35f9) +- 🎚️ Users can selectively disable specific built-in tools (timestamps, memory, chat history, notes, web search, knowledge bases) per model via the model editor's capabilities settings. [Commit](https://github.com/open-webui/open-webui/commit/60e916d6c0c5f7db9e6d670e12be2d1d4abc2dd6) +- 👁️ Pending tool calls are now displayed during response generation, so users know which tools are being invoked. [Commit](https://github.com/open-webui/open-webui/commit/1d08376860e775049abd1dd5f568ac0c6466c944) +- 📁 Administrators can now limit the number of files that can be uploaded to folders using the "FOLDER_MAX_FILE_COUNT" setting, preventing resource exhaustion from bulk uploads. [#19810](https://github.com/open-webui/open-webui/issues/19810), [Commit](https://github.com/open-webui/open-webui/commit/a1036e544d573e3d35e05c1c2472ba762c32431b), [Commit](https://github.com/open-webui/open-webui/commit/d3ee3fd23e762c9d83fe1da5636d03259e186e57) +- ⚡ Users experience transformative speed improvements across the entire application through completely reengineered database connection handling, delivering noticeably faster page loads, butter-smooth interactions, and rock-solid stability during intensive operations like user management and bulk data processing. [Commit](https://github.com/open-webui/open-webui/commit/2041ab483e21b3a757baa25c47dc2fa29018674f), [Commit](https://github.com/open-webui/open-webui/commit/145c7516f227ce56fd52373cee86217fadf16181), [Commit](https://github.com/open-webui/open-webui/commit/475dd91ed798f2efcdf27799d5c5cae3f0e6e847), [Commit](https://github.com/open-webui/open-webui/commit/5d1459df166cce8445eb1556cc26abbd65a3f9f4), [Commit](https://github.com/open-webui/open-webui/commit/2453b75ff0fb2dc75b929d96f417d84e332922d9), [Commit](https://github.com/open-webui/open-webui/commit/5649a668fad15393a52c27a2f188841af8b66989) +- 🚀 Users experience significantly faster initial page load times through dynamic loading of document processing libraries, reducing the initial bundle size. [#20200](https://github.com/open-webui/open-webui/pull/20200), [#20202](https://github.com/open-webui/open-webui/pull/20202), [#20203](https://github.com/open-webui/open-webui/pull/20203), [#20204](https://github.com/open-webui/open-webui/pull/20204) +- 💨 Administrators experience dramatically faster user list loading through optimized database queries that eliminate N+1 query patterns, reducing query count from 1+N to just 2 total queries regardless of user count. [#20427](https://github.com/open-webui/open-webui/pull/20427) +- 📋 Notes now load faster through optimized database queries that batch user lookups instead of fetching each note's author individually. [Commit](https://github.com/open-webui/open-webui/commit/084f0ef6a5491e186bf6b71c6386973ba18ef2fa) +- 💬 Channel messages, pinned messages, and thread replies now load faster through batched user lookups instead of individual queries per message. [#20458](https://github.com/open-webui/open-webui/pull/20458), [#20459](https://github.com/open-webui/open-webui/pull/20459), [#20460](https://github.com/open-webui/open-webui/pull/20460) +- 🔗 Users can now click citation content links to jump directly to the relevant portion of source documents with automatic text highlighting, making it easier to verify AI responses against their original sources. [#20116](https://github.com/open-webui/open-webui/pull/20116), [Commit](https://github.com/open-webui/open-webui/commit/40c45ffe1f9b45538d32c8ecba8cac62c6eca503) +- 📌 Users can now pin or hide models directly from the Workspace Models page and Admin Settings Models page, making it easier to manage which models appear in the sidebar without switching to the chat interface. [#20176](https://github.com/open-webui/open-webui/pull/20176) +- 🔎 Administrators can now quickly find settings using the new search bar in the Admin Settings sidebar, which supports fuzzy filtering by category names and related keywords like "whisper" for Audio or "rag" for Documents. [#20434](https://github.com/open-webui/open-webui/pull/20434) +- 🎛️ Users can now view read-only models in the workspace models list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20369](https://github.com/open-webui/open-webui/pull/20369) +- 📝 Users can now view read-only prompts in the workspace prompts list, with clear "Read Only" badges indicating when editing is restricted. [#20368](https://github.com/open-webui/open-webui/pull/20368) +- 🔧 Users can now view read-only tools in the workspace tools list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20370](https://github.com/open-webui/open-webui/pull/20370) +- 📂 Searching for files is now significantly faster, especially for users with large file collections. [Commit](https://github.com/open-webui/open-webui/commit/a9a979fb3db1743553ca0705f571c0b9c252841f) +- 🏆 The Evaluations leaderboard now calculates Elo ratings on the backend instead of in the browser, improving performance and enabling topic-based model ranking through semantic search. [#15392](https://github.com/open-webui/open-webui/pull/15392), [#20476](https://github.com/open-webui/open-webui/issues/20476), [Commit](https://github.com/open-webui/open-webui/commit/10838b3654bf6fdef02d57311f7f1c01df4cd033) +- 📊 The Evaluations leaderboard now includes a per-model activity chart displaying daily wins and losses as a diverging bar chart, with 30-day, 1-year, and all-time views using weekly aggregation for longer timeframes. +- 🎞️ Users can now upload animated GIF and WebP formats as model profile images, with animation preserved by skipping resize processing for these file types. [Commit](https://github.com/open-webui/open-webui/commit/00af37bb4ed1ea0957c7c84a6a8def3a7998b8ca) +- 📸 Users uploading profile images for users, models, and arena models now benefit from WebP compression at 80% quality instead of JPEG, resulting in significantly smaller file sizes and faster uploads while maintaining visual quality. [Commit](https://github.com/open-webui/open-webui/commit/b1d30673b69571e081abb881d34944cf33cdc67e) +- ⭐ Action Function developers can now update message favorite status using the new "chat:message:favorite" event, enabling the development of pin/unpin message actions without race conditions from frontend auto-save. [#20375](https://github.com/open-webui/open-webui/pull/20375) +- 🌐 Users with OpenAI-compatible models that have web search capabilities now see URL citations displayed as sources in the interface. [#20172](https://github.com/open-webui/open-webui/pull/20172), [Commit](https://github.com/open-webui/open-webui/commit/fe84afd09a2bc8a89311f30186ec2608a4edda3a) +- 📰 Users can now dismiss the "What's New" changelog modal permanently using the X button, matching the behavior of the "Okay, Let's Go!" button. [#20258](https://github.com/open-webui/open-webui/pull/20258) +- 📧 Administrators can now configure the admin contact email displayed in the Account Pending overlay directly from the Admin Panel instead of only through environment variables. [#12500](https://github.com/open-webui/open-webui/issues/12500), [#20260](https://github.com/open-webui/open-webui/pull/20260) +- 📄 Administrators can now enable markdown header text splitting as a preprocessing step that works with either character or token splitting, through the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" setting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6) +- 🧩 Administrators can now set a minimum chunk size target using the "CHUNK_MIN_SIZE_TARGET" setting to merge small markdown header chunks with neighbors, which improves retrieval quality by eliminating tiny meaningless fragments, significantly speeds up document processing and embedding, reduces storage costs, and lowers embedding API costs or local compute requirements. [#19595](https://github.com/open-webui/open-webui/issues/19595), [#20314](https://github.com/open-webui/open-webui/pull/20314), [Commit](https://github.com/open-webui/open-webui/commit/c32435958073cf002d87e78544baa88bc4e15d7f) +- 💨 Administrators can now enable KV prefix caching optimization by setting "RAG_SYSTEM_CONTEXT" to true, which injects RAG context into the system message instead of user messages, enabling models to reuse cached tokens for follow-up questions instead of reprocessing the entire context on each turn, significantly improving response times and reducing costs for cloud-based models. [#20301](https://github.com/open-webui/open-webui/discussions/20301), [#20317](https://github.com/open-webui/open-webui/pull/20317) +- 🖼️ Administrators and Action developers can now control image generation denoising steps per-request using a steps parameter, allowing Actions and API calls to override the global IMAGE_STEPS configuration for both ComfyUI and Automatic1111 engines. [#20337](https://github.com/open-webui/open-webui/pull/20337) +- 🗄️ Administrators running multi-pod deployments can now designate a master pod to handle database migrations using the "ENABLE_DB_MIGRATIONS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/9824f0e33359a917ac07b60bf1f972074d5c8203) +- 🎙️ Administrators can now configure Whisper's compute type using the "WHISPER_COMPUTE_TYPE" environment variable to fix compatibility issues with CUDA/GPU deployments. [Commit](https://github.com/open-webui/open-webui/commit/26af1f92e21ddfd08348570bf54a6f345ac69648) +- 🔍 Administrators can now control sigmoid normalization for CrossEncoder reranking models using the "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION" environment variable, enabled by default for proper relevance threshold behavior with MS MARCO models. [#20228](https://github.com/open-webui/open-webui/pull/20228) +- 🔒 Administrators can now disable SSL certificate verification for external tools using the "REQUESTS_VERIFY" environment variable, enabling integration with self-signed certificates for Tika, Ollama embeddings, and external rerankers. [#19968](https://github.com/open-webui/open-webui/issues/19968), [Commit](https://github.com/open-webui/open-webui/commit/dfc5dad63167eabb7fb027e63c324675b23f2e9d) +- 📈 Administrators can now control audit log output destinations using "ENABLE_AUDIT_STDOUT" and "ENABLE_AUDIT_LOGS_FILE" environment variables, allowing audit logs to be sent to container logs for centralized logging systems. [#20114](https://github.com/open-webui/open-webui/pull/20114), [Commit](https://github.com/open-webui/open-webui/commit/fdae5644e36972384b3e2513e3074f95f9f7381f) +- 🛡️ Administrators can now restrict non-admin user access to Interface Settings through per-user or per-group permissions. [#20424](https://github.com/open-webui/open-webui/pull/20424) +- 🧠 Administrators can now globally enable or disable the Memories feature and control access through per-user or per-group permissions, with the Personalization tab automatically hidden when the feature is disabled. [#20462](https://github.com/open-webui/open-webui/pull/20462) +- 🟢 Administrators can now globally enable or disable user status visibility through the "ENABLE_USER_STATUS" setting in Admin Settings. [#20488](https://github.com/open-webui/open-webui/pull/20488) +- 🪝 Channel managers can now create webhooks to allow external services to post messages to channels without authentication. [Commit](https://github.com/open-webui/open-webui/commit/cd296fcf0d79cecd1a6a3ee4e492c6b5246ca7ae) +- 📄 In the model editor users can now disable the "File Context" capability to skip automatic file content extraction and injection, forwarding raw messages with file attachment metadata instead for use with custom tools or future built-in file access tools. [Commit](https://github.com/open-webui/open-webui/commit/daccf0713e3ecd6d24f003a87b5f8b3c61852958), [Docs:Commit](https://github.com/open-webui/docs/commit/18ec6eaefc071a278ec57d4d1b8d66d686af0870) +- 🔊 In the model editor users can now configure a specific TTS voice for each model, overriding user preferences and global defaults to give different AI personas distinct voices. [#3097](https://github.com/open-webui/open-webui/issues/3097), [Commit](https://github.com/open-webui/open-webui/commit/bb6188abf04302f79d80b0d6cc42c232624b5757) +- 👥 Administrators now have three granular group sharing permission options instead of a simple on/off toggle, allowing them to choose between "No one", "Members", or "Anyone" for who can share content to each group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8) +- 📦 Administrators can now export knowledge bases as zip files containing text files for backup and archival purposes. [#20120](https://github.com/open-webui/open-webui/issues/20120), [Commit](https://github.com/open-webui/open-webui/commit/c1147578c073a8c7fa7e7f836149e1cdfec8f18d) +- 🚀 Administrators can now create an admin account automatically at startup via "WEBUI_ADMIN_EMAIL", "WEBUI_ADMIN_PASSWORD", and "WEBUI_ADMIN_NAME" environment variables, enabling headless and automated deployments without exposing the signup page. [#17654](https://github.com/open-webui/open-webui/issues/17654), [Commit](https://github.com/open-webui/open-webui/commit/1138929f4d083931305f1f925899971b190562ae) +- 🦆 Administrators can now select a specific search backend for DDGS instead of random selection, with options including Bing, Brave, DuckDuckGo, Google, Wikipedia, Yahoo, and others. [#20330](https://github.com/open-webui/open-webui/issues/20330), [#20366](https://github.com/open-webui/open-webui/pull/20366) +- 🧭 Administrators can now configure custom Jina Search API endpoints using the "JINA_API_BASE_URL" environment variable, enabling region-specific deployments such as EU data processing. [#19718](https://github.com/open-webui/open-webui/pull/19718), [Commit](https://github.com/open-webui/open-webui/commit/f7f8a263b92289df8d4f8dbc3bae09bd009a5699) +- 🔥 Administrators can now configure Firecrawl timeout values using the "FIRECRAWL_TIMEOUT" environment variable to control web scraping wait times. [#19973](https://github.com/open-webui/open-webui/pull/19973), [Commit](https://github.com/open-webui/open-webui/commit/89ad1c68d1aadf849960b5e202aa4651096b05f5) +- 💾 Administrators can now use openGauss as the vector database backend for knowledge base document storage and retrieval. [#20179](https://github.com/open-webui/open-webui/pull/20179) +- 🔄 Various improvements were implemented across the application to enhance performance, stability, and security. +- 📊 Users can now sync their anonymous usage statistics to the Open WebUI Community platform to power community leaderboards, drive model evaluations, and contribute to open-source AI research that benefits everyone, all while keeping conversations completely private (only metadata like model names, message counts, and ratings are shared). By sharing your stats, you're helping the community identify which models perform best, contributing to transparent AI benchmarking, and supporting the collective effort to make AI better for all. You can also download your stats as JSON for personal analysis. +- 🌐 Translations for German, Portuguese (Brazil), Spanish, Simplified Chinese, Traditional Chinese, and Polish were enhanced and expanded. + +### Fixed + +- 🔊 Text-to-speech now correctly splits on newlines in addition to punctuation, so markdown bullet points and lists are spoken as separate sentences instead of being merged together. [#5924](https://github.com/open-webui/open-webui/issues/5924), [Commit](https://github.com/open-webui/open-webui/commit/869108a3e1ce2b8110084113c1b392072e98fd5f) +- 🔒 Users are now protected from stored XSS vulnerabilities in iFrame embeds for citations and response messages through configurable same-origin sandbox settings instead of hardcoded values. [#20209](https://github.com/open-webui/open-webui/pull/20209), [#20210](https://github.com/open-webui/open-webui/pull/20210) +- 🔑 Image Generation, Web Search, and Audio (TTS/STT) API endpoints now enforce permission checks on the backend, closing a security gap where disabled features could previously be accessed via direct API calls. [#20471](https://github.com/open-webui/open-webui/pull/20471) +- 🛠️ Tools and Tool Servers (MCP and OpenAPI) now enforce access control checks on the backend, ensuring users can only access tools they have permission to use even via direct API calls. [#20443](https://github.com/open-webui/open-webui/issues/20443), [Commit](https://github.com/open-webui/open-webui/commit/9b06fdc8fe1c933071610336be05f11e77e6c8eb) +- 🔁 System prompts are no longer duplicated when using native function calling, fixing an issue where the prompt would be applied twice during tool-calling workflows. [Commit](https://github.com/open-webui/open-webui/commit/9223efaff0db6e56bfa157ef214d9590005156d2) +- 🗂️ Knowledge base uploads to folders no longer fail when "FOLDER_MAX_FILE_COUNT" is unset, fixing an issue where the default null value caused all uploads to error. [Commit](https://github.com/open-webui/open-webui/commit/ef9cd0e0ad6e45b8a3efec6f3858b3d69d42f619) +- 📝 The "Create Note" button in the chat input now correctly hides for users without Notes permissions instead of showing and returning a 401 error when clicked. [#20486](https://github.com/open-webui/open-webui/issues/20486), [Commit](https://github.com/open-webui/open-webui/commit/9e9616b670c1c4389193b18500a7d80d86d7e280) +- 📊 The Evaluations page no longer crashes when administrators have large amounts of feedback data, as the leaderboard now fetches only the minimal required fields instead of loading entire conversation snapshots. [#20476](https://github.com/open-webui/open-webui/issues/20476), [#20489](https://github.com/open-webui/open-webui/pull/20489), [Commit](https://github.com/open-webui/open-webui/commit/b2a1f71d920e55b143f1c02e61104938d2588762) +- 💬 Users can now export chats, use the Ask/Explain popup, and view chat lists correctly again after these features were broken by recent refactoring changes that caused 500 and 400 server errors. [#20146](https://github.com/open-webui/open-webui/issues/20146), [#20205](https://github.com/open-webui/open-webui/issues/20205), [#20206](https://github.com/open-webui/open-webui/issues/20206), [#20212](https://github.com/open-webui/open-webui/pull/20212) +- 💭 Users no longer experience data corruption when switching between chats during background operations like image generation, where messages from one chat would appear in another chat's history. [#20266](https://github.com/open-webui/open-webui/pull/20266) +- 🛡️ Users no longer encounter critical chat stability errors, including duplicate key errors from circular message dependencies, null message access during chat loading, and errors in the chat overview visualization. [#20268](https://github.com/open-webui/open-webui/pull/20268) +- 📡 Users with Channels no longer experience infinite recursion and connection pool exhaustion when fetching threaded replies, preventing RecursionError crashes during chat history loading. [#20299](https://github.com/open-webui/open-webui/pull/20299), [Commit](https://github.com/open-webui/open-webui/commit/c144122f608759c2b79472e1f6948a7c1600a3d1) +- 📎 Users no longer encounter TypeError crashes when viewing messages with file attachments that have undefined URL properties. [#20343](https://github.com/open-webui/open-webui/pull/20343) +- 🔐 Users with MCP integrations now experience reliable OAuth 2.1 token refresh after access token expiration through proper Protected Resource discovery, preventing integration failures that caused sessions to be deleted. [#19794](https://github.com/open-webui/open-webui/issues/19794), [#20138](https://github.com/open-webui/open-webui/pull/20138), [#20291](https://github.com/open-webui/open-webui/issues/20291), [Commit](https://github.com/open-webui/open-webui/commit/bf2b2962399e341926bdbf9e0a82101f31a90b23), [Commit](https://github.com/open-webui/open-webui/commit/89565c58c6ae6b5b129559ef68b5a0c18c110765) +- 📚 Users who belong to multiple groups can now see Knowledge Bases shared with those groups, fixing an issue where they would disappear when shared with more than one group. [#20124](https://github.com/open-webui/open-webui/issues/20124), [#20229](https://github.com/open-webui/open-webui/issues/20229), [Commit](https://github.com/open-webui/open-webui/commit/61e25dc2dce9c12dcb5b88a6b814060c4338e67b) +- 📂 Users now see the correct Knowledge Base name when hovering over # file references in chat input instead of "undefined". [#20329](https://github.com/open-webui/open-webui/issues/20329), [#20333](https://github.com/open-webui/open-webui/pull/20333) +- 📋 Users now see notes displayed in correct chronological order within their time range groupings, fixing an issue where insertion order was not preserved. [Commit](https://github.com/open-webui/open-webui/commit/3f577c0c3fbfd9f09c02940e4ae474f987149277) +- 📑 Users collaborating on notes now experience proper content sync when initializing from both HTML and JSON formats, fixing sync failures in collaborative editing sessions. [Commit](https://github.com/open-webui/open-webui/commit/e27fb3e291a735c715a089a80e7a49d2c2209096) +- 🔎 Users searching notes can now find hyphenated words and variations with spaces, so searching "todo" now finds "to-do" and "to do". [Commit](https://github.com/open-webui/open-webui/commit/a3270648d8b8535443d8ce2ea719f8e678e4e358) +- 📥 Users no longer experience false duplicate file warnings when reuploading files after initial processing failed, as the file hash is now only stored after successful processing completion. [#19264](https://github.com/open-webui/open-webui/issues/19264), [#20282](https://github.com/open-webui/open-webui/pull/20282), [Commit](https://github.com/open-webui/open-webui/commit/d3ab9f4b96eee7f91c9b1355cee055fdabca9730) +- 💾 Users experience significantly improved page load performance as model profile images now cache properly in browsers, avoiding unnecessary image refetches. [Commit](https://github.com/open-webui/open-webui/commit/bb821ab654e93908a3b4632c359753eeff053264) +- 🎨 Users can now successfully edit uploaded images instead of having new images generated, fixing an issue introduced by the file storage refactor where images with type "file" and content_type starting with "image/" weren't being recognized as editable images. [#20237](https://github.com/open-webui/open-webui/issues/20237), [#20169](https://github.com/open-webui/open-webui/pull/20169), [#20239](https://github.com/open-webui/open-webui/pull/20239), [Commit](https://github.com/open-webui/open-webui/commit/1148d1c927d096e14917b6d762789fca3188f281) +- 🌐 Users writing in Persian and Arabic now see properly displayed right-to-left text in the notes section through automatic text direction detection. [#19743](https://github.com/open-webui/open-webui/issues/19743), [#20102](https://github.com/open-webui/open-webui/pull/20102), [Commit](https://github.com/open-webui/open-webui/commit/b619a157bc54c5bc44d223d2ae3acb9ce4ac6a6c) +- 🤖 Users can now successfully @ mention models in Channels instead of experiencing silent failures. [Commit](https://github.com/open-webui/open-webui/commit/59957715836acb635f4b1c4ddbfb4ba7b82b3281) +- 📋 Users on Windows now see correctly preserved line breaks when using the {{CLIPBOARD}} variable through CRLF to LF normalization. [#19370](https://github.com/open-webui/open-webui/issues/19370), [#20283](https://github.com/open-webui/open-webui/pull/20283) +- 📁 Users now see the Knowledge Selector dropdown correctly displayed above the Create Folder modal instead of being hidden behind it. [#20219](https://github.com/open-webui/open-webui/issues/20219), [#20213](https://github.com/open-webui/open-webui/pull/20213) +- 🌅 Users now see profile images in non-PNG formats like SVG, JPEG, and GIF displayed correctly instead of appearing broken. [#20171](https://github.com/open-webui/open-webui/pull/20171) +- 🆕 Non-admin users with disabled temporary chat permissions can now successfully create new chats and use pinned models from the sidebar. [#20336](https://github.com/open-webui/open-webui/issues/20336), [#20367](https://github.com/open-webui/open-webui/pull/20367), [Commit](https://github.com/open-webui/open-webui/commit/e754940c031f9689fb4f6edb3625aa06aeb53377) +- 🎛️ Users can now successfully use workspace models in chat, fixing "Model not found" errors that occurred when using custom model presets. [#20340](https://github.com/open-webui/open-webui/issues/20340), [#20344](https://github.com/open-webui/open-webui/pull/20344), [Commit](https://github.com/open-webui/open-webui/commit/b55a46ae99c32068ed306a5ecdaafa9f75504cd7), [Commit](https://github.com/open-webui/open-webui/commit/2bb13d5dbc6e233856e8aa26143222ceda8f6c11) +- 🔁 Users can now regenerate messages without crashes when the parent message is missing or corrupted in the chat history. [#20264](https://github.com/open-webui/open-webui/pull/20264) +- ✏️ Users no longer experience TipTap rich text editor crashes with "editor view is not available" errors when plugins or async methods try to access the editor after it has been destroyed. [#20266](https://github.com/open-webui/open-webui/pull/20266) +- 📗 Administrators with bypass access control enabled now correctly have write access to all knowledge bases. [#20371](https://github.com/open-webui/open-webui/pull/20371) +- 🔍 Administrators using local CrossEncoder reranking models now see proper relevance threshold behavior through MS MARCO model score normalization to the 0-1 range via sigmoid activation. [#19999](https://github.com/open-webui/open-webui/issues/19999), [#20228](https://github.com/open-webui/open-webui/pull/20228) +- 🎯 Administrators using local SentenceTransformers embedding engine now benefit from proper batch size settings, preventing excessive memory usage from the default batch size of 32. [#20053](https://github.com/open-webui/open-webui/issues/20053), [#20054](https://github.com/open-webui/open-webui/pull/20054), [Commit](https://github.com/open-webui/open-webui/commit/e4a5b06ca68303512678b4d2dc296bc78b9f983f) +- 🔧 Administrators and users in offline mode or restricted environments like uv, poetry, and NixOS no longer experience crashes when Tools and Functions have frontmatter requirements, as pip installation is now skipped when offline mode is enabled. [#20320](https://github.com/open-webui/open-webui/issues/20320), [#20321](https://github.com/open-webui/open-webui/pull/20321), [Commit](https://github.com/open-webui/open-webui/commit/bd07ef8) +- 📄 Administrators can now properly configure the MinerU document parsing service as the MinerU Cloud API key field is now available in the Admin Panel Documents settings. [#20319](https://github.com/open-webui/open-webui/issues/20319), [#20328](https://github.com/open-webui/open-webui/pull/20328) +- ⚠️ Administrators no longer see SyntaxWarnings for invalid escape sequences in password validation regex patterns. [#20298](https://github.com/open-webui/open-webui/pull/20298), [Commit](https://github.com/open-webui/open-webui/commit/e55bf2c2ac391caed871d41f0484820091081908) +- 🎨 Users with ComfyUI workflows now see only the intended final output images in chat instead of duplicate images from intermediate processing nodes like masks, crops, or segmentation previews. [#20158](https://github.com/open-webui/open-webui/issues/20158), [#20182](https://github.com/open-webui/open-webui/pull/20182) +- 🖼️ Users with image generation enabled no longer see false vision capability warnings, allowing them to send follow-up messages after generating images and to send images to non-vision models for image editing. [#20129](https://github.com/open-webui/open-webui/issues/20129), [#20256](https://github.com/open-webui/open-webui/pull/20256) +- 🔌 Administrators no longer experience infinite loading screens when invalid or MCP-style configurations are used with OpenAPI connection types for external tools. [#20207](https://github.com/open-webui/open-webui/issues/20207), [#20257](https://github.com/open-webui/open-webui/pull/20257) +- 📥 Administrators no longer encounter TypeError crashes during SHA256 verification when uploading GGUF models via URL, fixing 500 Internal Server Error crashes. [#20263](https://github.com/open-webui/open-webui/issues/20263) +- 🚦 Users with Brave Search now experience automatic retry with a 1-second delay when hitting rate limits, preventing failures when sequential requests exceed the 1 request per second limit, though this only works reliably when web search concurrency is set to a maximum of 1. [#15134](https://github.com/open-webui/open-webui/issues/15134), [#20255](https://github.com/open-webui/open-webui/pull/20255) +- 🗄️ Administrators with Redis Sentinel deployments no longer experience crashes during websocket disconnections due to improper async-generator handling in the YDocManager. [#20142](https://github.com/open-webui/open-webui/issues/20142), [#20145](https://github.com/open-webui/open-webui/pull/20145) +- 🔐 Administrators using SCIM group management no longer encounter 500 errors when working with groups that have no members. [#20187](https://github.com/open-webui/open-webui/pull/20187) +- 🔗 Users now experience more reliable citations from AI models, especially when using smaller or weaker models that may not format citation references perfectly. [Commit](https://github.com/open-webui/open-webui/commit/c0ec04935b4eea3d334bfdec2fc41278f1085a49) +- 🕸️ Administrators can now successfully save WebSearch settings without encountering validation errors for domain filter lists, YouTube language settings, or timeout values. [#20422](https://github.com/open-webui/open-webui/pull/20422) +- 📦 Administrators installing with the uv package manager now experience successful installation after deprecated dependencies that were causing conflicts were removed. [#20177](https://github.com/open-webui/open-webui/issues/20177), [#20192](https://github.com/open-webui/open-webui/pull/20192) +- ⏱️ Administrators using custom "AIOHTTP_CLIENT_TIMEOUT" settings now see the configured timeout correctly applied to embedding generation, OAuth discovery, webhook calls, and tool/function loading instead of falling back to the default 300-second timeout. [Commit](https://github.com/open-webui/open-webui/commit/e67891a374625d9888ec391da561f0b4ed79ed5d) + +### Changed + +- ⚠️ This release includes a major overhaul of database connection handling in the backend that requires all instances in multi-worker, multi-server, or load-balanced deployments to be updated simultaneously; running mixed versions will cause failures due to incompatible database connection management between old and new instances. +- 📝 Administrators who previously used the standalone "Markdown (Header)" text splitter must now switch to "character" or "token" mode with the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" toggle enabled, as document chunking now applies markdown header splitting as a preprocessing step before character or token splitting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6) +- 🖼️ Users no longer see the "Generate Image" action button in chat message interfaces; custom function should be used. [Commit](https://github.com/open-webui/open-webui/commit/f0829ba6e6fd200702fb76efc43dd785cf87fec9) +- 🔗 Administrators will find the Admin Evaluations page at the new URL "/admin/evaluations/feedback" instead of "/admin/evaluations/feedbacks" to use the correct uncountable form of the word. [#20296](https://github.com/open-webui/open-webui/pull/20296) +- 🔐 Scripts or integrations that directly called Image Generation, Web Search, or Audio APIs while those features were disabled in the Admin UI will now receive 403 Forbidden errors, as backend permission enforcement has been added to match frontend restrictions. [#20471](https://github.com/open-webui/open-webui/pull/20471) +- 👥 The default group sharing permission changed from "Members" to "Anyone", meaning users can now share content to any group configured with "Anyone" permission regardless of their membership in that group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8) + +## [0.6.43] - 2025-12-22 + +### Fixed + +- 🐍 **Python dependency installation issues** were resolved by correcting pip dependency handling, preventing installation failures in certain environments and improving setup reliability. [Commit](https://github.com/open-webui/open-webui/commit/5c5f87a) +- 🎙️ **Speech-to-Text default content type handling** was fixed and refactored to ensure correct MIME type usage, improving compatibility across STT providers and preventing transcription errors caused by incorrect defaults. [Commit](https://github.com/open-webui/open-webui/commit/4ab917c) +- 🖼️ **Temporary chat image handling** was fixed and refactored, ensuring images generated or edited in temporary chats are correctly processed, stored, and displayed without inconsistencies or missing references. [Commit](https://github.com/open-webui/open-webui/commit/423983f) +- 🎨 **Image action button fixed**, restoring the ability to trigger image generation, editing, and related image actions from the chat UI. [Commit](https://github.com/open-webui/open-webui/commit/def8a00) + +## [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 ### Added diff --git a/Dockerfile b/Dockerfile index ad393338d81..ac81a3943ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ ARG USE_CUDA_VER=cu128 # IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 ARG USE_RERANKING_MODEL="" +ARG USE_AUXILIARY_EMBEDDING_MODEL=TaylorAI/bge-micro-v2 # Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base" @@ -42,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH} RUN npm run build ######## WebUI backend ######## -FROM python:3.11-slim-bookworm AS base +FROM python:3.11.14-slim-bookworm AS base # Use args ARG USE_CUDA @@ -52,9 +53,13 @@ ARG USE_SLIM ARG USE_PERMISSION_HARDENING ARG USE_EMBEDDING_MODEL ARG USE_RERANKING_MODEL +ARG USE_AUXILIARY_EMBEDDING_MODEL ARG UID ARG GID +# Python settings +ENV PYTHONUNBUFFERED=1 + ## Basis ## ENV ENV=prod \ PORT=8080 \ @@ -64,7 +69,8 @@ ENV ENV=prod \ USE_SLIM_DOCKER=${USE_SLIM} \ USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \ USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \ - USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} + USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} \ + USE_AUXILIARY_EMBEDDING_MODEL_DOCKER=${USE_AUXILIARY_EMBEDDING_MODEL} ## Basis URL Config ## ENV OLLAMA_BASE_URL="/ollama" \ @@ -85,6 +91,7 @@ ENV WHISPER_MODEL="base" \ ## RAG Embedding model settings ## ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \ RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \ + AUXILIARY_EMBEDDING_MODEL="$USE_AUXILIARY_EMBEDDING_MODEL_DOCKER" \ SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models" ## Tiktoken model settings ## @@ -133,6 +140,7 @@ RUN pip3 install --no-cache-dir uv && \ pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ else \ @@ -140,6 +148,7 @@ RUN pip3 install --no-cache-dir uv && \ uv pip install --system -r requirements.txt --no-cache-dir && \ if [ "$USE_SLIM" != "true" ]; then \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ fi; \ diff --git a/INSTALLATION.md b/INSTALLATION.md deleted file mode 100644 index 4298b173e9f..00000000000 --- a/INSTALLATION.md +++ /dev/null @@ -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 diff --git a/LICENSE b/LICENSE index 3991050972d..faa0129c659 100644 --- a/LICENSE +++ b/LICENSE @@ -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. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 638cdacabbd..a783db5c7e8 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) +![Open WebUI 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**. Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/) -![Open WebUI Demo](./demo.gif) +![Open WebUI Demo](./demo.png) > [!TIP] > **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 -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). ### Using the Dev Branch 🌙 diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 41e88df5d25..dab5b6cfe82 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -19,6 +19,7 @@ from open_webui.env import ( DATA_DIR, DATABASE_URL, + ENABLE_DB_MIGRATIONS, ENV, REDIS_URL, REDIS_KEY_PREFIX, @@ -67,7 +68,8 @@ def run_migrations(): log.exception(f"Error running migrations: {e}") -run_migrations() +if ENABLE_DB_MIGRATIONS: + run_migrations() class Config(Base): @@ -629,6 +631,12 @@ def __getattr__(self, key): == "true" ) +OAUTH_AUDIENCE = PersistentConfig( + "OAUTH_AUDIENCE", + "oauth.audience", + os.environ.get("OAUTH_AUDIENCE", ""), +) + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -1300,7 +1308,7 @@ def feishu_oauth_register(oauth: OAuth): USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = ( os.environ.get( - "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" + "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False" ).lower() == "true" ) @@ -1339,8 +1347,7 @@ def feishu_oauth_register(oauth: OAuth): USER_PERMISSIONS_NOTES_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower() - == "true" + os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true" ) USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = ( @@ -1463,6 +1470,15 @@ def feishu_oauth_register(oauth: OAuth): os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true" ) +USER_PERMISSIONS_FEATURES_MEMORIES = ( + os.environ.get("USER_PERMISSIONS_FEATURES_MEMORIES", "True").lower() == "true" +) + + +USER_PERMISSIONS_SETTINGS_INTERFACE = ( + os.environ.get("USER_PERMISSIONS_SETTINGS_INTERFACE", "True").lower() == "true" +) + DEFAULT_USER_PERMISSIONS = { "workspace": { @@ -1521,6 +1537,10 @@ def feishu_oauth_register(oauth: OAuth): "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, + "memories": USER_PERMISSIONS_FEATURES_MEMORIES, + }, + "settings": { + "interface": USER_PERMISSIONS_SETTINGS_INTERFACE, }, } @@ -1536,6 +1556,12 @@ def feishu_oauth_register(oauth: OAuth): os.environ.get("ENABLE_FOLDERS", "True").lower() == "true", ) +FOLDER_MAX_FILE_COUNT = PersistentConfig( + "FOLDER_MAX_FILE_COUNT", + "folders.max_file_count", + os.environ.get("FOLDER_MAX_FILE_COUNT", ""), +) + ENABLE_CHANNELS = PersistentConfig( "ENABLE_CHANNELS", "channels.enable", @@ -1548,6 +1574,12 @@ def feishu_oauth_register(oauth: OAuth): os.environ.get("ENABLE_NOTES", "True").lower() == "true", ) +ENABLE_USER_STATUS = PersistentConfig( + "ENABLE_USER_STATUS", + "users.enable_status", + os.environ.get("ENABLE_USER_STATUS", "True").lower() == "true", +) + ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig( "ENABLE_EVALUATION_ARENA_MODELS", "evaluation.arena.enable", @@ -2062,6 +2094,12 @@ class BannerModel(BaseModel): os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true", ) +ENABLE_MEMORIES = PersistentConfig( + "ENABLE_MEMORIES", + "memories.enable", + os.environ.get("ENABLE_MEMORIES", "True").lower() == "true", +) + CODE_INTERPRETER_ENGINE = PersistentConfig( "CODE_INTERPRETER_ENGINE", "code_interpreter.engine", @@ -2335,6 +2373,51 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_IVFFLAT_LISTS = 100 +# openGauss +OPENGAUSS_DB_URL = os.environ.get("OPENGAUSS_DB_URL", DATABASE_URL) + +OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int( + os.environ.get("OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH", "1536") +) + +OPENGAUSS_POOL_SIZE = os.environ.get("OPENGAUSS_POOL_SIZE", None) + +if OPENGAUSS_POOL_SIZE != None: + try: + OPENGAUSS_POOL_SIZE = int(OPENGAUSS_POOL_SIZE) + except Exception: + OPENGAUSS_POOL_SIZE = None + +OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get("OPENGAUSS_POOL_MAX_OVERFLOW", 0) + +if OPENGAUSS_POOL_MAX_OVERFLOW == "": + OPENGAUSS_POOL_MAX_OVERFLOW = 0 +else: + try: + OPENGAUSS_POOL_MAX_OVERFLOW = int(OPENGAUSS_POOL_MAX_OVERFLOW) + except Exception: + OPENGAUSS_POOL_MAX_OVERFLOW = 0 + +OPENGAUSS_POOL_TIMEOUT = os.environ.get("OPENGAUSS_POOL_TIMEOUT", 30) + +if OPENGAUSS_POOL_TIMEOUT == "": + OPENGAUSS_POOL_TIMEOUT = 30 +else: + try: + OPENGAUSS_POOL_TIMEOUT = int(OPENGAUSS_POOL_TIMEOUT) + except Exception: + OPENGAUSS_POOL_TIMEOUT = 30 + +OPENGAUSS_POOL_RECYCLE = os.environ.get("OPENGAUSS_POOL_RECYCLE", 3600) + +if OPENGAUSS_POOL_RECYCLE == "": + OPENGAUSS_POOL_RECYCLE = 3600 +else: + try: + OPENGAUSS_POOL_RECYCLE = int(OPENGAUSS_POOL_RECYCLE) + except Exception: + OPENGAUSS_POOL_RECYCLE = 3600 + # Pinecone PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None) PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None) @@ -2518,6 +2601,12 @@ class BannerModel(BaseModel): 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", "rag.mineru_api_key", @@ -2791,6 +2880,12 @@ class BannerModel(BaseModel): 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", @@ -2798,6 +2893,12 @@ class BannerModel(BaseModel): os.environ.get("RAG_TEXT_SPLITTER", ""), ) +ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = PersistentConfig( + "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", + "rag.enable_markdown_header_text_splitter", + os.environ.get("ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", "True").lower() == "true", +) + TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken") TIKTOKEN_ENCODING_NAME = PersistentConfig( @@ -2810,6 +2911,13 @@ class BannerModel(BaseModel): CHUNK_SIZE = PersistentConfig( "CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000")) ) + +CHUNK_MIN_SIZE_TARGET = PersistentConfig( + "CHUNK_MIN_SIZE_TARGET", + "rag.chunk_min_size_target", + int(os.environ.get("CHUNK_MIN_SIZE_TARGET", "0")), +) + CHUNK_OVERLAP = PersistentConfig( "CHUNK_OVERLAP", "rag.chunk_overlap", @@ -2977,7 +3085,7 @@ class BannerModel(BaseModel): WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( "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 +3102,12 @@ class BannerModel(BaseModel): 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", @@ -3020,6 +3134,12 @@ class BannerModel(BaseModel): 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", "rag.web.search.yacy_query_url", @@ -3098,12 +3218,24 @@ class BannerModel(BaseModel): os.getenv("SERPLY_API_KEY", ""), ) +DDGS_BACKEND = PersistentConfig( + "DDGS_BACKEND", + "rag.web.search.ddgs_backend", + os.getenv("DDGS_BACKEND", "auto"), +) + JINA_API_KEY = PersistentConfig( "JINA_API_KEY", "rag.web.search.jina_api_key", os.getenv("JINA_API_KEY", ""), ) +JINA_API_BASE_URL = PersistentConfig( + "JINA_API_BASE_URL", + "rag.web.search.jina_api_base_url", + os.getenv("JINA_API_BASE_URL", ""), +) + SEARCHAPI_API_KEY = PersistentConfig( "SEARCHAPI_API_KEY", "rag.web.search.searchapi_api_key", @@ -3238,6 +3370,12 @@ class BannerModel(BaseModel): os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"), ) +FIRECRAWL_TIMEOUT = PersistentConfig( + "FIRECRAWL_TIMEOUT", + "rag.web.loader.firecrawl_timeout", + os.environ.get("FIRECRAWL_TIMEOUT", ""), +) + EXTERNAL_WEB_SEARCH_URL = PersistentConfig( "EXTERNAL_WEB_SEARCH_URL", "rag.web.search.external_web_search_url", @@ -3450,10 +3588,16 @@ class BannerModel(BaseModel): 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", + "COMFYUI_WORKFLOW_NODES", "image_generation.comfyui.nodes", - [], + comfyui_workflow_nodes, ) IMAGES_OPENAI_API_BASE_URL = PersistentConfig( @@ -3570,10 +3714,16 @@ class BannerModel(BaseModel): 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", "images.edit.comfyui.nodes", - [], + images_edit_comfyui_workflow_nodes, ) #################################### @@ -3587,17 +3737,16 @@ class BannerModel(BaseModel): os.getenv("WHISPER_MODEL", "base"), ) +WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models") WHISPER_MODEL_AUTO_UPDATE = ( not OFFLINE_MODE and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true" ) -WHISPER_VAD_FILTER = PersistentConfig( - "WHISPER_VAD_FILTER", - "audio.stt.whisper_vad_filter", - os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true", -) +WHISPER_VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true" + +WHISPER_MULTILINGUAL = os.getenv("WHISPER_MULTILINGUAL", "False").lower() == "true" WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index e3c50ea8d11..ff48a3abfe7 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -85,32 +85,7 @@ log.exception(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"]) +SRC_LOG_LEVELS = {} # Legacy variable, do not remove WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") if WEBUI_NAME != "Open WebUI": @@ -142,6 +117,8 @@ DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "") INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4())) +ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true" + # Function to parse each section def parse_section(section): @@ -364,6 +341,16 @@ def parse_section(section): except Exception: DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 +# When enabled, get_db_context reuses existing sessions; set to False to always create new sessions +DATABASE_ENABLE_SESSION_SHARING = ( + os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true" +) + +# 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 = ( os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" ) @@ -374,6 +361,8 @@ def parse_section(section): ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true" +RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "False").lower() == "true" + #################################### # REDIS #################################### @@ -395,6 +384,13 @@ def parse_section(section): except ValueError: 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 #################################### @@ -422,6 +418,16 @@ def parse_section(section): os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" ) +#################################### +# Admin Account Runtime Creation +#################################### + +# Optional env vars for creating an admin account on startup +# Useful for headless/automated deployments +WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "") +WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "") +WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin") + WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None ) @@ -439,7 +445,15 @@ def parse_section(section): "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", ) -PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN) + +try: + PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}" + 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( + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$" + ) BYPASS_MODEL_ACCESS_CONTROL = ( @@ -546,6 +560,10 @@ def parse_section(section): # 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") if MODELS_CACHE_TTL == "": MODELS_CACHE_TTL = None @@ -620,9 +638,16 @@ def parse_section(section): WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "") + + if WEBSOCKET_REDIS_OPTIONS == "": - log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") - WEBSOCKET_REDIS_OPTIONS = None + if REDIS_SOCKET_CONNECT_TIMEOUT: + 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: try: WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS) @@ -663,6 +688,8 @@ def parse_section(section): WEBSOCKET_SERVER_PING_INTERVAL = 25 +REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true" + AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") if AIOHTTP_CLIENT_TIMEOUT == "": @@ -756,6 +783,16 @@ def parse_section(section): except Exception: SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None +# Whether to apply sigmoid normalization to CrossEncoder reranking scores. +# When enabled (default), scores are normalized to 0-1 range for proper +# relevance threshold behavior with MS MARCO models. +SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = ( + os.environ.get( + "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True" + ).lower() + == "true" +) + #################################### # OFFLINE_MODE #################################### @@ -772,6 +809,11 @@ def parse_section(section): #################################### # AUDIT LOGGING #################################### + + +ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true" +ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true" + # Where to store log file # Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to # provide the whole path, like: /app/audit.log diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 316efe18e7f..e1604d126ad 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -37,7 +37,7 @@ from open_webui.utils.tools import get_tools 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 ( add_or_update_system_message, @@ -54,7 +54,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) def get_function_module_by_id(request: Request, pipe_id: str): diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index b6913d87b09..6050e37fa30 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -9,23 +9,23 @@ OPEN_WEBUI_DIR, DATABASE_URL, DATABASE_SCHEMA, - SRC_LOG_LEVELS, DATABASE_POOL_MAX_OVERFLOW, DATABASE_POOL_RECYCLE, DATABASE_POOL_SIZE, DATABASE_POOL_TIMEOUT, DATABASE_ENABLE_SQLITE_WAL, + DATABASE_ENABLE_SESSION_SHARING, + ENABLE_DB_MIGRATIONS, ) from peewee_migrate import Router from sqlalchemy import Dialect, create_engine, MetaData, event, types from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.orm import scoped_session, sessionmaker, Session from sqlalchemy.pool import QueuePool, NullPool from sqlalchemy.sql.type_api import _T from typing_extensions import Self log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["DB"]) class JSONField(types.TypeDecorator): @@ -77,7 +77,8 @@ def handle_peewee_migration(DATABASE_URL): assert db.is_closed(), "Database connection is still open." -handle_peewee_migration(DATABASE_URL) +if ENABLE_DB_MIGRATIONS: + handle_peewee_migration(DATABASE_URL) SQLALCHEMY_DATABASE_URL = DATABASE_URL @@ -92,8 +93,6 @@ def handle_peewee_migration(DATABASE_URL): # Extract database path from SQLCipher URL db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "") - if db_path.startswith("/"): - db_path = db_path[1:] # Remove leading slash for relative paths # Create a custom creator function that uses sqlcipher3 def create_sqlcipher_connection(): @@ -150,7 +149,7 @@ def on_connect(dbapi_connection, connection_record): ) metadata_obj = MetaData(schema=DATABASE_SCHEMA) Base = declarative_base(metadata=metadata_obj) -Session = scoped_session(SessionLocal) +ScopedSession = scoped_session(SessionLocal) def get_session(): @@ -162,3 +161,12 @@ def get_session(): get_db = contextmanager(get_session) + + +@contextmanager +def get_db_context(db: Optional[Session] = None): + if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING: + yield db + else: + with get_db() as session: + yield session diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py index 554a5effdd2..80b1aab8ff2 100644 --- a/backend/open_webui/internal/wrappers.py +++ b/backend/open_webui/internal/wrappers.py @@ -2,7 +2,6 @@ import os from contextvars import ContextVar -from open_webui.env import SRC_LOG_LEVELS from peewee import * from peewee import InterfaceError as PeeWeeInterfaceError from peewee import PostgresqlDatabase @@ -10,7 +9,6 @@ from playhouse.shortcuts import ReconnectMixin log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["DB"]) db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None} 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 # Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite db_path = db_url.replace("sqlite+sqlcipher://", "") - if db_path.startswith("/"): - db_path = db_path[1:] # Remove leading slash for relative paths # Use Peewee's native SqlCipherDatabase with encryption db = SqlCipherDatabase(db_path, passphrase=database_password) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 21a1aee0436..82a87e3fd9d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -102,7 +102,9 @@ get_rf, ) -from open_webui.internal.db import Session, engine + +from sqlalchemy.orm import Session +from open_webui.internal.db import ScopedSession, engine, get_session from open_webui.models.functions import Functions from open_webui.models.models import Models @@ -143,6 +145,7 @@ CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, CODE_INTERPRETER_JUPYTER_TIMEOUT, + ENABLE_MEMORIES, # Image AUTOMATIC1111_API_AUTH, AUTOMATIC1111_BASE_URL, @@ -206,8 +209,10 @@ PLAYWRIGHT_TIMEOUT, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, + FIRECRAWL_TIMEOUT, WEB_LOADER_ENGINE, WEB_LOADER_CONCURRENT_REQUESTS, + WEB_LOADER_TIMEOUT, WHISPER_MODEL, WHISPER_VAD_FILTER, WHISPER_LANGUAGE, @@ -226,6 +231,7 @@ RAG_RERANKING_MODEL, RAG_EXTERNAL_RERANKER_URL, RAG_EXTERNAL_RERANKER_API_KEY, + RAG_EXTERNAL_RERANKER_TIMEOUT, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, RAG_EMBEDDING_ENGINE, @@ -248,6 +254,7 @@ RAG_OLLAMA_BASE_URL, RAG_OLLAMA_API_KEY, CHUNK_OVERLAP, + CHUNK_MIN_SIZE_TARGET, CHUNK_SIZE, CONTENT_EXTRACTION_ENGINE, DATALAB_MARKER_API_KEY, @@ -263,6 +270,7 @@ MINERU_API_MODE, MINERU_API_URL, MINERU_API_KEY, + MINERU_API_TIMEOUT, MINERU_PARAMS, DATALAB_MARKER_USE_LLM, EXTERNAL_DOCUMENT_LOADER_URL, @@ -277,6 +285,7 @@ MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY, RAG_TEXT_SPLITTER, + ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, TIKTOKEN_ENCODING_NAME, PDF_EXTRACT_IMAGES, YOUTUBE_LOADER_LANGUAGE, @@ -292,16 +301,19 @@ WEB_SEARCH_DOMAIN_FILTER_LIST, OLLAMA_CLOUD_WEB_SEARCH_API_KEY, JINA_API_KEY, + JINA_API_BASE_URL, SEARCHAPI_API_KEY, SEARCHAPI_ENGINE, SERPAPI_API_KEY, SERPAPI_ENGINE, SEARXNG_QUERY_URL, + SEARXNG_LANGUAGE, YACY_QUERY_URL, YACY_USERNAME, YACY_PASSWORD, SERPER_API_KEY, SERPLY_API_KEY, + DDGS_BACKEND, SERPSTACK_API_KEY, SERPSTACK_HTTPS, TAVILY_API_KEY, @@ -354,8 +366,10 @@ ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, API_KEYS_ALLOWED_ENDPOINTS, ENABLE_FOLDERS, + FOLDER_MAX_FILE_COUNT, ENABLE_CHANNELS, ENABLE_NOTES, + ENABLE_USER_STATUS, ENABLE_COMMUNITY_SHARING, ENABLE_MESSAGE_RATING, ENABLE_USER_WEBHOOKS, @@ -435,6 +449,7 @@ reset_config, ) from open_webui.env import ( + ENABLE_CUSTOM_MODEL_FALLBACK, LICENSE_KEY, AUDIT_EXCLUDED_PATHS, AUDIT_LOG_LEVEL, @@ -447,7 +462,6 @@ GLOBAL_LOG_LEVEL, MAX_BODY_LOG_SIZE, SAFE_MODE, - SRC_LOG_LEVELS, VERSION, DEPLOYMENT_ID, INSTANCE_ID, @@ -471,6 +485,11 @@ EXTERNAL_PWA_MANIFEST_URL, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_STAR_SESSIONS_MIDDLEWARE, + ENABLE_PUBLIC_ACTIVE_USERS_COUNT, + # Admin Account Runtime Creation + WEBUI_ADMIN_EMAIL, + WEBUI_ADMIN_PASSWORD, + WEBUI_ADMIN_NAME, ) @@ -495,6 +514,7 @@ decode_token, get_admin_user, get_verified_user, + create_admin_user, ) from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import ( @@ -528,7 +548,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) class SPAStaticFiles(StaticFiles): @@ -574,6 +593,12 @@ async def lifespan(app: FastAPI): if LICENSE_KEY: get_license_data(app, LICENSE_KEY) + # Create admin account from env vars if specified and no users exist + if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD: + if create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME): + # Disable signup since we now have an admin + app.state.config.ENABLE_SIGNUP = False + # This should be blocking (sync) so functions are not deactivated on first /get_models calls # when the first user lands on the / route. log.info("Installing external dependencies of functions and tools...") @@ -770,11 +795,13 @@ async def lifespan(app: FastAPI): app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS +app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS +app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS @@ -878,14 +905,21 @@ async def lifespan(app: FastAPI): app.state.config.MINERU_API_MODE = MINERU_API_MODE app.state.config.MINERU_API_URL = MINERU_API_URL app.state.config.MINERU_API_KEY = MINERU_API_KEY +app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT app.state.config.MINERU_PARAMS = MINERU_PARAMS app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER +app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ( + ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER +) + app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME app.state.config.CHUNK_SIZE = CHUNK_SIZE +app.state.config.CHUNK_MIN_SIZE_TARGET = CHUNK_MIN_SIZE_TARGET app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP + app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE @@ -895,6 +929,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY +app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT app.state.config.RAG_TEMPLATE = RAG_TEMPLATE @@ -922,6 +957,7 @@ async def lifespan(app: FastAPI): 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_TIMEOUT = WEB_LOADER_TIMEOUT app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( @@ -934,6 +970,7 @@ async def lifespan(app: FastAPI): 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_LANGUAGE = SEARXNG_LANGUAGE app.state.config.YACY_QUERY_URL = YACY_QUERY_URL app.state.config.YACY_USERNAME = YACY_USERNAME app.state.config.YACY_PASSWORD = YACY_PASSWORD @@ -947,12 +984,14 @@ async def lifespan(app: FastAPI): app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS app.state.config.SERPER_API_KEY = SERPER_API_KEY app.state.config.SERPLY_API_KEY = SERPLY_API_KEY +app.state.config.DDGS_BACKEND = DDGS_BACKEND app.state.config.TAVILY_API_KEY = TAVILY_API_KEY app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE app.state.config.JINA_API_KEY = JINA_API_KEY +app.state.config.JINA_API_BASE_URL = JINA_API_BASE_URL app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY app.state.config.EXA_API_KEY = EXA_API_KEY @@ -972,6 +1011,7 @@ async def lifespan(app: FastAPI): app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY +app.state.config.FIRECRAWL_TIMEOUT = FIRECRAWL_TIMEOUT app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH app.state.EMBEDDING_FUNCTION = None @@ -995,6 +1035,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_RERANKING_MODEL, app.state.config.RAG_EXTERNAL_RERANKER_URL, app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, ) else: app.state.rf = None @@ -1031,6 +1072,7 @@ async def lifespan(app: FastAPI): if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" else None ), + enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING, ) app.state.RERANKING_FUNCTION = get_reranking_function( @@ -1078,6 +1120,7 @@ async def lifespan(app: FastAPI): app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION +app.state.config.ENABLE_MEMORIES = ENABLE_MEMORIES app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL app.state.config.IMAGE_SIZE = IMAGE_SIZE @@ -1131,7 +1174,6 @@ async def lifespan(app: FastAPI): app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY app.state.config.WHISPER_MODEL = WHISPER_MODEL -app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY @@ -1314,7 +1356,7 @@ async def dispatch(self, request: Request, call_next): async def commit_session_after_request(request: Request, call_next): response = await call_next(request) # log.debug("Commit session after request") - Session.commit() + ScopedSession.commit() return response @@ -1530,6 +1572,7 @@ async def chat_completion( metadata = {} try: + model_info = None if not model_item.get("direct", False): if model_id not in request.app.state.MODELS: raise Exception("Model not found") @@ -1547,7 +1590,6 @@ async def chat_completion( raise e else: model = model_item - model_info = None request.state.direct = True request.state.model = model @@ -1556,6 +1598,26 @@ async def chat_completion( 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 stream_delta_chunk_size = form_data.get("params", {}).get( "stream_delta_chunk_size" @@ -1576,6 +1638,7 @@ async def chat_completion( "user_id": user.id, "chat_id": form_data.pop("chat_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), "session_id": form_data.pop("session_id", None), "filter_ids": form_data.pop("filter_ids", []), @@ -1600,15 +1663,38 @@ async def chat_completion( }, } - if metadata.get("chat_id") and (user and user.role != "admin"): - if not metadata["chat_id"].startswith("local:"): + if metadata.get("chat_id") and user: + 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) - if chat is None: + if chat is None and user.role != "admin": # admins can access any chat raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.DEFAULT(), ) + # Insert chat files from parent message if any + parent_message = metadata.get("parent_message") or {} + 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 form_data["metadata"] = metadata @@ -1843,10 +1929,12 @@ async def get_app_config(request: Request): "enable_login_form": app.state.config.ENABLE_LOGIN_FORM, "enable_websocket": ENABLE_WEBSOCKET_SUPPORT, "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_folders": app.state.config.ENABLE_FOLDERS, + "folder_max_file_count": app.state.config.FOLDER_MAX_FILE_COUNT, "enable_channels": app.state.config.ENABLE_CHANNELS, "enable_notes": app.state.config.ENABLE_NOTES, "enable_web_search": app.state.config.ENABLE_WEB_SEARCH, @@ -1857,10 +1945,12 @@ async def get_app_config(request: Request): "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, "enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS, + "enable_user_status": app.state.config.ENABLE_USER_STATUS, "enable_admin_export": ENABLE_ADMIN_EXPORT, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + "enable_memories": app.state.config.ENABLE_MEMORIES, **( { "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL, @@ -2019,10 +2109,19 @@ async def get_current_usage(user=Depends(get_verified_user)): This is an experimental endpoint and subject to change. """ 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 { "model_ids": get_models_in_use(), "user_count": Users.get_active_user_count(), } + except HTTPException: + raise except Exception as e: log.error(f"Error getting usage statistics: {e}") raise HTTPException(status_code=500, detail="Internal Server Error") @@ -2216,8 +2315,13 @@ async def oauth_login(provider: str, request: Request): # - Email addresses are considered unique, so we fail registration if the email address is already taken @app.get("/oauth/{provider}/login/callback") @app.get("/oauth/{provider}/callback") # Legacy endpoint -async def oauth_login_callback(provider: str, request: Request, response: Response): - return await oauth_manager.handle_callback(request, provider, response) +async def oauth_login_callback( + provider: str, + request: Request, + response: Response, + db: Session = Depends(get_session), +): + return await oauth_manager.handle_callback(request, provider, response, db=db) @app.get("/manifest.json") @@ -2276,7 +2380,7 @@ async def healthcheck(): @app.get("/health/db") async def healthcheck_with_db(): - Session.execute(text("SELECT 1;")).all() + ScopedSession.execute(text("SELECT 1;")).all() return {"status": True} diff --git a/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py new file mode 100644 index 00000000000..59fe57a421b --- /dev/null +++ b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py @@ -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") diff --git a/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py new file mode 100644 index 00000000000..181b2806667 --- /dev/null +++ b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py @@ -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") diff --git a/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py new file mode 100644 index 00000000000..20f4a6d7b64 --- /dev/null +++ b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py @@ -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 diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 8b03580e6cb..93f17dff115 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -2,14 +2,13 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.users import UserModel, UserProfileImageResponse, Users -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel from sqlalchemy import Boolean, Column, String, Text log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # DB MODEL @@ -89,8 +88,9 @@ def insert_new_auth( profile_image_url: str = "/user.png", role: str = "pending", oauth: Optional[dict] = None, + db: Optional[Session] = None, ) -> Optional[UserModel]: - with get_db() as db: + with get_db_context(db) as db: log.info("insert_new_auth") id = str(uuid.uuid4()) @@ -102,7 +102,7 @@ def insert_new_auth( db.add(result) user = Users.insert_new_user( - id, name, email, profile_image_url, role, oauth=oauth + id, name, email, profile_image_url, role, oauth=oauth, db=db ) db.commit() @@ -114,16 +114,16 @@ def insert_new_auth( return None def authenticate_user( - self, email: str, verify_password: callable + self, email: str, verify_password: callable, db: Optional[Session] = None ) -> Optional[UserModel]: log.info(f"authenticate_user: {email}") - user = Users.get_user_by_email(email) + user = Users.get_user_by_email(email, db=db) if not user: return None try: - with get_db() as db: + with get_db_context(db) as db: auth = db.query(Auth).filter_by(id=user.id, active=True).first() if auth: if verify_password(auth.password): @@ -135,32 +135,38 @@ def authenticate_user( except Exception: return None - def authenticate_user_by_api_key(self, api_key: str) -> Optional[UserModel]: + def authenticate_user_by_api_key( + self, api_key: str, db: Optional[Session] = None + ) -> Optional[UserModel]: log.info(f"authenticate_user_by_api_key: {api_key}") # if no api_key, return None if not api_key: return None try: - user = Users.get_user_by_api_key(api_key) + user = Users.get_user_by_api_key(api_key, db=db) return user if user else None except Exception: return False - def authenticate_user_by_email(self, email: str) -> Optional[UserModel]: + def authenticate_user_by_email( + self, email: str, db: Optional[Session] = None + ) -> Optional[UserModel]: log.info(f"authenticate_user_by_email: {email}") try: - with get_db() as db: + with get_db_context(db) as db: auth = db.query(Auth).filter_by(email=email, active=True).first() if auth: - user = Users.get_user_by_id(auth.id) + user = Users.get_user_by_id(auth.id, db=db) return user except Exception: return None - def update_user_password_by_id(self, id: str, new_password: str) -> bool: + def update_user_password_by_id( + self, id: str, new_password: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: result = ( db.query(Auth).filter_by(id=id).update({"password": new_password}) ) @@ -169,20 +175,22 @@ def update_user_password_by_id(self, id: str, new_password: str) -> bool: except Exception: return False - def update_email_by_id(self, id: str, email: str) -> bool: + def update_email_by_id( + self, id: str, email: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: result = db.query(Auth).filter_by(id=id).update({"email": email}) db.commit() return True if result == 1 else False except Exception: return False - def delete_auth_by_id(self, id: str) -> bool: + def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: # Delete User - result = Users.delete_user_by_id(id) + result = Users.delete_user_by_id(id, db=db) if result: db.query(Auth).filter_by(id=id).delete() diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 754f6e3dfa7..8e70918e1a0 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -1,16 +1,29 @@ import json +import secrets import time import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.groups import Groups from pydantic import BaseModel, ConfigDict 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.sql import exists @@ -137,6 +150,41 @@ class ChannelMemberModel(BaseModel): 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): __tablename__ = "channel_webhook" @@ -198,6 +246,11 @@ class CreateChannelForm(ChannelForm): type: Optional[str] = None +class ChannelWebhookForm(BaseModel): + name: str + profile_image_url: Optional[str] = None + + class ChannelTable: def _collect_unique_user_ids( @@ -258,9 +311,9 @@ def _create_membership_models( return memberships def insert_new_channel( - self, form_data: CreateChannelForm, user_id: str + self, form_data: CreateChannelForm, user_id: str, db: Optional[Session] = None ) -> Optional[ChannelModel]: - with get_db() as db: + with get_db_context(db) as db: channel = ChannelModel( **{ **form_data.model_dump(), @@ -291,8 +344,8 @@ def insert_new_channel( db.commit() return channel - def get_channels(self) -> list[ChannelModel]: - with get_db() as db: + def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: + with get_db_context(db) as db: channels = db.query(Channel).all() return [ChannelModel.model_validate(channel) for channel in channels] @@ -338,10 +391,12 @@ def _has_permission(self, db, query, filter: dict, permission: str = "read"): return query - def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]: - with get_db() as db: + def get_channels_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[ChannelModel]: + with get_db_context(db) as db: user_group_ids = [ - group.id for group in Groups.get_groups_by_member_id(user_id) + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) ] membership_channels = ( @@ -375,8 +430,10 @@ def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]: all_channels = membership_channels + standard_channels return [ChannelModel.model_validate(c) for c in all_channels] - def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]: - with get_db() as db: + def get_dm_channel_by_user_ids( + self, user_ids: list[str], db: Optional[Session] = None + ) -> Optional[ChannelModel]: + with get_db_context(db) as db: # Ensure uniqueness in case a list with duplicates is passed unique_user_ids = list(set(user_ids)) @@ -414,8 +471,9 @@ def add_members_to_channel( invited_by: str, user_ids: Optional[list[str]] = None, group_ids: Optional[list[str]] = None, + db: Optional[Session] = None, ) -> list[ChannelMemberModel]: - with get_db() as db: + with get_db_context(db) as db: # 1. Collect all user_ids including groups + inviter requested_users = self._collect_unique_user_ids( invited_by, user_ids, group_ids @@ -448,8 +506,9 @@ def remove_members_from_channel( self, channel_id: str, user_ids: list[str], + db: Optional[Session] = None, ) -> int: - with get_db() as db: + with get_db_context(db) as db: result = ( db.query(ChannelMember) .filter( @@ -461,8 +520,10 @@ def remove_members_from_channel( db.commit() return result # number of rows deleted - def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool: - with get_db() as db: + def is_user_channel_manager( + self, channel_id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: # Check if the user is the creator of the channel # or has a 'manager' role in ChannelMember channel = db.query(Channel).filter(Channel.id == channel_id).first() @@ -481,9 +542,9 @@ def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool: return membership is not None def join_channel( - self, channel_id: str, user_id: str + self, channel_id: str, user_id: str, db: Optional[Session] = None ) -> Optional[ChannelMemberModel]: - with get_db() as db: + with get_db_context(db) as db: # Check if the membership already exists existing_membership = ( db.query(ChannelMember) @@ -519,8 +580,10 @@ def join_channel( db.commit() return channel_member - def leave_channel(self, channel_id: str, user_id: str) -> bool: - with get_db() as db: + def leave_channel( + self, channel_id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -541,9 +604,9 @@ def leave_channel(self, channel_id: str, user_id: str) -> bool: return True def get_member_by_channel_and_user_id( - self, channel_id: str, user_id: str + self, channel_id: str, user_id: str, db: Optional[Session] = None ) -> Optional[ChannelMemberModel]: - with get_db() as db: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -554,8 +617,10 @@ def get_member_by_channel_and_user_id( ) return ChannelMemberModel.model_validate(membership) if membership else None - def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]: - with get_db() as db: + def get_members_by_channel_id( + self, channel_id: str, db: Optional[Session] = None + ) -> list[ChannelMemberModel]: + with get_db_context(db) as db: memberships = ( db.query(ChannelMember) .filter(ChannelMember.channel_id == channel_id) @@ -566,8 +631,14 @@ def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel] for membership in memberships ] - def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool: - with get_db() as db: + def pin_channel( + self, + channel_id: str, + user_id: str, + is_pinned: bool, + db: Optional[Session] = None, + ) -> bool: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -585,8 +656,10 @@ def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool: db.commit() return True - def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool: - with get_db() as db: + def update_member_last_read_at( + self, channel_id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -605,9 +678,13 @@ def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool: return True def update_member_active_status( - self, channel_id: str, user_id: str, is_active: bool + self, + channel_id: str, + user_id: str, + is_active: bool, + db: Optional[Session] = None, ) -> bool: - with get_db() as db: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -625,8 +702,10 @@ def update_member_active_status( db.commit() return True - def is_user_channel_member(self, channel_id: str, user_id: str) -> bool: - with get_db() as db: + def is_user_channel_member( + self, channel_id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: membership = ( db.query(ChannelMember) .filter( @@ -637,15 +716,153 @@ def is_user_channel_member(self, channel_id: str, user_id: str) -> bool: ) return membership is not None - def get_channel_by_id(self, id: str) -> Optional[ChannelModel]: - with get_db() as db: - channel = db.query(Channel).filter(Channel.id == id).first() - return ChannelModel.model_validate(channel) if channel else None + def get_channel_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChannelModel]: + try: + with get_db_context(db) as db: + channel = db.query(Channel).filter(Channel.id == id).first() + return ChannelModel.model_validate(channel) if channel else None + except Exception: + return None + + def get_channels_by_file_id( + self, file_id: str, db: Optional[Session] = None + ) -> list[ChannelModel]: + with get_db_context(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, db: Optional[Session] = None + ) -> list[ChannelModel]: + with get_db_context(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, db=db) + ] + + 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, db: Optional[Session] = None + ) -> Optional[ChannelModel]: + with get_db_context(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, db=db) + ] + + # 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( - self, id: str, form_data: ChannelForm + self, id: str, form_data: ChannelForm, db: Optional[Session] = None ) -> Optional[ChannelModel]: - with get_db() as db: + with get_db_context(db) as db: channel = db.query(Channel).filter(Channel.id == id).first() if not channel: return None @@ -663,11 +880,180 @@ def update_channel_by_id( db.commit() return ChannelModel.model_validate(channel) if channel else None - def delete_channel_by_id(self, id: str): - with get_db() as db: + def add_file_to_channel_by_id( + self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[ChannelFileModel]: + with get_db_context(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, + db: Optional[Session] = None, + ) -> bool: + try: + with get_db_context(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, db: Optional[Session] = None + ) -> bool: + try: + with get_db_context(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, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: db.query(Channel).filter(Channel.id == id).delete() db.commit() return True + #################### + # Webhook Methods + #################### + + def insert_webhook( + self, + channel_id: str, + user_id: str, + form_data: ChannelWebhookForm, + db: Optional[Session] = None, + ) -> Optional[ChannelWebhookModel]: + with get_db_context(db) as db: + webhook = ChannelWebhookModel( + id=str(uuid.uuid4()), + channel_id=channel_id, + user_id=user_id, + name=form_data.name, + profile_image_url=form_data.profile_image_url, + token=secrets.token_urlsafe(32), + last_used_at=None, + created_at=int(time.time_ns()), + updated_at=int(time.time_ns()), + ) + db.add(ChannelWebhook(**webhook.model_dump())) + db.commit() + return webhook + + def get_webhooks_by_channel_id( + self, channel_id: str, db: Optional[Session] = None + ) -> list[ChannelWebhookModel]: + with get_db_context(db) as db: + webhooks = ( + db.query(ChannelWebhook) + .filter(ChannelWebhook.channel_id == channel_id) + .all() + ) + return [ChannelWebhookModel.model_validate(w) for w in webhooks] + + def get_webhook_by_id( + self, webhook_id: str, db: Optional[Session] = None + ) -> Optional[ChannelWebhookModel]: + with get_db_context(db) as db: + webhook = ( + db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + ) + return ChannelWebhookModel.model_validate(webhook) if webhook else None + + def get_webhook_by_id_and_token( + self, webhook_id: str, token: str, db: Optional[Session] = None + ) -> Optional[ChannelWebhookModel]: + with get_db_context(db) as db: + webhook = ( + db.query(ChannelWebhook) + .filter( + ChannelWebhook.id == webhook_id, + ChannelWebhook.token == token, + ) + .first() + ) + return ChannelWebhookModel.model_validate(webhook) if webhook else None + + def update_webhook_by_id( + self, + webhook_id: str, + form_data: ChannelWebhookForm, + db: Optional[Session] = None, + ) -> Optional[ChannelWebhookModel]: + with get_db_context(db) as db: + webhook = ( + db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + ) + if not webhook: + return None + webhook.name = form_data.name + webhook.profile_image_url = form_data.profile_image_url + webhook.updated_at = int(time.time_ns()) + db.commit() + return ChannelWebhookModel.model_validate(webhook) + + def update_webhook_last_used_at( + self, webhook_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: + webhook = ( + db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() + ) + if not webhook: + return False + webhook.last_used_at = int(time.time_ns()) + db.commit() + return True + + def delete_webhook_by_id( + self, webhook_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: + result = ( + db.query(ChannelWebhook) + .filter(ChannelWebhook.id == webhook_id) + .delete() + ) + db.commit() + return result > 0 + Channels = ChannelTable() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 187a4522c92..12359eec9fc 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -4,13 +4,24 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders -from open_webui.env import SRC_LOG_LEVELS +from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db 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.sql import exists from sqlalchemy.sql.expression import bindparam @@ -20,7 +31,6 @@ #################### log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Chat(Base): @@ -75,6 +85,38 @@ class ChatModel(BaseModel): 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 #################### @@ -126,20 +168,95 @@ class ChatTitleIdResponse(BaseModel): 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 MessageStats(BaseModel): + id: str + role: str + model: Optional[str] = None + content_length: int + token_count: Optional[int] = None + timestamp: Optional[int] = None + rating: Optional[int] = None # Derived from message.annotation.rating + tags: Optional[list[str]] = None # Derived from message.annotation.tags + + +class ChatHistoryStats(BaseModel): + messages: dict[str, MessageStats] + currentId: Optional[str] = None + + +class ChatBody(BaseModel): + history: ChatHistoryStats + + +class AggregateChatStats(BaseModel): + average_response_time: float + average_user_message_content_length: float + average_assistant_message_content_length: float + models: dict[str, int] + message_count: int + history_models: dict[str, int] + history_message_count: int + history_user_message_count: int + history_assistant_message_count: int + + +class ChatStatsExport(BaseModel): + id: str + user_id: str + created_at: int + updated_at: int + tags: list[str] = [] + stats: AggregateChatStats + chat: ChatBody + + class ChatTable: def _clean_null_bytes(self, obj): - """ - Recursively remove actual null bytes (\x00) and unicode escape \\u0000 - from strings inside dict/list structures. - Safe for JSON objects. - """ - if isinstance(obj, str): - return obj.replace("\x00", "").replace("\u0000", "") - elif isinstance(obj, dict): - return {k: self._clean_null_bytes(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self._clean_null_bytes(v) for v in obj] - return obj + """Recursively remove null bytes from strings in dict/list structures.""" + return sanitize_data_for_db(obj) def _sanitize_chat_row(self, chat_item): """ @@ -164,8 +281,10 @@ def _sanitize_chat_row(self, chat_item): return changed - def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]: - with get_db() as db: + def insert_new_chat( + self, user_id: str, form_data: ChatForm, db: Optional[Session] = None + ) -> Optional[ChatModel]: + with get_db_context(db) as db: id = str(uuid.uuid4()) chat = ChatModel( **{ @@ -215,9 +334,12 @@ def _chat_import_form_to_chat_model( return chat def import_chats( - self, user_id: str, chat_import_forms: list[ChatImportForm] + self, + user_id: str, + chat_import_forms: list[ChatImportForm], + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: chats = [] for form_data in chat_import_forms: @@ -228,9 +350,11 @@ def import_chats( db.commit() return [ChatModel.model_validate(chat) for chat in chats] - def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]: + def update_chat_by_id( + self, id: str, chat: dict, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat_item = db.get(Chat, id) chat_item.chat = self._clean_null_bytes(chat) chat_item.title = ( @@ -310,7 +434,7 @@ def upsert_message_to_chat_by_id_and_message_id( # Sanitize message content for null characters before upserting if isinstance(message.get("content"), str): - message["content"] = message["content"].replace("\x00", "") + message["content"] = sanitize_text_for_db(message["content"]) chat = chat.chat history = chat.get("history", {}) @@ -349,31 +473,37 @@ def add_message_status_to_chat_by_id_and_message_id( def add_message_files_by_id_and_message_id( self, id: str, message_id: str, files: list[dict] ) -> list[dict]: - chat = self.get_chat_by_id(id) - if chat is None: - return None + with get_db_context() as db: + chat = self.get_chat_by_id(id, db=db) + if chat is None: + return None - chat = chat.chat - history = chat.get("history", {}) + chat = chat.chat + history = chat.get("history", {}) - message_files = [] + message_files = [] - if message_id in history.get("messages", {}): - message_files = history["messages"][message_id].get("files", []) - message_files = message_files + files - history["messages"][message_id]["files"] = message_files + if message_id in history.get("messages", {}): + message_files = history["messages"][message_id].get("files", []) + message_files = message_files + files + history["messages"][message_id]["files"] = message_files - chat["history"] = history - self.update_chat_by_id(id, chat) - return message_files + chat["history"] = history + self.update_chat_by_id(id, chat, db=db) + return message_files - def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: - with get_db() as db: + def insert_shared_chat_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: + with get_db_context(db) as db: # Get the existing chat to share chat = db.get(Chat, chat_id) + # Check if chat exists + if not chat: + return None # Check if the chat is already shared if chat.share_id: - return self.get_chat_by_id_and_user_id(chat.share_id, "shared") + return self.get_chat_by_id_and_user_id(chat.share_id, "shared", db=db) # Create a new chat with the same data, but with a new ID shared_chat = ChatModel( **{ @@ -402,16 +532,18 @@ def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: db.commit() return shared_chat if (shared_result and result) else None - def update_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: + def update_shared_chat_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, chat_id) shared_chat = ( db.query(Chat).filter_by(user_id=f"shared-{chat_id}").first() ) if shared_chat is None: - return self.insert_shared_chat_by_chat_id(chat_id) + return self.insert_shared_chat_by_chat_id(chat_id, db=db) shared_chat.title = chat.title shared_chat.chat = chat.chat @@ -426,9 +558,11 @@ def update_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: except Exception: return None - def delete_shared_chat_by_chat_id(self, chat_id: str) -> bool: + def delete_shared_chat_by_chat_id( + self, chat_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete() db.commit() @@ -436,9 +570,11 @@ def delete_shared_chat_by_chat_id(self, chat_id: str) -> bool: except Exception: return False - def unarchive_all_chats_by_user_id(self, user_id: str) -> bool: + def unarchive_all_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(user_id=user_id).update({"archived": False}) db.commit() return True @@ -446,10 +582,10 @@ def unarchive_all_chats_by_user_id(self, user_id: str) -> bool: return False def update_chat_share_id_by_id( - self, id: str, share_id: Optional[str] + self, id: str, share_id: Optional[str], db: Optional[Session] = None ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) chat.share_id = share_id db.commit() @@ -458,9 +594,11 @@ def update_chat_share_id_by_id( except Exception: return None - def toggle_chat_pinned_by_id(self, id: str) -> Optional[ChatModel]: + def toggle_chat_pinned_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) chat.pinned = not chat.pinned chat.updated_at = int(time.time()) @@ -470,9 +608,11 @@ def toggle_chat_pinned_by_id(self, id: str) -> Optional[ChatModel]: except Exception: return None - def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]: + def toggle_chat_archive_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) chat.archived = not chat.archived chat.folder_id = None @@ -483,9 +623,11 @@ def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]: except Exception: return None - def archive_all_chats_by_user_id(self, user_id: str) -> bool: + def archive_all_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(user_id=user_id).update({"archived": True}) db.commit() return True @@ -498,9 +640,10 @@ def get_archived_chat_list_by_user_id( filter: Optional[dict] = None, skip: int = 0, limit: int = 50, + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id, archived=True) if filter: @@ -539,8 +682,9 @@ def get_chat_list_by_user_id( filter: Optional[dict] = None, skip: int = 0, limit: int = 50, + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) if not include_archived: query = query.filter_by(archived=False) @@ -579,8 +723,9 @@ def get_chat_title_id_list_by_user_id( include_pinned: bool = False, skip: Optional[int] = None, limit: Optional[int] = None, + db: Optional[Session] = None, ) -> list[ChatTitleIdResponse]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) if not include_folders: @@ -617,9 +762,13 @@ def get_chat_title_id_list_by_user_id( ] def get_chat_list_by_chat_ids( - self, chat_ids: list[str], skip: int = 0, limit: int = 50 + self, + chat_ids: list[str], + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: all_chats = ( db.query(Chat) .filter(Chat.id.in_(chat_ids)) @@ -629,9 +778,11 @@ def get_chat_list_by_chat_ids( ) return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chat_by_id(self, id: str) -> Optional[ChatModel]: + def get_chat_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat_item = db.get(Chat, id) if chat_item is None: return None @@ -644,30 +795,36 @@ def get_chat_by_id(self, id: str) -> Optional[ChatModel]: except Exception: return None - def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]: + def get_chat_by_share_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: # it is possible that the shared link was deleted. hence, # we check if the chat is still shared by checking if a chat with the share_id exists chat = db.query(Chat).filter_by(share_id=id).first() if chat: - return self.get_chat_by_id(id) + return self.get_chat_by_id(id, db=db) else: return None except Exception: return None - def get_chat_by_id_and_user_id(self, id: str, user_id: str) -> Optional[ChatModel]: + def get_chat_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.query(Chat).filter_by(id=id, user_id=user_id).first() return ChatModel.model_validate(chat) except Exception: return None - def get_chats(self, skip: int = 0, limit: int = 50) -> list[ChatModel]: - with get_db() as db: + def get_chats( + self, skip: int = 0, limit: int = 50, db: Optional[Session] = None + ) -> list[ChatModel]: + with get_db_context(db) as db: all_chats = ( db.query(Chat) # .limit(limit).offset(skip) @@ -675,17 +832,56 @@ def get_chats(self, skip: int = 0, limit: int = 50) -> list[ChatModel]: ) return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chats_by_user_id(self, user_id: str) -> list[ChatModel]: - with get_db() as db: - all_chats = ( - db.query(Chat) - .filter_by(user_id=user_id) - .order_by(Chat.updated_at.desc()) + def get_chats_by_user_id( + self, + user_id: str, + filter: Optional[dict] = None, + skip: Optional[int] = None, + limit: Optional[int] = None, + db: Optional[Session] = None, + ) -> ChatListResponse: + with get_db_context(db) as db: + query = db.query(Chat).filter_by(user_id=user_id) + + if filter: + if filter.get("updated_at"): + query = query.filter(Chat.updated_at > filter.get("updated_at")) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by and direction: + if hasattr(Chat, order_by): + if direction.lower() == "asc": + query = query.order_by(getattr(Chat, order_by).asc()) + elif direction.lower() == "desc": + query = query.order_by(getattr(Chat, order_by).desc()) + else: + query = query.order_by(Chat.updated_at.desc()) + + else: + query = query.order_by(Chat.updated_at.desc()) + + 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, + } ) - return [ChatModel.model_validate(chat) for chat in all_chats] - def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: - with get_db() as db: + def get_pinned_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[ChatModel]: + with get_db_context(db) as db: all_chats = ( db.query(Chat) .filter_by(user_id=user_id, pinned=True, archived=False) @@ -693,8 +889,10 @@ def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: ) return [ChatModel.model_validate(chat) for chat in all_chats] - def get_archived_chats_by_user_id(self, user_id: str) -> list[ChatModel]: - with get_db() as db: + def get_archived_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[ChatModel]: + with get_db_context(db) as db: all_chats = ( db.query(Chat) .filter_by(user_id=user_id, archived=True) @@ -709,15 +907,16 @@ def get_chats_by_user_id_and_search_text( include_archived: bool = False, skip: int = 0, limit: int = 60, + db: Optional[Session] = None, ) -> list[ChatModel]: """ 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: return self.get_chat_list_by_user_id( - user_id, include_archived, filter={}, skip=skip, limit=limit + user_id, include_archived, filter={}, skip=skip, limit=limit, db=db ) search_text_words = search_text.split(" ") @@ -772,7 +971,7 @@ def get_chats_by_user_id_and_search_text( search_text = " ".join(search_text_words) - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter(Chat.user_id == user_id) if is_archived is not None: @@ -913,9 +1112,14 @@ def get_chats_by_user_id_and_search_text( return [ChatModel.model_validate(chat) for chat in all_chats] def get_chats_by_folder_id_and_user_id( - self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60 + self, + folder_id: str, + user_id: str, + skip: int = 0, + limit: int = 60, + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id) query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) query = query.filter_by(archived=False) @@ -931,9 +1135,9 @@ def get_chats_by_folder_id_and_user_id( return [ChatModel.model_validate(chat) for chat in all_chats] def get_chats_by_folder_ids_and_user_id( - self, folder_ids: list[str], user_id: str + self, folder_ids: list[str], user_id: str, db: Optional[Session] = None ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter( Chat.folder_id.in_(folder_ids), Chat.user_id == user_id ) @@ -946,10 +1150,10 @@ def get_chats_by_folder_ids_and_user_id( return [ChatModel.model_validate(chat) for chat in all_chats] def update_chat_folder_id_by_id_and_user_id( - self, id: str, user_id: str, folder_id: str + self, id: str, user_id: str, folder_id: str, db: Optional[Session] = None ) -> Optional[ChatModel]: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) chat.folder_id = folder_id chat.updated_at = int(time.time()) @@ -960,16 +1164,23 @@ def update_chat_folder_id_by_id_and_user_id( except Exception: return None - def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]: - with get_db() as db: + def get_chat_tags_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> list[TagModel]: + with get_db_context(db) as db: chat = db.get(Chat, id) tags = chat.meta.get("tags", []) return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags] def get_chat_list_by_user_id_and_tag_name( - self, user_id: str, tag_name: str, skip: int = 0, limit: int = 50 + self, + user_id: str, + tag_name: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, ) -> list[ChatModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) tag_id = tag_name.replace(" ", "_").lower() @@ -998,13 +1209,13 @@ def get_chat_list_by_user_id_and_tag_name( return [ChatModel.model_validate(chat) for chat in all_chats] def add_chat_tag_by_id_and_user_id_and_tag_name( - self, id: str, user_id: str, tag_name: str + self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None ) -> Optional[ChatModel]: tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id) if tag is None: tag = Tags.insert_new_tag(tag_name, user_id) try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) tag_id = tag.id @@ -1020,8 +1231,10 @@ def add_chat_tag_by_id_and_user_id_and_tag_name( except Exception: return None - def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> int: - with get_db() as db: # Assuming `get_db()` returns a session object + def count_chats_by_tag_name_and_user_id( + self, tag_name: str, user_id: str, db: Optional[Session] = None + ) -> int: + with get_db_context(db) as db: # Assuming `get_db()` returns a session object query = db.query(Chat).filter_by(user_id=user_id, archived=False) # Normalize the tag_name for consistency @@ -1056,8 +1269,10 @@ def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> in return count - def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int: - with get_db() as db: + def count_chats_by_folder_id_and_user_id( + self, folder_id: str, user_id: str, db: Optional[Session] = None + ) -> int: + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) query = query.filter_by(folder_id=folder_id) @@ -1067,10 +1282,10 @@ def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> return count def delete_tag_by_id_and_user_id_and_tag_name( - self, id: str, user_id: str, tag_name: str + self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) tags = chat.meta.get("tags", []) tag_id = tag_name.replace(" ", "_").lower() @@ -1085,9 +1300,11 @@ def delete_tag_by_id_and_user_id_and_tag_name( except Exception: return False - def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str) -> bool: + def delete_all_tags_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: chat = db.get(Chat, id) chat.meta = { **chat.meta, @@ -1099,30 +1316,34 @@ def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str) -> bool: except Exception: return False - def delete_chat_by_id(self, id: str) -> bool: + def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(id=id).delete() db.commit() - return True and self.delete_shared_chat_by_chat_id(id) + return True and self.delete_shared_chat_by_chat_id(id, db=db) except Exception: return False - def delete_chat_by_id_and_user_id(self, id: str, user_id: str) -> bool: + def delete_chat_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(id=id, user_id=user_id).delete() db.commit() - return True and self.delete_shared_chat_by_chat_id(id) + return True and self.delete_shared_chat_by_chat_id(id, db=db) except Exception: return False - def delete_chats_by_user_id(self, user_id: str) -> bool: + def delete_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: - self.delete_shared_chats_by_user_id(user_id) + with get_db_context(db) as db: + self.delete_shared_chats_by_user_id(user_id, db=db) db.query(Chat).filter_by(user_id=user_id).delete() db.commit() @@ -1132,10 +1353,10 @@ def delete_chats_by_user_id(self, user_id: str) -> bool: return False def delete_chats_by_user_id_and_folder_id( - self, user_id: str, folder_id: str + self, user_id: str, folder_id: str, db: Optional[Session] = None ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete() db.commit() @@ -1144,10 +1365,14 @@ def delete_chats_by_user_id_and_folder_id( return False def move_chats_by_user_id_and_folder_id( - self, user_id: str, folder_id: str, new_folder_id: Optional[str] + self, + user_id: str, + folder_id: str, + new_folder_id: Optional[str], + db: Optional[Session] = None, ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update( {"folder_id": new_folder_id} ) @@ -1157,9 +1382,11 @@ def move_chats_by_user_id_and_folder_id( except Exception: return False - def delete_shared_chats_by_user_id(self, user_id: str) -> bool: + def delete_shared_chats_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user] @@ -1170,5 +1397,102 @@ def delete_shared_chats_by_user_id(self, user_id: str) -> bool: except Exception: return False + def insert_chat_files( + self, + chat_id: str, + message_id: str, + file_ids: list[str], + user_id: str, + db: Optional[Session] = None, + ) -> 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, db=db + ) + ] + # 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_context(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, db: Optional[Session] = None + ) -> list[ChatFileModel]: + with get_db_context(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, db: Optional[Session] = None + ) -> bool: + try: + with get_db_context(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, db: Optional[Session] = None + ) -> list[ChatModel]: + with get_db_context(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() diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 5a91804b56c..048c10f85c9 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -3,15 +3,14 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.users import User -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, JSON, Boolean log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### @@ -62,6 +61,20 @@ class FeedbackResponse(BaseModel): updated_at: int +class FeedbackIdResponse(BaseModel): + id: str + user_id: str + created_at: int + updated_at: int + + +class LeaderboardFeedbackData(BaseModel): + """Minimal feedback data for leaderboard computation (excludes snapshot/meta).""" + + id: str + data: Optional[dict] = None + + class RatingData(BaseModel): rating: Optional[str | int] = None model_id: Optional[str] = None @@ -114,11 +127,22 @@ class FeedbackListResponse(BaseModel): total: int +class ModelHistoryEntry(BaseModel): + date: str + won: int + lost: int + + +class ModelHistoryResponse(BaseModel): + model_id: str + history: list[ModelHistoryEntry] + + class FeedbackTable: def insert_new_feedback( - self, user_id: str, form_data: FeedbackForm + self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None ) -> Optional[FeedbackModel]: - with get_db() as db: + with get_db_context(db) as db: id = str(uuid.uuid4()) feedback = FeedbackModel( **{ @@ -143,9 +167,11 @@ def insert_new_feedback( log.exception(f"Error creating a new feedback: {e}") return None - def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: + def get_feedback_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[FeedbackModel]: try: - with get_db() as db: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id).first() if not feedback: return None @@ -154,10 +180,10 @@ def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: return None def get_feedback_by_id_and_user_id( - self, id: str, user_id: str + self, id: str, user_id: str, db: Optional[Session] = None ) -> Optional[FeedbackModel]: try: - with get_db() as db: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() if not feedback: return None @@ -166,9 +192,13 @@ def get_feedback_by_id_and_user_id( return None def get_feedback_items( - self, filter: dict = {}, skip: int = 0, limit: int = 30 + self, + filter: dict = {}, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, ) -> FeedbackListResponse: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Feedback, User).join(User, Feedback.user_id == User.id) if filter: @@ -229,8 +259,8 @@ def get_feedback_items( return FeedbackListResponse(items=feedbacks, total=total) - def get_all_feedbacks(self) -> list[FeedbackModel]: - with get_db() as db: + def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel]: + with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) for feedback in db.query(Feedback) @@ -238,8 +268,110 @@ def get_all_feedbacks(self) -> list[FeedbackModel]: .all() ] - def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]: - with get_db() as db: + def get_all_feedback_ids( + self, db: Optional[Session] = None + ) -> list[FeedbackIdResponse]: + with get_db_context(db) as db: + return [ + FeedbackIdResponse( + id=row.id, + user_id=row.user_id, + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in db.query( + Feedback.id, + Feedback.user_id, + Feedback.created_at, + Feedback.updated_at, + ) + .order_by(Feedback.updated_at.desc()) + .all() + ] + + def get_feedbacks_for_leaderboard( + self, db: Optional[Session] = None + ) -> list[LeaderboardFeedbackData]: + """Fetch only id and data for leaderboard computation (excludes snapshot/meta).""" + with get_db_context(db) as db: + return [ + LeaderboardFeedbackData(id=row.id, data=row.data) + for row in db.query(Feedback.id, Feedback.data).all() + ] + + def get_model_evaluation_history( + self, model_id: str, days: int = 30, db: Optional[Session] = None + ) -> list[ModelHistoryEntry]: + """ + Get daily wins/losses for a specific model over the past N days. + If days=0, returns all time data starting from first feedback. + Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...] + """ + from datetime import datetime, timedelta + from collections import defaultdict + + with get_db_context(db) as db: + if days == 0: + # All time - no cutoff + rows = db.query(Feedback.created_at, Feedback.data).all() + else: + cutoff = int(time.time()) - (days * 86400) + rows = ( + db.query(Feedback.created_at, Feedback.data) + .filter(Feedback.created_at >= cutoff) + .all() + ) + + daily_counts = defaultdict(lambda: {"won": 0, "lost": 0}) + first_date = None + + for created_at, data in rows: + if not data: + continue + if data.get("model_id") != model_id: + continue + + rating_str = str(data.get("rating", "")) + if rating_str not in ("1", "-1"): + continue + + date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d") + if rating_str == "1": + daily_counts[date_str]["won"] += 1 + else: + daily_counts[date_str]["lost"] += 1 + + # Track first date for this model + if first_date is None or date_str < first_date: + first_date = date_str + + # Generate date range + result = [] + today = datetime.now().date() + + if days == 0 and first_date: + # All time: start from first feedback date + start_date = datetime.strptime(first_date, "%Y-%m-%d").date() + num_days = (today - start_date).days + 1 + else: + # Fixed range + num_days = days + start_date = today - timedelta(days=days - 1) + + for i in range(num_days): + d = start_date + timedelta(days=i) + date_str = d.strftime("%Y-%m-%d") + counts = daily_counts.get(date_str, {"won": 0, "lost": 0}) + result.append( + ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"]) + ) + + return result + + def get_feedbacks_by_type( + self, type: str, db: Optional[Session] = None + ) -> list[FeedbackModel]: + with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) for feedback in db.query(Feedback) @@ -248,8 +380,10 @@ def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]: .all() ] - def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]: - with get_db() as db: + def get_feedbacks_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[FeedbackModel]: + with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) for feedback in db.query(Feedback) @@ -259,9 +393,9 @@ def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]: ] def update_feedback_by_id( - self, id: str, form_data: FeedbackForm + self, id: str, form_data: FeedbackForm, db: Optional[Session] = None ) -> Optional[FeedbackModel]: - with get_db() as db: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id).first() if not feedback: return None @@ -279,9 +413,13 @@ def update_feedback_by_id( return FeedbackModel.model_validate(feedback) def update_feedback_by_id_and_user_id( - self, id: str, user_id: str, form_data: FeedbackForm + self, + id: str, + user_id: str, + form_data: FeedbackForm, + db: Optional[Session] = None, ) -> Optional[FeedbackModel]: - with get_db() as db: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() if not feedback: return None @@ -298,8 +436,8 @@ def update_feedback_by_id_and_user_id( db.commit() return FeedbackModel.model_validate(feedback) - def delete_feedback_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id).first() if not feedback: return False @@ -307,8 +445,10 @@ def delete_feedback_by_id(self, id: str) -> bool: db.commit() return True - def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool: - with get_db() as db: + def delete_feedback_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() if not feedback: return False @@ -316,8 +456,10 @@ def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool: db.commit() return True - def delete_feedbacks_by_user_id(self, user_id: str) -> bool: - with get_db() as db: + def delete_feedbacks_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: feedbacks = db.query(Feedback).filter_by(user_id=user_id).all() if not feedbacks: return False @@ -326,8 +468,8 @@ def delete_feedbacks_by_user_id(self, user_id: str) -> bool: db.commit() return True - def delete_all_feedbacks(self) -> bool: - with get_db() as db: + def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: feedbacks = db.query(Feedback).all() if not feedbacks: return False diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 1ed743df879..4097ae08e14 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -2,13 +2,12 @@ import time from typing import Optional -from open_webui.internal.db import Base, JSONField, get_db -from open_webui.env import SRC_LOG_LEVELS +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text, JSON log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # Files DB Schema @@ -83,7 +82,7 @@ class FileModelResponse(BaseModel): class FileMetadataResponse(BaseModel): id: str hash: Optional[str] = None - meta: dict + meta: Optional[dict] = None created_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -104,9 +103,16 @@ class FileUpdateForm(BaseModel): meta: Optional[dict] = None +class FileListResponse(BaseModel): + items: list[FileModel] + total: int + + class FilesTable: - def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]: - with get_db() as db: + def insert_new_file( + self, user_id: str, form_data: FileForm, db: Optional[Session] = None + ) -> Optional[FileModel]: + with get_db_context(db) as db: file = FileModel( **{ **form_data.model_dump(), @@ -129,16 +135,23 @@ def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileMod log.exception(f"Error inserting a new file: {e}") return None - def get_file_by_id(self, id: str) -> Optional[FileModel]: - with get_db() as db: - try: - file = db.get(File, id) - return FileModel.model_validate(file) - except Exception: - return None + def get_file_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[FileModel]: + try: + with get_db_context(db) as db: + try: + file = db.get(File, id) + return FileModel.model_validate(file) + except Exception: + return None + except Exception: + return None - def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]: - with get_db() as db: + def get_file_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[FileModel]: + with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id, user_id=user_id).first() if file: @@ -148,8 +161,10 @@ def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileMode except Exception: return None - def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]: - with get_db() as db: + def get_file_metadata_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[FileMetadataResponse]: + with get_db_context(db) as db: try: file = db.get(File, id) return FileMetadataResponse( @@ -162,12 +177,14 @@ def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]: except Exception: return None - def get_files(self) -> list[FileModel]: - with get_db() as db: + def get_files(self, db: Optional[Session] = None) -> list[FileModel]: + with get_db_context(db) as db: return [FileModel.model_validate(file) for file in db.query(File).all()] - def check_access_by_user_id(self, id, user_id, permission="write") -> bool: - file = self.get_file_by_id(id) + def check_access_by_user_id( + self, id, user_id, permission="write", db: Optional[Session] = None + ) -> bool: + file = self.get_file_by_id(id, db=db) if not file: return False if file.user_id == user_id: @@ -175,8 +192,10 @@ def check_access_by_user_id(self, id, user_id, permission="write") -> bool: # Implement additional access control logic here as needed return False - def get_files_by_ids(self, ids: list[str]) -> list[FileModel]: - with get_db() as db: + def get_files_by_ids( + self, ids: list[str], db: Optional[Session] = None + ) -> list[FileModel]: + with get_db_context(db) as db: return [ FileModel.model_validate(file) for file in db.query(File) @@ -185,8 +204,10 @@ def get_files_by_ids(self, ids: list[str]) -> list[FileModel]: .all() ] - def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse]: - with get_db() as db: + def get_file_metadatas_by_ids( + self, ids: list[str], db: Optional[Session] = None + ) -> list[FileMetadataResponse]: + with get_db_context(db) as db: return [ FileMetadataResponse( id=file.id, @@ -203,17 +224,81 @@ def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse .all() ] - def get_files_by_user_id(self, user_id: str) -> list[FileModel]: - with get_db() as db: + def get_files_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[FileModel]: + with get_db_context(db) as db: return [ FileModel.model_validate(file) for file in db.query(File).filter_by(user_id=user_id).all() ] + @staticmethod + def _glob_to_like_pattern(glob: str) -> str: + """ + Convert a glob/fnmatch pattern to a SQL LIKE pattern. + + Escapes SQL special characters and converts glob wildcards: + - `*` becomes `%` (match any sequence of characters) + - `?` becomes `_` (match exactly one character) + + Args: + glob: A glob pattern (e.g., "*.txt", "file?.doc") + + Returns: + A SQL LIKE compatible pattern with proper escaping. + """ + # Escape SQL special characters first, then convert glob wildcards + pattern = glob.replace("\\", "\\\\") + pattern = pattern.replace("%", "\\%") + pattern = pattern.replace("_", "\\_") + pattern = pattern.replace("*", "%") + pattern = pattern.replace("?", "_") + return pattern + + def search_files( + self, + user_id: Optional[str] = None, + filename: str = "*", + skip: int = 0, + limit: int = 100, + db: Optional[Session] = None, + ) -> list[FileModel]: + """ + Search files with glob pattern matching, optional user filter, and pagination. + + Args: + user_id: Filter by user ID. If None, returns files for all users. + filename: Glob pattern to match filenames (e.g., "*.txt"). Default "*" matches all. + skip: Number of results to skip for pagination. + limit: Maximum number of results to return. + db: Optional database session. + + Returns: + List of matching FileModel objects, ordered by updated_at descending. + """ + with get_db_context(db) as db: + query = db.query(File) + + if user_id: + query = query.filter_by(user_id=user_id) + + pattern = self._glob_to_like_pattern(filename) + if pattern != "%": + query = query.filter(File.filename.ilike(pattern, escape="\\")) + + return [ + FileModel.model_validate(file) + for file in query.order_by(File.updated_at.desc()) + .offset(skip) + .limit(limit) + .all() + ] + def update_file_by_id( - self, id: str, form_data: FileUpdateForm + self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None ) -> Optional[FileModel]: - with get_db() as db: + with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() @@ -233,40 +318,51 @@ def update_file_by_id( log.exception(f"Error updating file completely by id: {e}") return None - def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]: - with get_db() as db: + def update_file_hash_by_id( + self, id: str, hash: Optional[str], db: Optional[Session] = None + ) -> Optional[FileModel]: + with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() file.hash = hash + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) except Exception: return None - def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]: - with get_db() as db: + def update_file_data_by_id( + self, id: str, data: dict, db: Optional[Session] = None + ) -> Optional[FileModel]: + with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() file.data = {**(file.data if file.data else {}), **data} + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) except Exception as e: return None - def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel]: - with get_db() as db: + def update_file_metadata_by_id( + self, id: str, meta: dict, db: Optional[Session] = None + ) -> Optional[FileModel]: + with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() file.meta = {**(file.meta if file.meta else {}), **meta} + file.updated_at = int(time.time()) db.commit() return FileModel.model_validate(file) except Exception: return None - def delete_file_by_id(self, id: str) -> bool: - with get_db() as db: + return False + + def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(File).filter_by(id=id).delete() db.commit() @@ -275,8 +371,8 @@ def delete_file_by_id(self, id: str) -> bool: except Exception: return False - def delete_all_files(self) -> bool: - with get_db() as db: + def delete_all_files(self, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(File).delete() db.commit() diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 6e1735ecea2..34552089448 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -7,13 +7,12 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func +from sqlalchemy.orm import Session -from open_webui.internal.db import Base, get_db -from open_webui.env import SRC_LOG_LEVELS +from open_webui.internal.db import Base, JSONField, get_db, get_db_context log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### @@ -85,9 +84,13 @@ class FolderUpdateForm(BaseModel): class FolderTable: def insert_new_folder( - self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None + self, + user_id: str, + form_data: FolderForm, + parent_id: Optional[str] = None, + db: Optional[Session] = None, ) -> Optional[FolderModel]: - with get_db() as db: + with get_db_context(db) as db: id = str(uuid.uuid4()) folder = FolderModel( **{ @@ -113,10 +116,10 @@ def insert_new_folder( return None def get_folder_by_id_and_user_id( - self, id: str, user_id: str + self, id: str, user_id: str, db: Optional[Session] = None ) -> Optional[FolderModel]: try: - with get_db() as db: + with get_db_context(db) as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: @@ -127,15 +130,15 @@ def get_folder_by_id_and_user_id( return None def get_children_folders_by_id_and_user_id( - self, id: str, user_id: str + self, id: str, user_id: str, db: Optional[Session] = None ) -> Optional[list[FolderModel]]: try: - with get_db() as db: + with get_db_context(db) as db: folders = [] def get_children(folder): children = self.get_folders_by_parent_id_and_user_id( - folder.id, user_id + folder.id, user_id, db=db ) for child in children: get_children(child) @@ -150,18 +153,24 @@ def get_children(folder): except Exception: return None - def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]: - with get_db() as db: + def get_folders_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[FolderModel]: + with get_db_context(db) as db: return [ FolderModel.model_validate(folder) for folder in db.query(Folder).filter_by(user_id=user_id).all() ] def get_folder_by_parent_id_and_user_id_and_name( - self, parent_id: Optional[str], user_id: str, name: str + self, + parent_id: Optional[str], + user_id: str, + name: str, + db: Optional[Session] = None, ) -> Optional[FolderModel]: try: - with get_db() as db: + with get_db_context(db) as db: # Check if folder exists folder = ( db.query(Folder) @@ -179,9 +188,9 @@ def get_folder_by_parent_id_and_user_id_and_name( return None def get_folders_by_parent_id_and_user_id( - self, parent_id: Optional[str], user_id: str + self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None ) -> list[FolderModel]: - with get_db() as db: + with get_db_context(db) as db: return [ FolderModel.model_validate(folder) for folder in db.query(Folder) @@ -194,9 +203,10 @@ def update_folder_parent_id_by_id_and_user_id( id: str, user_id: str, parent_id: str, + db: Optional[Session] = None, ) -> Optional[FolderModel]: try: - with get_db() as db: + with get_db_context(db) as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: @@ -213,10 +223,14 @@ def update_folder_parent_id_by_id_and_user_id( return def update_folder_by_id_and_user_id( - self, id: str, user_id: str, form_data: FolderUpdateForm + self, + id: str, + user_id: str, + form_data: FolderUpdateForm, + db: Optional[Session] = None, ) -> Optional[FolderModel]: try: - with get_db() as db: + with get_db_context(db) as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: @@ -259,10 +273,10 @@ def update_folder_by_id_and_user_id( return def update_folder_is_expanded_by_id_and_user_id( - self, id: str, user_id: str, is_expanded: bool + self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None ) -> Optional[FolderModel]: try: - with get_db() as db: + with get_db_context(db) as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: @@ -278,10 +292,12 @@ def update_folder_is_expanded_by_id_and_user_id( log.error(f"update_folder: {e}") return - def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]: + def delete_folder_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> list[str]: try: folder_ids = [] - with get_db() as db: + with get_db_context(db) as db: folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() if not folder: return folder_ids @@ -291,7 +307,7 @@ def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]: # Delete all children folders def delete_children(folder): folder_children = self.get_folders_by_parent_id_and_user_id( - folder.id, user_id + folder.id, user_id, db=db ) for folder_child in folder_children: @@ -316,7 +332,7 @@ def normalize_folder_name(self, name: str) -> str: return name.strip().lower() def search_folders_by_names( - self, user_id: str, queries: list[str] + self, user_id: str, queries: list[str], db: Optional[Session] = None ) -> list[FolderModel]: """ Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive. @@ -326,7 +342,7 @@ def search_folders_by_names( return [] results = {} - with get_db() as db: + with get_db_context(db) as db: folders = db.query(Folder).filter_by(user_id=user_id).all() for folder in folders: if self.normalize_folder_name(folder.name) in normalized_queries: @@ -334,7 +350,7 @@ def search_folders_by_names( # get children folders children = self.get_children_folders_by_id_and_user_id( - folder.id, user_id + folder.id, user_id, db=db ) for child in children: results[child.id] = child @@ -347,14 +363,14 @@ def search_folders_by_names( return results def search_folders_by_name_contains( - self, user_id: str, query: str + self, user_id: str, query: str, db: Optional[Session] = None ) -> list[FolderModel]: """ Partial match: normalized name contains (as substring) the normalized query. """ normalized_query = self.normalize_folder_name(query) results = [] - with get_db() as db: + with get_db_context(db) as db: folders = db.query(Folder).filter_by(user_id=user_id).all() for folder in folders: norm_name = self.normalize_folder_name(folder.name) diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 91736f949a4..8e23bac0935 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -2,14 +2,13 @@ import time from typing import Optional -from open_webui.internal.db import Base, JSONField, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.users import Users, UserModel -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # Functions DB Schema @@ -105,7 +104,11 @@ class FunctionValves(BaseModel): class FunctionsTable: def insert_new_function( - self, user_id: str, type: str, form_data: FunctionForm + self, + user_id: str, + type: str, + form_data: FunctionForm, + db: Optional[Session] = None, ) -> Optional[FunctionModel]: function = FunctionModel( **{ @@ -118,7 +121,7 @@ def insert_new_function( ) try: - with get_db() as db: + with get_db_context(db) as db: result = Function(**function.model_dump()) db.add(result) db.commit() @@ -132,11 +135,14 @@ def insert_new_function( return None def sync_functions( - self, user_id: str, functions: list[FunctionWithValvesModel] + self, + user_id: str, + functions: list[FunctionWithValvesModel], + db: Optional[Session] = None, ) -> list[FunctionWithValvesModel]: # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present. try: - with get_db() as db: + with get_db_context(db) as db: # Get existing functions existing_functions = db.query(Function).all() existing_ids = {func.id for func in existing_functions} @@ -179,18 +185,20 @@ def sync_functions( log.exception(f"Error syncing functions for user {user_id}: {e}") return [] - def get_function_by_id(self, id: str) -> Optional[FunctionModel]: + def get_function_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[FunctionModel]: try: - with get_db() as db: + with get_db_context(db) as db: function = db.get(Function, id) return FunctionModel.model_validate(function) except Exception: return None def get_functions( - self, active_only=False, include_valves=False + self, active_only=False, include_valves=False, db: Optional[Session] = None ) -> list[FunctionModel | FunctionWithValvesModel]: - with get_db() as db: + with get_db_context(db) as db: if active_only: functions = db.query(Function).filter_by(is_active=True).all() @@ -207,12 +215,14 @@ def get_functions( FunctionModel.model_validate(function) for function in functions ] - def get_function_list(self) -> list[FunctionUserResponse]: - with get_db() as db: + def get_function_list( + self, db: Optional[Session] = None + ) -> list[FunctionUserResponse]: + with get_db_context(db) as db: functions = db.query(Function).order_by(Function.updated_at.desc()).all() user_ids = list(set(func.user_id for func in functions)) - users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} return [ @@ -230,9 +240,9 @@ def get_function_list(self) -> list[FunctionUserResponse]: ] def get_functions_by_type( - self, type: str, active_only=False + self, type: str, active_only=False, db: Optional[Session] = None ) -> list[FunctionModel]: - with get_db() as db: + with get_db_context(db) as db: if active_only: return [ FunctionModel.model_validate(function) @@ -246,8 +256,10 @@ def get_functions_by_type( for function in db.query(Function).filter_by(type=type).all() ] - def get_global_filter_functions(self) -> list[FunctionModel]: - with get_db() as db: + def get_global_filter_functions( + self, db: Optional[Session] = None + ) -> list[FunctionModel]: + with get_db_context(db) as db: return [ FunctionModel.model_validate(function) for function in db.query(Function) @@ -255,8 +267,10 @@ def get_global_filter_functions(self) -> list[FunctionModel]: .all() ] - def get_global_action_functions(self) -> list[FunctionModel]: - with get_db() as db: + def get_global_action_functions( + self, db: Optional[Session] = None + ) -> list[FunctionModel]: + with get_db_context(db) as db: return [ FunctionModel.model_validate(function) for function in db.query(Function) @@ -264,8 +278,10 @@ def get_global_action_functions(self) -> list[FunctionModel]: .all() ] - def get_function_valves_by_id(self, id: str) -> Optional[dict]: - with get_db() as db: + def get_function_valves_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[dict]: + with get_db_context(db) as db: try: function = db.get(Function, id) return function.valves if function.valves else {} @@ -274,23 +290,23 @@ def get_function_valves_by_id(self, id: str) -> Optional[dict]: return None def update_function_valves_by_id( - self, id: str, valves: dict + self, id: str, valves: dict, db: Optional[Session] = None ) -> Optional[FunctionValves]: - with get_db() as db: + with get_db_context(db) as db: try: function = db.get(Function, id) function.valves = valves function.updated_at = int(time.time()) db.commit() db.refresh(function) - return self.get_function_by_id(id) + return self.get_function_by_id(id, db=db) except Exception: return None def update_function_metadata_by_id( - self, id: str, metadata: dict + self, id: str, metadata: dict, db: Optional[Session] = None ) -> Optional[FunctionModel]: - with get_db() as db: + with get_db_context(db) as db: try: function = db.get(Function, id) @@ -303,7 +319,7 @@ def update_function_metadata_by_id( function.updated_at = int(time.time()) db.commit() db.refresh(function) - return self.get_function_by_id(id) + return self.get_function_by_id(id, db=db) else: return None except Exception as e: @@ -311,10 +327,10 @@ def update_function_metadata_by_id( return None def get_user_valves_by_id_and_user_id( - self, id: str, user_id: str + self, id: str, user_id: str, db: Optional[Session] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings @@ -329,10 +345,10 @@ def get_user_valves_by_id_and_user_id( return None def update_user_valves_by_id_and_user_id( - self, id: str, user_id: str, valves: dict + self, id: str, user_id: str, valves: dict, db: Optional[Session] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings @@ -344,7 +360,7 @@ def update_user_valves_by_id_and_user_id( user_settings["functions"]["valves"][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {"settings": user_settings}) + Users.update_user_by_id(user_id, {"settings": user_settings}, db=db) return user_settings["functions"]["valves"][id] except Exception as e: @@ -353,8 +369,10 @@ def update_user_valves_by_id_and_user_id( ) return None - def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]: - with get_db() as db: + def update_function_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[FunctionModel]: + with get_db_context(db) as db: try: db.query(Function).filter_by(id=id).update( { @@ -363,12 +381,12 @@ def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionMode } ) db.commit() - return self.get_function_by_id(id) + return self.get_function_by_id(id, db=db) except Exception: return None - def deactivate_all_functions(self) -> Optional[bool]: - with get_db() as db: + def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]: + with get_db_context(db) as db: try: db.query(Function).update( { @@ -381,8 +399,8 @@ def deactivate_all_functions(self) -> Optional[bool]: except Exception: return None - def delete_function_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(Function).filter_by(id=id).delete() db.commit() diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index a7900e2c78d..ae557f4daff 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -4,8 +4,8 @@ from typing import Optional import uuid -from open_webui.internal.db import Base, get_db -from open_webui.env import SRC_LOG_LEVELS +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.files import FileMetadataResponse @@ -26,7 +26,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # UserGroup DB Schema @@ -122,9 +121,9 @@ class GroupListResponse(BaseModel): class GroupTable: def insert_new_group( - self, user_id: str, form_data: GroupForm + self, user_id: str, form_data: GroupForm, db: Optional[Session] = None ) -> Optional[GroupModel]: - with get_db() as db: + with get_db_context(db) as db: group = GroupModel( **{ **form_data.model_dump(exclude_none=True), @@ -148,54 +147,84 @@ def insert_new_group( except Exception: return None - def get_all_groups(self) -> list[GroupModel]: - with get_db() as db: + def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]: + with get_db_context(db) as db: groups = db.query(Group).order_by(Group.updated_at.desc()).all() return [GroupModel.model_validate(group) for group in groups] - def get_groups(self, filter) -> list[GroupResponse]: - with get_db() as db: + def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]: + with get_db_context(db) as db: query = db.query(Group) if filter: if "query" in filter: query = query.filter(Group.name.ilike(f"%{filter['query']}%")) - if "member_id" in filter: - query = query.join( - GroupMember, GroupMember.group_id == Group.id - ).filter(GroupMember.user_id == filter["member_id"]) + # When share filter is present, member check is handled in the share logic if "share" in filter: share_value = filter["share"] - json_share = Group.data["config"]["share"].as_boolean() + member_id = filter.get("member_id") + json_share = Group.data["config"]["share"] + json_share_bool = json_share.as_boolean() + json_share_str = json_share.as_string() if share_value: - query = query.filter( - or_( - Group.data.is_(None), - json_share.is_(None), - json_share == True, - ) + # Groups open to anyone: data is null, share is null, or share is true + anyone_can_share = or_( + Group.data.is_(None), + json_share_bool.is_(None), + json_share_bool == True, ) + + if member_id: + # Also include member-only groups where user is a member + member_groups_subq = ( + db.query(GroupMember.group_id) + .filter(GroupMember.user_id == member_id) + .subquery() + ) + members_only_and_is_member = and_( + json_share_str == "members", + Group.id.in_(member_groups_subq), + ) + query = query.filter( + or_(anyone_can_share, members_only_and_is_member) + ) + else: + query = query.filter(anyone_can_share) else: query = query.filter( - and_(Group.data.isnot(None), json_share == False) + and_(Group.data.isnot(None), json_share_bool == False) ) + + else: + # Only apply member_id filter when share filter is NOT present + if "member_id" in filter: + query = query.join( + GroupMember, GroupMember.group_id == Group.id + ).filter(GroupMember.user_id == filter["member_id"]) + groups = query.order_by(Group.updated_at.desc()).all() return [ GroupResponse.model_validate( { **GroupModel.model_validate(group).model_dump(), - "member_count": self.get_group_member_count_by_id(group.id), + "member_count": self.get_group_member_count_by_id( + group.id, db=db + ), } ) for group in groups ] def search_groups( - self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30 + self, + filter: Optional[dict] = None, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, ) -> GroupListResponse: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Group) if filter: @@ -222,15 +251,17 @@ def search_groups( "items": [ GroupResponse.model_validate( **GroupModel.model_validate(group).model_dump(), - member_count=self.get_group_member_count_by_id(group.id), + member_count=self.get_group_member_count_by_id(group.id, db=db), ) for group in groups ], "total": total, } - def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]: - with get_db() as db: + def get_groups_by_member_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[GroupModel]: + with get_db_context(db) as db: return [ GroupModel.model_validate(group) for group in db.query(Group) @@ -240,16 +271,41 @@ def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]: .all() ] - def get_group_by_id(self, id: str) -> Optional[GroupModel]: + def get_groups_by_member_ids( + self, user_ids: list[str], db: Optional[Session] = None + ) -> dict[str, list[GroupModel]]: + """Fetch groups for multiple users in a single query to avoid N+1.""" + with get_db_context(db) as db: + # Query GroupMember joined with Group, filtering by user_ids + results = ( + db.query(GroupMember.user_id, Group) + .join(Group, Group.id == GroupMember.group_id) + .filter(GroupMember.user_id.in_(user_ids)) + .order_by(Group.updated_at.desc()) + .all() + ) + + # Group groups by user_id + user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids} + for user_id, group in results: + user_groups[user_id].append(GroupModel.model_validate(group)) + + return user_groups + + def get_group_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[GroupModel]: try: - with get_db() as db: + with get_db_context(db) as db: group = db.query(Group).filter_by(id=id).first() return GroupModel.model_validate(group) if group else None except Exception: return None - def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]: - with get_db() as db: + def get_group_user_ids_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[list[str]]: + with get_db_context(db) as db: members = ( db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all() ) @@ -259,8 +315,10 @@ def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]: return [m[0] for m in members] - def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]: - with get_db() as db: + def get_group_user_ids_by_ids( + self, group_ids: list[str], db: Optional[Session] = None + ) -> dict[str, list[str]]: + with get_db_context(db) as db: members = ( db.query(GroupMember.group_id, GroupMember.user_id) .filter(GroupMember.group_id.in_(group_ids)) @@ -276,8 +334,10 @@ def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str] return group_user_ids - def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None: - with get_db() as db: + def set_group_user_ids_by_id( + self, group_id: str, user_ids: list[str], db: Optional[Session] = None + ) -> None: + with get_db_context(db) as db: # Delete existing members db.query(GroupMember).filter(GroupMember.group_id == group_id).delete() @@ -297,8 +357,10 @@ def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None: db.add_all(new_members) db.commit() - def get_group_member_count_by_id(self, id: str) -> int: - with get_db() as db: + def get_group_member_count_by_id( + self, id: str, db: Optional[Session] = None + ) -> int: + with get_db_context(db) as db: count = ( db.query(func.count(GroupMember.user_id)) .filter(GroupMember.group_id == id) @@ -307,10 +369,14 @@ def get_group_member_count_by_id(self, id: str) -> int: return count if count else 0 def update_group_by_id( - self, id: str, form_data: GroupUpdateForm, overwrite: bool = False + self, + id: str, + form_data: GroupUpdateForm, + overwrite: bool = False, + db: Optional[Session] = None, ) -> Optional[GroupModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Group).filter_by(id=id).update( { **form_data.model_dump(exclude_none=True), @@ -318,22 +384,22 @@ def update_group_by_id( } ) db.commit() - return self.get_group_by_id(id=id) + return self.get_group_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def delete_group_by_id(self, id: str) -> bool: + def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Group).filter_by(id=id).delete() db.commit() return True except Exception: return False - def delete_all_groups(self) -> bool: - with get_db() as db: + def delete_all_groups(self, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(Group).delete() db.commit() @@ -342,8 +408,10 @@ def delete_all_groups(self) -> bool: except Exception: return False - def remove_user_from_all_groups(self, user_id: str) -> bool: - with get_db() as db: + def remove_user_from_all_groups( + self, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: try: # Find all groups the user belongs to groups = ( @@ -371,16 +439,16 @@ def remove_user_from_all_groups(self, user_id: str) -> bool: return False def create_groups_by_group_names( - self, user_id: str, group_names: list[str] + self, user_id: str, group_names: list[str], db: Optional[Session] = None ) -> list[GroupModel]: # check for existing groups - existing_groups = self.get_all_groups() + existing_groups = self.get_all_groups(db=db) existing_group_names = {group.name for group in existing_groups} new_groups = [] - with get_db() as db: + with get_db_context(db) as db: for group_name in group_names: if group_name not in existing_group_names: new_group = GroupModel( @@ -402,8 +470,10 @@ def create_groups_by_group_names( continue return new_groups - def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool: - with get_db() as db: + def sync_groups_by_group_names( + self, user_id: str, group_names: list[str], db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: try: now = int(time.time()) @@ -463,10 +533,13 @@ def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bo return False def add_users_to_group( - self, id: str, user_ids: Optional[list[str]] = None + self, + id: str, + user_ids: Optional[list[str]] = None, + db: Optional[Session] = None, ) -> Optional[GroupModel]: try: - with get_db() as db: + with get_db_context(db) as db: group = db.query(Group).filter_by(id=id).first() if not group: return None @@ -501,10 +574,13 @@ def add_users_to_group( return None def remove_users_from_group( - self, id: str, user_ids: Optional[list[str]] = None + self, + id: str, + user_ids: Optional[list[str]] = None, + db: Optional[Session] = None, ) -> Optional[GroupModel]: try: - with get_db() as db: + with get_db_context(db) as db: group = db.query(Group).filter_by(id=id).first() if not group: return None diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 2c72401181b..7f99f828c7d 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -4,12 +4,17 @@ from typing import Optional import uuid -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 sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context + +from open_webui.models.files import ( + File, + FileModel, + FileMetadataResponse, + FileModelResponse, +) 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 @@ -21,12 +26,14 @@ Text, JSON, UniqueConstraint, + or_, ) from open_webui.utils.access_control import has_access +from open_webui.utils.db.access_control import has_permission + log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # Knowledge DB Schema @@ -126,7 +133,7 @@ class KnowledgeResponse(KnowledgeModel): class KnowledgeUserResponse(KnowledgeUserModel): - files: Optional[list[FileMetadataResponse | dict]] = None + pass class KnowledgeForm(BaseModel): @@ -135,11 +142,25 @@ class KnowledgeForm(BaseModel): 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: def insert_new_knowledge( - self, user_id: str, form_data: KnowledgeForm + self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None ) -> Optional[KnowledgeModel]: - with get_db() as db: + with get_db_context(db) as db: knowledge = KnowledgeModel( **{ **form_data.model_dump(), @@ -162,15 +183,16 @@ def insert_new_knowledge( except Exception: return None - def get_knowledge_bases(self) -> list[KnowledgeUserModel]: - with get_db() as db: + def get_knowledge_bases( + self, skip: int = 0, limit: int = 30, db: Optional[Session] = None + ) -> list[KnowledgeUserModel]: + with get_db_context(db) as db: all_knowledge = ( db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() ) - 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, db=db) if user_ids else [] users_dict = {user.id: user for user in users} knowledge_bases = [] @@ -186,20 +208,154 @@ def get_knowledge_bases(self) -> list[KnowledgeUserModel]: ) return knowledge_bases - def check_access_by_user_id(self, id, user_id, permission="write") -> bool: - knowledge = self.get_knowledge_by_id(id) + def search_knowledge_bases( + self, + user_id: str, + filter: dict, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, + ) -> KnowledgeListResponse: + try: + with get_db_context(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, db: Optional[Session] = None + ) -> 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_context(db) as db: + # Base query: join Knowledge → KnowledgeFile → File + query = ( + db.query(File, User, Knowledge) + .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, knowledge in rows: + items.append( + FileUserResponse( + **FileModel.model_validate(file).model_dump(), + user=( + UserResponse( + **UserModel.model_validate(user).model_dump() + ) + if user + else None + ), + collection=KnowledgeModel.model_validate( + knowledge + ).model_dump(), + ) + ) + + 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", db: Optional[Session] = None + ) -> bool: + knowledge = self.get_knowledge_by_id(id, db=db) if not knowledge: return False if knowledge.user_id == user_id: return True - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } return has_access(user_id, permission, knowledge.access_control, user_group_ids) def get_knowledge_bases_by_user_id( - self, user_id: str, permission: str = "write" + self, user_id: str, permission: str = "write", db: Optional[Session] = None ) -> list[KnowledgeUserModel]: - knowledge_bases = self.get_knowledge_bases() - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + knowledge_bases = self.get_knowledge_bases(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } return [ knowledge_base for knowledge_base in knowledge_bases @@ -209,17 +365,38 @@ def get_knowledge_bases_by_user_id( ) ] - def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: + def get_knowledge_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[KnowledgeModel]: try: - with get_db() as db: + with get_db_context(db) as db: knowledge = db.query(Knowledge).filter_by(id=id).first() return KnowledgeModel.model_validate(knowledge) if knowledge else None except Exception: return None - def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: + def get_knowledge_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[KnowledgeModel]: + knowledge = self.get_knowledge_by_id(id, db=db) + 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, db=db) + } + 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, db: Optional[Session] = None + ) -> list[KnowledgeModel]: try: - with get_db() as db: + with get_db_context(db) as db: knowledges = ( db.query(Knowledge) .join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id) @@ -232,9 +409,94 @@ def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: except Exception: return [] - def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: + def search_files_by_id( + self, + knowledge_id: str, + user_id: str, + filter: dict, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, + ) -> KnowledgeFileListResponse: try: - with get_db() as db: + with get_db_context(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, db: Optional[Session] = None + ) -> list[FileModel]: + try: + with get_db_context(db) as db: files = ( db.query(File) .join(KnowledgeFile, File.id == KnowledgeFile.file_id) @@ -245,18 +507,24 @@ def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: except Exception: return [] - def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]: + def get_file_metadatas_by_id( + self, knowledge_id: str, db: Optional[Session] = None + ) -> list[FileMetadataResponse]: try: - with get_db() as db: - files = self.get_files_by_id(knowledge_id) + with get_db_context(db) as db: + files = self.get_files_by_id(knowledge_id, db=db) return [FileMetadataResponse(**file.model_dump()) for file in files] except Exception: return [] def add_file_to_knowledge_by_id( - self, knowledge_id: str, file_id: str, user_id: str + self, + knowledge_id: str, + file_id: str, + user_id: str, + db: Optional[Session] = None, ) -> Optional[KnowledgeFileModel]: - with get_db() as db: + with get_db_context(db) as db: knowledge_file = KnowledgeFileModel( **{ "id": str(uuid.uuid4()), @@ -280,9 +548,11 @@ def add_file_to_knowledge_by_id( except Exception: return None - def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool: + def remove_file_from_knowledge_by_id( + self, knowledge_id: str, file_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(KnowledgeFile).filter_by( knowledge_id=knowledge_id, file_id=file_id ).delete() @@ -291,9 +561,11 @@ def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> b except Exception: return False - def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: + def reset_knowledge_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[KnowledgeModel]: try: - with get_db() as db: + with get_db_context(db) as db: # Delete all knowledge_file entries for this knowledge_id db.query(KnowledgeFile).filter_by(knowledge_id=id).delete() db.commit() @@ -306,17 +578,21 @@ def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: ) db.commit() - return self.get_knowledge_by_id(id=id) + return self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None def update_knowledge_by_id( - self, id: str, form_data: KnowledgeForm, overwrite: bool = False + self, + id: str, + form_data: KnowledgeForm, + overwrite: bool = False, + db: Optional[Session] = None, ) -> Optional[KnowledgeModel]: try: - with get_db() as db: - knowledge = self.get_knowledge_by_id(id=id) + with get_db_context(db) as db: + knowledge = self.get_knowledge_by_id(id=id, db=db) db.query(Knowledge).filter_by(id=id).update( { **form_data.model_dump(), @@ -324,17 +600,17 @@ def update_knowledge_by_id( } ) db.commit() - return self.get_knowledge_by_id(id=id) + return self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None def update_knowledge_data_by_id( - self, id: str, data: dict + self, id: str, data: dict, db: Optional[Session] = None ) -> Optional[KnowledgeModel]: try: - with get_db() as db: - knowledge = self.get_knowledge_by_id(id=id) + with get_db_context(db) as db: + knowledge = self.get_knowledge_by_id(id=id, db=db) db.query(Knowledge).filter_by(id=id).update( { "data": data, @@ -342,22 +618,22 @@ def update_knowledge_data_by_id( } ) db.commit() - return self.get_knowledge_by_id(id=id) + return self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) return None - def delete_knowledge_by_id(self, id: str) -> bool: + def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Knowledge).filter_by(id=id).delete() db.commit() return True except Exception: return False - def delete_all_knowledge(self) -> bool: - with get_db() as db: + def delete_all_knowledge(self, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(Knowledge).delete() db.commit() diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index f5f2492b999..2dc96568565 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -2,7 +2,8 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db, get_db_context from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text @@ -41,8 +42,9 @@ def insert_new_memory( self, user_id: str, content: str, + db: Optional[Session] = None, ) -> Optional[MemoryModel]: - with get_db() as db: + with get_db_context(db) as db: id = str(uuid.uuid4()) memory = MemoryModel( @@ -68,8 +70,9 @@ def update_memory_by_id_and_user_id( id: str, user_id: str, content: str, + db: Optional[Session] = None, ) -> Optional[MemoryModel]: - with get_db() as db: + with get_db_context(db) as db: try: memory = db.get(Memory, id) if not memory or memory.user_id != user_id: @@ -83,32 +86,36 @@ def update_memory_by_id_and_user_id( except Exception: return None - def get_memories(self) -> list[MemoryModel]: - with get_db() as db: + def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]: + with get_db_context(db) as db: try: memories = db.query(Memory).all() return [MemoryModel.model_validate(memory) for memory in memories] except Exception: return None - def get_memories_by_user_id(self, user_id: str) -> list[MemoryModel]: - with get_db() as db: + def get_memories_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[MemoryModel]: + with get_db_context(db) as db: try: memories = db.query(Memory).filter_by(user_id=user_id).all() return [MemoryModel.model_validate(memory) for memory in memories] except Exception: return None - def get_memory_by_id(self, id: str) -> Optional[MemoryModel]: - with get_db() as db: + def get_memory_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[MemoryModel]: + with get_db_context(db) as db: try: memory = db.get(Memory, id) return MemoryModel.model_validate(memory) except Exception: return None - def delete_memory_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: try: db.query(Memory).filter_by(id=id).delete() db.commit() @@ -118,8 +125,10 @@ def delete_memory_by_id(self, id: str) -> bool: except Exception: return False - def delete_memories_by_user_id(self, user_id: str) -> bool: - with get_db() as db: + def delete_memories_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: try: db.query(Memory).filter_by(user_id=user_id).delete() db.commit() @@ -128,8 +137,10 @@ def delete_memories_by_user_id(self, user_id: str) -> bool: except Exception: return False - def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool: - with get_db() as db: + def delete_memory_by_id_and_user_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + with get_db_context(db) as db: try: memory = db.get(Memory, id) if not memory or memory.user_id != user_id: diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 98be21463d2..0851107b0b1 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -3,13 +3,14 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.users import Users, User, UserNameResponse 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 or_, func, select, and_, text from sqlalchemy.sql import exists @@ -108,11 +109,24 @@ class MessageUserResponse(MessageModel): 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): - reply_to_message: Optional[MessageUserResponse] = None + reply_to_message: Optional[MessageUserSlimResponse] = None -class MessageWithReactionsResponse(MessageUserResponse): +class MessageWithReactionsResponse(MessageUserSlimResponse): reactions: list[Reactions] @@ -124,9 +138,13 @@ class MessageResponse(MessageReplyToResponse): class MessageTable: def insert_new_message( - self, form_data: MessageForm, channel_id: str, user_id: str + self, + form_data: MessageForm, + channel_id: str, + user_id: str, + db: Optional[Session] = None, ) -> Optional[MessageModel]: - with get_db() as db: + with get_db_context(db) as db: channel_member = Channels.join_channel(channel_id, user_id) id = str(uuid.uuid4()) @@ -156,26 +174,57 @@ def insert_new_message( db.refresh(result) return MessageModel.model_validate(result) if result else None - def get_message_by_id(self, id: str) -> Optional[MessageResponse]: - with get_db() as db: + def get_message_by_id( + self, + id: str, + include_thread_replies: Optional[bool] = True, + db: Optional[Session] = None, + ) -> Optional[MessageResponse]: + with get_db_context(db) as db: message = db.get(Message, id) if not message: return None reply_to_message = ( - self.get_message_by_id(message.reply_to_id) + self.get_message_by_id( + message.reply_to_id, include_thread_replies=False, db=db + ) if message.reply_to_id else None ) - reactions = self.get_reactions_by_message_id(id) - thread_replies = self.get_thread_replies_by_message_id(id) + reactions = self.get_reactions_by_message_id(id, db=db) + + thread_replies = [] + if include_thread_replies: + thread_replies = self.get_thread_replies_by_message_id(id, db=db) + + # Check if message was sent by webhook (webhook info in meta takes precedence) + webhook_info = message.meta.get("webhook") if message.meta else None + if webhook_info and webhook_info.get("id"): + # Look up webhook by ID to get current name + webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook: + user_info = { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + } + else: + # Webhook was deleted, use placeholder + user_info = { + "id": webhook_info.get("id"), + "name": "Deleted Webhook", + "role": "webhook", + } + else: + user = Users.get_user_by_id(message.user_id, db=db) + user_info = user.model_dump() if user else None - user = Users.get_user_by_id(message.user_id) return MessageResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), - "user": user.model_dump() if user else None, + "user": user_info, "reply_to_message": ( reply_to_message.model_dump() if reply_to_message else None ), @@ -187,8 +236,10 @@ def get_message_by_id(self, id: str) -> Optional[MessageResponse]: } ) - def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]: - with get_db() as db: + def get_thread_replies_by_message_id( + self, id: str, db: Optional[Session] = None + ) -> list[MessageReplyToResponse]: + with get_db_context(db) as db: all_messages = ( db.query(Message) .filter_by(parent_id=id) @@ -199,14 +250,35 @@ def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToRespon messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id) + self.get_message_by_id( + message.reply_to_id, include_thread_replies=False, db=db + ) if message.reply_to_id else None ) + + webhook_info = message.meta.get("webhook") if message.meta else None + user_info = None + if webhook_info and webhook_info.get("id"): + webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook: + user_info = { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + } + else: + user_info = { + "id": webhook_info.get("id"), + "name": "Deleted Webhook", + "role": "webhook", + } + messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), + "user": user_info, "reply_to_message": ( reply_to_message.model_dump() if reply_to_message @@ -217,17 +289,23 @@ def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToRespon ) return messages - def get_reply_user_ids_by_message_id(self, id: str) -> list[str]: - with get_db() as db: + def get_reply_user_ids_by_message_id( + self, id: str, db: Optional[Session] = None + ) -> list[str]: + with get_db_context(db) as db: return [ message.user_id for message in db.query(Message).filter_by(parent_id=id).all() ] def get_messages_by_channel_id( - self, channel_id: str, skip: int = 0, limit: int = 50 + self, + channel_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, ) -> list[MessageReplyToResponse]: - with get_db() as db: + with get_db_context(db) as db: all_messages = ( db.query(Message) .filter_by(channel_id=channel_id, parent_id=None) @@ -240,14 +318,35 @@ def get_messages_by_channel_id( messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id) + self.get_message_by_id( + message.reply_to_id, include_thread_replies=False, db=db + ) if message.reply_to_id else None ) + + webhook_info = message.meta.get("webhook") if message.meta else None + user_info = None + if webhook_info and webhook_info.get("id"): + webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook: + user_info = { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + } + else: + user_info = { + "id": webhook_info.get("id"), + "name": "Deleted Webhook", + "role": "webhook", + } + messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), + "user": user_info, "reply_to_message": ( reply_to_message.model_dump() if reply_to_message @@ -259,9 +358,14 @@ def get_messages_by_channel_id( return messages def get_messages_by_parent_id( - self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50 + self, + channel_id: str, + parent_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, ) -> list[MessageReplyToResponse]: - with get_db() as db: + with get_db_context(db) as db: message = db.get(Message, parent_id) if not message: @@ -283,14 +387,35 @@ def get_messages_by_parent_id( messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id(message.reply_to_id) + self.get_message_by_id( + message.reply_to_id, include_thread_replies=False, db=db + ) if message.reply_to_id else None ) + + webhook_info = message.meta.get("webhook") if message.meta else None + user_info = None + if webhook_info and webhook_info.get("id"): + webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook: + user_info = { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + } + else: + user_info = { + "id": webhook_info.get("id"), + "name": "Deleted Webhook", + "role": "webhook", + } + messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), + "user": user_info, "reply_to_message": ( reply_to_message.model_dump() if reply_to_message @@ -301,8 +426,10 @@ def get_messages_by_parent_id( ) return messages - def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]: - with get_db() as db: + def get_last_message_by_channel_id( + self, channel_id: str, db: Optional[Session] = None + ) -> Optional[MessageModel]: + with get_db_context(db) as db: message = ( db.query(Message) .filter_by(channel_id=channel_id) @@ -312,9 +439,13 @@ def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageMod return MessageModel.model_validate(message) if message else None def get_pinned_messages_by_channel_id( - self, channel_id: str, skip: int = 0, limit: int = 50 + self, + channel_id: str, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, ) -> list[MessageModel]: - with get_db() as db: + with get_db_context(db) as db: all_messages = ( db.query(Message) .filter_by(channel_id=channel_id, is_pinned=True) @@ -326,9 +457,9 @@ def get_pinned_messages_by_channel_id( return [MessageModel.model_validate(message) for message in all_messages] def update_message_by_id( - self, id: str, form_data: MessageForm + self, id: str, form_data: MessageForm, db: Optional[Session] = None ) -> Optional[MessageModel]: - with get_db() as db: + with get_db_context(db) as db: message = db.get(Message, id) message.content = form_data.content message.data = { @@ -345,9 +476,13 @@ def update_message_by_id( return MessageModel.model_validate(message) if message else None def update_is_pinned_by_id( - self, id: str, is_pinned: bool, pinned_by: Optional[str] = None + self, + id: str, + is_pinned: bool, + pinned_by: Optional[str] = None, + db: Optional[Session] = None, ) -> Optional[MessageModel]: - with get_db() as db: + with get_db_context(db) as db: message = db.get(Message, id) message.is_pinned = is_pinned message.pinned_at = int(time.time_ns()) if is_pinned else None @@ -357,9 +492,13 @@ def update_is_pinned_by_id( return MessageModel.model_validate(message) if message else None def get_unread_message_count( - self, channel_id: str, user_id: str, last_read_at: Optional[int] = None + self, + channel_id: str, + user_id: str, + last_read_at: Optional[int] = None, + db: Optional[Session] = None, ) -> int: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Message).filter( Message.channel_id == channel_id, Message.parent_id == None, # only count top-level messages @@ -370,9 +509,9 @@ def get_unread_message_count( return query.count() def add_reaction_to_message( - self, id: str, user_id: str, name: str + self, id: str, user_id: str, name: str, db: Optional[Session] = None ) -> Optional[MessageReactionModel]: - with get_db() as db: + with get_db_context(db) as db: # check for existing reaction existing_reaction = ( db.query(MessageReaction) @@ -396,8 +535,10 @@ def add_reaction_to_message( db.refresh(result) return MessageReactionModel.model_validate(result) if result else None - def get_reactions_by_message_id(self, id: str) -> list[Reactions]: - with get_db() as db: + def get_reactions_by_message_id( + self, id: str, db: Optional[Session] = None + ) -> list[Reactions]: + with get_db_context(db) as db: # JOIN User so all user info is fetched in one query results = ( db.query(MessageReaction, User) @@ -427,29 +568,29 @@ def get_reactions_by_message_id(self, id: str) -> list[Reactions]: return [Reactions(**reaction) for reaction in reactions.values()] def remove_reaction_by_id_and_user_id_and_name( - self, id: str, user_id: str, name: str + self, id: str, user_id: str, name: str, db: Optional[Session] = None ) -> bool: - with get_db() as db: + with get_db_context(db) as db: db.query(MessageReaction).filter_by( message_id=id, user_id=user_id, name=name ).delete() db.commit() return True - def delete_reactions_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: db.query(MessageReaction).filter_by(message_id=id).delete() db.commit() return True - def delete_replies_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: db.query(Message).filter_by(parent_id=id).delete() db.commit() return True - def delete_message_by_id(self, id: str) -> bool: - with get_db() as db: + def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: db.query(Message).filter_by(id=id).delete() # Delete all reactions to this message @@ -458,5 +599,35 @@ def delete_message_by_id(self, id: str) -> bool: db.commit() return True + def search_messages_by_channel_ids( + self, + channel_ids: list[str], + query: str, + start_timestamp: Optional[int] = None, + end_timestamp: Optional[int] = None, + limit: int = 10, + db: Optional[Session] = None, + ) -> list[MessageModel]: + """Search messages in specified channels by content.""" + with get_db_context(db) as db: + query_builder = db.query(Message).filter( + Message.channel_id.in_(channel_ids), + Message.content.ilike(f"%{query}%"), + ) + + if start_timestamp: + query_builder = query_builder.filter( + Message.created_at >= start_timestamp + ) + if end_timestamp: + query_builder = query_builder.filter( + Message.created_at <= end_timestamp + ) + + messages = ( + query_builder.order_by(Message.created_at.desc()).limit(limit).all() + ) + return [MessageModel.model_validate(msg) for msg in messages] + Messages = MessageTable() diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 1c44d311ba9..5457413f0de 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -2,8 +2,8 @@ import time from typing import Optional -from open_webui.internal.db import Base, JSONField, get_db -from open_webui.env import SRC_LOG_LEVELS +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, Users, UserResponse @@ -22,7 +22,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### @@ -131,6 +130,10 @@ class ModelUserResponse(ModelModel): user: Optional[UserResponse] = None +class ModelAccessResponse(ModelUserResponse): + write_access: Optional[bool] = False + + class ModelResponse(ModelModel): pass @@ -140,6 +143,11 @@ class ModelListResponse(BaseModel): total: int +class ModelAccessListResponse(BaseModel): + items: list[ModelAccessResponse] + total: int + + class ModelForm(BaseModel): id: str base_model_id: Optional[str] = None @@ -152,7 +160,7 @@ class ModelForm(BaseModel): class ModelsTable: def insert_new_model( - self, form_data: ModelForm, user_id: str + self, form_data: ModelForm, user_id: str, db: Optional[Session] = None ) -> Optional[ModelModel]: model = ModelModel( **{ @@ -163,7 +171,7 @@ def insert_new_model( } ) try: - with get_db() as db: + with get_db_context(db) as db: result = Model(**model.model_dump()) db.add(result) db.commit() @@ -177,17 +185,17 @@ def insert_new_model( log.exception(f"Failed to insert a new model: {e}") return None - def get_all_models(self) -> list[ModelModel]: - with get_db() as db: + def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]: + with get_db_context(db) as db: return [ModelModel.model_validate(model) for model in db.query(Model).all()] - def get_models(self) -> list[ModelUserResponse]: - with get_db() as db: + def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: + with get_db_context(db) as db: all_models = db.query(Model).filter(Model.base_model_id != None).all() user_ids = list(set(model.user_id for model in all_models)) - users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} models = [] @@ -203,18 +211,20 @@ def get_models(self) -> list[ModelUserResponse]: ) return models - def get_base_models(self) -> list[ModelModel]: - with get_db() as db: + def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]: + with get_db_context(db) as db: return [ ModelModel.model_validate(model) for model in db.query(Model).filter(Model.base_model_id == None).all() ] def get_models_by_user_id( - self, user_id: str, permission: str = "write" + self, user_id: str, permission: str = "write", db: Optional[Session] = None ) -> list[ModelUserResponse]: - models = self.get_models() - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + models = self.get_models(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } return [ model for model in models @@ -265,9 +275,14 @@ def _has_permission(self, db, query, filter: dict, permission: str = "read"): return query def search_models( - self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 + self, + user_id: str, + filter: dict = {}, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, ) -> ModelListResponse: - with get_db() as db: + with get_db_context(db) as db: # Join GroupMember so we can order by group_id when requested query = db.query(Model, User).outerjoin(User, User.id == Model.user_id) query = query.filter(Model.base_model_id != None) @@ -293,7 +308,7 @@ def search_models( db, query, filter, - permission="write", + permission="read", ) tag = filter.get("tag") @@ -351,16 +366,30 @@ def search_models( return ModelListResponse(items=models, total=total) - def get_model_by_id(self, id: str) -> Optional[ModelModel]: + def get_model_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ModelModel]: try: - with get_db() as db: + with get_db_context(db) as db: model = db.get(Model, id) return ModelModel.model_validate(model) except Exception: return None - def toggle_model_by_id(self, id: str) -> Optional[ModelModel]: - with get_db() as db: + def get_models_by_ids( + self, ids: list[str], db: Optional[Session] = None + ) -> list[ModelModel]: + try: + with get_db_context(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, db: Optional[Session] = None + ) -> Optional[ModelModel]: + with get_db_context(db) as db: try: is_active = db.query(Model).filter_by(id=id).first().is_active @@ -372,13 +401,15 @@ def toggle_model_by_id(self, id: str) -> Optional[ModelModel]: ) db.commit() - return self.get_model_by_id(id) + return self.get_model_by_id(id, db=db) except Exception: return None - def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]: + def update_model_by_id( + self, id: str, model: ModelForm, db: Optional[Session] = None + ) -> Optional[ModelModel]: try: - with get_db() as db: + with get_db_context(db) as db: # update only the fields that are present in the model data = model.model_dump(exclude={"id"}) result = db.query(Model).filter_by(id=id).update(data) @@ -392,9 +423,9 @@ def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]: log.exception(f"Failed to update the model by id {id}: {e}") return None - def delete_model_by_id(self, id: str) -> bool: + def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Model).filter_by(id=id).delete() db.commit() @@ -402,9 +433,9 @@ def delete_model_by_id(self, id: str) -> bool: except Exception: return False - def delete_all_models(self) -> bool: + def delete_all_models(self, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Model).delete() db.commit() @@ -412,9 +443,11 @@ def delete_all_models(self) -> bool: except Exception: return False - def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]: + def sync_models( + self, user_id: str, models: list[ModelModel], db: Optional[Session] = None + ) -> list[ModelModel]: try: - with get_db() as db: + with get_db_context(db) as db: # Get existing models existing_models = db.query(Model).all() existing_ids = {model.id for model in existing_models} diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index af75fab598a..bd235307858 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -4,15 +4,19 @@ from typing import Optional from functools import lru_cache -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db, get_db_context from open_webui.models.groups import Groups 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 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 #################### @@ -75,13 +79,142 @@ class NoteUserResponse(NoteModel): 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: + 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( - self, - form_data: NoteForm, - user_id: str, + self, user_id: str, form_data: NoteForm, db: Optional[Session] = None ) -> Optional[NoteModel]: - with get_db() as db: + with get_db_context(db) as db: note = NoteModel( **{ "id": str(uuid.uuid4()), @@ -99,9 +232,9 @@ def insert_new_note( return note def get_notes( - self, skip: Optional[int] = None, limit: Optional[int] = None + self, skip: int = 0, limit: int = 50, db: Optional[Session] = None ) -> list[NoteModel]: - with get_db() as db: + with get_db_context(db) as db: query = db.query(Note).order_by(Note.updated_at.desc()) if skip is not None: query = query.offset(skip) @@ -110,83 +243,141 @@ def get_notes( notes = query.all() return [NoteModel.model_validate(note) for note in notes] - def get_notes_by_user_id( + def search_notes( self, user_id: str, - skip: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[NoteModel]: - with get_db() as db: - query = db.query(Note).filter(Note.user_id == user_id) - query = query.order_by(Note.updated_at.desc()) + filter: dict = {}, + skip: int = 0, + limit: int = 30, + db: Optional[Session] = None, + ) -> NoteListResponse: + with get_db_context(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: + # Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do") + normalized_query = query_key.replace("-", "").replace(" ", "") + query = query.filter( + or_( + func.replace( + func.replace(Note.title, "-", ""), " ", "" + ).ilike(f"%{normalized_query}%"), + func.replace( + func.replace( + cast(Note.data["content"]["md"], Text), "-", "" + ), + " ", + "", + ).ilike(f"%{normalized_query}%"), + ) + ) - if skip is not None: + 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 is not None: + if limit: query = query.limit(limit) - notes = query.all() - return [NoteModel.model_validate(note) for note in notes] + 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_permission( + def get_notes_by_user_id( self, user_id: str, - permission: str = "write", - skip: Optional[int] = None, - limit: Optional[int] = None, + permission: str = "read", + skip: int = 0, + limit: int = 50, + db: Optional[Session] = 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 + with get_db_context(db) as db: + user_group_ids = [ + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + ] - # Apply skip AFTER permission filtering so it counts only accessible notes - if skip and n_skipped < skip: - n_skipped += 1 - continue + 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 + ) - results.append(NoteModel.model_validate(note)) - if limit is not None and len(results) >= limit: - break + if skip is not None: + query = query.offset(skip) + if limit is not None: + query = query.limit(limit) - return results + notes = query.all() + return [NoteModel.model_validate(note) for note in notes] - def get_note_by_id(self, id: str) -> Optional[NoteModel]: - with get_db() as db: + def get_note_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[NoteModel]: + with get_db_context(db) as db: note = db.query(Note).filter(Note.id == id).first() return NoteModel.model_validate(note) if note else None def update_note_by_id( - self, id: str, form_data: NoteUpdateForm + self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None ) -> Optional[NoteModel]: - with get_db() as db: + with get_db_context(db) as db: note = db.query(Note).filter(Note.id == id).first() if not note: return None @@ -208,11 +399,14 @@ def update_note_by_id( db.commit() return NoteModel.model_validate(note) if note else None - def delete_note_by_id(self, id: str): - with get_db() as db: - db.query(Note).filter(Note.id == id).delete() - db.commit() - return True + def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool: + try: + with get_db_context(db) as db: + db.query(Note).filter(Note.id == id).delete() + db.commit() + return True + except Exception: + return False Notes = NoteTable() diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index d07faad35ee..f7ee5cceb87 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -8,14 +8,14 @@ from cryptography.fernet import Fernet -from open_webui.internal.db import Base, get_db -from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, get_db, get_db_context +from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text, Index log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # DB MODEL @@ -110,10 +110,11 @@ def create_session( user_id: str, provider: str, token: dict, + db: Optional[Session] = None, ) -> Optional[OAuthSessionModel]: """Create a new OAuth session""" try: - with get_db() as db: + with get_db_context(db) as db: current_time = int(time.time()) id = str(uuid.uuid4()) @@ -142,10 +143,12 @@ def create_session( log.error(f"Error creating OAuth session: {e}") return None - def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]: + def get_session_by_id( + self, session_id: str, db: Optional[Session] = None + ) -> Optional[OAuthSessionModel]: """Get OAuth session by ID""" try: - with get_db() as db: + with get_db_context(db) as db: session = db.query(OAuthSession).filter_by(id=session_id).first() if session: session.token = self._decrypt_token(session.token) @@ -157,11 +160,11 @@ def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]: return None def get_session_by_id_and_user_id( - self, session_id: str, user_id: str + self, session_id: str, user_id: str, db: Optional[Session] = None ) -> Optional[OAuthSessionModel]: """Get OAuth session by ID and user ID""" try: - with get_db() as db: + with get_db_context(db) as db: session = ( db.query(OAuthSession) .filter_by(id=session_id, user_id=user_id) @@ -177,11 +180,11 @@ def get_session_by_id_and_user_id( return None def get_session_by_provider_and_user_id( - self, provider: str, user_id: str + self, provider: str, user_id: str, db: Optional[Session] = None ) -> Optional[OAuthSessionModel]: """Get OAuth session by provider and user ID""" try: - with get_db() as db: + with get_db_context(db) as db: session = ( db.query(OAuthSession) .filter_by(provider=provider, user_id=user_id) @@ -196,10 +199,12 @@ def get_session_by_provider_and_user_id( log.error(f"Error getting OAuth session by provider and user ID: {e}") return None - def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]: + def get_sessions_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> List[OAuthSessionModel]: """Get all OAuth sessions for a user""" try: - with get_db() as db: + with get_db_context(db) as db: sessions = db.query(OAuthSession).filter_by(user_id=user_id).all() results = [] @@ -214,11 +219,11 @@ def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]: return [] def update_session_by_id( - self, session_id: str, token: dict + self, session_id: str, token: dict, db: Optional[Session] = None ) -> Optional[OAuthSessionModel]: """Update OAuth session tokens""" try: - with get_db() as db: + with get_db_context(db) as db: current_time = int(time.time()) db.query(OAuthSession).filter_by(id=session_id).update( @@ -240,10 +245,12 @@ def update_session_by_id( log.error(f"Error updating OAuth session tokens: {e}") return None - def delete_session_by_id(self, session_id: str) -> bool: + def delete_session_by_id( + self, session_id: str, db: Optional[Session] = None + ) -> bool: """Delete an OAuth session""" try: - with get_db() as db: + with get_db_context(db) as db: result = db.query(OAuthSession).filter_by(id=session_id).delete() db.commit() return result > 0 @@ -251,10 +258,12 @@ def delete_session_by_id(self, session_id: str) -> bool: log.error(f"Error deleting OAuth session: {e}") return False - def delete_sessions_by_user_id(self, user_id: str) -> bool: + def delete_sessions_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> bool: """Delete all OAuth sessions for a user""" try: - with get_db() as db: + with get_db_context(db) as db: result = db.query(OAuthSession).filter_by(user_id=user_id).delete() db.commit() return True @@ -262,10 +271,12 @@ def delete_sessions_by_user_id(self, user_id: str) -> bool: log.error(f"Error deleting OAuth sessions by user ID: {e}") return False - def delete_sessions_by_provider(self, provider: str) -> bool: + def delete_sessions_by_provider( + self, provider: str, db: Optional[Session] = None + ) -> bool: """Delete all OAuth sessions for a provider""" try: - with get_db() as db: + with get_db_context(db) as db: db.query(OAuthSession).filter_by(provider=provider).delete() db.commit() return True diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 7502f34ccd7..847597bc657 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -1,7 +1,8 @@ import time from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse @@ -62,6 +63,10 @@ class PromptUserResponse(PromptModel): user: Optional[UserResponse] = None +class PromptAccessResponse(PromptUserResponse): + write_access: Optional[bool] = False + + class PromptForm(BaseModel): command: str title: str @@ -71,7 +76,7 @@ class PromptForm(BaseModel): class PromptsTable: def insert_new_prompt( - self, user_id: str, form_data: PromptForm + self, user_id: str, form_data: PromptForm, db: Optional[Session] = None ) -> Optional[PromptModel]: prompt = PromptModel( **{ @@ -82,7 +87,7 @@ def insert_new_prompt( ) try: - with get_db() as db: + with get_db_context(db) as db: result = Prompt(**prompt.model_dump()) db.add(result) db.commit() @@ -94,21 +99,23 @@ def insert_new_prompt( except Exception: return None - def get_prompt_by_command(self, command: str) -> Optional[PromptModel]: + def get_prompt_by_command( + self, command: str, db: Optional[Session] = None + ) -> Optional[PromptModel]: try: - with get_db() as db: + with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() return PromptModel.model_validate(prompt) except Exception: return None - def get_prompts(self) -> list[PromptUserResponse]: - with get_db() as db: + def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: + with get_db_context(db) as db: all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all() user_ids = list(set(prompt.user_id for prompt in all_prompts)) - users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} prompts = [] @@ -126,10 +133,12 @@ def get_prompts(self) -> list[PromptUserResponse]: return prompts def get_prompts_by_user_id( - self, user_id: str, permission: str = "write" + self, user_id: str, permission: str = "write", db: Optional[Session] = None ) -> list[PromptUserResponse]: - prompts = self.get_prompts() - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + prompts = self.get_prompts(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } return [ prompt @@ -139,10 +148,10 @@ def get_prompts_by_user_id( ] def update_prompt_by_command( - self, command: str, form_data: PromptForm + self, command: str, form_data: PromptForm, db: Optional[Session] = None ) -> Optional[PromptModel]: try: - with get_db() as db: + with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() prompt.title = form_data.title prompt.content = form_data.content @@ -153,9 +162,11 @@ def update_prompt_by_command( except Exception: return None - def delete_prompt_by_command(self, command: str) -> bool: + def delete_prompt_by_command( + self, command: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Prompt).filter_by(command=command).delete() db.commit() diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index e1cbb68a0b3..64cb5595474 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -3,15 +3,14 @@ import uuid from typing import Optional -from open_webui.internal.db import Base, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### @@ -52,8 +51,10 @@ class TagChatIdForm(BaseModel): class TagTable: - def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]: - with get_db() as db: + def insert_new_tag( + self, name: str, user_id: str, db: Optional[Session] = None + ) -> Optional[TagModel]: + with get_db_context(db) as db: id = name.replace(" ", "_").lower() tag = TagModel(**{"id": id, "user_id": user_id, "name": name}) try: @@ -70,27 +71,29 @@ def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]: return None def get_tag_by_name_and_user_id( - self, name: str, user_id: str + self, name: str, user_id: str, db: Optional[Session] = None ) -> Optional[TagModel]: try: id = name.replace(" ", "_").lower() - with get_db() as db: + with get_db_context(db) as db: tag = db.query(Tag).filter_by(id=id, user_id=user_id).first() return TagModel.model_validate(tag) except Exception: return None - def get_tags_by_user_id(self, user_id: str) -> list[TagModel]: - with get_db() as db: + def get_tags_by_user_id( + self, user_id: str, db: Optional[Session] = None + ) -> list[TagModel]: + with get_db_context(db) as db: return [ TagModel.model_validate(tag) for tag in (db.query(Tag).filter_by(user_id=user_id).all()) ] def get_tags_by_ids_and_user_id( - self, ids: list[str], user_id: str + self, ids: list[str], user_id: str, db: Optional[Session] = None ) -> list[TagModel]: - with get_db() as db: + with get_db_context(db) as db: return [ TagModel.model_validate(tag) for tag in ( @@ -98,9 +101,11 @@ def get_tags_by_ids_and_user_id( ) ] - def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool: + def delete_tag_by_name_and_user_id( + self, name: str, user_id: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: id = name.replace(" ", "_").lower() res = db.query(Tag).filter_by(id=id, user_id=user_id).delete() log.debug(f"res: {res}") diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 7f6c7fd3f5b..cd7d0bd1a0c 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -2,11 +2,11 @@ import time from typing import Optional -from open_webui.internal.db import Base, JSONField, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.users import Users, UserResponse from open_webui.models.groups import Groups -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, Text, JSON @@ -14,7 +14,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # Tools DB Schema @@ -98,6 +97,10 @@ class ToolUserResponse(ToolResponse): model_config = ConfigDict(extra="allow") +class ToolAccessResponse(ToolUserResponse): + write_access: Optional[bool] = False + + class ToolForm(BaseModel): id: str name: str @@ -112,9 +115,13 @@ class ToolValves(BaseModel): class ToolsTable: def insert_new_tool( - self, user_id: str, form_data: ToolForm, specs: list[dict] + self, + user_id: str, + form_data: ToolForm, + specs: list[dict], + db: Optional[Session] = None, ) -> Optional[ToolModel]: - with get_db() as db: + with get_db_context(db) as db: tool = ToolModel( **{ **form_data.model_dump(), @@ -138,21 +145,23 @@ def insert_new_tool( log.exception(f"Error creating a new tool: {e}") return None - def get_tool_by_id(self, id: str) -> Optional[ToolModel]: + def get_tool_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[ToolModel]: try: - with get_db() as db: + with get_db_context(db) as db: tool = db.get(Tool, id) return ToolModel.model_validate(tool) except Exception: return None - def get_tools(self) -> list[ToolUserModel]: - with get_db() as db: + def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]: + with get_db_context(db) as db: all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all() user_ids = list(set(tool.user_id for tool in all_tools)) - users = Users.get_users_by_user_ids(user_ids) if user_ids else [] + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} tools = [] @@ -169,10 +178,12 @@ def get_tools(self) -> list[ToolUserModel]: return tools def get_tools_by_user_id( - self, user_id: str, permission: str = "write" + self, user_id: str, permission: str = "write", db: Optional[Session] = None ) -> list[ToolUserModel]: - tools = self.get_tools() - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + tools = self.get_tools(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } return [ tool @@ -181,31 +192,35 @@ def get_tools_by_user_id( or has_access(user_id, permission, tool.access_control, user_group_ids) ] - def get_tool_valves_by_id(self, id: str) -> Optional[dict]: + def get_tool_valves_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[dict]: try: - with get_db() as db: + with get_db_context(db) as db: tool = db.get(Tool, id) return tool.valves if tool.valves else {} except Exception as e: log.exception(f"Error getting tool valves by id {id}") return None - def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]: + def update_tool_valves_by_id( + self, id: str, valves: dict, db: Optional[Session] = None + ) -> Optional[ToolValves]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Tool).filter_by(id=id).update( {"valves": valves, "updated_at": int(time.time())} ) db.commit() - return self.get_tool_by_id(id) + return self.get_tool_by_id(id, db=db) except Exception: return None def get_user_valves_by_id_and_user_id( - self, id: str, user_id: str + self, id: str, user_id: str, db: Optional[Session] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings @@ -222,10 +237,10 @@ def get_user_valves_by_id_and_user_id( return None def update_user_valves_by_id_and_user_id( - self, id: str, user_id: str, valves: dict + self, id: str, user_id: str, valves: dict, db: Optional[Session] = None ) -> Optional[dict]: try: - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings @@ -237,7 +252,7 @@ def update_user_valves_by_id_and_user_id( user_settings["tools"]["valves"][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {"settings": user_settings}) + Users.update_user_by_id(user_id, {"settings": user_settings}, db=db) return user_settings["tools"]["valves"][id] except Exception as e: @@ -246,9 +261,11 @@ def update_user_valves_by_id_and_user_id( ) return None - def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: + def update_tool_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[ToolModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Tool).filter_by(id=id).update( {**updated, "updated_at": int(time.time())} ) @@ -260,9 +277,9 @@ def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: except Exception: return None - def delete_tool_by_id(self, id: str) -> bool: + def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(Tool).filter_by(id=id).delete() db.commit() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 86f9d011e8b..0d36d94b8f7 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -1,15 +1,16 @@ import time from typing import Optional -from open_webui.internal.db import Base, JSONField, get_db +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL + from open_webui.models.chats import Chats from open_webui.models.groups import Groups, GroupMember from open_webui.models.channels import ChannelMember - from open_webui.utils.misc import throttle @@ -243,8 +244,9 @@ def insert_new_user( profile_image_url: str = "/user.png", role: str = "pending", oauth: Optional[dict] = None, + db: Optional[Session] = None, ) -> Optional[UserModel]: - with get_db() as db: + with get_db_context(db) as db: user = UserModel( **{ "id": id, @@ -267,17 +269,21 @@ def insert_new_user( else: return None - def get_user_by_id(self, id: str) -> Optional[UserModel]: + def get_user_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() return UserModel.model_validate(user) except Exception: return None - def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: + def get_user_by_api_key( + self, api_key: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: user = ( db.query(User) .join(ApiKey, User.id == ApiKey.user_id) @@ -288,17 +294,21 @@ def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: except Exception: return None - def get_user_by_email(self, email: str) -> Optional[UserModel]: + def get_user_by_email( + self, email: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: user = db.query(User).filter_by(email=email).first() return UserModel.model_validate(user) except Exception: return None - def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: + def get_user_by_oauth_sub( + self, provider: str, sub: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: # type: Session + with get_db_context(db) as db: # type: Session dialect_name = db.bind.dialect.name query = db.query(User) @@ -320,8 +330,9 @@ def get_users( filter: Optional[dict] = None, skip: Optional[int] = None, limit: Optional[int] = None, + db: Optional[Session] = None, ) -> dict: - with get_db() as db: + with get_db_context(db) as db: # Join GroupMember so we can order by group_id when requested query = db.query(User) @@ -452,8 +463,10 @@ def get_users( "total": total, } - def get_users_by_group_id(self, group_id: str) -> list[UserModel]: - with get_db() as db: + def get_users_by_group_id( + self, group_id: str, db: Optional[Session] = None + ) -> list[UserModel]: + with get_db_context(db) as db: users = ( db.query(User) .join(GroupMember, User.id == GroupMember.user_id) @@ -462,30 +475,34 @@ def get_users_by_group_id(self, group_id: str) -> list[UserModel]: ) return [UserModel.model_validate(user) for user in users] - def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]: - with get_db() as db: + def get_users_by_user_ids( + self, user_ids: list[str], db: Optional[Session] = None + ) -> list[UserStatusModel]: + with get_db_context(db) as db: users = db.query(User).filter(User.id.in_(user_ids)).all() return [UserModel.model_validate(user) for user in users] - def get_num_users(self) -> Optional[int]: - with get_db() as db: + def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: + with get_db_context(db) as db: return db.query(User).count() - def has_users(self) -> bool: - with get_db() as db: + def has_users(self, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: return db.query(db.query(User).exists()).scalar() - def get_first_user(self) -> UserModel: + def get_first_user(self, db: Optional[Session] = None) -> UserModel: try: - with get_db() as db: + with get_db_context(db) as db: user = db.query(User).order_by(User.created_at).first() return UserModel.model_validate(user) except Exception: return None - def get_user_webhook_url_by_id(self, id: str) -> Optional[str]: + def get_user_webhook_url_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[str]: try: - with get_db() as db: + with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() if user.settings is None: @@ -499,8 +516,8 @@ def get_user_webhook_url_by_id(self, id: str) -> Optional[str]: except Exception: return None - def get_num_users_active_today(self) -> Optional[int]: - with get_db() as db: + def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]: + with get_db_context(db) as db: current_timestamp = int(datetime.datetime.now().timestamp()) today_midnight_timestamp = current_timestamp - (current_timestamp % 86400) query = db.query(User).filter( @@ -508,9 +525,11 @@ def get_num_users_active_today(self) -> Optional[int]: ) return query.count() - def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: + def update_user_role_by_id( + self, id: str, role: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(User).filter_by(id=id).update({"role": role}) db.commit() user = db.query(User).filter_by(id=id).first() @@ -519,10 +538,10 @@ def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: return None def update_user_status_by_id( - self, id: str, form_data: UserStatus + self, id: str, form_data: UserStatus, db: Optional[Session] = None ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(User).filter_by(id=id).update( {**form_data.model_dump(exclude_none=True)} ) @@ -534,10 +553,10 @@ def update_user_status_by_id( return None def update_user_profile_image_url_by_id( - self, id: str, profile_image_url: str + self, id: str, profile_image_url: str, db: Optional[Session] = None ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(User).filter_by(id=id).update( {"profile_image_url": profile_image_url} ) @@ -549,9 +568,11 @@ def update_user_profile_image_url_by_id( return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_last_active_by_id(self, id: str) -> Optional[UserModel]: + def update_last_active_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(User).filter_by(id=id).update( {"last_active_at": int(time.time())} ) @@ -563,7 +584,7 @@ def update_last_active_by_id(self, id: str) -> Optional[UserModel]: return None def update_user_oauth_by_id( - self, id: str, provider: str, sub: str + self, id: str, provider: str, sub: str, db: Optional[Session] = None ) -> Optional[UserModel]: """ Update or insert an OAuth provider/sub pair into the user's oauth JSON field. @@ -574,7 +595,7 @@ def update_user_oauth_by_id( } """ try: - with get_db() as db: + with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() if not user: return None @@ -594,9 +615,11 @@ def update_user_oauth_by_id( except Exception: return None - def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]: + def update_user_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: + with get_db_context(db) as db: db.query(User).filter_by(id=id).update(updated) db.commit() @@ -607,10 +630,16 @@ def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]: print(e) return None - def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]: + def update_user_settings_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[UserModel]: try: - with get_db() as db: - user_settings = db.query(User).filter_by(id=id).first().settings + with get_db_context(db) as db: + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + user_settings = user.settings if user_settings is None: user_settings = {} @@ -625,15 +654,15 @@ def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserMod except Exception: return None - def delete_user_by_id(self, id: str) -> bool: + def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: # Remove User from Groups Groups.remove_user_from_all_groups(id) # Delete User Chats - result = Chats.delete_chats_by_user_id(id) + result = Chats.delete_chats_by_user_id(id, db=db) if result: - with get_db() as db: + with get_db_context(db) as db: # Delete User db.query(User).filter_by(id=id).delete() db.commit() @@ -644,17 +673,21 @@ def delete_user_by_id(self, id: str) -> bool: except Exception: return False - def get_user_api_key_by_id(self, id: str) -> Optional[str]: + def get_user_api_key_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[str]: try: - with get_db() as db: + with get_db_context(db) as db: api_key = db.query(ApiKey).filter_by(user_id=id).first() return api_key.key if api_key else None except Exception: return None - def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: + def update_user_api_key_by_id( + self, id: str, api_key: str, db: Optional[Session] = None + ) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(ApiKey).filter_by(user_id=id).delete() db.commit() @@ -674,30 +707,32 @@ def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: except Exception: return False - def delete_user_api_key_by_id(self, id: str) -> bool: + def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: - with get_db() as db: + with get_db_context(db) as db: db.query(ApiKey).filter_by(user_id=id).delete() db.commit() return True except Exception: return False - def get_valid_user_ids(self, user_ids: list[str]) -> list[str]: - with get_db() as db: + def get_valid_user_ids( + self, user_ids: list[str], db: Optional[Session] = None + ) -> list[str]: + with get_db_context(db) as db: users = db.query(User).filter(User.id.in_(user_ids)).all() return [user.id for user in users] - def get_super_admin_user(self) -> Optional[UserModel]: - with get_db() as db: + def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]: + with get_db_context(db) as db: user = db.query(User).filter_by(role="admin").first() if user: return UserModel.model_validate(user) else: return None - def get_active_user_count(self) -> int: - with get_db() as db: + def get_active_user_count(self, db: Optional[Session] = None) -> int: + with get_db_context(db) as db: # Consider user active if last_active_at within the last 3 minutes three_minutes_ago = int(time.time()) - 180 count = ( @@ -705,8 +740,8 @@ def get_active_user_count(self) -> int: ) return count - def is_user_active(self, user_id: str) -> bool: - with get_db() as db: + def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool: + with get_db_context(db) as db: user = db.query(User).filter_by(id=user_id).first() if user and user.last_active_at: # Consider user active if last_active_at within the last 3 minutes diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index 998afd36f6c..e1371be2883 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -6,10 +6,8 @@ from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document from open_webui.utils.headers import include_user_info_headers -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class ExternalDocumentLoader(BaseLoader): diff --git a/backend/open_webui/retrieval/loaders/external_web.py b/backend/open_webui/retrieval/loaders/external_web.py index 68ed66162b4..39644caddb7 100644 --- a/backend/open_webui/retrieval/loaders/external_web.py +++ b/backend/open_webui/retrieval/loaders/external_web.py @@ -4,10 +4,8 @@ from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class ExternalWebLoader(BaseLoader): diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index 1346cd065c7..2b83e442834 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -30,11 +30,10 @@ 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, REQUESTS_VERIFY logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) known_source_ext = [ "go", @@ -115,7 +114,7 @@ def load(self) -> list[Document]: endpoint += "/" endpoint += "tika/text" - r = requests.put(endpoint, data=data, headers=headers) + r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY) if r.ok: raw_metadata = r.json() @@ -144,19 +143,17 @@ def load(self) -> list[Document]: with open(self.file_path, "rb") as f: headers = {} if self.api_key: - headers["Authorization"] = f"Bearer {self.api_key}" - - files = { - "files": ( - self.file_path, - f, - self.mime_type or "application/octet-stream", - ) - } + headers["X-Api-Key"] = f"Bearer {self.api_key}" r = requests.post( f"{self.url}/v1/convert/file", - files=files, + files={ + "files": ( + self.file_path, + f, + self.mime_type or "application/octet-stream", + ) + }, data={ "image_export_mode": "placeholder", **self.params, @@ -334,12 +331,21 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): elif self.engine == "mineru" and file_ext in [ "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( file_path=file_path, api_mode=self.kwargs.get("MINERU_API_MODE", "local"), api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"), api_key=self.kwargs.get("MINERU_API_KEY", ""), params=self.kwargs.get("MINERU_PARAMS", {}), + timeout=mineru_timeout, ) elif ( self.engine == "mistral_ocr" diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py index 360af804c71..617be8e87ab 100644 --- a/backend/open_webui/retrieval/loaders/mineru.py +++ b/backend/open_webui/retrieval/loaders/mineru.py @@ -26,11 +26,13 @@ def __init__( api_url: str = "http://localhost:8000", api_key: str = "", params: dict = None, + timeout: Optional[int] = 300, ): self.file_path = file_path self.api_mode = api_mode.lower() self.api_url = api_url.rstrip("/") self.api_key = api_key + self.timeout = timeout # Parse params dict with defaults self.params = params or {} @@ -101,7 +103,7 @@ def _load_local_api(self) -> List[Document]: f"{self.api_url}/file_parse", data=form_data, files=files, - timeout=300, # 5 minute timeout for large documents + timeout=self.timeout, ) response.raise_for_status() @@ -300,7 +302,7 @@ def _upload_to_presigned_url(self, upload_url: str) -> None: response = requests.put( upload_url, data=f, - timeout=300, # 5 minute timeout for large files + timeout=self.timeout, ) response.raise_for_status() except FileNotFoundError: diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index 6a2d235559a..68570757c84 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -9,11 +9,10 @@ from contextlib import asynccontextmanager 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) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class MistralLoader: diff --git a/backend/open_webui/retrieval/loaders/tavily.py b/backend/open_webui/retrieval/loaders/tavily.py index 15a3d7f97fe..f298de80b44 100644 --- a/backend/open_webui/retrieval/loaders/tavily.py +++ b/backend/open_webui/retrieval/loaders/tavily.py @@ -4,10 +4,8 @@ from langchain_core.document_loaders import BaseLoader from langchain_core.documents import Document -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class TavilyLoader(BaseLoader): diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index cba602ed870..faf7b4452e0 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -4,10 +4,8 @@ from typing import Any, Dict, Generator, List, Optional, Sequence, Union from urllib.parse import parse_qs, urlparse from langchain_core.documents import Document -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) ALLOWED_SCHEMES = {"http", "https"} ALLOWED_NETLOCS = { diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index 7ec888437a0..2a8c0329d79 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -5,12 +5,10 @@ from colbert.infra import ColBERTConfig from colbert.modeling.checkpoint import Checkpoint -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.models.base_reranker import BaseReranker log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class ColBERT(BaseReranker): diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py index 822cb3e3dd3..095143d20d6 100644 --- a/backend/open_webui/retrieval/models/external.py +++ b/backend/open_webui/retrieval/models/external.py @@ -4,13 +4,12 @@ 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, REQUESTS_VERIFY from open_webui.retrieval.models.base_reranker import BaseReranker from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class ExternalReranker(BaseReranker): @@ -19,10 +18,12 @@ def __init__( api_key: str, url: str = "http://localhost:8080/v1/rerank", model: str = "reranker", + timeout: Optional[int] = None, ): self.api_key = api_key self.url = url self.model = model + self.timeout = timeout def predict( self, sentences: List[Tuple[str, str]], user=None @@ -53,6 +54,8 @@ def predict( f"{self.url}", headers=headers, json=payload, + timeout=self.timeout, + verify=REQUESTS_VERIFY, ) r.raise_for_status() diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 711b1a8b796..56315c73fdd 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -12,7 +12,10 @@ from urllib.parse import quote 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_core.documents import Document @@ -37,9 +40,10 @@ from open_webui.env import ( - SRC_LOG_LEVELS, + AIOHTTP_CLIENT_TIMEOUT, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, + AIOHTTP_CLIENT_SESSION_SSL, ) from open_webui.config import ( RAG_EMBEDDING_QUERY_PREFIX, @@ -48,7 +52,6 @@ ) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) from typing import Any @@ -594,7 +597,9 @@ async def agenerate_openai_batch_embeddings( if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.post( f"{url}/embeddings", headers=headers, json=form_data ) as r: @@ -683,7 +688,9 @@ async def agenerate_azure_openai_batch_embeddings( if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.post(full_url, headers=headers, json=form_data) as r: r.raise_for_status() data = await r.json() @@ -759,9 +766,14 @@ async def agenerate_ollama_batch_embeddings( if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.post( - f"{url}/api/embed", headers=headers, json=form_data + f"{url}/api/embed", + headers=headers, + json=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() data = await r.json() @@ -790,7 +802,9 @@ async def async_embedding_function(query, prefix=None, user=None): return await asyncio.to_thread( ( lambda query, prefix=None: embedding_function.encode( - query, **({"prompt": prefix} if prefix else {}) + query, + batch_size=int(embedding_batch_size), + **({"prompt": prefix} if prefix else {}), ).tolist() ), query, @@ -1281,7 +1295,7 @@ async def acompress_documents( scores = None if reranking: - scores = self.reranking_function(query, documents) + scores = await asyncio.to_thread(self.reranking_function, query, documents) else: from sentence_transformers import util diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index 1fdb064c51f..b7ea5244b4f 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -24,10 +24,8 @@ CHROMA_CLIENT_AUTH_PROVIDER, CHROMA_CLIENT_AUTH_CREDENTIALS, ) -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class ChromaClient(VectorDBBase): @@ -71,7 +69,11 @@ def delete_collection(self, collection_name: str): return self.client.delete_collection(name=collection_name) def search( - self, collection_name: str, vectors: list[list[float | int]], limit: int + self, + collection_name: str, + vectors: list[list[float | int]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. try: @@ -80,6 +82,7 @@ def search( result = collection.query( query_embeddings=vectors, n_results=limit, + where=filter, ) # chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1 diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index 6de0d859f8a..e209453f5c6 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -153,7 +153,11 @@ def delete_collection(self, collection_name: str): # Status: works def search( - self, collection_name: str, vectors: list[list[float]], limit: int + self, + collection_name: str, + vectors: list[list[float]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: query = { "size": limit, diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 3dae4672f3f..35cf6b3829c 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -25,10 +25,8 @@ MILVUS_DISKANN_MAX_DEGREE, MILVUS_DISKANN_SEARCH_LIST_SIZE, ) -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class MilvusClient(VectorDBBase): @@ -181,7 +179,11 @@ def delete_collection(self, collection_name: str): ) def search( - self, collection_name: str, vectors: list[list[float | int]], limit: int + self, + collection_name: str, + vectors: list[list[float | int]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. collection_name = collection_name.replace("-", "_") diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index cd2ceed7957..c58189b2a30 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -12,7 +12,6 @@ MILVUS_HNSW_EFCONSTRUCTION, MILVUS_IVF_FLAT_NLIST, ) -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -29,7 +28,6 @@ ) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) RESOURCE_ID_FIELD = "resource_id" @@ -159,7 +157,11 @@ def upsert(self, collection_name: str, items: List[VectorItem]): collection.insert(entities) def search( - self, collection_name: str, vectors: List[List[float]], limit: int + self, + collection_name: str, + vectors: List[List[float]], + filter: Optional[Dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: if not vectors: return None diff --git a/backend/open_webui/retrieval/vector/dbs/opengauss.py b/backend/open_webui/retrieval/vector/dbs/opengauss.py new file mode 100644 index 00000000000..7d4f9ea0929 --- /dev/null +++ b/backend/open_webui/retrieval/vector/dbs/opengauss.py @@ -0,0 +1,427 @@ +from typing import Optional, List, Dict, Any +import logging +import re +import json +from sqlalchemy import ( + func, + literal, + cast, + column, + create_engine, + Column, + Integer, + MetaData, + LargeBinary, + select, + text, + Text, + Table, + values, +) +from sqlalchemy.sql import true +from sqlalchemy.pool import NullPool, QueuePool + +from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker +from sqlalchemy.dialects.postgresql import JSONB, array +from pgvector.sqlalchemy import Vector +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.exc import NoSuchTableError + +from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 +from sqlalchemy.dialects import registry + + +class OpenGaussDialect(PGDialect_psycopg2): + name = "opengauss" + + def _get_server_version_info(self, connection): + try: + version = connection.exec_driver_sql("SELECT version()").scalar() + if not version: + return (9, 0, 0) + + match = re.search( + r"openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?", version, re.IGNORECASE + ) + if match: + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + return super()._get_server_version_info(connection) + except Exception: + return (9, 0, 0) + + +# Register dialect +registry.register("opengauss", __name__, "OpenGaussDialect") + +from open_webui.retrieval.vector.utils import process_metadata +from open_webui.retrieval.vector.main import ( + VectorDBBase, + VectorItem, + SearchResult, + GetResult, +) +from open_webui.config import ( + OPENGAUSS_DB_URL, + OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH, + OPENGAUSS_POOL_SIZE, + OPENGAUSS_POOL_MAX_OVERFLOW, + OPENGAUSS_POOL_TIMEOUT, + OPENGAUSS_POOL_RECYCLE, +) + +from open_webui.env import SRC_LOG_LEVELS + +VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH +Base = declarative_base() + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +class DocumentChunk(Base): + __tablename__ = "document_chunk" + + id = Column(Text, primary_key=True) + vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True) + collection_name = Column(Text, nullable=False) + text = Column(Text, nullable=True) + vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True) + + +class OpenGaussClient(VectorDBBase): + def __init__(self) -> None: + if not OPENGAUSS_DB_URL: + from open_webui.internal.db import ScopedSession + + self.session = ScopedSession + else: + engine_kwargs = {"pool_pre_ping": True, "dialect": OpenGaussDialect()} + + if isinstance(OPENGAUSS_POOL_SIZE, int) and OPENGAUSS_POOL_SIZE > 0: + engine_kwargs.update( + { + "pool_size": OPENGAUSS_POOL_SIZE, + "max_overflow": OPENGAUSS_POOL_MAX_OVERFLOW, + "pool_timeout": OPENGAUSS_POOL_TIMEOUT, + "pool_recycle": OPENGAUSS_POOL_RECYCLE, + "poolclass": QueuePool, + } + ) + else: + engine_kwargs["poolclass"] = NullPool + + engine = create_engine(OPENGAUSS_DB_URL, **engine_kwargs) + + SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine, expire_on_commit=False + ) + self.session = scoped_session(SessionLocal) + + try: + connection = self.session.connection() + Base.metadata.create_all(bind=connection) + + self.session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_document_chunk_vector " + "ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);" + ) + ) + self.session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name " + "ON document_chunk (collection_name);" + ) + ) + self.session.commit() + log.info("OpenGauss vector database initialization completed.") + except Exception as e: + self.session.rollback() + log.exception(f"OpenGauss Initialization failed.: {e}") + raise + + def check_vector_length(self) -> None: + metadata = MetaData() + try: + document_chunk_table = Table( + "document_chunk", metadata, autoload_with=self.session.bind + ) + except NoSuchTableError: + return + + if "vector" in document_chunk_table.columns: + vector_column = document_chunk_table.columns["vector"] + vector_type = vector_column.type + if isinstance(vector_type, Vector): + db_vector_length = vector_type.dim + if db_vector_length != VECTOR_LENGTH: + raise Exception( + f"Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database." + ) + else: + raise Exception("The 'vector' column type is not Vector.") + else: + raise Exception( + "The 'vector' column does not exist in the 'document_chunk' table." + ) + + def adjust_vector_length(self, vector: List[float]) -> List[float]: + current_length = len(vector) + if current_length < VECTOR_LENGTH: + vector += [0.0] * (VECTOR_LENGTH - current_length) + elif current_length > VECTOR_LENGTH: + vector = vector[:VECTOR_LENGTH] + return vector + + def insert(self, collection_name: str, items: List[VectorItem]) -> None: + try: + new_items = [] + for item in items: + vector = self.adjust_vector_length(item["vector"]) + new_chunk = DocumentChunk( + id=item["id"], + vector=vector, + collection_name=collection_name, + text=item["text"], + vmetadata=process_metadata(item["metadata"]), + ) + new_items.append(new_chunk) + self.session.bulk_save_objects(new_items) + self.session.commit() + log.info( + f"Inserting {len(new_items)} items into collection '{collection_name}'." + ) + except Exception as e: + self.session.rollback() + log.exception(f"Failed to insert data: {e}") + raise + + def upsert(self, collection_name: str, items: List[VectorItem]) -> None: + try: + for item in items: + vector = self.adjust_vector_length(item["vector"]) + existing = ( + self.session.query(DocumentChunk) + .filter(DocumentChunk.id == item["id"]) + .first() + ) + if existing: + existing.vector = vector + existing.text = item["text"] + existing.vmetadata = process_metadata(item["metadata"]) + existing.collection_name = collection_name + else: + new_chunk = DocumentChunk( + id=item["id"], + vector=vector, + collection_name=collection_name, + text=item["text"], + vmetadata=process_metadata(item["metadata"]), + ) + self.session.add(new_chunk) + self.session.commit() + log.info( + f"Inserting/updating {len(items)} items in collection '{collection_name}'." + ) + except Exception as e: + self.session.rollback() + log.exception(f"Failed to insert or update data.: {e}") + raise + + def search( + self, + collection_name: str, + vectors: List[List[float]], + filter: Optional[Dict[str, Any]] = None, + limit: int = 10, + ) -> Optional[SearchResult]: + try: + if not vectors: + return None + + vectors = [self.adjust_vector_length(vector) for vector in vectors] + num_queries = len(vectors) + + def vector_expr(vector): + return cast(array(vector), Vector(VECTOR_LENGTH)) + + qid_col = column("qid", Integer) + q_vector_col = column("q_vector", Vector(VECTOR_LENGTH)) + query_vectors = ( + values(qid_col, q_vector_col) + .data( + [(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)] + ) + .alias("query_vectors") + ) + + result_fields = [ + DocumentChunk.id, + DocumentChunk.text, + DocumentChunk.vmetadata, + (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label( + "distance" + ), + ] + + subq = ( + select(*result_fields) + .where(DocumentChunk.collection_name == collection_name) + .order_by( + DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector) + ) + ) + if limit is not None: + subq = subq.limit(limit) + subq = subq.lateral("result") + + stmt = ( + select( + query_vectors.c.qid, + subq.c.id, + subq.c.text, + subq.c.vmetadata, + subq.c.distance, + ) + .select_from(query_vectors) + .join(subq, true()) + .order_by(query_vectors.c.qid, subq.c.distance) + ) + + result_proxy = self.session.execute(stmt) + results = result_proxy.all() + + ids = [[] for _ in range(num_queries)] + distances = [[] for _ in range(num_queries)] + documents = [[] for _ in range(num_queries)] + metadatas = [[] for _ in range(num_queries)] + + for row in results: + qid = int(row.qid) + ids[qid].append(row.id) + distances[qid].append((2.0 - row.distance) / 2.0) + documents[qid].append(row.text) + metadatas[qid].append(row.vmetadata) + + self.session.rollback() + return SearchResult( + ids=ids, distances=distances, documents=documents, metadatas=metadatas + ) + except Exception as e: + self.session.rollback() + log.exception(f"Vector search failed: {e}") + return None + + def query( + self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None + ) -> Optional[GetResult]: + try: + query = self.session.query(DocumentChunk).filter( + DocumentChunk.collection_name == collection_name + ) + + for key, value in filter.items(): + query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) + + if limit is not None: + query = query.limit(limit) + + results = query.all() + + if not results: + return None + + ids = [[result.id for result in results]] + documents = [[result.text for result in results]] + metadatas = [[result.vmetadata for result in results]] + + self.session.rollback() + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + except Exception as e: + self.session.rollback() + log.exception(f"Conditional query failed: {e}") + return None + + def get( + self, collection_name: str, limit: Optional[int] = None + ) -> Optional[GetResult]: + try: + query = self.session.query(DocumentChunk).filter( + DocumentChunk.collection_name == collection_name + ) + if limit is not None: + query = query.limit(limit) + + results = query.all() + + if not results: + return None + + ids = [[result.id for result in results]] + documents = [[result.text for result in results]] + metadatas = [[result.vmetadata for result in results]] + + self.session.rollback() + return GetResult(ids=ids, documents=documents, metadatas=metadatas) + except Exception as e: + self.session.rollback() + log.exception(f"Failed to retrieve data: {e}") + return None + + def delete( + self, + collection_name: str, + ids: Optional[List[str]] = None, + filter: Optional[Dict[str, Any]] = None, + ) -> None: + try: + query = self.session.query(DocumentChunk).filter( + DocumentChunk.collection_name == collection_name + ) + if ids: + query = query.filter(DocumentChunk.id.in_(ids)) + if filter: + for key, value in filter.items(): + query = query.filter( + DocumentChunk.vmetadata[key].astext == str(value) + ) + deleted = query.delete(synchronize_session=False) + self.session.commit() + log.info(f"Deleted {deleted} items from collection '{collection_name}'") + except Exception as e: + self.session.rollback() + log.exception(f"Failed to delete data: {e}") + raise + + def reset(self) -> None: + try: + deleted = self.session.query(DocumentChunk).delete() + self.session.commit() + log.info(f"Reset completed. Deleted {deleted} items") + except Exception as e: + self.session.rollback() + log.exception(f"Reset failed: {e}") + raise + + def close(self) -> None: + pass + + def has_collection(self, collection_name: str) -> bool: + try: + exists = ( + self.session.query(DocumentChunk) + .filter(DocumentChunk.collection_name == collection_name) + .first() + is not None + ) + self.session.rollback() + return exists + except Exception as e: + self.session.rollback() + log.exception(f"Failed to check collection existence: {e}") + return False + + def delete_collection(self, collection_name: str) -> None: + self.delete(collection_name) + log.info(f"Collection '{collection_name}' has been deleted") diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 2e946710e24..dc9c35805e4 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -113,7 +113,11 @@ def delete_collection(self, collection_name: str): self.client.indices.delete(index=self._get_index_name(collection_name)) def search( - self, collection_name: str, vectors: list[list[float | int]], limit: int + self, + collection_name: str, + vectors: list[list[float | int]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: try: if not self.has_collection(collection_name): diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index b714588bdc2..9f16f82bc96 100644 --- a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -55,10 +55,8 @@ ORACLE_DB_POOL_MAX, ORACLE_DB_POOL_INCREMENT, ) -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class Oracle23aiClient(VectorDBBase): @@ -523,7 +521,11 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: raise def search( - self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: """ Search for similar vectors in the database. diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 85c2ef009d9..15430db1143 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -51,7 +51,6 @@ PGVECTOR_USE_HALFVEC, ) -from open_webui.env import SRC_LOG_LEVELS VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH USE_HALFVEC = PGVECTOR_USE_HALFVEC @@ -61,7 +60,6 @@ Base = declarative_base() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def pgcrypto_encrypt(val, key): @@ -92,9 +90,9 @@ def __init__(self) -> None: # if no pgvector uri, use the existing database connection if not PGVECTOR_DB_URL: - from open_webui.internal.db import Session + from open_webui.internal.db import ScopedSession - self.session = Session + self.session = ScopedSession else: if isinstance(PGVECTOR_POOL_SIZE, int): if PGVECTOR_POOL_SIZE > 0: @@ -429,7 +427,8 @@ def search( self, collection_name: str, vectors: List[List[float]], - limit: Optional[int] = None, + filter: Optional[Dict[str, Any]] = None, + limit: int = 10, ) -> Optional[SearchResult]: try: if not vectors: @@ -477,9 +476,47 @@ def vector_expr(vector): ) # Build the lateral subquery for each query vector + where_clauses = [DocumentChunk.collection_name == collection_name] + + # Apply metadata filter if provided + if filter: + for key, value in filter.items(): + if isinstance(value, dict) and "$in" in value: + # Handle $in operator: {"field": {"$in": [values]}} + in_values = value["$in"] + if PGVECTOR_PGCRYPTO: + where_clauses.append( + pgcrypto_decrypt( + DocumentChunk.vmetadata, + PGVECTOR_PGCRYPTO_KEY, + JSONB, + )[key].astext.in_([str(v) for v in in_values]) + ) + else: + where_clauses.append( + DocumentChunk.vmetadata[key].astext.in_( + [str(v) for v in in_values] + ) + ) + else: + # Handle simple equality: {"field": "value"} + if PGVECTOR_PGCRYPTO: + where_clauses.append( + pgcrypto_decrypt( + DocumentChunk.vmetadata, + PGVECTOR_PGCRYPTO_KEY, + JSONB, + )[key].astext + == str(value) + ) + else: + where_clauses.append( + DocumentChunk.vmetadata[key].astext == str(value) + ) + subq = ( select(*result_fields) - .where(DocumentChunk.collection_name == collection_name) + .where(*where_clauses) .order_by( (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)) ) diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index 5bef0d9ea7d..fc3c98f8cf9 100644 --- a/backend/open_webui/retrieval/vector/dbs/pinecone.py +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -31,7 +31,6 @@ PINECONE_METRIC, PINECONE_CLOUD, ) -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.utils import process_metadata @@ -39,7 +38,6 @@ BATCH_SIZE = 100 # Recommended batch size for Pinecone operations log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class PineconeClient(VectorDBBase): @@ -393,7 +391,11 @@ async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> N ) def search( - self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: """Search for similar vectors in a collection.""" if not vectors or not vectors[0]: diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index ea432974993..d42984e1d6a 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -22,12 +22,10 @@ QDRANT_TIMEOUT, QDRANT_HNSW_M, ) -from open_webui.env import SRC_LOG_LEVELS NO_LIMIT = 999999999 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class QdrantClient(VectorDBBase): @@ -147,7 +145,11 @@ def delete_collection(self, collection_name: str): ) def search( - self, collection_name: str, vectors: list[list[float | int]], limit: int + self, + collection_name: str, + vectors: list[list[float | int]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. if limit is None: diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index e9fa03d4591..f87f85a23be 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -13,7 +13,6 @@ QDRANT_TIMEOUT, QDRANT_HNSW_M, ) -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.main import ( GetResult, SearchResult, @@ -30,7 +29,6 @@ DEFAULT_DIMENSION = 384 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def _tenant_filter(tenant_id: str) -> models.FieldCondition: @@ -256,7 +254,11 @@ def delete( ) def search( - self, collection_name: str, vectors: List[List[float | int]], limit: int + self, + collection_name: str, + vectors: List[List[float | int]], + filter: Optional[Dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: """ Search for the nearest neighbor items based on the vectors with tenant isolation. diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py index e2a7adfd8b8..96e487f1111 100644 --- a/backend/open_webui/retrieval/vector/dbs/s3vector.py +++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py @@ -6,13 +6,11 @@ SearchResult, ) from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION -from open_webui.env import SRC_LOG_LEVELS from typing import List, Optional, Dict, Any, Union import logging import boto3 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) class S3VectorClient(VectorDBBase): @@ -297,7 +295,11 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: raise def search( - self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: """ Search for similar vectors in a collection using multiple query vectors. diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py index 6bb8a1ecb43..d204e8293a5 100644 --- a/backend/open_webui/retrieval/vector/dbs/weaviate.py +++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py @@ -159,7 +159,11 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: ) def search( - self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: sane_collection_name = self._sanitize_collection_name(collection_name) if not self.client.collections.exists(sane_collection_name): diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py index b843e0926d0..68595fb5956 100644 --- a/backend/open_webui/retrieval/vector/factory.py +++ b/backend/open_webui/retrieval/vector/factory.py @@ -53,6 +53,10 @@ def get_vector(vector_type: str) -> VectorDBBase: from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient return PgvectorClient() + case VectorType.OPENGAUSS: + from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient + + return OpenGaussClient() case VectorType.ELASTICSEARCH: from open_webui.retrieval.vector.dbs.elasticsearch import ( ElasticsearchClient, diff --git a/backend/open_webui/retrieval/vector/main.py b/backend/open_webui/retrieval/vector/main.py index 53f752f5790..a76fec99562 100644 --- a/backend/open_webui/retrieval/vector/main.py +++ b/backend/open_webui/retrieval/vector/main.py @@ -53,7 +53,11 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @abstractmethod def search( - self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int + self, + collection_name: str, + vectors: List[List[Union[float, int]]], + filter: Optional[Dict] = None, + limit: int = 10, ) -> Optional[SearchResult]: """Search for similar vectors in a collection.""" pass diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py index 292cad1e785..de20133fce4 100644 --- a/backend/open_webui/retrieval/vector/type.py +++ b/backend/open_webui/retrieval/vector/type.py @@ -12,3 +12,4 @@ class VectorType(StrEnum): ORACLE23AI = "oracle23ai" S3VECTOR = "s3vector" WEAVIATE = "weaviate" + OPENGAUSS = "opengauss" diff --git a/backend/open_webui/retrieval/web/azure.py b/backend/open_webui/retrieval/web/azure.py index 814cf4b63c7..3859ccc9b7f 100644 --- a/backend/open_webui/retrieval/web/azure.py +++ b/backend/open_webui/retrieval/web/azure.py @@ -1,10 +1,8 @@ import logging from typing import Optional from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) """ Azure AI Search integration for Open WebUI. diff --git a/backend/open_webui/retrieval/web/bing.py b/backend/open_webui/retrieval/web/bing.py index 0a3ba4621cd..4c9822b9007 100644 --- a/backend/open_webui/retrieval/web/bing.py +++ b/backend/open_webui/retrieval/web/bing.py @@ -4,11 +4,9 @@ from typing import Optional import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS import argparse log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) """ Documentation: https://docs.microsoft.com/en-us/bing/search-apis/bing-web-search/overview """ diff --git a/backend/open_webui/retrieval/web/bocha.py b/backend/open_webui/retrieval/web/bocha.py index f26da36f84b..7e3c9b0a402 100644 --- a/backend/open_webui/retrieval/web/bocha.py +++ b/backend/open_webui/retrieval/web/bocha.py @@ -4,20 +4,18 @@ import requests import json from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def _parse_response(response): - result = {} + results = [] if "data" in response: data = response["data"] if "webPages" in data: webPages = data["webPages"] if "value" in webPages: - result["webpage"] = [ + results = [ { "id": item.get("id", ""), "name": item.get("name", ""), @@ -31,7 +29,7 @@ def _parse_response(response): } for item in webPages["value"] ] - return result + return results def search_bocha( @@ -53,7 +51,7 @@ def search_bocha( response = requests.post(url, headers=headers, data=payload, timeout=5) response.raise_for_status() results = _parse_response(response.json()) - print(results) + if filter_list: results = get_filtered_results(results, filter_list) @@ -61,5 +59,5 @@ def search_bocha( SearchResult( link=result["url"], title=result.get("name"), snippet=result.get("summary") ) - for result in results.get("webpage", [])[:count] + for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/brave.py b/backend/open_webui/retrieval/web/brave.py index 7bea5756203..49c8a88e811 100644 --- a/backend/open_webui/retrieval/web/brave.py +++ b/backend/open_webui/retrieval/web/brave.py @@ -1,12 +1,11 @@ import logging +import time from typing import Optional import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_brave( @@ -27,6 +26,14 @@ def search_brave( params = {"q": query, "count": count} response = requests.get(url, headers=headers, params=params) + + # Handle 429 rate limiting - Brave free tier allows 1 request/second + # If rate limited, wait 1 second and retry once before failing + if response.status_code == 429: + log.info("Brave Search API rate limited (429), retrying after 1 second...") + time.sleep(1) + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() json_response = response.json() diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index e4cf9d00ec7..7528418cdbb 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -4,10 +4,8 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results from ddgs import DDGS from ddgs.exceptions import RatelimitException -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_duckduckgo( @@ -15,12 +13,14 @@ def search_duckduckgo( count: int, filter_list: Optional[list[str]] = None, concurrent_requests: Optional[int] = None, + backend: Optional[str] = "auto", ) -> list[SearchResult]: """ Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects. Args: query (str): The query to search for count (int): The number of results to return + backend (str): The search backend to use (auto, duckduckgo, google, brave, etc.) Returns: list[SearchResult]: A list of search results @@ -34,7 +34,7 @@ def search_duckduckgo( # Use the ddgs.text() method to perform the search try: search_results = ddgs.text( - query, safesearch="moderate", max_results=count, backend="lite" + query, safesearch="moderate", max_results=count, backend=backend ) except RatelimitException as e: log.error(f"RatelimitException: {e}") diff --git a/backend/open_webui/retrieval/web/exa.py b/backend/open_webui/retrieval/web/exa.py index 927adef4134..df9554fab20 100644 --- a/backend/open_webui/retrieval/web/exa.py +++ b/backend/open_webui/retrieval/web/exa.py @@ -3,11 +3,9 @@ from typing import Optional import requests -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.web.main import SearchResult log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) EXA_API_BASE = "https://api.exa.ai" diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py index 13f6a5aa68a..527c918a47a 100644 --- a/backend/open_webui/retrieval/web/external.py +++ b/backend/open_webui/retrieval/web/external.py @@ -5,14 +5,12 @@ from fastapi import Request -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.utils.headers import include_user_info_headers log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_external( diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index 2d9b104bca4..82635aa8ca1 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -2,11 +2,9 @@ from typing import Optional, List from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_firecrawl( diff --git a/backend/open_webui/retrieval/web/google_pse.py b/backend/open_webui/retrieval/web/google_pse.py index 69de24711af..96fa8c98cd3 100644 --- a/backend/open_webui/retrieval/web/google_pse.py +++ b/backend/open_webui/retrieval/web/google_pse.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_google_pse( diff --git a/backend/open_webui/retrieval/web/jina_search.py b/backend/open_webui/retrieval/web/jina_search.py index a87293db5cc..d1168bb36f6 100644 --- a/backend/open_webui/retrieval/web/jina_search.py +++ b/backend/open_webui/retrieval/web/jina_search.py @@ -2,24 +2,26 @@ import requests from open_webui.retrieval.web.main import SearchResult -from open_webui.env import SRC_LOG_LEVELS from yarl import URL 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, base_url: str = "" +) -> list[SearchResult]: """ Search using Jina's Search API and return the results as a list of SearchResult objects. Args: + api_key (str): The Jina API key query (str): The query to search for count (int): The number of results to return + base_url (str): Optional custom base URL for the Jina API Returns: list[SearchResult]: A list of search results """ - jina_search_endpoint = "https://s.jina.ai/" + jina_search_endpoint = base_url if base_url else "https://s.jina.ai/" headers = { "Accept": "application/json", diff --git a/backend/open_webui/retrieval/web/kagi.py b/backend/open_webui/retrieval/web/kagi.py index 0b69da8bcef..f0303acf699 100644 --- a/backend/open_webui/retrieval/web/kagi.py +++ b/backend/open_webui/retrieval/web/kagi.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_kagi( diff --git a/backend/open_webui/retrieval/web/mojeek.py b/backend/open_webui/retrieval/web/mojeek.py index d298b0ee51c..d48f7aeef89 100644 --- a/backend/open_webui/retrieval/web/mojeek.py +++ b/backend/open_webui/retrieval/web/mojeek.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_mojeek( diff --git a/backend/open_webui/retrieval/web/ollama.py b/backend/open_webui/retrieval/web/ollama.py index a199a14389b..71bd9d51245 100644 --- a/backend/open_webui/retrieval/web/ollama.py +++ b/backend/open_webui/retrieval/web/ollama.py @@ -3,11 +3,9 @@ from typing import Optional 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, get_filtered_results log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_ollama_cloud( @@ -38,6 +36,9 @@ def search_ollama_cloud( results = data.get("results", []) log.info(f"Found {len(results)} results") + if filter_list: + results = get_filtered_results(results, filter_list) + return [ SearchResult( link=result.get("url", ""), diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py index 4e046668fa0..aae802b432d 100644 --- a/backend/open_webui/retrieval/web/perplexity.py +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -3,7 +3,6 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS MODELS = Literal[ "sonar", @@ -16,7 +15,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_perplexity( diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py index 97961f478ba..5c591ff64ff 100644 --- a/backend/open_webui/retrieval/web/perplexity_search.py +++ b/backend/open_webui/retrieval/web/perplexity_search.py @@ -4,11 +4,9 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results from open_webui.utils.headers import include_user_info_headers -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_perplexity_search( diff --git a/backend/open_webui/retrieval/web/searchapi.py b/backend/open_webui/retrieval/web/searchapi.py index d7704638c2b..caf781c5df6 100644 --- a/backend/open_webui/retrieval/web/searchapi.py +++ b/backend/open_webui/retrieval/web/searchapi.py @@ -4,10 +4,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_searchapi( diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index 15e3c098a9d..b3d4eb87951 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_searxng( @@ -27,7 +25,7 @@ def search_searxng( count (int): The maximum number of results to retrieve from the search. 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). 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. @@ -40,7 +38,7 @@ def search_searxng( """ # 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") time_range = kwargs.get("time_range", "") categories = "".join(kwargs.get("categories", [])) diff --git a/backend/open_webui/retrieval/web/serpapi.py b/backend/open_webui/retrieval/web/serpapi.py index 8762210bfd2..bb421b500f6 100644 --- a/backend/open_webui/retrieval/web/serpapi.py +++ b/backend/open_webui/retrieval/web/serpapi.py @@ -4,10 +4,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_serpapi( diff --git a/backend/open_webui/retrieval/web/serper.py b/backend/open_webui/retrieval/web/serper.py index 685e34375de..5a745e304ef 100644 --- a/backend/open_webui/retrieval/web/serper.py +++ b/backend/open_webui/retrieval/web/serper.py @@ -4,10 +4,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_serper( diff --git a/backend/open_webui/retrieval/web/serply.py b/backend/open_webui/retrieval/web/serply.py index a9b473eb044..68843eba85b 100644 --- a/backend/open_webui/retrieval/web/serply.py +++ b/backend/open_webui/retrieval/web/serply.py @@ -4,10 +4,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_serply( diff --git a/backend/open_webui/retrieval/web/serpstack.py b/backend/open_webui/retrieval/web/serpstack.py index d4dbda57ca9..97db8587242 100644 --- a/backend/open_webui/retrieval/web/serpstack.py +++ b/backend/open_webui/retrieval/web/serpstack.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_serpstack( diff --git a/backend/open_webui/retrieval/web/sougou.py b/backend/open_webui/retrieval/web/sougou.py index af7957c4fc8..d8747c3ade3 100644 --- a/backend/open_webui/retrieval/web/sougou.py +++ b/backend/open_webui/retrieval/web/sougou.py @@ -4,10 +4,8 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_sougou( diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index bfd102afa61..6d9ff89a87e 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -3,10 +3,8 @@ import requests from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_tavily( diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index bdbde0b3a91..6c1ea4b1bf5 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -33,19 +33,19 @@ PLAYWRIGHT_WS_URL, PLAYWRIGHT_TIMEOUT, WEB_LOADER_ENGINE, + WEB_LOADER_TIMEOUT, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, + FIRECRAWL_TIMEOUT, TAVILY_API_KEY, TAVILY_EXTRACT_DEPTH, EXTERNAL_WEB_LOADER_URL, EXTERNAL_WEB_LOADER_API_KEY, WEB_FETCH_FILTER_LIST, ) -from open_webui.env import SRC_LOG_LEVELS from open_webui.utils.misc import is_string_allowed log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def resolve_hostname(hostname): @@ -190,6 +190,7 @@ def __init__( continue_on_failure: bool = True, api_key: Optional[str] = None, api_url: Optional[str] = None, + timeout: Optional[int] = None, mode: Literal["crawl", "scrape", "map"] = "scrape", proxy: Optional[Dict[str, str]] = None, params: Optional[Dict] = None, @@ -232,6 +233,7 @@ def __init__( self.continue_on_failure = continue_on_failure self.api_key = api_key self.api_url = api_url + self.timeout = timeout self.mode = mode self.params = params or {} @@ -254,7 +256,7 @@ def lazy_load(self) -> Iterator[Document]: ignore_invalid_urls=True, remove_base64_images=True, max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=len(self.web_paths) * 3, + wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, **self.params, ) @@ -295,7 +297,7 @@ async def alazy_load(self): ignore_invalid_urls=True, remove_base64_images=True, max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values - wait_timeout=len(self.web_paths) * 3, + wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3, **self.params, ) @@ -674,6 +676,20 @@ def get_web_loader( if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web": 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": WebLoaderClass = SafePlaywrightURLLoader web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value @@ -684,6 +700,11 @@ def get_web_loader( WebLoaderClass = SafeFireCrawlLoader web_loader_args["api_key"] = FIRECRAWL_API_KEY.value web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value + if FIRECRAWL_TIMEOUT.value: + try: + web_loader_args["timeout"] = int(FIRECRAWL_TIMEOUT.value) + except ValueError: + pass if WEB_LOADER_ENGINE.value == "tavily": WebLoaderClass = SafeTavilyLoader diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py index bc61425cbca..2419717b24b 100644 --- a/backend/open_webui/retrieval/web/yacy.py +++ b/backend/open_webui/retrieval/web/yacy.py @@ -4,10 +4,8 @@ import requests from requests.auth import HTTPDigestAuth from open_webui.retrieval.web.main import SearchResult, get_filtered_results -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_yacy( diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 9c84f9c7041..52e0182cad1 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -33,13 +33,18 @@ 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.access_control import has_permission from open_webui.utils.headers import include_user_info_headers from open_webui.config import ( WHISPER_MODEL_AUTO_UPDATE, + WHISPER_COMPUTE_TYPE, WHISPER_MODEL_DIR, + WHISPER_VAD_FILTER, CACHE_DIR, WHISPER_LANGUAGE, + WHISPER_MULTILINGUAL, ELEVENLABS_API_BASE_URL, ) @@ -48,7 +53,6 @@ ENV, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, - SRC_LOG_LEVELS, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, ) @@ -63,7 +67,6 @@ AZURE_MAX_FILE_SIZE = AZURE_MAX_FILE_SIZE_MB * 1024 * 1024 # Convert MB to bytes log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["AUDIO"]) SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -130,7 +133,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False): faster_whisper_kwargs = { "model_size_or_path": model, "device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu", - "compute_type": "int8", + "compute_type": WHISPER_COMPUTE_TYPE, "download_root": WHISPER_MODEL_DIR, "local_files_only": not auto_update, } @@ -329,6 +332,20 @@ def load_speech_pipeline(request): @router.post("/speech") async def speech(request: Request, user=Depends(get_verified_user)): + if request.app.state.config.TTS_ENGINE == "": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if user.role != "admin" and not has_permission( + user.id, "chat.tts", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + body = await request.body() name = hashlib.sha256( body @@ -586,8 +603,9 @@ def transcription_handler(request, file_path, metadata, user=None): segments, info = model.transcribe( file_path, beam_size=5, - vad_filter=request.app.state.config.WHISPER_VAD_FILTER, + vad_filter=WHISPER_VAD_FILTER, language=languages[0], + multilingual=WHISPER_MULTILINGUAL, ) log.info( "Detected language '%s' with probability %f" @@ -1151,21 +1169,19 @@ def transcription( language: Optional[str] = Form(None), user=Depends(get_verified_user), ): + if user.role != "admin" and not has_permission( + user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) log.info(f"file.content_type: {file.content_type}") - stt_supported_content_types = getattr( request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] ) - if not any( - fnmatch(file.content_type, content_type) - for content_type in ( - stt_supported_content_types - if stt_supported_content_types - and any(t.strip() for t in stt_supported_content_types) - else ["audio/*", "video/webm"] - ) - ): + if not strict_match_mime_type(stt_supported_content_types, file.content_type): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 3d83dcaea68..30d4ebe4cc3 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -37,7 +37,6 @@ WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, ENABLE_INITIAL_ADMIN_SIGNUP, - SRC_LOG_LEVELS, ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response, JSONResponse @@ -63,6 +62,8 @@ get_password_hash, get_http_authorization_cred, ) +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.groups import apply_default_group_assignment @@ -81,7 +82,6 @@ router = APIRouter() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) signin_rate_limiter = RateLimiter( redis_client=get_redis_client(), limit=5 * 3, window=60 * 3 @@ -105,7 +105,10 @@ class SessionUserInfoResponse(SessionUserResponse, UserStatus): @router.get("/", response_model=SessionUserInfoResponse) async def get_session_user( - request: Request, response: Response, user=Depends(get_current_user) + request: Request, + response: Response, + user=Depends(get_current_user), + db: Session = Depends(get_session), ): auth_header = request.headers.get("Authorization") @@ -139,7 +142,7 @@ async def get_session_user( ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, request.app.state.config.USER_PERMISSIONS, db=db ) return { @@ -168,12 +171,15 @@ async def get_session_user( @router.post("/update/profile", response_model=UserProfileImageResponse) async def update_profile( - form_data: UpdateProfileForm, session_user=Depends(get_verified_user) + form_data: UpdateProfileForm, + session_user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if session_user: user = Users.update_user_by_id( session_user.id, form_data.model_dump(), + db=db, ) if user: return user @@ -183,6 +189,32 @@ async def update_profile( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) +############################ +# Update Timezone +############################ + + +class UpdateTimezoneForm(BaseModel): + timezone: str + + +@router.post("/update/timezone") +async def update_timezone( + form_data: UpdateTimezoneForm, + session_user=Depends(get_current_user), + db: Session = Depends(get_session), +): + if session_user: + Users.update_user_by_id( + session_user.id, + {"timezone": form_data.timezone}, + db=db, + ) + return {"status": True} + else: + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + + ############################ # Update Password ############################ @@ -190,13 +222,17 @@ async def update_profile( @router.post("/update/password", response_model=bool) async def update_password( - form_data: UpdatePasswordForm, session_user=Depends(get_current_user) + form_data: UpdatePasswordForm, + session_user=Depends(get_current_user), + db: Session = Depends(get_session), ): if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) if session_user: user = Auths.authenticate_user( - session_user.email, lambda pw: verify_password(form_data.password, pw) + session_user.email, + lambda pw: verify_password(form_data.password, pw), + db=db, ) if user: @@ -205,7 +241,7 @@ async def update_password( except Exception as e: raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.new_password) - return Auths.update_user_password_by_id(user.id, hashed) + return Auths.update_user_password_by_id(user.id, hashed, db=db) else: raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD) else: @@ -216,7 +252,12 @@ async def update_password( # LDAP Authentication ############################ @router.post("/ldap", response_model=SessionUserResponse) -async def ldap_auth(request: Request, response: Response, form_data: LdapForm): +async def ldap_auth( + request: Request, + response: Response, + form_data: LdapForm, + db: Session = Depends(get_session), +): # Security checks FIRST - before loading any config if not request.app.state.config.ENABLE_LDAP: raise HTTPException(400, detail="LDAP authentication is not enabled") @@ -288,13 +329,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): f"{LDAP_ATTRIBUTE_FOR_MAIL}", "cn", ] - if ENABLE_LDAP_GROUP_MANAGEMENT: search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}") log.info( f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes" ) - log.info(f"LDAP search attributes: {search_attributes}") search_success = connection_app.search( @@ -302,15 +341,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})", attributes=search_attributes, ) - if not search_success or not connection_app.entries: raise HTTPException(400, detail="User not found in the LDAP server") entry = connection_app.entries[0] - username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower() + entry_username = entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"].value email = entry[ f"{LDAP_ATTRIBUTE_FOR_MAIL}" ].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: raise HTTPException(400, "User does not have a valid email address.") elif isinstance(email, str): @@ -320,13 +366,13 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): else: email = str(email).lower() - cn = str(entry["cn"]) - user_dn = entry.entry_dn + cn = str(entry["cn"]) # common name + user_dn = entry.entry_dn # user distinguished name user_groups = [] if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry: 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: log.info(f"LDAP group_dns original: {group_dns}") @@ -377,16 +423,16 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ) 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: - log.info(f"No groups found for user {username}") + log.info(f"No groups found for user {username_list}") elif ENABLE_LDAP_GROUP_MANAGEMENT: log.warning( 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( server, user_dn, @@ -397,12 +443,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): if not connection_user.bind(): raise HTTPException(400, "Authentication failed.") - user = Users.get_user_by_email(email) + user = Users.get_user_by_email(email, db=db) if not user: try: role = ( "admin" - if not Users.has_users() + if not Users.has_users(db=db) else request.app.state.config.DEFAULT_USER_ROLE ) @@ -411,6 +457,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): password=str(uuid.uuid4()), name=cn, role=role, + db=db, ) if not user: @@ -421,6 +468,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, + db=db, ) except HTTPException: @@ -431,7 +479,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): 500, detail="Internal error occurred during LDAP user creation." ) - user = Auths.authenticate_user_by_email(email) + user = Auths.authenticate_user_by_email(email, db=db) if user: expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) @@ -461,7 +509,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, request.app.state.config.USER_PERMISSIONS, db=db ) if ( @@ -470,9 +518,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): and user_groups ): if ENABLE_LDAP_GROUP_CREATION: - Groups.create_groups_by_group_names(user.id, user_groups) + Groups.create_groups_by_group_names(user.id, user_groups, db=db) try: - Groups.sync_groups_by_group_names(user.id, user_groups) + Groups.sync_groups_by_group_names(user.id, user_groups, db=db) log.info( f"Successfully synced groups for user {user.id}: {user_groups}" ) @@ -505,7 +553,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): @router.post("/signin", response_model=SessionUserResponse) -async def signin(request: Request, response: Response, form_data: SigninForm): +async def signin( + request: Request, + response: Response, + form_data: SigninForm, + db: Session = Depends(get_session), +): if not ENABLE_PASSWORD_AUTH: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -526,14 +579,15 @@ async def signin(request: Request, response: Response, form_data: SigninForm): except Exception as e: pass - if not Users.get_user_by_email(email.lower()): + if not Users.get_user_by_email(email.lower(), db=db): await signup( request, response, SignupForm(email=email, password=str(uuid.uuid4()), name=name), + db=db, ) - user = Auths.authenticate_user_by_email(email) + user = Auths.authenticate_user_by_email(email, db=db) if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin": group_names = request.headers.get( WEBUI_AUTH_TRUSTED_GROUPS_HEADER, "" @@ -541,28 +595,33 @@ async def signin(request: Request, response: Response, form_data: SigninForm): group_names = [name.strip() for name in group_names if name.strip()] if group_names: - Groups.sync_groups_by_group_names(user.id, group_names) + Groups.sync_groups_by_group_names(user.id, group_names, db=db) elif WEBUI_AUTH == False: admin_email = "admin@localhost" admin_password = "admin" - if Users.get_user_by_email(admin_email.lower()): + if Users.get_user_by_email(admin_email.lower(), db=db): user = Auths.authenticate_user( - admin_email.lower(), lambda pw: verify_password(admin_password, pw) + admin_email.lower(), + lambda pw: verify_password(admin_password, pw), + db=db, ) else: - if Users.has_users(): + if Users.has_users(db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS) await signup( request, response, SignupForm(email=admin_email, password=admin_password, name="User"), + db=db, ) user = Auths.authenticate_user( - admin_email.lower(), lambda pw: verify_password(admin_password, pw) + admin_email.lower(), + lambda pw: verify_password(admin_password, pw), + db=db, ) else: if signin_rate_limiter.is_limited(form_data.email.lower()): @@ -581,7 +640,9 @@ async def signin(request: Request, response: Response, form_data: SigninForm): form_data.password = password_bytes.decode("utf-8", errors="ignore") user = Auths.authenticate_user( - form_data.email.lower(), lambda pw: verify_password(form_data.password, pw) + form_data.email.lower(), + lambda pw: verify_password(form_data.password, pw), + db=db, ) if user: @@ -613,7 +674,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, request.app.state.config.USER_PERMISSIONS, db=db ) return { @@ -637,8 +698,13 @@ async def signin(request: Request, response: Response, form_data: SigninForm): @router.post("/signup", response_model=SessionUserResponse) -async def signup(request: Request, response: Response, form_data: SignupForm): - has_users = Users.has_users() +async def signup( + request: Request, + response: Response, + form_data: SignupForm, + db: Session = Depends(get_session), +): + has_users = Users.has_users(db=db) if WEBUI_AUTH: if ( @@ -660,7 +726,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT ) - if Users.get_user_by_email(form_data.email.lower()): + if Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) try: @@ -678,6 +744,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): form_data.name, form_data.profile_image_url, role, + db=db, ) if user: @@ -720,7 +787,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, request.app.state.config.USER_PERMISSIONS, db=db ) if not has_users: @@ -730,6 +797,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, + db=db, ) return { @@ -751,7 +819,9 @@ async def signup(request: Request, response: Response, form_data: SignupForm): @router.get("/signout") -async def signout(request: Request, response: Response): +async def signout( + request: Request, response: Response, db: Session = Depends(get_session) +): # get auth token from headers or cookies token = None @@ -773,7 +843,7 @@ async def signout(request: Request, response: Response): if oauth_session_id: response.delete_cookie("oauth_session_id") - session = OAuthSessions.get_session_by_id(oauth_session_id) + session = OAuthSessions.get_session_by_id(oauth_session_id, db=db) oauth_server_metadata_url = ( request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session @@ -836,14 +906,17 @@ async def signout(request: Request, response: Response): @router.post("/add", response_model=SigninResponse) async def add_user( - request: Request, form_data: AddUserForm, user=Depends(get_admin_user) + request: Request, + form_data: AddUserForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): if not validate_email_format(form_data.email.lower()): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT ) - if Users.get_user_by_email(form_data.email.lower()): + if Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) try: @@ -859,12 +932,14 @@ async def add_user( form_data.name, form_data.profile_image_url, form_data.role, + db=db, ) if user: apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, user.id, + db=db, ) token = create_token(data={"id": user.id}) @@ -892,7 +967,9 @@ async def add_user( @router.get("/admin/details") -async def get_admin_details(request: Request, user=Depends(get_current_user)): +async def get_admin_details( + request: Request, user=Depends(get_current_user), db: Session = Depends(get_session) +): if request.app.state.config.SHOW_ADMIN_DETAILS: admin_email = request.app.state.config.ADMIN_EMAIL admin_name = None @@ -900,11 +977,11 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)): log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}") if admin_email: - admin = Users.get_user_by_email(admin_email) + admin = Users.get_user_by_email(admin_email, db=db) if admin: admin_name = admin.name else: - admin = Users.get_first_user() + admin = Users.get_first_user(db=db) if admin: admin_email = admin.email admin_name = admin.name @@ -926,6 +1003,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)): async def get_admin_config(request: Request, user=Depends(get_admin_user)): return { "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, + "ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL, "WEBUI_URL": request.app.state.config.WEBUI_URL, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, @@ -937,9 +1015,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, + "FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, + "ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS, "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, @@ -948,6 +1029,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): class AdminConfig(BaseModel): SHOW_ADMIN_DETAILS: bool + ADMIN_EMAIL: Optional[str] = None WEBUI_URL: str ENABLE_SIGNUP: bool ENABLE_API_KEYS: bool @@ -959,9 +1041,12 @@ class AdminConfig(BaseModel): ENABLE_COMMUNITY_SHARING: bool ENABLE_MESSAGE_RATING: bool ENABLE_FOLDERS: bool + FOLDER_MAX_FILE_COUNT: Optional[int | str] = None ENABLE_CHANNELS: bool + ENABLE_MEMORIES: bool ENABLE_NOTES: bool ENABLE_USER_WEBHOOKS: bool + ENABLE_USER_STATUS: bool PENDING_USER_OVERLAY_TITLE: Optional[str] = None PENDING_USER_OVERLAY_CONTENT: Optional[str] = None RESPONSE_WATERMARK: Optional[str] = None @@ -972,6 +1057,7 @@ async def update_admin_config( request: Request, form_data: AdminConfig, user=Depends(get_admin_user) ): request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS + request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP @@ -984,7 +1070,11 @@ async def update_admin_config( ) request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS + request.app.state.config.FOLDER_MAX_FILE_COUNT = ( + int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else "" + ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS + request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: @@ -1004,6 +1094,7 @@ async def update_admin_config( request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS + request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS request.app.state.config.PENDING_USER_OVERLAY_TITLE = ( form_data.PENDING_USER_OVERLAY_TITLE @@ -1016,6 +1107,7 @@ async def update_admin_config( return { "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, + "ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL, "WEBUI_URL": request.app.state.config.WEBUI_URL, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, @@ -1027,9 +1119,12 @@ async def update_admin_config( "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, + "FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, + "ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, + "ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS, "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, @@ -1146,7 +1241,9 @@ async def update_ldap_config( # create api key @router.post("/api_key", response_model=ApiKey) -async def generate_api_key(request: Request, user=Depends(get_current_user)): +async def generate_api_key( + request: Request, user=Depends(get_current_user), db: Session = Depends(get_session) +): if not request.app.state.config.ENABLE_API_KEYS or not has_permission( user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS ): @@ -1156,7 +1253,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)): ) api_key = create_api_key() - success = Users.update_user_api_key_by_id(user.id, api_key) + success = Users.update_user_api_key_by_id(user.id, api_key, db=db) if success: return { @@ -1168,14 +1265,18 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)): # delete api key @router.delete("/api_key", response_model=bool) -async def delete_api_key(user=Depends(get_current_user)): - return Users.delete_user_api_key_by_id(user.id) +async def delete_api_key( + user=Depends(get_current_user), db: Session = Depends(get_session) +): + return Users.delete_user_api_key_by_id(user.id, db=db) # get api key @router.get("/api_key", response_model=ApiKey) -async def get_api_key(user=Depends(get_current_user)): - api_key = Users.get_user_api_key_by_id(user.id) +async def get_api_key( + user=Depends(get_current_user), db: Session = Depends(get_session) +): + api_key = Users.get_user_api_key_by_id(user.id, db=db) if api_key: return { "api_key": api_key, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 0dff67da3e6..4e697142bf2 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1,11 +1,14 @@ import json import logging +import base64 +import io from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks +from fastapi.responses import Response, StreamingResponse, FileResponse from pydantic import BaseModel - +from pydantic import field_validator from open_webui.socket.main import ( emit_to_users, @@ -29,6 +32,8 @@ ChannelForm, ChannelResponse, CreateChannelForm, + ChannelWebhookModel, + ChannelWebhookForm, ) from open_webui.models.messages import ( Messages, @@ -39,9 +44,11 @@ ) +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.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import STATIC_DIR from open_webui.utils.models import ( @@ -60,12 +67,28 @@ ) from open_webui.utils.webhook import post_webhook from open_webui.utils.channels import extract_mentions, replace_mentions +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) 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 ############################ @@ -80,25 +103,32 @@ class ChannelListItemResponse(ChannelModel): @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), + db: Session = Depends(get_session), +): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - channels = Channels.get_channels_by_user_id(user.id) + channels = Channels.get_channels_by_user_id(user.id, db=db) channel_list = [] for channel in channels: - last_message = Messages.get_last_message_by_channel_id(channel.id) + last_message = Messages.get_last_message_by_channel_id(channel.id, db=db) last_message_at = last_message.created_at if last_message else None - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) + channel_member = Channels.get_member_by_channel_and_user_id( + channel.id, user.id, db=db + ) unread_count = ( Messages.get_unread_message_count( - channel.id, user.id, channel_member.last_read_at + channel.id, user.id, channel_member.last_read_at, db=db ) if channel_member else 0 @@ -109,13 +139,16 @@ async def get_channels(request: Request, user=Depends(get_verified_user)): if channel.type == "dm": user_ids = [ member.user_id - for member in Channels.get_members_by_channel_id(channel.id) + for member in Channels.get_members_by_channel_id(channel.id, db=db) ] users = [ UserIdNameStatusResponse( - **{**user.model_dump(), "is_active": Users.is_user_active(user.id)} + **{ + **user.model_dump(), + "is_active": Users.is_user_active(user.id, db=db), + } ) - for user in Users.get_users_by_user_ids(user_ids) + for user in Users.get_users_by_user_ids(user_ids, db=db) ] channel_list.append( @@ -132,10 +165,15 @@ async def get_channels(request: Request, user=Depends(get_verified_user)): @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), + db: Session = Depends(get_session), +): + check_channels_access(request) if user.role == "admin": - return Channels.get_channels() - return Channels.get_channels_by_user_id(user.id) + return Channels.get_channels(db=db) + return Channels.get_channels_by_user_id(user.id, db=db) ############################ @@ -145,10 +183,14 @@ async def get_all_channels(user=Depends(get_verified_user)): @router.get("/users/{user_id}", response_model=Optional[ChannelModel]) 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), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -156,11 +198,15 @@ async def get_dm_channel_by_user_id( ) try: - existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id]) + existing_channel = Channels.get_dm_channel_by_user_ids( + [user.id, user_id], db=db + ) if existing_channel: participant_ids = [ member.user_id - for member in Channels.get_members_by_channel_id(existing_channel.id) + for member in Channels.get_members_by_channel_id( + existing_channel.id, db=db + ) ] await emit_to_users( @@ -172,7 +218,9 @@ async def get_dm_channel_by_user_id( f"channel:{existing_channel.id}", participant_ids ) - Channels.update_member_active_status(existing_channel.id, user.id, True) + Channels.update_member_active_status( + existing_channel.id, user.id, True, db=db + ) return ChannelModel(**existing_channel.model_dump()) channel = Channels.insert_new_channel( @@ -182,12 +230,13 @@ async def get_dm_channel_by_user_id( user_ids=[user_id], ), user.id, + db=db, ) if channel: participant_ids = [ member.user_id - for member in Channels.get_members_by_channel_id(channel.id) + for member in Channels.get_members_by_channel_id(channel.id, db=db) ] await emit_to_users( @@ -214,10 +263,14 @@ async def get_dm_channel_by_user_id( @router.post("/create", response_model=Optional[ChannelModel]) 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), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -234,13 +287,13 @@ async def create_new_channel( try: if form_data.type == "dm": existing_channel = Channels.get_dm_channel_by_user_ids( - [user.id, *form_data.user_ids] + [user.id, *form_data.user_ids], db=db ) if existing_channel: participant_ids = [ member.user_id for member in Channels.get_members_by_channel_id( - existing_channel.id + existing_channel.id, db=db ) ] await emit_to_users( @@ -252,15 +305,17 @@ async def create_new_channel( f"channel:{existing_channel.id}", participant_ids ) - Channels.update_member_active_status(existing_channel.id, user.id, True) + Channels.update_member_active_status( + existing_channel.id, user.id, True, db=db + ) return ChannelModel(**existing_channel.model_dump()) - channel = Channels.insert_new_channel(form_data, user.id) + channel = Channels.insert_new_channel(form_data, user.id, db=db) if channel: participant_ids = [ member.user_id - for member in Channels.get_members_by_channel_id(channel.id) + for member in Channels.get_members_by_channel_id(channel.id, db=db) ] await emit_to_users( @@ -294,8 +349,14 @@ class ChannelFullResponse(ChannelResponse): @router.get("/{id}", response_model=Optional[ChannelFullResponse]) -async def get_channel_by_id(id: str, user=Depends(get_verified_user)): - channel = Channels.get_channel_by_id(id) +async def get_channel_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -305,23 +366,29 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): users = None if channel.type in ["group", "dm"]: - if not Channels.is_user_channel_member(channel.id, user.id): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) user_ids = [ - member.user_id for member in Channels.get_members_by_channel_id(channel.id) + member.user_id + for member in Channels.get_members_by_channel_id(channel.id, db=db) ] users = [ UserIdNameStatusResponse( - **{**user.model_dump(), "is_active": Users.is_user_active(user.id)} + **{ + **user.model_dump(), + "is_active": Users.is_user_active(user.id, db=db), + } ) - for user in Users.get_users_by_user_ids(user_ids) + for user in Users.get_users_by_user_ids(user_ids, db=db) ] - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) + channel_member = Channels.get_member_by_channel_and_user_id( + channel.id, user.id, db=db + ) unread_count = Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -331,7 +398,9 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): **channel.model_dump(), "user_ids": user_ids, "users": users, - "is_manager": Channels.is_user_channel_manager(channel.id, user.id), + "is_manager": Channels.is_user_channel_manager( + channel.id, user.id, db=db + ), "write_access": True, "user_count": len(user_ids), "last_read_at": channel_member.last_read_at if channel_member else None, @@ -340,19 +409,25 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): ) else: if user.role != "admin" and not has_access( - user.id, type="read", access_control=channel.access_control + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) write_access = has_access( - user.id, type="write", access_control=channel.access_control, strict=False + user.id, + type="write", + access_control=channel.access_control, + strict=False, + db=db, ) user_count = len(get_users_with_access("read", channel.access_control)) - channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) + channel_member = Channels.get_member_by_channel_and_user_id( + channel.id, user.id, db=db + ) unread_count = Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -362,7 +437,9 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): **channel.model_dump(), "user_ids": user_ids, "users": users, - "is_manager": Channels.is_user_channel_manager(channel.id, user.id), + "is_manager": Channels.is_user_channel_manager( + channel.id, user.id, db=db + ), "write_access": write_access or user.role == "admin", "user_count": user_count, "last_read_at": channel_member.last_read_at if channel_member else None, @@ -381,15 +458,18 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): @router.get("/{id}/members", response_model=UserListResponse) async def get_channel_members_by_id( + request: Request, id: str, query: Optional[str] = None, order_by: Optional[str] = None, direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + check_channels_access(request) - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -401,22 +481,23 @@ async def get_channel_members_by_id( skip = (page - 1) * limit if channel.type in ["group", "dm"]: - if not Channels.is_user_channel_member(channel.id, user.id): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) if channel.type == "dm": user_ids = [ - member.user_id for member in Channels.get_members_by_channel_id(channel.id) + member.user_id + for member in Channels.get_members_by_channel_id(channel.id, db=db) ] - users = Users.get_users_by_user_ids(user_ids) + users = Users.get_users_by_user_ids(user_ids, db=db) total = len(users) return { "users": [ UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) ) for user in users ], @@ -443,7 +524,7 @@ async def get_channel_members_by_id( filter["user_ids"] = permitted_ids.get("user_ids") filter["group_ids"] = permitted_ids.get("group_ids") - result = Users.get_users(filter=filter, skip=skip, limit=limit) + result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) users = result["users"] total = result["total"] @@ -451,7 +532,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) ) for user in users ], @@ -470,22 +551,27 @@ class UpdateActiveMemberForm(BaseModel): @router.post("/{id}/members/active", response_model=bool) async def update_is_active_member_by_id_and_user_id( + request: Request, id: str, form_data: UpdateActiveMemberForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if not Channels.is_user_channel_member(channel.id, user.id): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - Channels.update_member_active_status(channel.id, user.id, form_data.is_active) + Channels.update_member_active_status( + channel.id, user.id, form_data.is_active, db=db + ) return True @@ -505,16 +591,18 @@ async def add_members_by_id( id: str, form_data: UpdateMembersForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -527,7 +615,7 @@ async def add_members_by_id( try: memberships = Channels.add_members_to_channel( - channel.id, user.id, form_data.user_ids, form_data.group_ids + channel.id, user.id, form_data.user_ids, form_data.group_ids, db=db ) return memberships @@ -553,16 +641,18 @@ async def remove_members_by_id( id: str, form_data: RemoveMembersForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -574,7 +664,9 @@ async def remove_members_by_id( ) try: - deleted = Channels.remove_members_from_channel(channel.id, form_data.user_ids) + deleted = Channels.remove_members_from_channel( + channel.id, form_data.user_ids, db=db + ) return deleted except Exception as e: @@ -591,17 +683,22 @@ async def remove_members_by_id( @router.post("/{id}/update", response_model=Optional[ChannelModel]) 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), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -613,7 +710,7 @@ async def update_channel_by_id( ) try: - channel = Channels.update_channel_by_id(id, form_data) + channel = Channels.update_channel_by_id(id, form_data, db=db) return ChannelModel(**channel.model_dump()) except Exception as e: log.exception(e) @@ -629,17 +726,21 @@ async def update_channel_by_id( @router.delete("/{id}/delete", response_model=bool) async def delete_channel_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + check_channels_access(request) 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -651,7 +752,7 @@ async def delete_channel_by_id( ) try: - Channels.delete_channel_by_id(id) + Channels.delete_channel_by_id(id, db=db) return True except Exception as e: log.exception(e) @@ -666,58 +767,82 @@ async def delete_channel_by_id( 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]) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): 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 + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) channel_member = Channels.join_channel( - id, user.id + id, user.id, db=db ) # Ensure user is a member of the channel - message_list = Messages.get_messages_by_channel_id(id, skip, limit) - users = {} + message_list = Messages.get_messages_by_channel_id(id, skip, limit, db=db) + + if not message_list: + return [] + + # Batch fetch all users in a single query (fixes N+1 problem) + user_ids = list(set(m.user_id for m in message_list)) + users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: - if message.user_id not in users: - user = Users.get_user_by_id(message.user_id) - users[message.user_id] = user - - thread_replies = Messages.get_thread_replies_by_message_id(message.id) + thread_replies = Messages.get_thread_replies_by_message_id(message.id, db=db) latest_thread_reply_at = ( thread_replies[0].created_at if thread_replies else None ) + # Use message.user if present (for webhooks), otherwise look up by user_id + user_info = message.user + if user_info is None and message.user_id in users: + user_info = UserNameResponse(**users[message.user_id].model_dump()) + messages.append( MessageUserResponse( **{ **message.model_dump(), "reply_count": len(thread_replies), "latest_reply_at": latest_thread_reply_at, - "reactions": Messages.get_reactions_by_message_id(message.id), - "user": UserNameResponse(**users[message.user_id].model_dump()), + "reactions": Messages.get_reactions_by_message_id( + message.id, db=db + ), + "user": user_info, } ) ) @@ -734,22 +859,27 @@ async def get_channel_messages( @router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): 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 + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -759,21 +889,38 @@ async def get_pinned_channel_messages( skip = (page - 1) * PAGE_ITEM_COUNT_PINNED limit = PAGE_ITEM_COUNT_PINNED - message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit) - users = {} + message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit, db=db) + + if not message_list: + return [] + + # Batch fetch all users in a single query (fixes N+1 problem) + user_ids = list(set(m.user_id for m in message_list)) + users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: - if message.user_id not in users: - user = Users.get_user_by_id(message.user_id) - users[message.user_id] = user + # Check for webhook identity in meta + webhook_info = message.meta.get("webhook") if message.meta else None + if webhook_info: + user_info = UserNameResponse( + id=webhook_info.get("id"), + name=webhook_info.get("name"), + role="webhook", + ) + elif message.user_id in users: + user_info = UserNameResponse(**users[message.user_id].model_dump()) + else: + user_info = None messages.append( MessageWithReactionsResponse( **{ **message.model_dump(), - "reactions": Messages.get_reactions_by_message_id(message.id), - "user": UserNameResponse(**users[message.user_id].model_dump()), + "reactions": Messages.get_reactions_by_message_id( + message.id, db=db + ), + "user": user_info, } ) ) @@ -786,12 +933,14 @@ async def get_pinned_channel_messages( ############################ -async def send_notification(name, webui_url, channel, message, active_user_ids): +async def send_notification( + name, webui_url, channel, message, active_user_ids, db=None +): users = get_users_with_access("read", channel.access_control) for user in users: if (user.id not in active_user_ids) and Channels.is_user_channel_member( - channel.id, user.id + channel.id, user.id, db=db ): if user.settings: webhook_url = user.settings.ui.get("notifications", {}).get( @@ -813,7 +962,7 @@ async def send_notification(name, webui_url, channel, message, active_user_ids): return True -async def model_response_handler(request, channel, message, user): +async def model_response_handler(request, channel, message, user, db=None): MODELS = { model["id"]: model for model in get_filtered_models(await get_all_models(request, user=user), user) @@ -851,6 +1000,7 @@ async def model_response_handler(request, channel, message, user): thread_messages = Messages.get_messages_by_parent_id( channel.id, message.parent_id if message.parent_id else message.id, + db=db, )[::-1] response_message, channel = await new_message_handler( @@ -870,6 +1020,7 @@ async def model_response_handler(request, channel, message, user): } ), user, + db, ) thread_history = [] @@ -879,7 +1030,9 @@ async def model_response_handler(request, channel, message, user): for thread_message in thread_messages: message_user = None if thread_message.user_id not in message_users: - message_user = Users.get_user_by_id(thread_message.user_id) + message_user = Users.get_user_by_id( + thread_message.user_id, db=db + ) message_users[thread_message.user_id] = message_user else: message_user = message_users[thread_message.user_id] @@ -906,6 +1059,10 @@ async def model_response_handler(request, channel, message, user): for file in thread_message_files: if file.get("type", "") == "image": 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) system_message = { @@ -954,6 +1111,7 @@ async def model_response_handler(request, channel, message, user): if res: if res.get("choices", []) and len(res["choices"]) > 0: await update_message_by_id( + request, channel.id, response_message.id, MessageForm( @@ -965,9 +1123,11 @@ async def model_response_handler(request, channel, message, user): } ), user, + db, ) elif res.get("error", None): await update_message_by_id( + request, channel.id, response_message.id, MessageForm( @@ -979,6 +1139,7 @@ async def model_response_handler(request, channel, message, user): } ), user, + db, ) except Exception as e: log.info(e) @@ -988,39 +1149,43 @@ async def model_response_handler(request, channel, message, user): async def new_message_handler( - request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user) + request: Request, id: str, form_data: MessageForm, user, db ): - channel = Channels.get_channel_by_id(id) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) else: if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False + user.id, + type="write", + access_control=channel.access_control, + strict=False, + db=db, ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) try: - message = Messages.insert_new_message(form_data, channel.id, user.id) + message = Messages.insert_new_message(form_data, channel.id, user.id, db=db) if message: if channel.type in ["group", "dm"]: - members = Channels.get_members_by_channel_id(channel.id) + members = Channels.get_members_by_channel_id(channel.id, db=db) for member in members: if not member.is_active: Channels.update_member_active_status( - channel.id, member.user_id, True + channel.id, member.user_id, True, db=db ) - message = Messages.get_message_by_id(message.id) + message = Messages.get_message_by_id(message.id, db=db) event_data = { "channel_id": channel.id, "message_id": message.id, @@ -1040,7 +1205,7 @@ async def new_message_handler( if message.parent_id: # If this message is a reply, emit to the parent message as well - parent_message = Messages.get_message_by_id(message.parent_id) + parent_message = Messages.get_message_by_id(message.parent_id, db=db) if parent_message: await sio.emit( @@ -1074,12 +1239,26 @@ async def post_new_message( form_data: MessageForm, background_tasks: BackgroundTasks, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + check_channels_access(request) try: - message, channel = await new_message_handler(request, id, form_data, user) + message, channel = await new_message_handler(request, id, form_data, user, db) + 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, db=db + ) + except Exception as e: + log.debug(e) + active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") + # NOTE: We intentionally do NOT pass db to background_handler. + # Background tasks should manage their own short-lived sessions to avoid + # holding database connections during slow operations (e.g., LLM calls). async def background_handler(): await model_response_handler(request, channel, message, user) await send_notification( @@ -1108,30 +1287,35 @@ async def background_handler(): ############################ -@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( - id: str, message_id: str, user=Depends(get_verified_user) + request: Request, + id: str, + message_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): 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 + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1142,16 +1326,63 @@ async def get_channel_message( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - return MessageUserResponse( + return MessageResponse( **{ **message.model_dump(), "user": UserNameResponse( - **Users.get_user_by_id(message.user_id).model_dump() + **Users.get_user_by_id(message.user_id, db=db).model_dump() ), } ) +############################ +# 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), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) + 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, db=db): + 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, db=db + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + message = Messages.get_message_by_id(message_id, db=db) + 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 ############################ @@ -1165,28 +1396,34 @@ class PinMessageForm(BaseModel): "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] ) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): 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 + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1198,13 +1435,13 @@ async def pin_channel_message( ) try: - Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id) - message = Messages.get_message_by_id(message_id) + Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id, db=db) + message = Messages.get_message_by_id(message_id, db=db) return MessageUserResponse( **{ **message.model_dump(), "user": UserNameResponse( - **Users.get_user_by_id(message.user_id).model_dump() + **Users.get_user_by_id(message.user_id, db=db).model_dump() ), } ) @@ -1224,39 +1461,51 @@ async def pin_channel_message( "/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse] ) async def get_channel_thread_messages( + request: Request, id: str, message_id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): 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 + user.id, type="read", access_control=channel.access_control, db=db ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit) - users = {} + message_list = Messages.get_messages_by_parent_id( + id, message_id, skip, limit, db=db + ) + + if not message_list: + return [] + + # Batch fetch all users in a single query (fixes N+1 problem) + user_ids = list(set(m.user_id for m in message_list)) + users = {u.id: u for u in Users.get_users_by_user_ids(user_ids, db=db)} messages = [] for message in message_list: - if message.user_id not in users: - user = Users.get_user_by_id(message.user_id) - users[message.user_id] = user + # Use message.user if present (for webhooks), otherwise look up by user_id + user_info = message.user + if user_info is None and message.user_id in users: + user_info = UserNameResponse(**users[message.user_id].model_dump()) messages.append( MessageUserResponse( @@ -1264,8 +1513,10 @@ async def get_channel_thread_messages( **message.model_dump(), "reply_count": 0, "latest_reply_at": None, - "reactions": Messages.get_reactions_by_message_id(message.id), - "user": UserNameResponse(**users[message.user_id].model_dump()), + "reactions": Messages.get_reactions_by_message_id( + message.id, db=db + ), + "user": user_info, } ) ) @@ -1282,15 +1533,21 @@ async def get_channel_thread_messages( "/{id}/messages/{message_id}/update", response_model=Optional[MessageModel] ) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1302,7 +1559,7 @@ async def update_message_by_id( ) if channel.type in ["group", "dm"]: - if not Channels.is_user_channel_member(channel.id, user.id): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) @@ -1311,7 +1568,7 @@ async def update_message_by_id( user.role != "admin" and message.user_id != user.id and not has_access( - user.id, type="read", access_control=channel.access_control + user.id, type="read", access_control=channel.access_control, db=db ) ): raise HTTPException( @@ -1319,8 +1576,8 @@ async def update_message_by_id( ) try: - message = Messages.update_message_by_id(message_id, form_data) - message = Messages.get_message_by_id(message_id) + message = Messages.update_message_by_id(message_id, form_data, db=db) + message = Messages.get_message_by_id(message_id, db=db) if message: await sio.emit( @@ -1357,28 +1614,38 @@ class ReactionForm(BaseModel): @router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) else: if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False + user.id, + type="write", + access_control=channel.access_control, + strict=False, + db=db, ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1390,8 +1657,8 @@ async def add_reaction_to_message( ) try: - Messages.add_reaction_to_message(message_id, user.id, form_data.name) - message = Messages.get_message_by_id(message_id) + Messages.add_reaction_to_message(message_id, user.id, form_data.name, db=db) + message = Messages.get_message_by_id(message_id, db=db) await sio.emit( "events:channel", @@ -1426,28 +1693,38 @@ async def add_reaction_to_message( @router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) 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): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) else: if user.role != "admin" and not has_access( - user.id, type="write", access_control=channel.access_control, strict=False + user.id, + type="write", + access_control=channel.access_control, + strict=False, + db=db, ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1460,10 +1737,10 @@ async def remove_reaction_by_id_and_user_id_and_name( try: Messages.remove_reaction_by_id_and_user_id_and_name( - message_id, user.id, form_data.name + message_id, user.id, form_data.name, db=db ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) await sio.emit( "events:channel", @@ -1498,15 +1775,20 @@ async def remove_reaction_by_id_and_user_id_and_name( @router.delete("/{id}/messages/{message_id}/delete", response_model=bool) 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), + db: Session = Depends(get_session), ): - channel = Channels.get_channel_by_id(id) + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) if not channel: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - message = Messages.get_message_by_id(message_id) + message = Messages.get_message_by_id(message_id, db=db) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -1518,7 +1800,7 @@ async def delete_message_by_id( ) if channel.type in ["group", "dm"]: - if not Channels.is_user_channel_member(channel.id, user.id): + if not Channels.is_user_channel_member(channel.id, user.id, db=db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) @@ -1531,6 +1813,7 @@ async def delete_message_by_id( type="write", access_control=channel.access_control, strict=False, + db=db, ) ): raise HTTPException( @@ -1538,7 +1821,7 @@ async def delete_message_by_id( ) try: - Messages.delete_message_by_id(message_id) + Messages.delete_message_by_id(message_id, db=db) await sio.emit( "events:channel", { @@ -1559,7 +1842,7 @@ async def delete_message_by_id( if message.parent_id: # If this message is a reply, emit to the parent message as well - parent_message = Messages.get_message_by_id(message.parent_id) + parent_message = Messages.get_message_by_id(message.parent_id, db=db) if parent_message: await sio.emit( @@ -1583,3 +1866,263 @@ async def delete_message_by_id( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) + + +############################ +# Webhooks +############################ + + +@router.get("/webhooks/{webhook_id}/profile/image") +async def get_webhook_profile_image( + webhook_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """Get webhook profile image by webhook ID.""" + webhook = Channels.get_webhook_by_id(webhook_id, db=db) + if not webhook: + # Return default favicon if webhook not found + return FileResponse(f"{STATIC_DIR}/favicon.png") + + if webhook.profile_image_url: + # Check if it's url or base64 + if webhook.profile_image_url.startswith("http"): + return Response( + status_code=status.HTTP_302_FOUND, + headers={"Location": webhook.profile_image_url}, + ) + elif webhook.profile_image_url.startswith("data:image"): + try: + header, base64_data = webhook.profile_image_url.split(",", 1) + image_data = base64.b64decode(base64_data) + image_buffer = io.BytesIO(image_data) + media_type = header.split(";")[0].lstrip("data:") + + return StreamingResponse( + image_buffer, + media_type=media_type, + headers={"Content-Disposition": "inline"}, + ) + except Exception as e: + pass + + # Return default favicon if no profile image + return FileResponse(f"{STATIC_DIR}/favicon.png") + + +@router.get("/{id}/webhooks", response_model=list[ChannelWebhookModel]) +async def get_channel_webhooks( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + # Only channel managers can view webhooks + if ( + not Channels.is_user_channel_manager(channel.id, user.id, db=db) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED + ) + + return Channels.get_webhooks_by_channel_id(id, db=db) + + +@router.post("/{id}/webhooks/create", response_model=ChannelWebhookModel) +async def create_channel_webhook( + request: Request, + id: str, + form_data: ChannelWebhookForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + # Only channel managers can create webhooks + if ( + not Channels.is_user_channel_manager(channel.id, user.id, db=db) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED + ) + + webhook = Channels.insert_webhook(id, user.id, form_data, db=db) + if not webhook: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + return webhook + + +@router.post("/{id}/webhooks/{webhook_id}/update", response_model=ChannelWebhookModel) +async def update_channel_webhook( + request: Request, + id: str, + webhook_id: str, + form_data: ChannelWebhookForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + # Only channel managers can update webhooks + if ( + not Channels.is_user_channel_manager(channel.id, user.id, db=db) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED + ) + + webhook = Channels.get_webhook_by_id(webhook_id, db=db) + if not webhook or webhook.channel_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + updated = Channels.update_webhook_by_id(webhook_id, form_data, db=db) + if not updated: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + return updated + + +@router.delete("/{id}/webhooks/{webhook_id}/delete", response_model=bool) +async def delete_channel_webhook( + request: Request, + id: str, + webhook_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + # Only channel managers can delete webhooks + if ( + not Channels.is_user_channel_manager(channel.id, user.id, db=db) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED + ) + + webhook = Channels.get_webhook_by_id(webhook_id, db=db) + if not webhook or webhook.channel_id != id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + return Channels.delete_webhook_by_id(webhook_id, db=db) + + +############################ +# Public Webhook Endpoint +############################ + + +class WebhookMessageForm(BaseModel): + content: str + + +@router.post("/webhooks/{webhook_id}/{token}") +async def post_webhook_message( + request: Request, + webhook_id: str, + token: str, + form_data: WebhookMessageForm, + db: Session = Depends(get_session), +): + """Public endpoint to post messages via webhook. No authentication required.""" + check_channels_access(request) + + # Validate webhook + webhook = Channels.get_webhook_by_id_and_token(webhook_id, token, db=db) + if not webhook: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid webhook URL", + ) + + channel = Channels.get_channel_by_id(webhook.channel_id, db=db) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + # Create message with webhook identity stored in meta + message = Messages.insert_new_message( + MessageForm(content=form_data.content, meta={"webhook": {"id": webhook.id}}), + webhook.channel_id, + webhook.user_id, # Required for DB but webhook info in meta takes precedence + db=db, + ) + + if not message: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to create message", + ) + + # Update last_used_at + Channels.update_webhook_last_used_at(webhook_id, db=db) + + # Get full message and emit event + message = Messages.get_message_by_id(message.id, db=db) + + event_data = { + "channel_id": channel.id, + "message_id": message.id, + "data": { + "type": "message", + "data": { + **message.model_dump(), + "user": { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + }, + }, + }, + "user": { + "id": webhook.id, + "name": webhook.name, + "role": "webhook", + }, + "channel": channel.model_dump(), + } + + await sio.emit( + "events:channel", + event_data, + to=f"channel:{channel.id}", + ) + + return {"success": True, "message_id": message.id} diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 78cd8bdb1a8..9a43234aa65 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1,23 +1,33 @@ import json import logging from typing import Optional +from sqlalchemy.orm import Session +import asyncio +from fastapi.responses import StreamingResponse +from open_webui.utils.misc import get_message_list from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, ChatImportForm, + ChatUsageStatsListResponse, ChatsImportForm, ChatResponse, Chats, ChatTitleIdResponse, + ChatStatsExport, + AggregateChatStats, + ChatBody, + ChatHistoryStats, + MessageStats, ) from open_webui.models.tags import TagModel, Tags from open_webui.models.folders import Folders +from open_webui.internal.db import get_session from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel @@ -26,7 +36,6 @@ from open_webui.utils.access_control import has_permission log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -42,6 +51,7 @@ def get_session_user_chat_list( page: Optional[int] = None, include_pinned: Optional[bool] = False, include_folders: Optional[bool] = False, + db: Session = Depends(get_session), ): try: if page is not None: @@ -54,10 +64,14 @@ def get_session_user_chat_list( include_pinned=include_pinned, skip=skip, limit=limit, + db=db, ) else: return Chats.get_chat_title_id_list_by_user_id( - user.id, include_folders=include_folders, include_pinned=include_pinned + user.id, + include_folders=include_folders, + include_pinned=include_pinned, + db=db, ) except Exception as e: log.exception(e) @@ -67,12 +81,461 @@ def get_session_user_chat_list( ############################ -# DeleteAllChats +# 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), + db: Session = Depends(get_session), +): + try: + limit = items_per_page + skip = (page - 1) * limit + + result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit, db=db) + + 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() + ) + + +############################ +# GetChatStatsExport +############################ + + +CHAT_EXPORT_PAGE_ITEM_COUNT = 10 + + +class ChatStatsExportList(BaseModel): + type: str = "chats" + items: list[ChatStatsExport] + total: int + page: int + + +def _process_chat_for_export(chat) -> Optional[ChatStatsExport]: + try: + + def get_message_content_length(message): + content = message.get("content", "") + if isinstance(content, str): + return len(content) + elif isinstance(content, list): + return sum( + len(item.get("text", "")) + for item in content + if item.get("type") == "text" + ) + return 0 + + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + + history_models = {} + history_message_count = len(messages_map) + history_user_messages = [] + history_assistant_messages = [] + + export_messages = {} + for key, message in messages_map.items(): + try: + content_length = get_message_content_length(message) + + # Extract rating safely + rating = message.get("annotation", {}).get("rating") + tags = message.get("annotation", {}).get("tags") + + message_stat = MessageStats( + id=message.get("id"), + role=message.get("role"), + model=message.get("model"), + timestamp=message.get("timestamp"), + content_length=content_length, + token_count=None, # Populate if available, e.g. message.get("info", {}).get("token_count") + rating=rating, + tags=tags, + ) + + export_messages[key] = message_stat + + # --- Aggregation Logic (copied/adapted from usage stats) --- + role = message.get("role", "") + if role == "user": + history_user_messages.append(message) + elif role == "assistant": + history_assistant_messages.append(message) + model = message.get("model") + if model: + if model not in history_models: + history_models[model] = 0 + history_models[model] += 1 + except Exception as e: + log.debug(f"Error processing message {key}: {e}") + continue + + # Calculate Averages + average_user_message_content_length = ( + sum(get_message_content_length(m) for m in history_user_messages) + / len(history_user_messages) + if history_user_messages + else 0 + ) + + average_assistant_message_content_length = ( + sum(get_message_content_length(m) for m in history_assistant_messages) + / len(history_assistant_messages) + if history_assistant_messages + else 0 + ) + + # Response Times + 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] + # Ensure timestamps exist + t1 = message.get("timestamp") + t0 = user_message.get("timestamp") + if t1 and t0: + response_times.append(t1 - t0) + + average_response_time = ( + sum(response_times) / len(response_times) if response_times else 0 + ) + + # Current Message List Logic (Main path) + 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") + if model: + if model not in models: + models[model] = 0 + models[model] += 1 + + # Construct Aggregate Stats + stats = AggregateChatStats( + 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, + 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), + ) + + # Construct Chat Body + chat_body = ChatBody( + history=ChatHistoryStats(messages=export_messages, currentId=message_id) + ) + + return ChatStatsExport( + id=chat.id, + user_id=chat.user_id, + created_at=chat.created_at, + updated_at=chat.updated_at, + tags=chat.meta.get("tags", []), + stats=stats, + chat=chat_body, + ) + except Exception as e: + log.exception(f"Error exporting stats for chat {chat.id}: {e}") + return None + + +def calculate_chat_stats( + user_id, skip=0, limit=10, filter=None, db: Optional[Session] = None +): + if filter is None: + filter = {} + + result = Chats.get_chats_by_user_id( + user_id, + skip=skip, + limit=limit, + filter=filter, + db=db, + ) + + chat_stats_export_list = [] + for chat in result.items: + chat_stat = _process_chat_for_export(chat) + if chat_stat: + chat_stats_export_list.append(chat_stat) + + return chat_stats_export_list, result.total + + +def generate_chat_stats_jsonl_generator(user_id, filter): + """ + Synchronous generator for streaming chat stats export. + + NOTE: We intentionally do NOT pass a shared db session here. Instead, we let + each batch create its own short-lived session via get_db_context(None). + This is critical for SQLite in low-resource environments because: + 1. SQLite uses file-level locking + 2. Holding a session open for the entire streaming duration blocks other requests + 3. Short-lived sessions release locks between batches, allowing other operations + """ + skip = 0 + limit = CHAT_EXPORT_PAGE_ITEM_COUNT + + while True: + # Each batch gets its own session that closes after the query + result = Chats.get_chats_by_user_id( + user_id, + filter=filter, + skip=skip, + limit=limit, + db=None, # Let get_db_context create a fresh session per batch + ) + if not result.items: + break + + for chat in result.items: + try: + chat_stat = _process_chat_for_export(chat) + if chat_stat: + yield chat_stat.model_dump_json() + "\n" + except Exception as e: + log.exception(f"Error processing chat {chat.id}: {e}") + + skip += limit + + +@router.get("/stats/export", response_model=ChatStatsExportList) +async def export_chat_stats( + request: Request, + updated_at: Optional[int] = None, + page: Optional[int] = 1, + stream: bool = False, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + # Check if the user has permission to share/export chats + if (user.role != "admin") and ( + not request.app.state.config.ENABLE_COMMUNITY_SHARING + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + # Fetch chats with date filtering + filter = {"order_by": "updated_at", "direction": "asc"} + + if updated_at: + filter["updated_at"] = updated_at + + if stream: + return StreamingResponse( + generate_chat_stats_jsonl_generator(user.id, filter), + media_type="application/x-ndjson", + headers={ + "Content-Disposition": f"attachment; filename=chat-stats-export-{user.id}.jsonl" + }, + ) + else: + limit = CHAT_EXPORT_PAGE_ITEM_COUNT + skip = (page - 1) * limit + + chat_stats_export_list, total = await asyncio.to_thread( + calculate_chat_stats, user.id, skip, limit, filter, db=db + ) + + return ChatStatsExportList( + items=chat_stats_export_list, total=total, page=page + ) + + except Exception as e: + log.debug(f"Error exporting chat stats: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + +############################ +# GetSingleChatStatsExport ############################ +@router.get("/stats/export/{chat_id}", response_model=Optional[ChatStatsExport]) +async def export_single_chat_stats( + request: Request, + chat_id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + """ + Export stats for exactly one chat by ID. + Returns ChatStatsExport for the specified chat. + """ + # Check if the user has permission to share/export chats + if (user.role != "admin") and ( + not request.app.state.config.ENABLE_COMMUNITY_SHARING + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + chat = Chats.get_chat_by_id(chat_id, db=db) + + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + # Verify the chat belongs to the user (unless admin) + if chat.user_id != user.id and user.role != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + # Process the chat for export + chat_stats = await asyncio.to_thread(_process_chat_for_export, chat) + + if not chat_stats: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to process chat stats", + ) + + return chat_stats + + except HTTPException: + raise + except Exception as e: + log.debug(f"Error exporting single chat stats: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + @router.delete("/", response_model=bool) -async def delete_all_user_chats(request: Request, user=Depends(get_verified_user)): +async def delete_all_user_chats( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if user.role == "user" and not has_permission( user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS @@ -82,7 +545,7 @@ async def delete_all_user_chats(request: Request, user=Depends(get_verified_user detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Chats.delete_chats_by_user_id(user.id) + result = Chats.delete_chats_by_user_id(user.id, db=db) return result @@ -99,6 +562,7 @@ async def get_user_chat_list_by_user_id( order_by: Optional[str] = None, direction: Optional[str] = None, user=Depends(get_admin_user), + db: Session = Depends(get_session), ): if not ENABLE_ADMIN_CHAT_ACCESS: raise HTTPException( @@ -121,7 +585,7 @@ async def get_user_chat_list_by_user_id( filter["direction"] = direction return Chats.get_chat_list_by_user_id( - user_id, include_archived=True, filter=filter, skip=skip, limit=limit + user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db ) @@ -131,9 +595,13 @@ async def get_user_chat_list_by_user_id( @router.post("/new", response_model=Optional[ChatResponse]) -async def create_new_chat(form_data: ChatForm, user=Depends(get_verified_user)): +async def create_new_chat( + form_data: ChatForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): try: - chat = Chats.insert_new_chat(user.id, form_data) + chat = Chats.insert_new_chat(user.id, form_data, db=db) return ChatResponse(**chat.model_dump()) except Exception as e: log.exception(e) @@ -148,9 +616,13 @@ async def create_new_chat(form_data: ChatForm, user=Depends(get_verified_user)): @router.post("/import", response_model=list[ChatResponse]) -async def import_chats(form_data: ChatsImportForm, user=Depends(get_verified_user)): +async def import_chats( + form_data: ChatsImportForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): try: - chats = Chats.import_chats(user.id, form_data.chats) + chats = Chats.import_chats(user.id, form_data.chats, db=db) return chats except Exception as e: log.exception(e) @@ -166,7 +638,10 @@ async def import_chats(form_data: ChatsImportForm, user=Depends(get_verified_use @router.get("/search", response_model=list[ChatTitleIdResponse]) def search_user_chats( - text: str, page: Optional[int] = None, user=Depends(get_verified_user) + text: str, + page: Optional[int] = None, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if page is None: page = 1 @@ -177,7 +652,7 @@ def search_user_chats( chat_list = [ ChatTitleIdResponse(**chat.model_dump()) for chat in Chats.get_chats_by_user_id_and_search_text( - user.id, text, skip=skip, limit=limit + user.id, text, skip=skip, limit=limit, db=db ) ] @@ -186,9 +661,9 @@ def search_user_chats( if page == 1 and len(words) == 1 and words[0].startswith("tag:"): tag_id = words[0].replace("tag:", "") if len(chat_list) == 0: - if Tags.get_tag_by_name_and_user_id(tag_id, user.id): + if Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db): log.debug(f"deleting tag: {tag_id}") - Tags.delete_tag_by_name_and_user_id(tag_id, user.id) + Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) return chat_list @@ -199,23 +674,30 @@ def search_user_chats( @router.get("/folder/{folder_id}", response_model=list[ChatResponse]) -async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)): +async def get_chats_by_folder_id( + folder_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): folder_ids = [folder_id] children_folders = Folders.get_children_folders_by_id_and_user_id( - folder_id, user.id + folder_id, user.id, db=db ) if children_folders: folder_ids.extend([folder.id for folder in children_folders]) return [ ChatResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_folder_ids_and_user_id(folder_ids, user.id) + for chat in Chats.get_chats_by_folder_ids_and_user_id( + folder_ids, user.id, db=db + ) ] @router.get("/folder/{folder_id}/list") async def get_chat_list_by_folder_id( - folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user) + folder_id: str, + page: Optional[int] = 1, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): try: limit = 10 @@ -224,7 +706,7 @@ async def get_chat_list_by_folder_id( return [ {"title": chat.title, "id": chat.id, "updated_at": chat.updated_at} for chat in Chats.get_chats_by_folder_id_and_user_id( - folder_id, user.id, skip=skip, limit=limit + folder_id, user.id, skip=skip, limit=limit, db=db ) ] @@ -241,10 +723,12 @@ async def get_chat_list_by_folder_id( @router.get("/pinned", response_model=list[ChatTitleIdResponse]) -async def get_user_pinned_chats(user=Depends(get_verified_user)): +async def get_user_pinned_chats( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): return [ ChatTitleIdResponse(**chat.model_dump()) - for chat in Chats.get_pinned_chats_by_user_id(user.id) + for chat in Chats.get_pinned_chats_by_user_id(user.id, db=db) ] @@ -254,11 +738,11 @@ async def get_user_pinned_chats(user=Depends(get_verified_user)): @router.get("/all", response_model=list[ChatResponse]) -async def get_user_chats(user=Depends(get_verified_user)): - return [ - ChatResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_user_id(user.id) - ] +async def get_user_chats( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + result = Chats.get_chats_by_user_id(user.id, db=db) + return [ChatResponse(**chat.model_dump()) for chat in result.items] ############################ @@ -267,10 +751,12 @@ async def get_user_chats(user=Depends(get_verified_user)): @router.get("/all/archived", response_model=list[ChatResponse]) -async def get_user_archived_chats(user=Depends(get_verified_user)): +async def get_user_archived_chats( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): return [ ChatResponse(**chat.model_dump()) - for chat in Chats.get_archived_chats_by_user_id(user.id) + for chat in Chats.get_archived_chats_by_user_id(user.id, db=db) ] @@ -280,9 +766,11 @@ async def get_user_archived_chats(user=Depends(get_verified_user)): @router.get("/all/tags", response_model=list[TagModel]) -async def get_all_user_tags(user=Depends(get_verified_user)): +async def get_all_user_tags( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): try: - tags = Tags.get_tags_by_user_id(user.id) + tags = Tags.get_tags_by_user_id(user.id, db=db) return tags except Exception as e: log.exception(e) @@ -297,13 +785,15 @@ async def get_all_user_tags(user=Depends(get_verified_user)): @router.get("/all/db", response_model=list[ChatResponse]) -async def get_all_user_chats_in_db(user=Depends(get_admin_user)): +async def get_all_user_chats_in_db( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): if not ENABLE_ADMIN_EXPORT: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - return [ChatResponse(**chat.model_dump()) for chat in Chats.get_chats()] + return [ChatResponse(**chat.model_dump()) for chat in Chats.get_chats(db=db)] ############################ @@ -318,6 +808,7 @@ async def get_archived_session_user_chat_list( order_by: Optional[str] = None, direction: Optional[str] = None, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if page is None: page = 1 @@ -340,6 +831,7 @@ async def get_archived_session_user_chat_list( filter=filter, skip=skip, limit=limit, + db=db, ) ] @@ -352,8 +844,10 @@ async def get_archived_session_user_chat_list( @router.post("/archive/all", response_model=bool) -async def archive_all_chats(user=Depends(get_verified_user)): - return Chats.archive_all_chats_by_user_id(user.id) +async def archive_all_chats( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + return Chats.archive_all_chats_by_user_id(user.id, db=db) ############################ @@ -362,8 +856,10 @@ async def archive_all_chats(user=Depends(get_verified_user)): @router.post("/unarchive/all", response_model=bool) -async def unarchive_all_chats(user=Depends(get_verified_user)): - return Chats.unarchive_all_chats_by_user_id(user.id) +async def unarchive_all_chats( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + return Chats.unarchive_all_chats_by_user_id(user.id, db=db) ############################ @@ -372,16 +868,18 @@ async def unarchive_all_chats(user=Depends(get_verified_user)): @router.get("/share/{share_id}", response_model=Optional[ChatResponse]) -async def get_shared_chat_by_id(share_id: str, user=Depends(get_verified_user)): +async def get_shared_chat_by_id( + share_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "pending": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND ) if user.role == "user" or (user.role == "admin" and not ENABLE_ADMIN_CHAT_ACCESS): - chat = Chats.get_chat_by_share_id(share_id) + chat = Chats.get_chat_by_share_id(share_id, db=db) elif user.role == "admin" and ENABLE_ADMIN_CHAT_ACCESS: - chat = Chats.get_chat_by_id(share_id) + chat = Chats.get_chat_by_id(share_id, db=db) if chat: return ChatResponse(**chat.model_dump()) @@ -408,13 +906,15 @@ class TagFilterForm(TagForm): @router.post("/tags", response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_tag_name( - form_data: TagFilterForm, user=Depends(get_verified_user) + form_data: TagFilterForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): chats = Chats.get_chat_list_by_user_id_and_tag_name( - user.id, form_data.name, form_data.skip, form_data.limit + user.id, form_data.name, form_data.skip, form_data.limit, db=db ) if len(chats) == 0: - Tags.delete_tag_by_name_and_user_id(form_data.name, user.id) + Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) return chats @@ -425,8 +925,10 @@ async def get_user_chat_list_by_tag_name( @router.get("/{id}", response_model=Optional[ChatResponse]) -async def get_chat_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def get_chat_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: return ChatResponse(**chat.model_dump()) @@ -444,12 +946,15 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}", response_model=Optional[ChatResponse]) async def update_chat_by_id( - id: str, form_data: ChatForm, user=Depends(get_verified_user) + id: str, + form_data: ChatForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = {**chat.chat, **form_data.chat} - chat = Chats.update_chat_by_id(id, updated_chat) + chat = Chats.update_chat_by_id(id, updated_chat, db=db) return ChatResponse(**chat.model_dump()) else: raise HTTPException( @@ -467,9 +972,13 @@ class MessageForm(BaseModel): @router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse]) async def update_chat_message_by_id( - id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user) + id: str, + message_id: str, + form_data: MessageForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id(id) + chat = Chats.get_chat_by_id(id, db=db) if not chat: raise HTTPException( @@ -489,6 +998,7 @@ async def update_chat_message_by_id( { "content": form_data.content, }, + db=db, ) event_emitter = get_event_emitter( @@ -525,9 +1035,13 @@ class EventForm(BaseModel): @router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool]) async def send_chat_message_event_by_id( - id: str, message_id: str, form_data: EventForm, user=Depends(get_verified_user) + id: str, + message_id: str, + form_data: EventForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id(id) + chat = Chats.get_chat_by_id(id, db=db) if not chat: raise HTTPException( @@ -565,14 +1079,24 @@ async def send_chat_message_event_by_id( @router.delete("/{id}", response_model=bool) -async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): +async def delete_chat_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if user.role == "admin": - chat = Chats.get_chat_by_id(id) + chat = Chats.get_chat_by_id(id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id) == 1: - Tags.delete_tag_by_name_and_user_id(tag, user.id) + if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1: + Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) - result = Chats.delete_chat_by_id(id) + result = Chats.delete_chat_by_id(id, db=db) return result else: @@ -584,12 +1108,17 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.get_chat_by_id(id) + chat = Chats.get_chat_by_id(id, db=db) + if not chat: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id) == 1: - Tags.delete_tag_by_name_and_user_id(tag, user.id) + if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1: + Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) - result = Chats.delete_chat_by_id_and_user_id(id, user.id) + result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) return result @@ -599,8 +1128,10 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_verified @router.get("/{id}/pinned", response_model=Optional[bool]) -async def get_pinned_status_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def get_pinned_status_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: return chat.pinned else: @@ -615,10 +1146,12 @@ async def get_pinned_status_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/pin", response_model=Optional[ChatResponse]) -async def pin_chat_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def pin_chat_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.toggle_chat_pinned_by_id(id) + chat = Chats.toggle_chat_pinned_by_id(id, db=db) return chat else: raise HTTPException( @@ -637,9 +1170,12 @@ class CloneForm(BaseModel): @router.post("/{id}/clone", response_model=Optional[ChatResponse]) async def clone_chat_by_id( - form_data: CloneForm, id: str, user=Depends(get_verified_user) + form_data: CloneForm, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = { **chat.chat, @@ -660,6 +1196,7 @@ async def clone_chat_by_id( } ) ], + db=db, ) if chats: @@ -682,12 +1219,14 @@ async def clone_chat_by_id( @router.post("/{id}/clone/shared", response_model=Optional[ChatResponse]) -async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): +async def clone_shared_chat_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin": - chat = Chats.get_chat_by_id(id) + chat = Chats.get_chat_by_id(id, db=db) else: - chat = Chats.get_chat_by_share_id(id) + chat = Chats.get_chat_by_share_id(id, db=db) if chat: updated_chat = { @@ -709,6 +1248,7 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): } ) ], + db=db, ) if chats: @@ -731,23 +1271,28 @@ async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/archive", response_model=Optional[ChatResponse]) -async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def archive_chat_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.toggle_chat_archive_by_id(id) + chat = Chats.toggle_chat_archive_by_id(id, db=db) # Delete tags if chat is archived if chat.archived: for tag_id in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id) == 0: + if ( + Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id, db=db) + == 0 + ): log.debug(f"deleting tag: {tag_id}") - Tags.delete_tag_by_name_and_user_id(tag_id, user.id) + Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) else: for tag_id in chat.meta.get("tags", []): - tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id) + tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db) if tag is None: log.debug(f"inserting tag: {tag_id}") - tag = Tags.insert_new_tag(tag_id, user.id) + tag = Tags.insert_new_tag(tag_id, user.id, db=db) return ChatResponse(**chat.model_dump()) else: @@ -762,7 +1307,12 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/share", response_model=Optional[ChatResponse]) -async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)): +async def share_chat_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if (user.role != "admin") and ( not has_permission( user.id, "chat.share", request.app.state.config.USER_PERMISSIONS @@ -773,14 +1323,14 @@ async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_ detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: if chat.share_id: - shared_chat = Chats.update_shared_chat_by_chat_id(chat.id) + shared_chat = Chats.update_shared_chat_by_chat_id(chat.id, db=db) return ChatResponse(**shared_chat.model_dump()) - shared_chat = Chats.insert_shared_chat_by_chat_id(chat.id) + shared_chat = Chats.insert_shared_chat_by_chat_id(chat.id, db=db) if not shared_chat: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -801,14 +1351,16 @@ async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_ @router.delete("/{id}/share", response_model=Optional[bool]) -async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def delete_shared_chat_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: if not chat.share_id: return False - result = Chats.delete_shared_chat_by_chat_id(id) - update_result = Chats.update_chat_share_id_by_id(id, None) + result = Chats.delete_shared_chat_by_chat_id(id, db=db) + update_result = Chats.update_chat_share_id_by_id(id, None, db=db) return result and update_result != None else: @@ -829,12 +1381,15 @@ class ChatFolderIdForm(BaseModel): @router.post("/{id}/folder", response_model=Optional[ChatResponse]) async def update_chat_folder_id_by_id( - id: str, form_data: ChatFolderIdForm, user=Depends(get_verified_user) + id: str, + form_data: ChatFolderIdForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: chat = Chats.update_chat_folder_id_by_id_and_user_id( - id, user.id, form_data.folder_id + id, user.id, form_data.folder_id, db=db ) return ChatResponse(**chat.model_dump()) else: @@ -849,11 +1404,13 @@ async def update_chat_folder_id_by_id( @router.get("/{id}/tags", response_model=list[TagModel]) -async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def get_chat_tags_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id) + return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND @@ -867,9 +1424,12 @@ async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/tags", response_model=list[TagModel]) async def add_tag_by_id_and_tag_name( - id: str, form_data: TagForm, user=Depends(get_verified_user) + id: str, + form_data: TagForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: tags = chat.meta.get("tags", []) tag_id = form_data.name.replace(" ", "_").lower() @@ -882,12 +1442,12 @@ async def add_tag_by_id_and_tag_name( if tag_id not in tags: Chats.add_chat_tag_by_id_and_user_id_and_tag_name( - id, user.id, form_data.name + id, user.id, form_data.name, db=db ) - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id) + return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() @@ -901,18 +1461,26 @@ async def add_tag_by_id_and_tag_name( @router.delete("/{id}/tags", response_model=list[TagModel]) async def delete_tag_by_id_and_tag_name( - id: str, form_data: TagForm, user=Depends(get_verified_user) + id: str, + form_data: TagForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - Chats.delete_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name) + Chats.delete_tag_by_id_and_user_id_and_tag_name( + id, user.id, form_data.name, db=db + ) - if Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id) == 0: - Tags.delete_tag_by_name_and_user_id(form_data.name, user.id) + if ( + Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) + == 0 + ): + Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) - chat = Chats.get_chat_by_id_and_user_id(id, user.id) + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) tags = chat.meta.get("tags", []) - return Tags.get_tags_by_ids_and_user_id(tags, user.id) + return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND @@ -925,14 +1493,16 @@ async def delete_tag_by_id_and_tag_name( @router.delete("/{id}/tags/all", response_model=Optional[bool]) -async def delete_all_tags_by_id(id: str, user=Depends(get_verified_user)): - chat = Chats.get_chat_by_id_and_user_id(id, user.id) +async def delete_all_tags_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - Chats.delete_all_tags_by_id_and_user_id(id, user.id) + Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db) for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id) == 0: - Tags.delete_tag_by_name_and_user_id(tag, user.id) + if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 0: + Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) return True else: diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 0eb88e767e1..152e2c3edc3 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -6,6 +6,7 @@ from typing import Optional +from open_webui.env import AIOHTTP_CLIENT_TIMEOUT from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.config import get_config, save_config from open_webui.config import BannerModel @@ -18,7 +19,6 @@ from open_webui.utils.mcp.client import MCPClient from open_webui.models.oauth_sessions import OAuthSessions -from open_webui.env import SRC_LOG_LEVELS from open_webui.utils.oauth import ( get_discovery_urls, @@ -32,7 +32,6 @@ router = APIRouter() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) ############################ @@ -230,7 +229,10 @@ async def verify_tool_servers_config( log.debug( f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}" ) - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, + timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), + ) as session: async with session.get( discovery_url ) as oauth_server_metadata_response: diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 3e5e14801c7..22bb20df9bf 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -1,23 +1,279 @@ from typing import Optional +import logging from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.concurrency import run_in_threadpool from pydantic import BaseModel from open_webui.models.users import Users, UserModel from open_webui.models.feedbacks import ( + FeedbackIdResponse, FeedbackModel, FeedbackResponse, FeedbackForm, FeedbackUserResponse, FeedbackListResponse, + LeaderboardFeedbackData, + ModelHistoryEntry, + ModelHistoryResponse, Feedbacks, ) from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session + +log = logging.getLogger(__name__) + router = APIRouter() +# Leaderboard Elo Rating Computation +# +# How it works: +# 1. Each model starts with a rating of 1000 +# 2. When a user picks a winner between two models, ratings are adjusted: +# - Winner gains points, loser loses points +# - The amount depends on expected outcome (upset = bigger change) +# 3. The Elo formula: new_rating = old_rating + K * (actual - expected) +# - K=32 controls how much ratings can change per match +# - expected = probability of winning based on current ratings +# +# Query-based re-ranking (optional): +# When a user searches for a topic (e.g., "coding"), we want to show +# which models perform best FOR THAT TOPIC. We do this by: +# 1. Computing semantic similarity between the query and each feedback's tags +# 2. Using that similarity as a weight in the Elo calculation +# 3. Feedbacks about "coding" contribute more to the final ranking +# 4. Feedbacks about unrelated topics (e.g., "cooking") contribute less +# This gives topic-specific leaderboards without needing separate data. + +import os + +EMBEDDING_MODEL_NAME = os.environ.get( + "AUXILIARY_EMBEDDING_MODEL", "TaylorAI/bge-micro-v2" +) +_embedding_model = None + + +def _get_embedding_model(): + global _embedding_model + if _embedding_model is None: + try: + from sentence_transformers import SentenceTransformer + + _embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME) + except Exception as e: + log.error(f"Embedding model load failed: {e}") + return _embedding_model + + +def _calculate_elo( + feedbacks: list[LeaderboardFeedbackData], similarities: dict = None +) -> dict: + """ + Calculate Elo ratings for models based on user feedback. + + Each feedback represents a comparison where a user rated one model + against its opponents (sibling_model_ids). Rating=1 means the model won, + rating=-1 means it lost. + + The Elo system adjusts ratings based on: + - Current rating difference (upsets cause bigger swings) + - Optional similarity weights (for query-based filtering) + + Returns: {model_id: {"rating": float, "won": int, "lost": int}} + """ + K_FACTOR = 32 # Standard Elo K-factor for rating volatility + model_stats = {} + + def get_or_create_stats(model_id): + if model_id not in model_stats: + model_stats[model_id] = {"rating": 1000.0, "won": 0, "lost": 0} + return model_stats[model_id] + + for feedback in feedbacks: + data = feedback.data or {} + winner_id = data.get("model_id") + rating_value = str(data.get("rating", "")) + if not winner_id or rating_value not in ("1", "-1"): + continue + + won = rating_value == "1" + weight = similarities.get(feedback.id, 1.0) if similarities else 1.0 + + for opponent_id in data.get("sibling_model_ids") or []: + winner = get_or_create_stats(winner_id) + opponent = get_or_create_stats(opponent_id) + expected = 1 / (1 + 10 ** ((opponent["rating"] - winner["rating"]) / 400)) + + winner["rating"] += K_FACTOR * ((1 if won else 0) - expected) * weight + opponent["rating"] += ( + K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight + ) + + if won: + winner["won"] += 1 + opponent["lost"] += 1 + else: + winner["lost"] += 1 + opponent["won"] += 1 + + return model_stats + + +def _get_top_tags(feedbacks: list[LeaderboardFeedbackData], limit: int = 5) -> dict: + """ + Count tag occurrences per model and return the most frequent ones. + + Each feedback can have tags describing the conversation topic. + This aggregates those tags per model to show what topics each model + is commonly used for. + + Returns: {model_id: [{"tag": str, "count": int}, ...]} + """ + from collections import defaultdict + + tag_counts = defaultdict(lambda: defaultdict(int)) + + for feedback in feedbacks: + data = feedback.data or {} + model_id = data.get("model_id") + if model_id: + for tag in data.get("tags", []): + tag_counts[model_id][tag] += 1 + + return { + model_id: [ + {"tag": tag, "count": count} + for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit] + ] + for model_id, tags in tag_counts.items() + } + + +def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) -> dict: + """ + Compute how relevant each feedback is to a search query. + + Uses embeddings to find semantic similarity between the query and + each feedback's tags. Higher similarity means the feedback is more + relevant to what the user searched for. + + This is used to weight Elo calculations - feedbacks matching the + query have more influence on the final rankings. + + Returns: {feedback_id: similarity_score (0-1)} + """ + import numpy as np + + embedding_model = _get_embedding_model() + if not embedding_model: + return {} + + all_tags = list( + { + tag + for feedback in feedbacks + if feedback.data + for tag in feedback.data.get("tags", []) + } + ) + if not all_tags: + return {} + + try: + tag_embeddings = embedding_model.encode(all_tags) + query_embedding = embedding_model.encode([query])[0] + except Exception as e: + log.error(f"Embedding error: {e}") + return {} + + # Vectorized cosine similarity + tag_norms = np.linalg.norm(tag_embeddings, axis=1) + query_norm = np.linalg.norm(query_embedding) + similarities = np.dot(tag_embeddings, query_embedding) / ( + tag_norms * query_norm + 1e-9 + ) + tag_similarity_map = dict(zip(all_tags, similarities.tolist())) + + return { + feedback.id: max( + ( + tag_similarity_map.get(tag, 0) + for tag in (feedback.data or {}).get("tags", []) + ), + default=0, + ) + for feedback in feedbacks + } + + +class LeaderboardEntry(BaseModel): + model_id: str + rating: int + won: int + lost: int + count: int + top_tags: list[dict] + + +class LeaderboardResponse(BaseModel): + entries: list[LeaderboardEntry] + + +@router.get("/leaderboard", response_model=LeaderboardResponse) +async def get_leaderboard( + query: Optional[str] = None, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get model leaderboard with Elo ratings. Query filters by tag similarity.""" + feedbacks = Feedbacks.get_feedbacks_for_leaderboard(db=db) + + similarities = None + if query and query.strip(): + similarities = await run_in_threadpool( + _compute_similarities, feedbacks, query.strip() + ) + + elo_stats = _calculate_elo(feedbacks, similarities) + tags_by_model = _get_top_tags(feedbacks) + + entries = sorted( + [ + LeaderboardEntry( + model_id=mid, + rating=round(s["rating"]), + won=s["won"], + lost=s["lost"], + count=s["won"] + s["lost"], + top_tags=tags_by_model.get(mid, []), + ) + for mid, s in elo_stats.items() + ], + key=lambda e: e.rating, + reverse=True, + ) + + return LeaderboardResponse(entries=entries) + + +@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse) +async def get_model_history( + model_id: str, + days: int = 30, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Get daily win/loss history for a specific model.""" + history = Feedbacks.get_model_evaluation_history( + model_id=model_id, days=days, db=db + ) + return ModelHistoryResponse(model_id=model_id, history=history) + + ############################ # GetConfig ############################ @@ -59,32 +315,49 @@ async def update_config( @router.get("/feedbacks/all", response_model=list[FeedbackResponse]) -async def get_all_feedbacks(user=Depends(get_admin_user)): - feedbacks = Feedbacks.get_all_feedbacks() +async def get_all_feedbacks( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + feedbacks = Feedbacks.get_all_feedbacks(db=db) return feedbacks +@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse]) +async def get_all_feedback_ids( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + return Feedbacks.get_all_feedback_ids(db=db) + + @router.delete("/feedbacks/all") -async def delete_all_feedbacks(user=Depends(get_admin_user)): - success = Feedbacks.delete_all_feedbacks() +async def delete_all_feedbacks( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + success = Feedbacks.delete_all_feedbacks(db=db) return success @router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) -async def get_all_feedbacks(user=Depends(get_admin_user)): - feedbacks = Feedbacks.get_all_feedbacks() +async def export_all_feedbacks( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + feedbacks = Feedbacks.get_all_feedbacks(db=db) return feedbacks @router.get("/feedbacks/user", response_model=list[FeedbackUserResponse]) -async def get_feedbacks(user=Depends(get_verified_user)): - feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id) +async def get_feedbacks( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db) return feedbacks @router.delete("/feedbacks", response_model=bool) -async def delete_feedbacks(user=Depends(get_verified_user)): - success = Feedbacks.delete_feedbacks_by_user_id(user.id) +async def delete_feedbacks( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db) return success @@ -97,6 +370,7 @@ async def get_feedbacks( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_admin_user), + db: Session = Depends(get_session), ): limit = PAGE_ITEM_COUNT @@ -109,7 +383,7 @@ async def get_feedbacks( if direction: filter["direction"] = direction - result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit) + result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db) return result @@ -118,8 +392,11 @@ async def create_feedback( request: Request, form_data: FeedbackForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data) + feedback = Feedbacks.insert_new_feedback( + user_id=user.id, form_data=form_data, db=db + ) if not feedback: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -130,11 +407,15 @@ async def create_feedback( @router.get("/feedback/{id}", response_model=FeedbackModel) -async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): +async def get_feedback_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin": - feedback = Feedbacks.get_feedback_by_id(id=id) + feedback = Feedbacks.get_feedback_by_id(id=id, db=db) else: - feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id) + feedback = Feedbacks.get_feedback_by_id_and_user_id( + id=id, user_id=user.id, db=db + ) if not feedback: raise HTTPException( @@ -146,13 +427,16 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): @router.post("/feedback/{id}", response_model=FeedbackModel) async def update_feedback_by_id( - id: str, form_data: FeedbackForm, user=Depends(get_verified_user) + id: str, + form_data: FeedbackForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if user.role == "admin": - feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data) + feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db) else: feedback = Feedbacks.update_feedback_by_id_and_user_id( - id=id, user_id=user.id, form_data=form_data + id=id, user_id=user.id, form_data=form_data, db=db ) if not feedback: @@ -164,11 +448,15 @@ async def update_feedback_by_id( @router.delete("/feedback/{id}") -async def delete_feedback_by_id(id: str, user=Depends(get_verified_user)): +async def delete_feedback_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin": - success = Feedbacks.delete_feedback_by_id(id=id) + success = Feedbacks.delete_feedback_by_id(id=id, db=db) else: - success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id) + success = Feedbacks.delete_feedback_by_id_and_user_id( + id=id, user_id=user.id, db=db + ) if not success: raise HTTPException( diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 8af921bc7a2..e3dd63525ad 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -2,7 +2,6 @@ import os import uuid import json -from fnmatch import fnmatch from pathlib import Path from typing import Optional from urllib.parse import quote @@ -22,11 +21,13 @@ ) from fastapi.responses import FileResponse, StreamingResponse +from sqlalchemy.orm import Session +from open_webui.internal.db import get_session, SessionLocal from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.models.channels import Channels from open_webui.models.users import Users from open_webui.models.files import ( FileForm, @@ -34,11 +35,11 @@ FileModelResponse, Files, ) +from open_webui.models.chats import Chats from open_webui.models.knowledge import Knowledges 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.audio import transcribe @@ -47,11 +48,10 @@ 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.misc import strict_match_mime_type from pydantic import BaseModel log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -63,9 +63,12 @@ # TODO: Optimize this function to use the knowledge_file table for faster lookups. def has_access_to_file( - file_id: Optional[str], access_type: str, user=Depends(get_verified_user) + file_id: Optional[str], + access_type: str, + user=Depends(get_verified_user), + db: Optional[Session] = None, ) -> bool: - file = Files.get_file_by_id(file_id) + file = Files.get_file_by_id(file_id, db=db) log.debug(f"Checking if user has {access_type} access to file") if not file: raise HTTPException( @@ -73,24 +76,37 @@ def has_access_to_file( detail=ERROR_MESSAGES.NOT_FOUND, ) - 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)} - + # 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, db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } for knowledge_base in knowledge_bases: 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, db=db ): return True knowledge_base_id = file.meta.get("collection_name") if file.meta else None if knowledge_base_id: knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( - user.id, access_type + user.id, access_type, db=db ) for knowledge_base in knowledge_bases: if knowledge_base.id == knowledge_base_id: 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, db=db) + 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, db=db) + if chats: + return True + return False @@ -99,54 +115,78 @@ def has_access_to_file( ############################ -def process_uploaded_file(request, file, file_path, file_item, file_metadata, user): - try: - if file.content_type: - stt_supported_content_types = getattr( - request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] - ) - - if any( - fnmatch(file.content_type, content_type) - for content_type in ( - stt_supported_content_types - if stt_supported_content_types - and any(t.strip() for t in stt_supported_content_types) - else ["audio/*", "video/webm"] +def process_uploaded_file( + request, + file, + file_path, + file_item, + file_metadata, + user, + db: Optional[Session] = None, +): + def _process_handler(db_session): + try: + if file.content_type: + stt_supported_content_types = getattr( + request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] ) - ): - file_path = Storage.get_file(file_path) - result = transcribe(request, file_path, file_metadata, user) + if strict_match_mime_type( + stt_supported_content_types, file.content_type + ): + file_path_processed = Storage.get_file(file_path) + result = transcribe( + request, file_path_processed, file_metadata, user + ) + + process_file( + request, + ProcessFileForm( + file_id=file_item.id, content=result.get("text", "") + ), + user=user, + db=db_session, + ) + elif (not file.content_type.startswith(("image/", "video/"))) or ( + request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" + ): + process_file( + request, + ProcessFileForm(file_id=file_item.id), + user=user, + db=db_session, + ) + else: + raise Exception( + f"File type {file.content_type} is not supported for processing" + ) + else: + log.info( + f"File type {file.content_type} is not provided, but trying to process anyway" + ) process_file( request, - ProcessFileForm( - file_id=file_item.id, content=result.get("text", "") - ), + ProcessFileForm(file_id=file_item.id), user=user, + db=db_session, ) - elif (not file.content_type.startswith(("image/", "video/"))) or ( - request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" - ): - process_file(request, ProcessFileForm(file_id=file_item.id), user=user) - else: - raise Exception( - f"File type {file.content_type} is not supported for processing" - ) - else: - log.info( - f"File type {file.content_type} is not provided, but trying to process anyway" + + except Exception as e: + log.error(f"Error processing file: {file_item.id}") + Files.update_file_data_by_id( + file_item.id, + { + "status": "failed", + "error": str(e.detail) if hasattr(e, "detail") else str(e), + }, + db=db_session, ) - process_file(request, ProcessFileForm(file_id=file_item.id), user=user) - except Exception as e: - log.error(f"Error processing file: {file_item.id}") - Files.update_file_data_by_id( - file_item.id, - { - "status": "failed", - "error": str(e.detail) if hasattr(e, "detail") else str(e), - }, - ) + + if db: + _process_handler(db) + else: + with SessionLocal() as db_session: + _process_handler(db_session) @router.post("/", response_model=FileModelResponse) @@ -158,6 +198,7 @@ def upload_file( process: bool = Query(True), process_in_background: bool = Query(True), user=Depends(get_verified_user), + db: Session = Depends(get_session), ): return upload_file_handler( request, @@ -167,6 +208,7 @@ def upload_file( process_in_background=process_in_background, user=user, background_tasks=background_tasks, + db=db, ) @@ -178,8 +220,9 @@ def upload_file_handler( process_in_background: bool = Query(True), user=Depends(get_verified_user), background_tasks: Optional[BackgroundTasks] = None, + db: Optional[Session] = None, ): - log.info(f"file.content_type: {file.content_type}") + log.info(f"file.content_type: {file.content_type} {process}") if isinstance(metadata, str): try: @@ -245,8 +288,18 @@ def upload_file_handler( }, } ), + db=db, ) + if "channel_id" in file_metadata: + channel = Channels.get_channel_by_id_and_user_id( + file_metadata["channel_id"], user.id, db=db + ) + if channel: + Channels.add_file_to_channel_by_id( + channel.id, file_item.id, user.id, db=db + ) + if process: if background_tasks and process_in_background: background_tasks.add_task( @@ -267,6 +320,7 @@ def upload_file_handler( file_item, file_metadata, user, + db=db, ) return {"status": True, **file_item.model_dump()} else: @@ -292,11 +346,15 @@ def upload_file_handler( @router.get("/", response_model=list[FileModelResponse]) -async def list_files(user=Depends(get_verified_user), content: bool = Query(True)): +async def list_files( + user=Depends(get_verified_user), + content: bool = Query(True), + db: Session = Depends(get_session), +): if user.role == "admin": - files = Files.get_files() + files = Files.get_files(db=db) else: - files = Files.get_files_by_user_id(user.id) + files = Files.get_files_by_user_id(user.id, db=db) if not content: for file in files: @@ -318,34 +376,41 @@ async def search_files( description="Filename pattern to search for. Supports wildcards such as '*.txt'", ), content: bool = Query(True), + skip: int = Query(0, ge=0, description="Number of files to skip"), + limit: int = Query( + 100, ge=1, le=1000, description="Maximum number of files to return" + ), user=Depends(get_verified_user), + db: Session = Depends(get_session), ): """ Search for files by filename with support for wildcard patterns. + Uses SQL-based filtering with pagination for better performance. """ - # Get files according to user role - if user.role == "admin": - files = Files.get_files() - else: - files = Files.get_files_by_user_id(user.id) - - # Get matching files - matching_files = [ - file for file in files if fnmatch(file.filename.lower(), filename.lower()) - ] + # Determine user_id: null for admin (search all), user.id for regular users + user_id = None if user.role == "admin" else user.id + + # Use optimized database query with pagination + files = Files.search_files( + user_id=user_id, + filename=filename, + skip=skip, + limit=limit, + db=db, + ) - if not matching_files: + if not files: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No files found matching the pattern.", ) if not content: - for file in matching_files: - if "content" in file.data: + for file in files: + if file.data and "content" in file.data: del file.data["content"] - return matching_files + return files ############################ @@ -354,8 +419,10 @@ async def search_files( @router.delete("/all") -async def delete_all_files(user=Depends(get_admin_user)): - result = Files.delete_all_files() +async def delete_all_files( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + result = Files.delete_all_files(db=db) if result: try: Storage.delete_all_files() @@ -381,8 +448,10 @@ async def delete_all_files(user=Depends(get_admin_user)): @router.get("/{id}", response_model=Optional[FileModel]) -async def get_file_by_id(id: str, user=Depends(get_verified_user)): - file = Files.get_file_by_id(id) +async def get_file_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -393,7 +462,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)): if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): return file else: @@ -405,9 +474,12 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)): @router.get("/{id}/process/status") async def get_file_process_status( - id: str, stream: bool = Query(False), user=Depends(get_verified_user) + id: str, + stream: bool = Query(False), + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - file = Files.get_file_by_id(id) + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -418,37 +490,40 @@ async def get_file_process_status( if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 - async def event_stream(file_item): - if file_item: - for _ in range(MAX_FILE_PROCESSING_DURATION): - file_item = Files.get_file_by_id(file_item.id) - if file_item: - data = file_item.model_dump().get("data", {}) - status = data.get("status") - - if status: - event = {"status": status} - if status == "failed": - event["error"] = data.get("error") - - yield f"data: {json.dumps(event)}\n\n" - if status in ("completed", "failed"): - break - else: - # Legacy + async def event_stream(file_id): + # NOTE: We intentionally do NOT capture the request's db session here. + # Each poll creates its own short-lived session to avoid holding a + # connection for hours. A WebSocket push would be more efficient. + for _ in range(MAX_FILE_PROCESSING_DURATION): + file_item = Files.get_file_by_id(file_id) # Creates own session + if file_item: + data = file_item.model_dump().get("data", {}) + status = data.get("status") + + if status: + event = {"status": status} + if status == "failed": + event["error"] = data.get("error") + + yield f"data: {json.dumps(event)}\n\n" + if status in ("completed", "failed"): break + else: + # Legacy + break + else: + yield f"data: {json.dumps({'status': 'not_found'})}\n\n" + break - await asyncio.sleep(0.5) - else: - yield f"data: {json.dumps({'status': 'not_found'})}\n\n" + await asyncio.sleep(1) return StreamingResponse( - event_stream(file), + event_stream(file.id), media_type="text/event-stream", ) else: @@ -466,8 +541,10 @@ async def event_stream(file_item): @router.get("/{id}/data/content") -async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): - file = Files.get_file_by_id(id) +async def get_file_data_content_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -478,7 +555,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): return {"content": file.data.get("content", "")} else: @@ -499,9 +576,13 @@ class ContentForm(BaseModel): @router.post("/{id}/data/content/update") async def update_file_data_content_by_id( - request: Request, id: str, form_data: ContentForm, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: ContentForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - file = Files.get_file_by_id(id) + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -512,7 +593,7 @@ async def update_file_data_content_by_id( if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "write", user) + or has_access_to_file(id, "write", user, db=db) ): try: process_file( @@ -520,7 +601,7 @@ async def update_file_data_content_by_id( ProcessFileForm(file_id=id, content=form_data.content), user=user, ) - file = Files.get_file_by_id(id=id) + file = Files.get_file_by_id(id=id, db=db) except Exception as e: log.exception(e) log.error(f"Error processing file: {file.id}") @@ -540,9 +621,12 @@ async def update_file_data_content_by_id( @router.get("/{id}/content") async def get_file_content_by_id( - id: str, user=Depends(get_verified_user), attachment: bool = Query(False) + id: str, + user=Depends(get_verified_user), + attachment: bool = Query(False), + db: Session = Depends(get_session), ): - file = Files.get_file_by_id(id) + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -553,7 +637,7 @@ async def get_file_content_by_id( if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): try: file_path = Storage.get_file(file.path) @@ -609,8 +693,10 @@ async def get_file_content_by_id( @router.get("/{id}/content/html") -async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): - file = Files.get_file_by_id(id) +async def get_html_file_content_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -618,7 +704,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.NOT_FOUND, ) - file_user = Users.get_user_by_id(file.user_id) + file_user = Users.get_user_by_id(file.user_id, db=db) if not file_user.role == "admin": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -628,7 +714,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): try: file_path = Storage.get_file(file.path) @@ -658,8 +744,10 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): @router.get("/{id}/content/{file_name}") -async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): - file = Files.get_file_by_id(id) +async def get_file_content_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -670,7 +758,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "read", user) + or has_access_to_file(id, "read", user, db=db) ): file_path = file.path @@ -720,8 +808,10 @@ def generator(): @router.delete("/{id}") -async def delete_file_by_id(id: str, user=Depends(get_verified_user)): - file = Files.get_file_by_id(id) +async def delete_file_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + file = Files.get_file_by_id(id, db=db) if not file: raise HTTPException( @@ -732,10 +822,10 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)): if ( file.user_id == user.id or user.role == "admin" - or has_access_to_file(id, "write", user) + or has_access_to_file(id, "write", user, db=db) ): - result = Files.delete_file_by_id(id) + result = Files.delete_file_by_id(id, db=db) if result: try: Storage.delete_file(file.path) diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index fe2bf367bf9..1c9b2229cf0 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -21,8 +21,9 @@ 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.internal.db import get_session +from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request @@ -34,7 +35,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -46,7 +46,11 @@ @router.get("/", response_model=list[FolderNameIdResponse]) -async def get_folders(request: Request, user=Depends(get_verified_user)): +async def get_folders( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if request.app.state.config.ENABLE_FOLDERS is False: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -57,22 +61,23 @@ async def get_folders(request: Request, user=Depends(get_verified_user)): user.id, "features.folders", request.app.state.config.USER_PERMISSIONS, + db=db, ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - folders = Folders.get_folders_by_user_id(user.id) + folders = Folders.get_folders_by_user_id(user.id, db=db) # Verify folder data integrity folder_list = [] for folder in folders: if folder.parent_id and not Folders.get_folder_by_id_and_user_id( - folder.parent_id, user.id + folder.parent_id, user.id, db=db ): folder = Folders.update_folder_parent_id_by_id_and_user_id( - folder.id, user.id, None + folder.id, user.id, None, db=db ) if folder.data: @@ -82,12 +87,12 @@ async def get_folders(request: Request, user=Depends(get_verified_user)): if file.get("type") == "file": if Files.check_access_by_user_id( - file.get("id"), user.id, "read" + file.get("id"), user.id, "read", db=db ): valid_files.append(file) elif file.get("type") == "collection": if Knowledges.check_access_by_user_id( - file.get("id"), user.id, "read" + file.get("id"), user.id, "read", db=db ): valid_files.append(file) else: @@ -95,7 +100,7 @@ async def get_folders(request: Request, user=Depends(get_verified_user)): folder.data["files"] = valid_files Folders.update_folder_by_id_and_user_id( - folder.id, user.id, FolderUpdateForm(data=folder.data) + folder.id, user.id, FolderUpdateForm(data=folder.data), db=db ) folder_list.append(FolderNameIdResponse(**folder.model_dump())) @@ -109,9 +114,13 @@ async def get_folders(request: Request, user=Depends(get_verified_user)): @router.post("/") -def create_folder(form_data: FolderForm, user=Depends(get_verified_user)): +def create_folder( + form_data: FolderForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - None, user.id, form_data.name + None, user.id, form_data.name, db=db ) if folder: @@ -121,7 +130,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)): ) try: - folder = Folders.insert_new_folder(user.id, form_data) + folder = Folders.insert_new_folder(user.id, form_data, db=db) return folder except Exception as e: log.exception(e) @@ -138,8 +147,10 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)): @router.get("/{id}", response_model=Optional[FolderModel]) -async def get_folder_by_id(id: str, user=Depends(get_verified_user)): - folder = Folders.get_folder_by_id_and_user_id(id, user.id) +async def get_folder_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: return folder else: @@ -156,15 +167,18 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/update") async def update_folder_name_by_id( - id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user) + id: str, + form_data: FolderUpdateForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id) + folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: if form_data.name is not None: # Check if folder with same name exists existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - folder.parent_id, user.id, form_data.name + folder.parent_id, user.id, form_data.name, db=db ) if existing_folder and existing_folder.id != id: raise HTTPException( @@ -173,7 +187,9 @@ async def update_folder_name_by_id( ) try: - folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data) + folder = Folders.update_folder_by_id_and_user_id( + id, user.id, form_data, db=db + ) return folder except Exception as e: log.exception(e) @@ -200,12 +216,15 @@ class FolderParentIdForm(BaseModel): @router.post("/{id}/update/parent") async def update_folder_parent_id_by_id( - id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user) + id: str, + form_data: FolderParentIdForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id) + folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - form_data.parent_id, user.id, folder.name + form_data.parent_id, user.id, folder.name, db=db ) if existing_folder: @@ -216,7 +235,7 @@ async def update_folder_parent_id_by_id( try: folder = Folders.update_folder_parent_id_by_id_and_user_id( - id, user.id, form_data.parent_id + id, user.id, form_data.parent_id, db=db ) return folder except Exception as e: @@ -244,13 +263,16 @@ class FolderIsExpandedForm(BaseModel): @router.post("/{id}/update/expanded") async def update_folder_is_expanded_by_id( - id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user) + id: str, + form_data: FolderIsExpandedForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - folder = Folders.get_folder_by_id_and_user_id(id, user.id) + folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: try: folder = Folders.update_folder_is_expanded_by_id_and_user_id( - id, user.id, form_data.is_expanded + id, user.id, form_data.is_expanded, db=db ) return folder except Exception as e: @@ -278,10 +300,11 @@ async def delete_folder_by_id( id: str, delete_contents: Optional[bool] = True, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - if Chats.count_chats_by_folder_id_and_user_id(id, user.id): + if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): chat_delete_permission = has_permission( - user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS + user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS, db=db ) if user.role != "admin" and not chat_delete_permission: raise HTTPException( @@ -290,19 +313,21 @@ async def delete_folder_by_id( ) folders = [] - folders.append(Folders.get_folder_by_id_and_user_id(id, user.id)) + folders.append(Folders.get_folder_by_id_and_user_id(id, user.id, db=db)) while folders: folder = folders.pop() if folder: try: - folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id) + folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db) for folder_id in folder_ids: if delete_contents: - Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id) + Chats.delete_chats_by_user_id_and_folder_id( + user.id, folder_id, db=db + ) else: Chats.move_chats_by_user_id_and_folder_id( - user.id, folder_id, None + user.id, folder_id, None, db=db ) return True @@ -316,7 +341,7 @@ async def delete_folder_by_id( finally: # Get all subfolders subfolders = Folders.get_folders_by_parent_id_and_user_id( - folder.id, user.id + folder.id, user.id, db=db ) folders.extend(subfolders) diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index c8f131553c3..ad473189111 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Optional +from open_webui.env import AIOHTTP_CLIENT_TIMEOUT from open_webui.models.functions import ( FunctionForm, FunctionModel, @@ -23,12 +24,12 @@ from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, HttpUrl +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -39,13 +40,17 @@ @router.get("/", response_model=list[FunctionResponse]) -async def get_functions(user=Depends(get_verified_user)): - return Functions.get_functions() +async def get_functions( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + return Functions.get_functions(db=db) @router.get("/list", response_model=list[FunctionUserResponse]) -async def get_function_list(user=Depends(get_admin_user)): - return Functions.get_function_list() +async def get_function_list( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + return Functions.get_function_list(db=db) ############################ @@ -54,8 +59,12 @@ async def get_function_list(user=Depends(get_admin_user)): @router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel]) -async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)): - return Functions.get_functions(include_valves=include_valves) +async def get_functions( + include_valves: bool = False, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + return Functions.get_functions(include_valves=include_valves, db=db) ############################ @@ -112,7 +121,9 @@ async def load_function_from_url( ) try: - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.get( url, headers={"Content-Type": "application/json"} ) as resp: @@ -144,7 +155,10 @@ class SyncFunctionsForm(BaseModel): @router.post("/sync", response_model=list[FunctionWithValvesModel]) async def sync_functions( - request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) + request: Request, + form_data: SyncFunctionsForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): try: for function in form_data.functions: @@ -166,7 +180,7 @@ async def sync_functions( ) raise e - return Functions.sync_functions(user.id, form_data.functions) + return Functions.sync_functions(user.id, form_data.functions, db=db) except Exception as e: log.exception(f"Failed to load a function: {e}") raise HTTPException( @@ -182,7 +196,10 @@ async def sync_functions( @router.post("/create", response_model=Optional[FunctionResponse]) async def create_new_function( - request: Request, form_data: FunctionForm, user=Depends(get_admin_user) + request: Request, + form_data: FunctionForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): if not form_data.id.isidentifier(): raise HTTPException( @@ -192,7 +209,7 @@ async def create_new_function( form_data.id = form_data.id.lower() - function = Functions.get_function_by_id(form_data.id) + function = Functions.get_function_by_id(form_data.id, db=db) if function is None: try: form_data.content = replace_imports(form_data.content) @@ -205,13 +222,17 @@ async def create_new_function( FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS[form_data.id] = function_module - function = Functions.insert_new_function(user.id, function_type, form_data) + function = Functions.insert_new_function( + user.id, function_type, form_data, db=db + ) function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) if function_type == "filter" and getattr(function_module, "toggle", None): - Functions.update_function_metadata_by_id(id, {"toggle": True}) + Functions.update_function_metadata_by_id( + form_data.id, {"toggle": True}, db=db + ) if function: return function @@ -239,8 +260,10 @@ async def create_new_function( @router.get("/id/{id}", response_model=Optional[FunctionModel]) -async def get_function_by_id(id: str, user=Depends(get_admin_user)): - function = Functions.get_function_by_id(id) +async def get_function_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + function = Functions.get_function_by_id(id, db=db) if function: return function @@ -257,11 +280,13 @@ async def get_function_by_id(id: str, user=Depends(get_admin_user)): @router.post("/id/{id}/toggle", response_model=Optional[FunctionModel]) -async def toggle_function_by_id(id: str, user=Depends(get_admin_user)): - function = Functions.get_function_by_id(id) +async def toggle_function_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + function = Functions.get_function_by_id(id, db=db) if function: function = Functions.update_function_by_id( - id, {"is_active": not function.is_active} + id, {"is_active": not function.is_active}, db=db ) if function: @@ -284,11 +309,13 @@ async def toggle_function_by_id(id: str, user=Depends(get_admin_user)): @router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel]) -async def toggle_global_by_id(id: str, user=Depends(get_admin_user)): - function = Functions.get_function_by_id(id) +async def toggle_global_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + function = Functions.get_function_by_id(id, db=db) if function: function = Functions.update_function_by_id( - id, {"is_global": not function.is_global} + id, {"is_global": not function.is_global}, db=db ) if function: @@ -312,7 +339,11 @@ async def toggle_global_by_id(id: str, user=Depends(get_admin_user)): @router.post("/id/{id}/update", response_model=Optional[FunctionModel]) async def update_function_by_id( - request: Request, id: str, form_data: FunctionForm, user=Depends(get_admin_user) + request: Request, + id: str, + form_data: FunctionForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): try: form_data.content = replace_imports(form_data.content) @@ -327,10 +358,10 @@ async def update_function_by_id( updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} log.debug(updated) - function = Functions.update_function_by_id(id, updated) + function = Functions.update_function_by_id(id, updated, db=db) if function_type == "filter" and getattr(function_module, "toggle", None): - Functions.update_function_metadata_by_id(id, {"toggle": True}) + Functions.update_function_metadata_by_id(id, {"toggle": True}, db=db) if function: return function @@ -354,9 +385,12 @@ async def update_function_by_id( @router.delete("/id/{id}/delete", response_model=bool) async def delete_function_by_id( - request: Request, id: str, user=Depends(get_admin_user) + request: Request, + id: str, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): - result = Functions.delete_function_by_id(id) + result = Functions.delete_function_by_id(id, db=db) if result: FUNCTIONS = request.app.state.FUNCTIONS @@ -372,11 +406,13 @@ async def delete_function_by_id( @router.get("/id/{id}/valves", response_model=Optional[dict]) -async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)): - function = Functions.get_function_by_id(id) +async def get_function_valves_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + function = Functions.get_function_by_id(id, db=db) if function: try: - valves = Functions.get_function_valves_by_id(id) + valves = Functions.get_function_valves_by_id(id, db=db) return valves except Exception as e: raise HTTPException( @@ -397,9 +433,12 @@ async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)): @router.get("/id/{id}/valves/spec", response_model=Optional[dict]) async def get_function_valves_spec_by_id( - request: Request, id: str, user=Depends(get_admin_user) + request: Request, + id: str, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): - function = Functions.get_function_by_id(id) + function = Functions.get_function_by_id(id, db=db) if function: function_module, function_type, frontmatter = get_function_module_from_cache( request, id @@ -423,9 +462,13 @@ async def get_function_valves_spec_by_id( @router.post("/id/{id}/valves/update", response_model=Optional[dict]) async def update_function_valves_by_id( - request: Request, id: str, form_data: dict, user=Depends(get_admin_user) + request: Request, + id: str, + form_data: dict, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): - function = Functions.get_function_by_id(id) + function = Functions.get_function_by_id(id, db=db) if function: function_module, function_type, frontmatter = get_function_module_from_cache( request, id @@ -439,7 +482,7 @@ async def update_function_valves_by_id( valves = Valves(**form_data) valves_dict = valves.model_dump(exclude_unset=True) - Functions.update_function_valves_by_id(id, valves_dict) + Functions.update_function_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: log.exception(f"Error updating function values by id {id}: {e}") @@ -466,11 +509,15 @@ async def update_function_valves_by_id( @router.get("/id/{id}/valves/user", response_model=Optional[dict]) -async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user)): - function = Functions.get_function_by_id(id) +async def get_function_user_valves_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + function = Functions.get_function_by_id(id, db=db) if function: try: - user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id) + user_valves = Functions.get_user_valves_by_id_and_user_id( + id, user.id, db=db + ) return user_valves except Exception as e: raise HTTPException( @@ -486,9 +533,12 @@ async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user @router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) async def get_function_user_valves_spec_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - function = Functions.get_function_by_id(id) + function = Functions.get_function_by_id(id, db=db) if function: function_module, function_type, frontmatter = get_function_module_from_cache( request, id @@ -507,9 +557,13 @@ async def get_function_user_valves_spec_by_id( @router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) async def update_function_user_valves_by_id( - request: Request, id: str, form_data: dict, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: dict, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - function = Functions.get_function_by_id(id) + function = Functions.get_function_by_id(id, db=db) if function: function_module, function_type, frontmatter = get_function_module_from_cache( @@ -524,7 +578,7 @@ async def update_function_user_valves_by_id( user_valves = UserValves(**form_data) user_valves_dict = user_valves.model_dump(exclude_unset=True) Functions.update_user_valves_by_id_and_user_id( - id, user.id, user_valves_dict + id, user.id, user_valves_dict, db=db ) return user_valves_dict except Exception as e: diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 7d2efcf8998..cc0cb8f5a3d 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -16,12 +16,13 @@ from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, status +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session + from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -31,16 +32,21 @@ @router.get("/", response_model=list[GroupResponse]) -async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)): +async def get_groups( + share: Optional[bool] = None, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): filter = {} + + # Admins can share to all groups regardless of share setting if user.role != "admin": filter["member_id"] = user.id + if share is not None: + filter["share"] = share - if share is not None: - filter["share"] = share - - groups = Groups.get_groups(filter=filter) + groups = Groups.get_groups(filter=filter, db=db) return groups @@ -51,13 +57,17 @@ async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_use @router.post("/create", response_model=Optional[GroupResponse]) -async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)): +async def create_new_group( + form_data: GroupForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): try: - group = Groups.insert_new_group(user.id, form_data) + group = Groups.insert_new_group(user.id, form_data, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -78,12 +88,14 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)): @router.get("/id/{id}", response_model=Optional[GroupResponse]) -async def get_group_by_id(id: str, user=Depends(get_admin_user)): - group = Groups.get_group_by_id(id) +async def get_group_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + group = Groups.get_group_by_id(id, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -103,13 +115,15 @@ class GroupExportResponse(GroupResponse): @router.get("/id/{id}/export", response_model=Optional[GroupExportResponse]) -async def export_group_by_id(id: str, user=Depends(get_admin_user)): - group = Groups.get_group_by_id(id) +async def export_group_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + group = Groups.get_group_by_id(id, db=db) if group: return GroupExportResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), - user_ids=Groups.get_group_user_ids_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), + user_ids=Groups.get_group_user_ids_by_id(group.id, db=db), ) else: raise HTTPException( @@ -124,9 +138,11 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user)): @router.post("/id/{id}/users", response_model=list[UserInfoResponse]) -async def get_users_in_group(id: str, user=Depends(get_admin_user)): +async def get_users_in_group( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): try: - users = Users.get_users_by_group_id(id) + users = Users.get_users_by_group_id(id, db=db) return users except Exception as e: log.exception(f"Error adding users to group {id}: {e}") @@ -143,14 +159,17 @@ async def get_users_in_group(id: str, user=Depends(get_admin_user)): @router.post("/id/{id}/update", response_model=Optional[GroupResponse]) async def update_group_by_id( - id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user) + id: str, + form_data: GroupUpdateForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): try: - group = Groups.update_group_by_id(id, form_data) + group = Groups.update_group_by_id(id, form_data, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -172,17 +191,20 @@ async def update_group_by_id( @router.post("/id/{id}/users/add", response_model=Optional[GroupResponse]) async def add_user_to_group( - id: str, form_data: UserIdsForm, user=Depends(get_admin_user) + id: str, + form_data: UserIdsForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): try: if form_data.user_ids: - form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids) + form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids, db=db) - group = Groups.add_users_to_group(id, form_data.user_ids) + group = Groups.add_users_to_group(id, form_data.user_ids, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -199,14 +221,17 @@ async def add_user_to_group( @router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse]) async def remove_users_from_group( - id: str, form_data: UserIdsForm, user=Depends(get_admin_user) + id: str, + form_data: UserIdsForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): try: - group = Groups.remove_users_from_group(id, form_data.user_ids) + group = Groups.remove_users_from_group(id, form_data.user_ids, db=db) if group: return GroupResponse( **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), + member_count=Groups.get_group_member_count_by_id(group.id, db=db), ) else: raise HTTPException( @@ -227,9 +252,11 @@ async def remove_users_from_group( @router.delete("/id/{id}/delete", response_model=bool) -async def delete_group_by_id(id: str, user=Depends(get_admin_user)): +async def delete_group_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): try: - result = Groups.delete_group_by_id(id) + result = Groups.delete_group_by_id(id, db=db) if result: return result else: diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 8aabf0f73bf..0fc6930b81f 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -16,10 +16,15 @@ from open_webui.config import CACHE_DIR from open_webui.constants import ERROR_MESSAGES -from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS +from open_webui.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.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_permission from open_webui.utils.headers import include_user_info_headers +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session from open_webui.utils.images.comfyui import ( ComfyUICreateImageForm, ComfyUIEditImageForm, @@ -31,7 +36,6 @@ from pydantic import BaseModel log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["IMAGES"]) IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -196,12 +200,12 @@ async def update_config( set_image_model(request, form_data.IMAGE_GENERATION_MODEL) if ( 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( status_code=400, detail=ERROR_MESSAGES.INCORRECT_FORMAT( - " (auto is only allowed with gpt-image-1)." + " (auto is only allowed with gpt-image models)." ), ) @@ -380,6 +384,7 @@ def get_models(request: Request, user=Depends(get_verified_user)): {"id": "dall-e-2", "name": "DALL·E 2"}, {"id": "dall-e-3", "name": "DALL·E 3"}, {"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": return [ @@ -459,6 +464,7 @@ class CreateImageForm(BaseModel): prompt: str size: Optional[str] = None n: int = 1 + steps: Optional[int] = None negative_prompt: Optional[str] = None @@ -494,7 +500,7 @@ def get_image_data(data: str, headers=None): return None, None -def upload_image(request, image_data, content_type, metadata, user): +def upload_image(request, image_data, content_type, metadata, user, db=None): image_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(image_data), @@ -510,15 +516,51 @@ def upload_image(request, image_data, content_type, metadata, user): process=False, 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, + db=db, + ) + url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) - return url + return file_item, url @router.post("/generations") +async def generate_images( + request: Request, form_data: CreateImageForm, user=Depends(get_verified_user) +): + if not request.app.state.config.ENABLE_IMAGE_GENERATION: + raise HTTPException( + status_code=403, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if user.role != "admin" and not has_permission( + user.id, "features.image_generation", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=403, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return await image_generations(request, form_data, user=user) + + async def image_generations( request: Request, form_data: CreateImageForm, - user=Depends(get_verified_user), + metadata: Optional[dict] = None, + user=None, ): # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an @@ -535,6 +577,9 @@ async def image_generations( size = form_data.size width, height = tuple(map(int, size.split("x"))) + + metadata = metadata or {} + model = get_image_model(request) r = None @@ -564,7 +609,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"} ), **( @@ -593,7 +640,9 @@ async def image_generations( else: 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}) return images @@ -643,7 +692,9 @@ async def image_generations( image_data, content_type = get_image_data( 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}) elif model.endswith(":generateContent"): for image in res["candidates"]: @@ -652,8 +703,12 @@ async def image_generations( image_data, content_type = get_image_data( part["inlineData"]["data"] ) - 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}) @@ -667,8 +722,15 @@ async def image_generations( "n": form_data.n, } - if request.app.state.config.IMAGE_STEPS is not None: - data["steps"] = request.app.state.config.IMAGE_STEPS + if ( + request.app.state.config.IMAGE_STEPS is not None + or form_data.steps is not None + ): + data["steps"] = ( + form_data.steps + if form_data.steps is not None + else request.app.state.config.IMAGE_STEPS + ) if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt @@ -703,11 +765,11 @@ async def image_generations( } image_data, content_type = get_image_data(image["url"], headers) - url = upload_image( + _, url = upload_image( request, image_data, content_type, - form_data.model_dump(exclude_none=True), + {**form_data.model_dump(exclude_none=True), **metadata}, user, ) images.append({"url": url}) @@ -726,8 +788,15 @@ async def image_generations( "height": height, } - if request.app.state.config.IMAGE_STEPS is not None: - data["steps"] = request.app.state.config.IMAGE_STEPS + if ( + request.app.state.config.IMAGE_STEPS is not None + or form_data.steps is not None + ): + data["steps"] = ( + form_data.steps + if form_data.steps is not None + else request.app.state.config.IMAGE_STEPS + ) if form_data.negative_prompt is not None: data["negative_prompt"] = form_data.negative_prompt @@ -750,11 +819,11 @@ async def image_generations( for image in res["images"]: image_data, content_type = get_image_data(image) - url = upload_image( + _, url = upload_image( request, image_data, content_type, - {**data, "info": res["info"]}, + {**data, "info": res["info"], **metadata}, user, ) images.append({"url": url}) @@ -781,10 +850,13 @@ class EditImageForm(BaseModel): async def image_edits( request: Request, form_data: EditImageForm, + metadata: Optional[dict] = None, user=Depends(get_verified_user), ): size = None width, height = None, None + metadata = metadata or {} + if ( request.app.state.config.IMAGE_EDIT_SIZE and "x" in request.app.state.config.IMAGE_EDIT_SIZE @@ -805,6 +877,9 @@ async def image_edits( try: async def load_url_image(data): + if data.startswith("data:"): + return data + if data.startswith("http://") or data.startswith("https://"): r = await asyncio.to_thread(requests.get, data) r.raise_for_status() @@ -812,10 +887,14 @@ async def load_url_image(data): image_data = base64.b64encode(r.content).decode("utf-8") return f"data:{r.headers['content-type']};base64,{image_data}" - elif data.startswith("/api/v1/files"): - file_id = data.split("/api/v1/files/")[1].split("/content")[0] - file_response = await get_file_content_by_id(file_id, user) + else: + file_id = None + if data.startswith("/api/v1/files"): + file_id = data.split("/api/v1/files/")[1].split("/content")[0] + else: + file_id = data + file_response = await get_file_content_by_id(file_id, user) if isinstance(file_response, FileResponse): file_path = file_response.path @@ -825,7 +904,6 @@ async def load_url_image(data): mime_type, _ = mimetypes.guess_type(file_path) return f"data:{mime_type};base64,{image_data}" - return data # Load image(s) from URL(s) if necessary @@ -867,7 +945,7 @@ def get_image_file_item(base64_string, param_name="image"): **({"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"} ), } @@ -902,7 +980,9 @@ def get_image_file_item(base64_string, param_name="image"): else: 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}) return images @@ -955,8 +1035,12 @@ def get_image_file_item(base64_string, param_name="image"): image_data, content_type = get_image_data( part["inlineData"]["data"] ) - 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}) @@ -1033,11 +1117,11 @@ def get_image_file_item(base64_string, param_name="image"): } image_data, content_type = get_image_data(image_url, headers) - url = upload_image( + _, url = upload_image( request, image_data, content_type, - form_data.model_dump(exclude_none=True), + {**form_data.model_dump(exclude_none=True), **metadata}, user, ) images.append({"url": url}) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 3bfc961ac32..9fc30424ca1 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1,10 +1,17 @@ from typing import List, Optional from pydantic import BaseModel from fastapi import APIRouter, Depends, HTTPException, status, Request, Query +from fastapi.responses import StreamingResponse from fastapi.concurrency import run_in_threadpool import logging +import io +import zipfile +from sqlalchemy.orm import Session +from open_webui.internal.db import get_session +from open_webui.models.groups import Groups from open_webui.models.knowledge import ( + KnowledgeFileListResponse, Knowledges, KnowledgeForm, KnowledgeResponse, @@ -21,17 +28,15 @@ from open_webui.storage.provider import Storage from open_webui.constants import ERROR_MESSAGES -from open_webui.utils.auth import get_verified_user +from open_webui.utils.auth import get_verified_user, get_admin_user from open_webui.utils.access_control import has_access, has_permission -from open_webui.env import SRC_LOG_LEVELS from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.models.models import Models, ModelForm log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -39,41 +44,177 @@ # 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") +############################ +# Knowledge Base Embedding +############################ - return [ - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), - ) - for knowledge_base in knowledge_bases - ] +KNOWLEDGE_BASES_COLLECTION = "knowledge-bases" -@router.get("/list", response_model=list[KnowledgeUserResponse]) -async def get_knowledge_list(user=Depends(get_verified_user)): - # 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") +async def embed_knowledge_base_metadata( + request: Request, + knowledge_base_id: str, + name: str, + description: str, +) -> bool: + """Generate and store embedding for knowledge base.""" + try: + content = f"{name}\n\n{description}" if description else name + embedding = await request.app.state.EMBEDDING_FUNCTION(content) + VECTOR_DB_CLIENT.upsert( + collection_name=KNOWLEDGE_BASES_COLLECTION, + items=[ + { + "id": knowledge_base_id, + "text": content, + "vector": embedding, + "metadata": { + "knowledge_base_id": knowledge_base_id, + }, + } + ], + ) + return True + except Exception as e: + log.error(f"Failed to embed knowledge base {knowledge_base_id}: {e}") + return False + - return [ - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), +def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: + """Remove knowledge base embedding.""" + try: + VECTOR_DB_CLIENT.delete( + collection_name=KNOWLEDGE_BASES_COLLECTION, + ids=[knowledge_base_id], ) - for knowledge_base in knowledge_bases - ] + return True + except Exception as e: + log.debug(f"Failed to remove embedding for {knowledge_base_id}: {e}") + return False + + +class KnowledgeAccessResponse(KnowledgeUserResponse): + write_access: Optional[bool] = False + + +class KnowledgeAccessListResponse(BaseModel): + items: list[KnowledgeAccessResponse] + total: int + + +@router.get("/", response_model=KnowledgeAccessListResponse) +async def get_knowledge_bases( + page: Optional[int] = 1, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + 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, db=db) + 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, db=db + ) + + return KnowledgeAccessListResponse( + items=[ + KnowledgeAccessResponse( + **knowledge_base.model_dump(), + write_access=( + user.id == knowledge_base.user_id + or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or has_access( + user.id, "write", knowledge_base.access_control, db=db + ) + ), + ) + 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), + db: Session = Depends(get_session), +): + 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, db=db) + 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, db=db + ) + + return KnowledgeAccessListResponse( + items=[ + KnowledgeAccessResponse( + **knowledge_base.model_dump(), + write_access=( + user.id == knowledge_base.user_id + or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or has_access( + user.id, "write", knowledge_base.access_control, db=db + ) + ), + ) + 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), + db: Session = Depends(get_session), +): + 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, db=db) + 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, db=db + ) ############################ @@ -83,10 +224,13 @@ async def get_knowledge_list(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[KnowledgeResponse]) async def create_new_knowledge( - request: Request, form_data: KnowledgeForm, user=Depends(get_verified_user) + request: Request, + form_data: KnowledgeForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if user.role != "admin" and not has_permission( - user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS + user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -101,13 +245,21 @@ async def create_new_knowledge( user.id, "sharing.public_knowledge", request.app.state.config.USER_PERMISSIONS, + db=db, ) ): form_data.access_control = {} - knowledge = Knowledges.insert_new_knowledge(user.id, form_data) + knowledge = Knowledges.insert_new_knowledge(user.id, form_data, db=db) if knowledge: + # Embed knowledge base for semantic search + await embed_knowledge_base_metadata( + request, + knowledge.id, + knowledge.name, + knowledge.description, + ) return knowledge else: raise HTTPException( @@ -122,20 +274,24 @@ async def create_new_knowledge( @router.post("/reindex", response_model=bool) -async def reindex_knowledge_files(request: Request, user=Depends(get_verified_user)): +async def reindex_knowledge_files( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if user.role != "admin": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - knowledge_bases = Knowledges.get_knowledge_bases() + knowledge_bases = Knowledges.get_knowledge_bases(db=db) log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") for knowledge_base in knowledge_bases: try: - files = Knowledges.get_files_by_id(knowledge_base.id) + files = Knowledges.get_files_by_id(knowledge_base.id, db=db) try: if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): VECTOR_DB_CLIENT.delete_collection( @@ -155,6 +311,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us file_id=file.id, collection_name=knowledge_base.id ), user=user, + db=db, ) except Exception as e: log.error( @@ -179,33 +336,69 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us return True +############################ +# ReindexKnowledgeBases +############################ + + +@router.post("/metadata/reindex", response_model=dict) +async def reindex_knowledge_base_metadata_embeddings( + request: Request, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): + """Batch embed all existing knowledge bases. Admin only.""" + knowledge_bases = Knowledges.get_knowledge_bases(db=db) + log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases") + + success_count = 0 + for kb in knowledge_bases: + if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description): + success_count += 1 + + log.info(f"Embedding reindex complete: {success_count}/{len(knowledge_bases)}") + return {"total": len(knowledge_bases), "success": success_count} + + ############################ # GetKnowledgeById ############################ class KnowledgeFilesResponse(KnowledgeResponse): - files: list[FileMetadataResponse] + files: Optional[list[FileMetadataResponse]] = None + write_access: Optional[bool] = False @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) -async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): - knowledge = Knowledges.get_knowledge_by_id(id=id) +async def get_knowledge_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if knowledge: if ( user.role == "admin" or knowledge.user_id == user.id - or has_access(user.id, "read", knowledge.access_control) + or has_access(user.id, "read", knowledge.access_control, db=db) ): return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + write_access=( + user.id == knowledge.user_id + or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or has_access(user.id, "write", knowledge.access_control, db=db) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) else: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) @@ -221,8 +414,9 @@ async def update_knowledge_by_id( id: str, form_data: KnowledgeForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -231,7 +425,7 @@ async def update_knowledge_by_id( # Is the user the original creator, in a group with write access, or an admin if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -247,15 +441,23 @@ async def update_knowledge_by_id( user.id, "sharing.public_knowledge", request.app.state.config.USER_PERMISSIONS, + db=db, ) ): form_data.access_control = {} - knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) + knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data, db=db) if knowledge: + # Re-embed knowledge base for semantic search + await embed_knowledge_base_metadata( + request, + knowledge.id, + knowledge.name, + knowledge.description, + ) return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -264,6 +466,60 @@ 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), + db: Session = Depends(get_session), +): + + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + 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, db=db) + ): + 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, db=db + ) + + ############################ # AddFileToKnowledge ############################ @@ -279,8 +535,9 @@ def add_file_to_knowledge_by_id( id: str, form_data: KnowledgeFileIdForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -289,7 +546,7 @@ def add_file_to_knowledge_by_id( if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -297,7 +554,7 @@ def add_file_to_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id) + file = Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -309,17 +566,18 @@ def add_file_to_knowledge_by_id( 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 try: process_file( request, ProcessFileForm(file_id=form_data.file_id, collection_name=id), user=user, + db=db, + ) + + # 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, db=db ) except Exception as e: log.debug(e) @@ -331,7 +589,7 @@ def add_file_to_knowledge_by_id( if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -346,8 +604,9 @@ def update_file_from_knowledge_by_id( id: str, form_data: KnowledgeFileIdForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -356,7 +615,7 @@ def update_file_from_knowledge_by_id( if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): @@ -365,7 +624,7 @@ def update_file_from_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id) + file = Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -383,6 +642,7 @@ def update_file_from_knowledge_by_id( request, ProcessFileForm(file_id=form_data.file_id, collection_name=id), user=user, + db=db, ) except Exception as e: raise HTTPException( @@ -393,7 +653,7 @@ def update_file_from_knowledge_by_id( if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -413,8 +673,9 @@ def remove_file_from_knowledge_by_id( form_data: KnowledgeFileIdForm, delete_file: bool = Query(True), user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -423,7 +684,7 @@ def remove_file_from_knowledge_by_id( if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -431,7 +692,7 @@ def remove_file_from_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - file = Files.get_file_by_id(form_data.file_id) + file = Files.get_file_by_id(form_data.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -439,7 +700,7 @@ def remove_file_from_knowledge_by_id( ) Knowledges.remove_file_from_knowledge_by_id( - knowledge_id=id, file_id=form_data.file_id + knowledge_id=id, file_id=form_data.file_id, db=db ) # Remove content from the vector database @@ -468,12 +729,12 @@ def remove_file_from_knowledge_by_id( pass # Delete file from database - Files.delete_file_by_id(form_data.file_id) + Files.delete_file_by_id(form_data.file_id, db=db) if knowledge: return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), ) else: raise HTTPException( @@ -488,8 +749,10 @@ def remove_file_from_knowledge_by_id( @router.delete("/{id}/delete", response_model=bool) -async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): - knowledge = Knowledges.get_knowledge_by_id(id=id) +async def delete_knowledge_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -498,7 +761,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -509,7 +772,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})") # Get all models - models = Models.get_all_models() + models = Models.get_all_models(db=db) log.info(f"Found {len(models)} models to check for knowledge base {id}") # Update models that reference this knowledge base @@ -533,7 +796,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): access_control=model.access_control, is_active=model.is_active, ) - Models.update_model_by_id(model.id, model_form) + Models.update_model_by_id(model.id, model_form, db=db) # Clean up vector DB try: @@ -541,7 +804,11 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): except Exception as e: log.debug(e) pass - result = Knowledges.delete_knowledge_by_id(id=id) + + # Remove knowledge base embedding + remove_knowledge_base_metadata_embedding(id) + + result = Knowledges.delete_knowledge_by_id(id=id, db=db) return result @@ -551,8 +818,10 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): @router.post("/{id}/reset", response_model=Optional[KnowledgeResponse]) -async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): - knowledge = Knowledges.get_knowledge_by_id(id=id) +async def reset_knowledge_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -561,7 +830,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -575,7 +844,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): log.debug(e) pass - knowledge = Knowledges.reset_knowledge_by_id(id=id) + knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db) return knowledge @@ -590,11 +859,12 @@ async def add_files_to_knowledge_batch( id: str, form_data: list[KnowledgeFileIdForm], user=Depends(get_verified_user), + db: Session = Depends(get_session), ): """ Add multiple files to a knowledge base """ - knowledge = Knowledges.get_knowledge_by_id(id=id) + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -603,7 +873,7 @@ async def add_files_to_knowledge_batch( if ( knowledge.user_id != user.id - and not has_access(user.id, "write", knowledge.access_control) + and not has_access(user.id, "write", knowledge.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -615,7 +885,7 @@ async def add_files_to_knowledge_batch( log.info(f"files/batch/add - {len(form_data)} files") files: List[FileModel] = [] for form in form_data: - file = Files.get_file_by_id(form.file_id) + file = Files.get_file_by_id(form.file_id, db=db) if not file: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -629,6 +899,7 @@ async def add_files_to_knowledge_batch( request=request, form_data=BatchProcessFilesForm(files=files, collection_name=id), user=user, + db=db, ) except Exception as e: log.error( @@ -640,7 +911,7 @@ async def add_files_to_knowledge_batch( successful_file_ids = [r.file_id for r in result.results if r.status == "completed"] for file_id in successful_file_ids: Knowledges.add_file_to_knowledge_by_id( - knowledge_id=id, file_id=file_id, user_id=user.id + knowledge_id=id, file_id=file_id, user_id=user.id, db=db ) # If there were any errors, include them in the response @@ -648,7 +919,7 @@ async def add_files_to_knowledge_batch( error_details = [f"{err.file_id}: {err.error}" for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), warnings={ "message": "Some files failed to process", "errors": error_details, @@ -657,5 +928,53 @@ async def add_files_to_knowledge_batch( return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), + ) + + +############################ +# ExportKnowledgeById +############################ + + +@router.get("/{id}/export") +async def export_knowledge_by_id( + id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + """ + Export a knowledge base as a zip file containing .txt files. + Admin only. + """ + + knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) + if not knowledge: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + files = Knowledges.get_files_by_id(id, db=db) + + # Create zip file in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for file in files: + content = file.data.get("content", "") if file.data else "" + if content: + # Use original filename with .txt extension + filename = file.filename + if not filename.endswith(".txt"): + filename = f"{filename}.txt" + zf.writestr(filename, content) + + zip_buffer.seek(0) + + # Sanitize knowledge name for filename + safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in knowledge.name) + zip_filename = f"{safe_name}.zip" + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={zip_filename}"}, ) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 8e45a14dfb7..e0ba36c76fd 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel import logging import asyncio @@ -7,11 +7,13 @@ from open_webui.models.memories import Memories, MemoryModel from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.utils.auth import get_verified_user -from open_webui.env import SRC_LOG_LEVELS +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session +from open_webui.utils.access_control import has_permission +from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -27,8 +29,26 @@ async def get_embeddings(request: Request): @router.get("/", response_model=list[MemoryModel]) -async def get_memories(user=Depends(get_verified_user)): - return Memories.get_memories_by_user_id(user.id) +async def get_memories( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + return Memories.get_memories_by_user_id(user.id, db=db) ############################ @@ -49,8 +69,23 @@ async def add_memory( request: Request, form_data: AddMemoryForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - memory = Memories.insert_new_memory(user.id, form_data.content) + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + memory = Memories.insert_new_memory(user.id, form_data.content, db=db) vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) @@ -81,9 +116,26 @@ class QueryMemoryForm(BaseModel): @router.post("/query") async def query_memory( - request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) + request: Request, + form_data: QueryMemoryForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - memories = Memories.get_memories_by_user_id(user.id) + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + memories = Memories.get_memories_by_user_id(user.id, db=db) if not memories: raise HTTPException(status_code=404, detail="No memories found for user") @@ -103,11 +155,27 @@ async def query_memory( ############################ @router.post("/reset", response_model=bool) async def reset_memory_from_vector_db( - request: Request, user=Depends(get_verified_user) + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}") - memories = Memories.get_memories_by_user_id(user.id) + memories = Memories.get_memories_by_user_id(user.id, db=db) # Generate vectors in parallel vectors = await asyncio.gather( @@ -142,8 +210,26 @@ async def reset_memory_from_vector_db( @router.delete("/delete/user", response_model=bool) -async def delete_memory_by_user_id(user=Depends(get_verified_user)): - result = Memories.delete_memories_by_user_id(user.id) +async def delete_memory_by_user_id( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + result = Memories.delete_memories_by_user_id(user.id, db=db) if result: try: @@ -166,9 +252,24 @@ async def update_memory_by_id( request: Request, form_data: MemoryUpdateModel, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + memory = Memories.update_memory_by_id_and_user_id( - memory_id, user.id, form_data.content + memory_id, user.id, form_data.content, db=db ) if memory is None: raise HTTPException(status_code=404, detail="Memory not found") @@ -200,8 +301,27 @@ async def update_memory_by_id( @router.delete("/{memory_id}", response_model=bool) -async def delete_memory_by_id(memory_id: str, user=Depends(get_verified_user)): - result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) +async def delete_memory_by_id( + memory_id: str, + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if not request.app.state.config.ENABLE_MEMORIES: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not has_permission( + user.id, "features.memories", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) if result: VECTOR_DB_CLIENT.delete( diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index df5a7377dcc..a1f642bbce5 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -11,6 +11,8 @@ ModelModel, ModelResponse, ModelListResponse, + ModelAccessListResponse, + ModelAccessResponse, Models, ) @@ -30,6 +32,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session log = logging.getLogger(__name__) @@ -49,7 +53,7 @@ def is_valid_model_id(model_id: str) -> bool: @router.get( - "/list", response_model=ModelListResponse + "/list", response_model=ModelAccessListResponse ) # do NOT use "/" as path, conflicts with main.py async def get_models( query: Optional[str] = None, @@ -59,6 +63,7 @@ async def get_models( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): limit = PAGE_ITEM_COUNT @@ -79,13 +84,27 @@ async def get_models( filter["direction"] = direction if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id) + groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] filter["user_id"] = user.id - return Models.search_models(user.id, filter=filter, skip=skip, limit=limit) + result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) + return ModelAccessListResponse( + items=[ + ModelAccessResponse( + **model.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == model.user_id + or has_access(user.id, "write", model.access_control, db=db) + ), + ) + for model in result.items + ], + total=result.total, + ) ########################### @@ -94,8 +113,10 @@ async def get_models( @router.get("/base", response_model=list[ModelResponse]) -async def get_base_models(user=Depends(get_admin_user)): - return Models.get_base_models() +async def get_base_models( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + return Models.get_base_models(db=db) ########################### @@ -104,11 +125,13 @@ async def get_base_models(user=Depends(get_admin_user)): @router.get("/tags", response_model=list[str]) -async def get_model_tags(user=Depends(get_verified_user)): +async def get_model_tags( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - models = Models.get_models() + models = Models.get_models(db=db) else: - models = Models.get_models_by_user_id(user.id) + models = Models.get_models_by_user_id(user.id, db=db) tags_set = set() for model in models: @@ -132,16 +155,17 @@ async def create_new_model( request: Request, form_data: ModelForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if user.role != "admin" and not has_permission( - user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS + user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - model = Models.get_model_by_id(form_data.id) + model = Models.get_model_by_id(form_data.id, db=db) if model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -155,7 +179,7 @@ async def create_new_model( ) else: - model = Models.insert_new_model(form_data, user.id) + model = Models.insert_new_model(form_data, user.id, db=db) if model: return model else: @@ -171,9 +195,16 @@ async def create_new_model( @router.get("/export", response_model=list[ModelModel]) -async def export_models(request: Request, user=Depends(get_verified_user)): +async def export_models( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if user.role != "admin" and not has_permission( - user.id, "workspace.models_export", request.app.state.config.USER_PERMISSIONS + user.id, + "workspace.models_export", + request.app.state.config.USER_PERMISSIONS, + db=db, ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -181,9 +212,9 @@ async def export_models(request: Request, user=Depends(get_verified_user)): ) if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - return Models.get_models() + return Models.get_models(db=db) else: - return Models.get_models_by_user_id(user.id) + return Models.get_models_by_user_id(user.id, db=db) ############################ @@ -200,9 +231,13 @@ async def import_models( request: Request, user=Depends(get_verified_user), form_data: ModelsImportForm = (...), + db: Session = Depends(get_session), ): if user.role != "admin" and not has_permission( - user.id, "workspace.models_import", request.app.state.config.USER_PERMISSIONS + user.id, + "workspace.models_import", + request.app.state.config.USER_PERMISSIONS, + db=db, ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -216,7 +251,7 @@ async def import_models( model_id = model_data.get("id") if model_id and is_valid_model_id(model_id): - existing_model = Models.get_model_by_id(model_id) + existing_model = Models.get_model_by_id(model_id, db=db) if existing_model: # Update existing model model_data["meta"] = model_data.get("meta", {}) @@ -225,13 +260,15 @@ async def import_models( updated_model = ModelForm( **{**existing_model.model_dump(), **model_data} ) - Models.update_model_by_id(model_id, updated_model) + Models.update_model_by_id(model_id, updated_model, db=db) else: # Insert new model model_data["meta"] = model_data.get("meta", {}) model_data["params"] = model_data.get("params", {}) new_model = ModelForm(**model_data) - Models.insert_new_model(user_id=user.id, form_data=new_model) + Models.insert_new_model( + user_id=user.id, form_data=new_model, db=db + ) return True else: raise HTTPException(status_code=400, detail="Invalid JSON format") @@ -251,9 +288,12 @@ class SyncModelsForm(BaseModel): @router.post("/sync", response_model=list[ModelModel]) async def sync_models( - request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user) + request: Request, + form_data: SyncModelsForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), ): - return Models.sync_models(user.id, form_data.models) + return Models.sync_models(user.id, form_data.models, db=db) ########################### @@ -266,19 +306,33 @@ class ModelIdForm(BaseModel): # Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id -@router.get("/model", response_model=Optional[ModelResponse]) -async def get_model_by_id(id: str, user=Depends(get_verified_user)): - model = Models.get_model_by_id(id) +@router.get("/model", response_model=Optional[ModelAccessResponse]) +async def get_model_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + model = Models.get_model_by_id(id, db=db) if model: if ( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or model.user_id == user.id - or has_access(user.id, "read", model.access_control) + or has_access(user.id, "read", model.access_control, db=db) ): - return model + return ModelAccessResponse( + **model.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == model.user_id + or has_access(user.id, "write", model.access_control, db=db) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) else: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) @@ -289,9 +343,14 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)): @router.get("/model/profile/image") -async def get_model_profile_image(id: str, user=Depends(get_verified_user)): - model = Models.get_model_by_id(id) +def get_model_profile_image( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + model = Models.get_model_by_id(id, db=db) + if model: + etag = f'"{model.updated_at}"' if model.updated_at else None + if model.meta.profile_image_url: if model.meta.profile_image_url.startswith("http"): return Response( @@ -303,11 +362,16 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)): header, base64_data = model.meta.profile_image_url.split(",", 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) + media_type = header.split(";")[0].lstrip("data:") + + headers = {"Content-Disposition": "inline"} + if etag: + headers["ETag"] = etag return StreamingResponse( image_buffer, - media_type="image/png", - headers={"Content-Disposition": "inline; filename=image.png"}, + media_type=media_type, + headers=headers, ) except Exception as e: pass @@ -323,15 +387,17 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)): @router.post("/model/toggle", response_model=Optional[ModelResponse]) -async def toggle_model_by_id(id: str, user=Depends(get_verified_user)): - model = Models.get_model_by_id(id) +async def toggle_model_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + model = Models.get_model_by_id(id, db=db) if model: if ( user.role == "admin" or model.user_id == user.id - or has_access(user.id, "write", model.access_control) + or has_access(user.id, "write", model.access_control, db=db) ): - model = Models.toggle_model_by_id(id) + model = Models.toggle_model_by_id(id, db=db) if model: return model @@ -361,8 +427,9 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user)): async def update_model_by_id( form_data: ModelForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - model = Models.get_model_by_id(form_data.id) + model = Models.get_model_by_id(form_data.id, db=db) if not model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -371,7 +438,7 @@ async def update_model_by_id( if ( model.user_id != user.id - and not has_access(user.id, "write", model.access_control) + and not has_access(user.id, "write", model.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -379,7 +446,9 @@ async def update_model_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump())) + model = Models.update_model_by_id( + form_data.id, ModelForm(**form_data.model_dump()), db=db + ) return model @@ -389,8 +458,12 @@ async def update_model_by_id( @router.post("/model/delete", response_model=bool) -async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_user)): - model = Models.get_model_by_id(form_data.id) +async def delete_model_by_id( + form_data: ModelIdForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + model = Models.get_model_by_id(form_data.id, db=db) if not model: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -400,18 +473,20 @@ async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_u if ( user.role != "admin" and model.user_id != user.id - and not has_access(user.id, "write", model.access_control) + and not has_access(user.id, "write", model.access_control, db=db) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - result = Models.delete_model_by_id(form_data.id) + result = Models.delete_model_by_id(form_data.id, db=db) return result @router.delete("/delete/all", response_model=bool) -async def delete_all_models(user=Depends(get_admin_user)): - result = Models.delete_all_models() +async def delete_all_models( + user=Depends(get_admin_user), db: Session = Depends(get_session) +): + result = Models.delete_all_models(db=db) return result diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 3858c4670f2..56730e2b6af 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -8,20 +8,30 @@ 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.notes import Notes, NoteModel, NoteForm, NoteUserResponse - -from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT +from open_webui.models.notes import ( + NoteListResponse, + Notes, + NoteModel, + NoteForm, + NoteUserResponse, +) + +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.env import SRC_LOG_LEVELS from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -30,43 +40,69 @@ ############################ -@router.get("/", response_model=list[NoteUserResponse]) -async def get_notes(request: Request, user=Depends(get_verified_user)): +class NoteItemResponse(BaseModel): + id: str + title: str + data: Optional[dict] + updated_at: int + created_at: int + user: Optional[UserResponse] = None + +@router.get("/", response_model=list[NoteItemResponse]) +async def get_notes( + request: Request, + page: Optional[int] = None, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - notes = [ + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + + notes = Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit, db=db) + if not notes: + return [] + + user_ids = list(set(note.user_id for note in notes)) + users = {user.id: user for user in Users.get_users_by_user_ids(user_ids, db=db)} + + return [ NoteUserResponse( **{ **note.model_dump(), - "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), + "user": UserResponse(**users[note.user_id].model_dump()), } ) - for note in Notes.get_notes_by_permission(user.id, "write") + for note in notes + if note.user_id in users ] - return notes - - -class NoteTitleIdResponse(BaseModel): - id: str - title: str - updated_at: int - created_at: int - -@router.get("/list", response_model=list[NoteTitleIdResponse]) -async def get_note_list( - request: Request, page: Optional[int] = None, user=Depends(get_verified_user) +@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), + db: Session = Depends(get_session), ): 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -79,14 +115,26 @@ async def get_note_list( limit = 60 skip = (page - 1) * limit - notes = [ - NoteTitleIdResponse(**note.model_dump()) - for note in Notes.get_notes_by_permission( - user.id, "write", skip=skip, limit=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, db=db) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id - return notes + return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) ############################ @@ -96,11 +144,13 @@ async def get_note_list( @router.post("/create", response_model=Optional[NoteModel]) 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), + db: Session = Depends(get_session), ): - 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -108,7 +158,7 @@ async def create_new_note( ) try: - note = Notes.insert_new_note(form_data, user.id) + note = Notes.insert_new_note(user.id, form_data, db=db) return note except Exception as e: log.exception(e) @@ -122,17 +172,26 @@ async def create_new_note( ############################ -@router.get("/{id}", response_model=Optional[NoteModel]) -async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): +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), + db: Session = Depends(get_session), +): 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id) + note = Notes.get_note_by_id(id, db=db) if not note: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -140,13 +199,29 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us if user.role != "admin" and ( user.id != note.user_id - and (not has_access(user.id, type="read", access_control=note.access_control)) + and ( + not has_access( + user.id, type="read", access_control=note.access_control, db=db + ) + ) ): raise HTTPException( 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, + db=db, + ) + ) + + return NoteResponse(**note.model_dump(), write_access=write_access) ############################ @@ -156,17 +231,21 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us @router.post("/{id}/update", response_model=Optional[NoteModel]) async def update_note_by_id( - request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: NoteForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id) + note = Notes.get_note_by_id(id, db=db) if not note: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -174,7 +253,9 @@ async def update_note_by_id( if user.role != "admin" and ( user.id != note.user_id - and not has_access(user.id, type="write", access_control=note.access_control) + and not has_access( + user.id, type="write", access_control=note.access_control, db=db + ) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -188,12 +269,13 @@ async def update_note_by_id( user.id, "sharing.public_notes", request.app.state.config.USER_PERMISSIONS, + db=db, ) ): form_data.access_control = {} try: - note = Notes.update_note_by_id(id, form_data) + note = Notes.update_note_by_id(id, form_data, db=db) await sio.emit( "note-events", note.model_dump(), @@ -214,16 +296,21 @@ async def update_note_by_id( @router.delete("/{id}/delete", response_model=bool) -async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): +async def delete_note_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): 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, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - note = Notes.get_note_by_id(id) + note = Notes.get_note_by_id(id, db=db) if not note: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND @@ -231,14 +318,16 @@ async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified if user.role != "admin" and ( user.id != note.user_id - and not has_access(user.id, type="write", access_control=note.access_control) + and not has_access( + user.id, type="write", access_control=note.access_control, db=db + ) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) try: - note = Notes.delete_note_by_id(id) + note = Notes.delete_note_by_id(id, db=db) return True except Exception as e: log.exception(e) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 7873efafce6..0af35de38f4 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -38,6 +38,9 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, ConfigDict, validator from starlette.background import BackgroundTask +from sqlalchemy.orm import Session + +from open_webui.internal.db import get_session from open_webui.models.models import Models @@ -58,7 +61,6 @@ ) from open_webui.env import ( ENV, - SRC_LOG_LEVELS, MODELS_CACHE_TTL, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, @@ -68,7 +70,6 @@ from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OLLAMA"]) ########################################## @@ -423,14 +424,14 @@ async def get_all_models(request: Request, user: UserModel = None): return models -async def get_filtered_models(models, user): +async def get_filtered_models(models, user, db=None): # Filter models based on user access control filtered_models = [] for model in models.get("models", []): - model_info = Models.get_model_by_id(model["model"]) + model_info = Models.get_model_by_id(model["model"], db=db) if model_info: if user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, type="read", access_control=model_info.access_control, db=db ): filtered_models.append(model) return filtered_models @@ -1255,6 +1256,8 @@ async def generate_chat_completion( url_idx: Optional[int] = None, user=Depends(get_verified_user), bypass_filter: Optional[bool] = False, + bypass_system_prompt: bool = False, + db: Session = Depends(get_session), ): if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True @@ -1276,11 +1279,16 @@ async def generate_chat_completion( del payload["metadata"] model_id = payload["model"] - model_info = Models.get_model_by_id(model_id) + model_info = Models.get_model_by_id(model_id, db=db) if model_info: 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() @@ -1288,14 +1296,18 @@ async def generate_chat_completion( system = params.pop("system", None) payload = apply_model_params_to_body_ollama(params, payload) - payload = apply_system_prompt_to_body(system, payload, metadata, user) + if not bypass_system_prompt: + payload = apply_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model if not bypass_filter and user.role == "user": if not ( user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, + type="read", + access_control=model_info.access_control, + db=db, ) ): raise HTTPException( @@ -1367,6 +1379,7 @@ async def generate_openai_completion( form_data: dict, url_idx: Optional[int] = None, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): metadata = form_data.pop("metadata", None) @@ -1387,7 +1400,7 @@ async def generate_openai_completion( if ":" not in model_id: model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) + model_info = Models.get_model_by_id(model_id, db=db) if model_info: if model_info.base_model_id: payload["model"] = model_info.base_model_id @@ -1401,7 +1414,10 @@ async def generate_openai_completion( if not ( user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, + type="read", + access_control=model_info.access_control, + db=db, ) ): raise HTTPException( @@ -1446,6 +1462,7 @@ async def generate_openai_chat_completion( form_data: dict, url_idx: Optional[int] = None, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): metadata = form_data.pop("metadata", None) @@ -1466,7 +1483,7 @@ async def generate_openai_chat_completion( if ":" not in model_id: model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) + model_info = Models.get_model_by_id(model_id, db=db) if model_info: if model_info.base_model_id: payload["model"] = model_info.base_model_id @@ -1484,7 +1501,10 @@ async def generate_openai_chat_completion( if not ( user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, + type="read", + access_control=model_info.access_control, + db=db, ) ): raise HTTPException( @@ -1527,6 +1547,7 @@ async def get_openai_models( request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): models = [] @@ -1579,10 +1600,13 @@ async def get_openai_models( # Filter models based on user access control filtered_models = [] for model in models: - model_info = Models.get_model_by_id(model["id"]) + model_info = Models.get_model_by_id(model["id"], db=db) if model_info: if user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, + type="read", + access_control=model_info.access_control, + db=db, ): filtered_models.append(model) models = filtered_models @@ -1649,11 +1673,10 @@ async def download_file_stream( if done: file.close() + hashed = calculate_sha256(file_path, chunk_size) with open(file_path, "rb") as file: chunk_size = 1024 * 1024 * 2 - hashed = calculate_sha256(file, chunk_size) - url = f"{ollama_url}/api/blobs/sha256:{hashed}" with requests.Session() as session: response = session.post(url, data=file, timeout=30) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index a74a59ca1f0..ec4ce2f4a8c 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -19,6 +19,9 @@ ) from pydantic import BaseModel from starlette.background import BackgroundTask +from sqlalchemy.orm import Session + +from open_webui.internal.db import get_session from open_webui.models.models import Models from open_webui.config import ( @@ -35,7 +38,6 @@ from open_webui.models.users import UserModel from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS from open_webui.utils.payload import ( @@ -53,7 +55,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OPENAI"]) ########################################## @@ -455,14 +456,14 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: return responses -async def get_filtered_models(models, user): +async def get_filtered_models(models, user, db=None): # Filter models based on user access control filtered_models = [] for model in models.get("data", []): - model_info = Models.get_model_by_id(model["id"]) + model_info = Models.get_model_by_id(model["id"], db=db) if model_info: if user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, type="read", access_control=model_info.access_control, db=db ): filtered_models.append(model) return filtered_models @@ -799,6 +800,8 @@ async def generate_chat_completion( form_data: dict, user=Depends(get_verified_user), bypass_filter: Optional[bool] = False, + bypass_system_prompt: bool = False, + db: Session = Depends(get_session), ): if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True @@ -809,13 +812,18 @@ async def generate_chat_completion( metadata = payload.pop("metadata", None) model_id = form_data.get("model") - model_info = Models.get_model_by_id(model_id) + model_info = Models.get_model_by_id(model_id, db=db) # Check model info and override the payload if model_info: if model_info.base_model_id: - payload["model"] = model_info.base_model_id - model_id = 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 + model_id = base_model_id params = model_info.params.model_dump() @@ -823,14 +831,18 @@ async def generate_chat_completion( system = params.pop("system", None) payload = apply_model_params_to_body_openai(params, payload) - payload = apply_system_prompt_to_body(system, payload, metadata, user) + if not bypass_system_prompt: + payload = apply_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model if not bypass_filter and user.role == "user": if not ( user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, + type="read", + access_control=model_info.access_control, + db=db, ) ): raise HTTPException( @@ -891,10 +903,11 @@ async def generate_chat_completion( del payload["max_tokens"] # Convert the modified body back to JSON - if "logit_bias" in payload: - payload["logit_bias"] = json.loads( - convert_logit_bias_input_to_json(payload["logit_bias"]) - ) + if "logit_bias" in payload and payload["logit_bias"]: + 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( request, url, key, api_config, metadata, user=user diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index f80ea91f848..7a42acffc1f 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -18,7 +18,7 @@ from starlette.responses import FileResponse 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.constants import ERROR_MESSAGES @@ -28,7 +28,6 @@ from open_webui.utils.auth import get_admin_user log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) ################################## diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 6a957f2547c..19d25685ad0 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -4,6 +4,7 @@ from open_webui.models.prompts import ( PromptForm, PromptUserResponse, + PromptAccessResponse, PromptModel, Prompts, ) @@ -11,6 +12,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session router = APIRouter() @@ -20,23 +23,37 @@ @router.get("/", response_model=list[PromptModel]) -async def get_prompts(user=Depends(get_verified_user)): +async def get_prompts( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - prompts = Prompts.get_prompts() + prompts = Prompts.get_prompts(db=db) else: - prompts = Prompts.get_prompts_by_user_id(user.id, "read") + prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) return prompts -@router.get("/list", response_model=list[PromptUserResponse]) -async def get_prompt_list(user=Depends(get_verified_user)): +@router.get("/list", response_model=list[PromptAccessResponse]) +async def get_prompt_list( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - prompts = Prompts.get_prompts() + prompts = Prompts.get_prompts(db=db) else: - prompts = Prompts.get_prompts_by_user_id(user.id, "write") - - return prompts + prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) + + return [ + PromptAccessResponse( + **prompt.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == prompt.user_id + or has_access(user.id, "write", prompt.access_control, db=db) + ), + ) + for prompt in prompts + ] ############################ @@ -46,16 +63,23 @@ async def get_prompt_list(user=Depends(get_verified_user)): @router.post("/create", response_model=Optional[PromptModel]) async def create_new_prompt( - request: Request, form_data: PromptForm, user=Depends(get_verified_user) + request: Request, + form_data: PromptForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if user.role != "admin" and not ( has_permission( - user.id, "workspace.prompts", request.app.state.config.USER_PERMISSIONS + user.id, + "workspace.prompts", + request.app.state.config.USER_PERMISSIONS, + db=db, ) or has_permission( user.id, "workspace.prompts_import", request.app.state.config.USER_PERMISSIONS, + db=db, ) ): raise HTTPException( @@ -63,9 +87,9 @@ async def create_new_prompt( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - prompt = Prompts.get_prompt_by_command(form_data.command) + prompt = Prompts.get_prompt_by_command(form_data.command, db=db) if prompt is None: - prompt = Prompts.insert_new_prompt(user.id, form_data) + prompt = Prompts.insert_new_prompt(user.id, form_data, db=db) if prompt: return prompt @@ -84,17 +108,26 @@ async def create_new_prompt( ############################ -@router.get("/command/{command}", response_model=Optional[PromptModel]) -async def get_prompt_by_command(command: str, user=Depends(get_verified_user)): - prompt = Prompts.get_prompt_by_command(f"/{command}") +@router.get("/command/{command}", response_model=Optional[PromptAccessResponse]) +async def get_prompt_by_command( + command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) if prompt: if ( user.role == "admin" or prompt.user_id == user.id - or has_access(user.id, "read", prompt.access_control) + or has_access(user.id, "read", prompt.access_control, db=db) ): - return prompt + return PromptAccessResponse( + **prompt.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == prompt.user_id + or has_access(user.id, "write", prompt.access_control, db=db) + ), + ) else: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -112,8 +145,9 @@ async def update_prompt_by_command( command: str, form_data: PromptForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - prompt = Prompts.get_prompt_by_command(f"/{command}") + prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) if not prompt: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -123,7 +157,7 @@ async def update_prompt_by_command( # Is the user the original creator, in a group with write access, or an admin if ( prompt.user_id != user.id - and not has_access(user.id, "write", prompt.access_control) + and not has_access(user.id, "write", prompt.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -131,7 +165,7 @@ async def update_prompt_by_command( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - prompt = Prompts.update_prompt_by_command(f"/{command}", form_data) + prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db) if prompt: return prompt else: @@ -147,8 +181,10 @@ async def update_prompt_by_command( @router.delete("/command/{command}/delete", response_model=bool) -async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)): - prompt = Prompts.get_prompt_by_command(f"/{command}") +async def delete_prompt_by_command( + command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + prompt = Prompts.get_prompt_by_command(f"/{command}", db=db) if not prompt: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -157,7 +193,7 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user) if ( prompt.user_id != user.id - and not has_access(user.id, "write", prompt.access_control) + and not has_access(user.id, "write", prompt.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -165,5 +201,5 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user) detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - result = Prompts.delete_prompt_by_command(f"/{command}") + result = Prompts.delete_prompt_by_command(f"/{command}", db=db) return result diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index b7ed9938954..db3f80f1499 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -14,6 +14,7 @@ from fastapi import ( Depends, FastAPI, + Query, File, Form, HTTPException, @@ -28,13 +29,18 @@ import tiktoken -from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter -from langchain_text_splitters import MarkdownHeaderTextSplitter +from langchain_text_splitters import ( + RecursiveCharacterTextSplitter, + TokenTextSplitter, + MarkdownHeaderTextSplitter, +) from langchain_core.documents import Document from open_webui.models.files import FileModel, FileUpdateForm, Files from open_webui.models.knowledge import Knowledges from open_webui.storage.provider import Storage +from open_webui.internal.db import get_session +from sqlalchemy.orm import Session from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT @@ -84,8 +90,10 @@ from open_webui.retrieval.vector.utils import filter_metadata from open_webui.utils.misc import ( calculate_sha256_string, + sanitize_text_for_db, ) from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_permission from open_webui.config import ( ENV, @@ -99,19 +107,18 @@ RAG_EMBEDDING_QUERY_PREFIX, ) from open_webui.env import ( - SRC_LOG_LEVELS, DEVICE_TYPE, DOCKER, SENTENCE_TRANSFORMERS_BACKEND, SENTENCE_TRANSFORMERS_MODEL_KWARGS, SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, + SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION, ) from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) ########################################## # @@ -148,9 +155,14 @@ def get_rf( reranking_model: Optional[str] = None, external_reranker_url: str = "", external_reranker_api_key: str = "", + external_reranker_timeout: str = "", auto_update: bool = RAG_RERANKING_MODEL_AUTO_UPDATE, ): 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 any(model in reranking_model for model in ["jinaai/jina-colbert-v2"]): try: @@ -173,12 +185,14 @@ def get_rf( url=external_reranker_url, api_key=external_reranker_api_key, model=reranking_model, + timeout=timeout_value, ) except Exception as e: log.error(f"ExternalReranking: {e}") raise Exception(ERROR_MESSAGES.DEFAULT(e)) else: import sentence_transformers + import torch try: rf = sentence_transformers.CrossEncoder( @@ -187,6 +201,11 @@ def get_rf( trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, + activation_fn=( + torch.nn.Sigmoid() + if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION + else None + ), ) except Exception as e: log.error(f"CrossEncoder: {e}") @@ -475,15 +494,19 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, "MINERU_API_URL": request.app.state.config.MINERU_API_URL, "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, + "MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT, "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, # Reranking settings "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, "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_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, + "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, + "CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET, "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, # File upload settings "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, @@ -507,6 +530,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, "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_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, @@ -520,12 +544,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, + "DDGS_BACKEND": request.app.state.config.DDGS_BACKEND, "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, "JINA_API_KEY": request.app.state.config.JINA_API_KEY, + "JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL, "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "EXA_API_KEY": request.app.state.config.EXA_API_KEY, @@ -536,11 +562,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, + "FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, @@ -565,6 +593,7 @@ class WebConfig(BaseModel): BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None SEARXNG_QUERY_URL: Optional[str] = None + SEARXNG_LANGUAGE: Optional[str] = None YACY_QUERY_URL: Optional[str] = None YACY_USERNAME: Optional[str] = None YACY_PASSWORD: Optional[str] = None @@ -578,12 +607,14 @@ class WebConfig(BaseModel): SERPSTACK_HTTPS: Optional[bool] = None SERPER_API_KEY: Optional[str] = None SERPLY_API_KEY: Optional[str] = None + DDGS_BACKEND: Optional[str] = None TAVILY_API_KEY: Optional[str] = None SEARCHAPI_API_KEY: Optional[str] = None SEARCHAPI_ENGINE: Optional[str] = None SERPAPI_API_KEY: Optional[str] = None SERPAPI_ENGINE: Optional[str] = None JINA_API_KEY: Optional[str] = None + JINA_API_BASE_URL: Optional[str] = None BING_SEARCH_V7_ENDPOINT: Optional[str] = None BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None EXA_API_KEY: Optional[str] = None @@ -594,11 +625,13 @@ class WebConfig(BaseModel): SOUGOU_API_SID: Optional[str] = None SOUGOU_API_SK: Optional[str] = None WEB_LOADER_ENGINE: Optional[str] = None + WEB_LOADER_TIMEOUT: Optional[str] = None ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None PLAYWRIGHT_WS_URL: Optional[str] = None PLAYWRIGHT_TIMEOUT: Optional[int] = None FIRECRAWL_API_KEY: Optional[str] = None FIRECRAWL_API_BASE_URL: Optional[str] = None + FIRECRAWL_TIMEOUT: Optional[str] = None TAVILY_EXTRACT_DEPTH: Optional[str] = None EXTERNAL_WEB_SEARCH_URL: Optional[str] = None EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None @@ -656,6 +689,7 @@ class ConfigForm(BaseModel): MINERU_API_MODE: Optional[str] = None MINERU_API_URL: Optional[str] = None MINERU_API_KEY: Optional[str] = None + MINERU_API_TIMEOUT: Optional[str] = None MINERU_PARAMS: Optional[dict] = None # Reranking settings @@ -663,10 +697,13 @@ class ConfigForm(BaseModel): RAG_RERANKING_ENGINE: Optional[str] = None RAG_EXTERNAL_RERANKER_URL: Optional[str] = None RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None + RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None # Chunking settings TEXT_SPLITTER: Optional[str] = None + ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: Optional[bool] = None CHUNK_SIZE: Optional[int] = None + CHUNK_MIN_SIZE_TARGET: Optional[int] = None CHUNK_OVERLAP: Optional[int] = None # File upload settings @@ -877,6 +914,11 @@ async def update_rag_config( if form_data.MINERU_API_KEY is not None 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 = ( form_data.MINERU_PARAMS if form_data.MINERU_PARAMS is not None @@ -914,6 +956,12 @@ async def update_rag_config( 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( f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}" ) @@ -934,6 +982,7 @@ async def update_rag_config( request.app.state.config.RAG_RERANKING_MODEL, request.app.state.config.RAG_EXTERNAL_RERANKER_URL, 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( @@ -962,6 +1011,11 @@ async def update_rag_config( if form_data.CHUNK_SIZE is not None else request.app.state.config.CHUNK_SIZE ) + request.app.state.config.CHUNK_MIN_SIZE_TARGET = ( + form_data.CHUNK_MIN_SIZE_TARGET + if form_data.CHUNK_MIN_SIZE_TARGET is not None + else request.app.state.config.CHUNK_MIN_SIZE_TARGET + ) request.app.state.config.CHUNK_OVERLAP = ( form_data.CHUNK_OVERLAP if form_data.CHUNK_OVERLAP is not None @@ -1024,6 +1078,7 @@ async def update_rag_config( 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_LANGUAGE = form_data.web.SEARXNG_LANGUAGE request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD @@ -1045,12 +1100,14 @@ async def update_rag_config( request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY + request.app.state.config.DDGS_BACKEND = form_data.web.DDGS_BACKEND request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY + request.app.state.config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL request.app.state.config.BING_SEARCH_V7_ENDPOINT = ( form_data.web.BING_SEARCH_V7_ENDPOINT ) @@ -1071,6 +1128,8 @@ async def update_rag_config( # Web loader settings 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 = ( form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION ) @@ -1080,6 +1139,7 @@ async def update_rag_config( request.app.state.config.FIRECRAWL_API_BASE_URL = ( form_data.web.FIRECRAWL_API_BASE_URL ) + request.app.state.config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT request.app.state.config.EXTERNAL_WEB_SEARCH_URL = ( form_data.web.EXTERNAL_WEB_SEARCH_URL ) @@ -1145,15 +1205,19 @@ async def update_rag_config( "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, "MINERU_API_URL": request.app.state.config.MINERU_API_URL, "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, + "MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT, "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, # Reranking settings "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, "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_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, + "CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET, + "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, # File upload settings "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, @@ -1177,6 +1241,7 @@ async def update_rag_config( "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, + "SEARXNG_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, @@ -1196,6 +1261,7 @@ async def update_rag_config( "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, "JINA_API_KEY": request.app.state.config.JINA_API_KEY, + "JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL, "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "EXA_API_KEY": request.app.state.config.EXA_API_KEY, @@ -1206,11 +1272,13 @@ async def update_rag_config( "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, + "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, + "FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, @@ -1230,6 +1298,85 @@ async def update_rag_config( #################################### +def can_merge_chunks(a: Document, b: Document) -> bool: + if a.metadata.get("source") != b.metadata.get("source"): + return False + + a_file_id = a.metadata.get("file_id") + b_file_id = b.metadata.get("file_id") + + if a_file_id is not None and b_file_id is not None: + return a_file_id == b_file_id + + return True + + +def merge_docs_to_target_size( + request: Request, + chunks: list[Document], +) -> list[Document]: + """ + Best-effort normalization of chunk sizes. + + Attempts to grow small chunks up to a desired minimum size, + without exceeding the maximum size or crossing source/file + boundaries. + """ + min_chunk_size_target = request.app.state.config.CHUNK_MIN_SIZE_TARGET + max_chunk_size = request.app.state.config.CHUNK_SIZE + + if min_chunk_size_target <= 0: + return chunks + + measure_chunk_size = len + if request.app.state.config.TEXT_SPLITTER == "token": + encoding = tiktoken.get_encoding( + str(request.app.state.config.TIKTOKEN_ENCODING_NAME) + ) + measure_chunk_size = lambda text: len(encoding.encode(text)) + + processed_chunks: list[Document] = [] + + current_chunk: Document | None = None + current_content: str = "" + + for next_chunk in chunks: + if current_chunk is None: + current_chunk = next_chunk + current_content = next_chunk.page_content + continue # First chunk initialization + + proposed_content = f"{current_content}\n\n{next_chunk.page_content}" + + can_merge = ( + can_merge_chunks(current_chunk, next_chunk) + and measure_chunk_size(current_content) < min_chunk_size_target + and measure_chunk_size(proposed_content) <= max_chunk_size + ) + + if can_merge: + current_content = proposed_content + else: + processed_chunks.append( + Document( + page_content=current_content, + metadata={**current_chunk.metadata}, + ) + ) + current_chunk = next_chunk + current_content = next_chunk.page_content + + if current_chunk is not None: + processed_chunks.append( + Document( + page_content=current_content, + metadata={**current_chunk.metadata}, + ) + ) + + return processed_chunks + + def save_docs_to_vector_db( request: Request, docs, @@ -1267,13 +1414,46 @@ def _get_docs_info(docs: list[Document]) -> str: filter={"hash": metadata["hash"]}, ) - if result is not None: + if result is not None and result.ids and len(result.ids) > 0: existing_doc_ids = result.ids[0] if existing_doc_ids: log.info(f"Document with hash {metadata['hash']} already exists") raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) if split: + if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: + log.info("Using markdown header text splitter") + # Define headers to split on - covering most common markdown header levels + markdown_splitter = MarkdownHeaderTextSplitter( + headers_to_split_on=[ + ("#", "Header 1"), + ("##", "Header 2"), + ("###", "Header 3"), + ("####", "Header 4"), + ("#####", "Header 5"), + ("######", "Header 6"), + ], + strip_headers=False, # Keep headers in content for context + ) + + split_docs = [] + for doc in docs: + split_docs.extend( + [ + Document( + page_content=split_chunk.page_content, + metadata={**doc.metadata}, + ) + for split_chunk in markdown_splitter.split_text( + doc.page_content + ) + ] + ) + + docs = split_docs + if request.app.state.config.CHUNK_MIN_SIZE_TARGET > 0: + docs = merge_docs_to_target_size(request, docs) + if request.app.state.config.TEXT_SPLITTER in ["", "character"]: text_splitter = RecursiveCharacterTextSplitter( chunk_size=request.app.state.config.CHUNK_SIZE, @@ -1294,59 +1474,13 @@ def _get_docs_info(docs: list[Document]) -> str: add_start_index=True, ) docs = text_splitter.split_documents(docs) - elif request.app.state.config.TEXT_SPLITTER == "markdown_header": - log.info("Using markdown header text splitter") - - # Define headers to split on - covering most common markdown header levels - headers_to_split_on = [ - ("#", "Header 1"), - ("##", "Header 2"), - ("###", "Header 3"), - ("####", "Header 4"), - ("#####", "Header 5"), - ("######", "Header 6"), - ] - - markdown_splitter = MarkdownHeaderTextSplitter( - headers_to_split_on=headers_to_split_on, - strip_headers=False, # Keep headers in content for context - ) - - md_split_docs = [] - for doc in docs: - md_header_splits = markdown_splitter.split_text(doc.page_content) - text_splitter = RecursiveCharacterTextSplitter( - chunk_size=request.app.state.config.CHUNK_SIZE, - chunk_overlap=request.app.state.config.CHUNK_OVERLAP, - add_start_index=True, - ) - md_header_splits = text_splitter.split_documents(md_header_splits) - - # Convert back to Document objects, preserving original metadata - for split_chunk in md_header_splits: - headings_list = [] - # Extract header values in order based on headers_to_split_on - for _, header_meta_key_name in headers_to_split_on: - if header_meta_key_name in split_chunk.metadata: - headings_list.append( - split_chunk.metadata[header_meta_key_name] - ) - - md_split_docs.append( - Document( - page_content=split_chunk.page_content, - metadata={**doc.metadata, "headings": headings_list}, - ) - ) - - docs = md_split_docs else: raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter")) if len(docs) == 0: 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 = [ { **doc.metadata, @@ -1401,6 +1535,7 @@ def _get_docs_info(docs: list[Document]) -> str: if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" else None ), + enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, ) # Run async embedding in sync context @@ -1447,14 +1582,15 @@ def process_file( request: Request, form_data: ProcessFileForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): """ Process a file and save its content to the vector database. """ if user.role == "admin": - file = Files.get_file_by_id(form_data.file_id) + file = Files.get_file_by_id(form_data.file_id, db=db) else: - file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id) + file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db) if file: try: @@ -1557,6 +1693,7 @@ def process_file( MINERU_API_MODE=request.app.state.config.MINERU_API_MODE, MINERU_API_URL=request.app.state.config.MINERU_API_URL, MINERU_API_KEY=request.app.state.config.MINERU_API_KEY, + MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT, MINERU_PARAMS=request.app.state.config.MINERU_PARAMS, ) docs = loader.load( @@ -1595,12 +1732,13 @@ def process_file( Files.update_file_data_by_id( file.id, {"content": text_content}, + db=db, ) hash = calculate_sha256_string(text_content) - Files.update_file_hash_by_id(file.id, hash) if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: - Files.update_file_data_by_id(file.id, {"status": "completed"}) + Files.update_file_data_by_id(file.id, {"status": "completed"}, db=db) + Files.update_file_hash_by_id(file.id, hash, db=db) return { "status": True, "collection_name": None, @@ -1629,12 +1767,15 @@ def process_file( { "collection_name": collection_name, }, + db=db, ) Files.update_file_data_by_id( file.id, {"status": "completed"}, + db=db, ) + Files.update_file_hash_by_id(file.id, hash, db=db) return { "status": True, @@ -1652,7 +1793,10 @@ def process_file( Files.update_file_data_by_id( file.id, {"status": "failed"}, + db=db, ) + # Clear the hash so the file can be re-uploaded after fixing the issue + Files.update_file_hash_by_id(file.id, None, db=db) if "No pandoc was found" in str(e): raise HTTPException( @@ -1715,44 +1859,53 @@ async def process_text( @router.post("/process/youtube") @router.post("/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: - 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( get_content_from_url, request, form_data.url ) log.debug(f"text_content: {content}") - if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: - await run_in_threadpool( - save_docs_to_vector_db, - request, - docs, - collection_name, - overwrite=True, - user=user, - ) - else: - collection_name = None + if process: + collection_name = form_data.collection_name + if not collection_name: + collection_name = calculate_sha256_string(form_data.url)[:63] - return { - "status": True, - "collection_name": collection_name, - "filename": form_data.url, - "file": { - "data": { - "content": content, - }, - "meta": { - "name": form_data.url, - "source": form_data.url, + if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: + await run_in_threadpool( + save_docs_to_vector_db, + request, + docs, + collection_name, + 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, + }, }, - }, - } + } + else: + return { + "status": True, + "content": content, + } except Exception as e: log.exception(e) raise HTTPException( @@ -1809,11 +1962,13 @@ def search_web( raise Exception("No PERPLEXITY_API_KEY found in environment variables") elif engine == "searxng": if request.app.state.config.SEARXNG_QUERY_URL: + searxng_kwargs = {"language": request.app.state.config.SEARXNG_LANGUAGE} return search_searxng( request.app.state.config.SEARXNG_QUERY_URL, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + **searxng_kwargs, ) else: raise Exception("No SEARXNG_QUERY_URL found in environment variables") @@ -1923,6 +2078,7 @@ def search_web( request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + backend=request.app.state.config.DDGS_BACKEND, ) elif engine == "tavily": if request.app.state.config.TAVILY_API_KEY: @@ -1971,6 +2127,7 @@ def search_web( request.app.state.config.JINA_API_KEY, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, + request.app.state.config.JINA_API_BASE_URL, ) elif engine == "bing": return search_bing( @@ -2057,6 +2214,19 @@ def search_web( async def process_web_search( request: Request, form_data: SearchForm, user=Depends(get_verified_user) ): + if not request.app.state.config.ENABLE_WEB_SEARCH: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if user.role != "admin" and not has_permission( + user.id, "features.web_search", request.app.state.config.USER_PERMISSIONS + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) urls = [] result_items = [] @@ -2066,16 +2236,38 @@ async def process_web_search( f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" ) - search_tasks = [ - run_in_threadpool( - search_web, - request, - request.app.state.config.WEB_SEARCH_ENGINE, - query, - user, - ) - for query in form_data.queries - ] + # Use semaphore to limit concurrent requests based on WEB_SEARCH_CONCURRENT_REQUESTS + # 0 or None = unlimited (previous behavior), positive number = limited concurrency + # Set to 1 for sequential execution (rate-limited APIs like Brave free tier) + concurrent_limit = request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS + + if concurrent_limit: + # Limited concurrency with semaphore + semaphore = asyncio.Semaphore(concurrent_limit) + + 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) @@ -2346,10 +2538,19 @@ class DeleteForm(BaseModel): @router.post("/delete") -def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin_user)): +def delete_entries_from_collection( + form_data: DeleteForm, + user=Depends(get_admin_user), + db: Session = Depends(get_session), +): try: if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): - file = Files.get_file_by_id(form_data.file_id) + file = Files.get_file_by_id(form_data.file_id, db=db) + if not file: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) hash = file.hash VECTOR_DB_CLIENT.delete( @@ -2365,9 +2566,9 @@ def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin @router.post("/reset/db") -def reset_vector_db(user=Depends(get_admin_user)): +def reset_vector_db(user=Depends(get_admin_user), db: Session = Depends(get_session)): VECTOR_DB_CLIENT.reset() - Knowledges.delete_all_knowledge() + Knowledges.delete_all_knowledge(db=db) @router.post("/reset/uploads") @@ -2425,6 +2626,7 @@ async def process_files_batch( request: Request, form_data: BatchProcessFilesForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ) -> BatchProcessFilesResponse: """ Process a batch of files and save them to the vector database. @@ -2487,7 +2689,9 @@ async def process_files_batch( # Update all files with collection name for file_update, file_result in zip(file_updates, file_results): - Files.update_file_by_id(id=file_result.file_id, form_data=file_update) + Files.update_file_by_id( + id=file_result.file_id, form_data=file_update, db=db + ) file_result.status = "completed" except Exception as e: diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index c2ee4d1c357..9070256770b 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -24,10 +24,12 @@ get_verified_user, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS + + +from sqlalchemy.orm import Session +from open_webui.internal.db import get_session log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -298,7 +300,7 @@ def get_scim_auth( ) -def user_to_scim(user: UserModel, request: Request) -> SCIMUser: +def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components name_parts = user.name.split(" ", 1) if user.name else ["", ""] @@ -306,7 +308,7 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser: family_name = name_parts[1] if len(name_parts) > 1 else "" # Get user's groups - user_groups = Groups.get_groups_by_member_id(user.id) + user_groups = Groups.get_groups_by_member_id(user.id, db=db) groups = [ { "value": group.id, @@ -347,13 +349,13 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser: ) -def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup: +def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: """Convert internal Group model to SCIM Group""" - member_ids = Groups.get_group_user_ids_by_id(group.id) + member_ids = Groups.get_group_user_ids_by_id(group.id, db) or [] members = [] for user_id in member_ids: - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if user: members.append( SCIMGroupMember( @@ -485,6 +487,7 @@ async def get_users( count: int = Query(20, ge=1, le=100), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """List SCIM Users""" skip = startIndex - 1 @@ -496,20 +499,20 @@ async def get_users( # In production, you'd want a more robust filter parser if "userName eq" in filter: email = filter.split('"')[1] - user = Users.get_user_by_email(email) + user = Users.get_user_by_email(email, db=db) users_list = [user] if user else [] total = 1 if user else 0 else: - response = Users.get_users(skip=skip, limit=limit) + response = Users.get_users(skip=skip, limit=limit, db=db) users_list = response["users"] total = response["total"] else: - response = Users.get_users(skip=skip, limit=limit) + response = Users.get_users(skip=skip, limit=limit, db=db) users_list = response["users"] total = response["total"] # Convert to SCIM format - scim_users = [user_to_scim(user, request) for user in users_list] + scim_users = [user_to_scim(user, request, db=db) for user in users_list] return SCIMListResponse( totalResults=total, @@ -524,15 +527,16 @@ async def get_user( user_id: str, request: Request, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Get SCIM User by ID""" - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if not user: return scim_error( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found" ) - return user_to_scim(user, request) + return user_to_scim(user, request, db=db) @router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED) @@ -540,10 +544,11 @@ async def create_user( request: Request, user_data: SCIMUserCreateRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Create SCIM User""" # Check if user already exists - existing_user = Users.get_user_by_email(user_data.userName) + existing_user = Users.get_user_by_email(user_data.userName, db=db) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -574,6 +579,7 @@ async def create_user( email=email, profile_image_url=profile_image, role="user" if user_data.active else "pending", + db=db, ) if not new_user: @@ -582,7 +588,7 @@ async def create_user( detail="Failed to create user", ) - return user_to_scim(new_user, request) + return user_to_scim(new_user, request, db=db) @router.put("/Users/{user_id}", response_model=SCIMUser) @@ -591,9 +597,10 @@ async def update_user( request: Request, user_data: SCIMUserUpdateRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Update SCIM User (full update)""" - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -626,14 +633,14 @@ async def update_user( update_data["profile_image_url"] = user_data.photos[0].value # Update user - updated_user = Users.update_user_by_id(user_id, update_data) + updated_user = Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update user", ) - return user_to_scim(updated_user, request) + return user_to_scim(updated_user, request, db=db) @router.patch("/Users/{user_id}", response_model=SCIMUser) @@ -642,9 +649,10 @@ async def patch_user( request: Request, patch_data: SCIMPatchRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Update SCIM User (partial update)""" - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -672,7 +680,7 @@ async def patch_user( # Update user if update_data: - updated_user = Users.update_user_by_id(user_id, update_data) + updated_user = Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -681,7 +689,7 @@ async def patch_user( else: updated_user = user - return user_to_scim(updated_user, request) + return user_to_scim(updated_user, request, db=db) @router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -689,16 +697,17 @@ async def delete_user( user_id: str, request: Request, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Delete SCIM User""" - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found", ) - success = Users.delete_user_by_id(user_id) + success = Users.delete_user_by_id(user_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -716,10 +725,11 @@ async def get_groups( count: int = Query(20, ge=1, le=100), filter: Optional[str] = None, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """List SCIM Groups""" # Get all groups - groups_list = Groups.get_all_groups() + groups_list = Groups.get_all_groups(db=db) # Apply pagination total = len(groups_list) @@ -728,7 +738,7 @@ async def get_groups( paginated_groups = groups_list[start:end] # Convert to SCIM format - scim_groups = [group_to_scim(group, request) for group in paginated_groups] + scim_groups = [group_to_scim(group, request, db=db) for group in paginated_groups] return SCIMListResponse( totalResults=total, @@ -743,16 +753,17 @@ async def get_group( group_id: str, request: Request, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Get SCIM Group by ID""" - group = Groups.get_group_by_id(group_id) + group = Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - return group_to_scim(group, request) + return group_to_scim(group, request, db=db) @router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) @@ -760,6 +771,7 @@ async def create_group( request: Request, group_data: SCIMGroupCreateRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Create SCIM Group""" # Extract member IDs @@ -777,14 +789,14 @@ async def create_group( ) # Need to get the creating user's ID - we'll use the first admin - admin_user = Users.get_super_admin_user() + admin_user = Users.get_super_admin_user(db=db) if not admin_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No admin user found", ) - new_group = Groups.insert_new_group(admin_user.id, form) + new_group = Groups.insert_new_group(admin_user.id, form, db=db) if not new_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -800,12 +812,12 @@ async def create_group( description=new_group.description, ) - Groups.update_group_by_id(new_group.id, update_form) - Groups.set_group_user_ids_by_id(new_group.id, member_ids) + Groups.update_group_by_id(new_group.id, update_form, db=db) + Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db) - new_group = Groups.get_group_by_id(new_group.id) + new_group = Groups.get_group_by_id(new_group.id, db=db) - return group_to_scim(new_group, request) + return group_to_scim(new_group, request, db=db) @router.put("/Groups/{group_id}", response_model=SCIMGroup) @@ -814,9 +826,10 @@ async def update_group( request: Request, group_data: SCIMGroupUpdateRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Update SCIM Group (full update)""" - group = Groups.get_group_by_id(group_id) + group = Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -834,17 +847,17 @@ async def update_group( # Handle members if provided if group_data.members is not None: member_ids = [member.value for member in group_data.members] - Groups.set_group_user_ids_by_id(group_id, member_ids) + Groups.set_group_user_ids_by_id(group_id, member_ids, db=db) # Update group - updated_group = Groups.update_group_by_id(group_id, update_form) + updated_group = Groups.update_group_by_id(group_id, update_form, db=db) if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update group", ) - return group_to_scim(updated_group, request) + return group_to_scim(updated_group, request, db=db) @router.patch("/Groups/{group_id}", response_model=SCIMGroup) @@ -853,9 +866,10 @@ async def patch_group( request: Request, patch_data: SCIMPatchRequest, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Update SCIM Group (partial update)""" - group = Groups.get_group_by_id(group_id) + group = Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -880,7 +894,7 @@ async def patch_group( elif path == "members": # Replace all members Groups.set_group_user_ids_by_id( - group_id, [member["value"] for member in value] + group_id, [member["value"] for member in value], db=db ) elif op == "add": @@ -889,22 +903,24 @@ async def patch_group( if isinstance(value, list): for member in value: if isinstance(member, dict) and "value" in member: - Groups.add_users_to_group(group_id, [member["value"]]) + Groups.add_users_to_group( + group_id, [member["value"]], db=db + ) elif op == "remove": if path and path.startswith("members[value eq"): # Remove specific member member_id = path.split('"')[1] - Groups.remove_users_from_group(group_id, [member_id]) + Groups.remove_users_from_group(group_id, [member_id], db=db) # Update group - updated_group = Groups.update_group_by_id(group_id, update_form) + updated_group = Groups.update_group_by_id(group_id, update_form, db=db) if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update group", ) - return group_to_scim(updated_group, request) + return group_to_scim(updated_group, request, db=db) @router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -912,16 +928,17 @@ async def delete_group( group_id: str, request: Request, _: bool = Depends(get_scim_auth), + db: Session = Depends(get_session), ): """Delete SCIM Group""" - group = Groups.get_group_by_id(group_id) + group = Groups.get_group_by_id(group_id, db=db) if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group {group_id} not found", ) - success = Groups.delete_group_by_id(group_id) + success = Groups.delete_group_by_id(group_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 040c2382d7a..37a80e2ce03 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -35,11 +35,9 @@ DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE, DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, ) -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index aa8d95943af..03018d24a1f 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -4,9 +4,12 @@ import time import re import aiohttp +from open_webui.env import AIOHTTP_CLIENT_TIMEOUT from open_webui.models.groups import Groups from pydantic import BaseModel, HttpUrl from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session +from open_webui.internal.db import get_session from open_webui.models.oauth_sessions import OAuthSessions @@ -15,6 +18,7 @@ ToolModel, ToolResponse, ToolUserResponse, + ToolAccessResponse, Tools, ) from open_webui.utils.plugin import ( @@ -27,13 +31,11 @@ from open_webui.utils.access_control import has_access, has_permission 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.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() @@ -53,11 +55,15 @@ def get_tool_module(request, tool_id, load_from_db=True): @router.get("/", response_model=list[ToolUserResponse]) -async def get_tools(request: Request, user=Depends(get_verified_user)): +async def get_tools( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): tools = [] # Local Tools - for tool in Tools.get_tools(): + for tool in Tools.get_tools(db=db): tool_module = get_tool_module(request, tool.id) tools.append( ToolUserResponse( @@ -142,12 +148,14 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): # Admin can see all tools return tools else: - 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, db=db) + } tools = [ tool for tool in tools if tool.user_id == user.id - or has_access(user.id, "read", tool.access_control, user_group_ids) + or has_access(user.id, "read", tool.access_control, user_group_ids, db=db) ] return tools @@ -157,13 +165,26 @@ async def get_tools(request: Request, user=Depends(get_verified_user)): ############################ -@router.get("/list", response_model=list[ToolUserResponse]) -async def get_tool_list(user=Depends(get_verified_user)): +@router.get("/list", response_model=list[ToolAccessResponse]) +async def get_tool_list( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - tools = Tools.get_tools() + tools = Tools.get_tools(db=db) else: - tools = Tools.get_tools_by_user_id(user.id, "write") - return tools + tools = Tools.get_tools_by_user_id(user.id, "read", db=db) + + return [ + ToolAccessResponse( + **tool.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == tool.user_id + or has_access(user.id, "write", tool.access_control, db=db) + ), + ) + for tool in tools + ] ############################ @@ -220,7 +241,9 @@ async def load_tool_from_url( ) try: - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.get( url, headers={"Content-Type": "application/json"} ) as resp: @@ -247,9 +270,16 @@ async def load_tool_from_url( @router.get("/export", response_model=list[ToolModel]) -async def export_tools(request: Request, user=Depends(get_verified_user)): +async def export_tools( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): if user.role != "admin" and not has_permission( - user.id, "workspace.tools_export", request.app.state.config.USER_PERMISSIONS + user.id, + "workspace.tools_export", + request.app.state.config.USER_PERMISSIONS, + db=db, ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -257,9 +287,9 @@ async def export_tools(request: Request, user=Depends(get_verified_user)): ) if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - return Tools.get_tools() + return Tools.get_tools(db=db) else: - return Tools.get_tools_by_user_id(user.id, "read") + return Tools.get_tools_by_user_id(user.id, "read", db=db) ############################ @@ -272,13 +302,17 @@ async def create_new_tools( request: Request, form_data: ToolForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): if user.role != "admin" and not ( has_permission( - user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS + user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS, db=db ) or has_permission( - user.id, "workspace.tools_import", request.app.state.config.USER_PERMISSIONS + user.id, + "workspace.tools_import", + request.app.state.config.USER_PERMISSIONS, + db=db, ) ): raise HTTPException( @@ -294,7 +328,7 @@ async def create_new_tools( form_data.id = form_data.id.lower() - tools = Tools.get_tool_by_id(form_data.id) + tools = Tools.get_tool_by_id(form_data.id, db=db) if tools is None: try: form_data.content = replace_imports(form_data.content) @@ -307,7 +341,7 @@ async def create_new_tools( TOOLS[form_data.id] = tool_module specs = get_tool_specs(TOOLS[form_data.id]) - tools = Tools.insert_new_tool(user.id, form_data, specs) + tools = Tools.insert_new_tool(user.id, form_data, specs, db=db) tool_cache_dir = CACHE_DIR / "tools" / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) @@ -337,20 +371,34 @@ async def create_new_tools( ############################ -@router.get("/id/{id}", response_model=Optional[ToolModel]) -async def get_tools_by_id(id: str, user=Depends(get_verified_user)): - tools = Tools.get_tool_by_id(id) +@router.get("/id/{id}", response_model=Optional[ToolAccessResponse]) +async def get_tools_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + tools = Tools.get_tool_by_id(id, db=db) if tools: if ( user.role == "admin" or tools.user_id == user.id - or has_access(user.id, "read", tools.access_control) + or has_access(user.id, "read", tools.access_control, db=db) ): - return tools + return ToolAccessResponse( + **tools.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == tools.user_id + or has_access(user.id, "write", tools.access_control, db=db) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) else: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) @@ -366,8 +414,9 @@ async def update_tools_by_id( id: str, form_data: ToolForm, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -377,7 +426,7 @@ async def update_tools_by_id( # Is the user the original creator, in a group with write access, or an admin if ( tools.user_id != user.id - and not has_access(user.id, "write", tools.access_control) + and not has_access(user.id, "write", tools.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -401,7 +450,7 @@ async def update_tools_by_id( } log.debug(updated) - tools = Tools.update_tool_by_id(id, updated) + tools = Tools.update_tool_by_id(id, updated, db=db) if tools: return tools @@ -425,9 +474,12 @@ async def update_tools_by_id( @router.delete("/id/{id}/delete", response_model=bool) async def delete_tools_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -436,7 +488,7 @@ async def delete_tools_by_id( if ( tools.user_id != user.id - and not has_access(user.id, "write", tools.access_control) + and not has_access(user.id, "write", tools.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -444,7 +496,7 @@ async def delete_tools_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - result = Tools.delete_tool_by_id(id) + result = Tools.delete_tool_by_id(id, db=db) if result: TOOLS = request.app.state.TOOLS if id in TOOLS: @@ -459,11 +511,13 @@ async def delete_tools_by_id( @router.get("/id/{id}/valves", response_model=Optional[dict]) -async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)): - tools = Tools.get_tool_by_id(id) +async def get_tools_valves_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + tools = Tools.get_tool_by_id(id, db=db) if tools: try: - valves = Tools.get_tool_valves_by_id(id) + valves = Tools.get_tool_valves_by_id(id, db=db) return valves except Exception as e: raise HTTPException( @@ -484,9 +538,12 @@ async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)): @router.get("/id/{id}/valves/spec", response_model=Optional[dict]) async def get_tools_valves_spec_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if tools: if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] @@ -512,9 +569,13 @@ async def get_tools_valves_spec_by_id( @router.post("/id/{id}/valves/update", response_model=Optional[dict]) async def update_tools_valves_by_id( - request: Request, id: str, form_data: dict, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: dict, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if not tools: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -523,7 +584,7 @@ async def update_tools_valves_by_id( if ( tools.user_id != user.id - and not has_access(user.id, "write", tools.access_control) + and not has_access(user.id, "write", tools.access_control, db=db) and user.role != "admin" ): raise HTTPException( @@ -548,7 +609,7 @@ async def update_tools_valves_by_id( form_data = {k: v for k, v in form_data.items() if v is not None} valves = Valves(**form_data) valves_dict = valves.model_dump(exclude_unset=True) - Tools.update_tool_valves_by_id(id, valves_dict) + Tools.update_tool_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: log.exception(f"Failed to update tool valves by id {id}: {e}") @@ -564,11 +625,13 @@ async def update_tools_valves_by_id( @router.get("/id/{id}/valves/user", response_model=Optional[dict]) -async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)): - tools = Tools.get_tool_by_id(id) +async def get_tools_user_valves_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + tools = Tools.get_tool_by_id(id, db=db) if tools: try: - user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id) + user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) return user_valves except Exception as e: raise HTTPException( @@ -584,9 +647,12 @@ async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)): @router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) async def get_tools_user_valves_spec_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if tools: if id in request.app.state.TOOLS: tools_module = request.app.state.TOOLS[id] @@ -607,9 +673,13 @@ async def get_tools_user_valves_spec_by_id( @router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) async def update_tools_user_valves_by_id( - request: Request, id: str, form_data: dict, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: dict, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - tools = Tools.get_tool_by_id(id) + tools = Tools.get_tool_by_id(id, db=db) if tools: if id in request.app.state.TOOLS: @@ -626,7 +696,7 @@ async def update_tools_user_valves_by_id( user_valves = UserValves(**form_data) user_valves_dict = user_valves.model_dump(exclude_unset=True) Tools.update_user_valves_by_id_and_user_id( - id, user.id, user_valves_dict + id, user.id, user_valves_dict, db=db ) return user_valves_dict except Exception as e: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 3c1bbb72a87..8eb4b5d1991 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -1,5 +1,6 @@ import logging from typing import Optional +from sqlalchemy.orm import Session import base64 import io @@ -28,7 +29,8 @@ ) 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.internal.db import get_session from open_webui.utils.auth import ( @@ -41,7 +43,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -61,6 +62,7 @@ async def get_users( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_admin_user), + db: Session = Depends(get_session), ): limit = PAGE_ITEM_COUNT @@ -75,19 +77,23 @@ async def get_users( if direction: filter["direction"] = direction - result = Users.get_users(filter=filter, skip=skip, limit=limit) + filter["direction"] = direction + + result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) users = result["users"] total = result["total"] + # Fetch groups for all users in a single query to avoid N+1 + user_ids = [user.id for user in users] + user_groups = Groups.get_groups_by_member_ids(user_ids, db=db) + return { "users": [ UserGroupIdsModel( **{ **user.model_dump(), - "group_ids": [ - group.id for group in Groups.get_groups_by_member_id(user.id) - ], + "group_ids": [group.id for group in user_groups.get(user.id, [])], } ) for user in users @@ -99,8 +105,9 @@ async def get_users( @router.get("/all", response_model=UserInfoListResponse) async def get_all_users( user=Depends(get_admin_user), + db: Session = Depends(get_session), ): - return Users.get_users() + return Users.get_users(db=db) @router.get("/search", response_model=UserInfoListResponse) @@ -110,16 +117,13 @@ async def search_users( direction: Optional[str] = None, page: Optional[int] = 1, user=Depends(get_verified_user), + db: Session = Depends(get_session), ): limit = PAGE_ITEM_COUNT page = max(1, page) skip = (page - 1) * limit - filter = {} - if query: - filter["query"] = query - filter = {} if query: filter["query"] = query @@ -128,7 +132,7 @@ async def search_users( if direction: filter["direction"] = direction - return Users.get_users(filter=filter, skip=skip, limit=limit) + return Users.get_users(filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -137,8 +141,10 @@ async def search_users( @router.get("/groups") -async def get_user_groups(user=Depends(get_verified_user)): - return Groups.get_groups_by_member_id(user.id) +async def get_user_groups( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + return Groups.get_groups_by_member_id(user.id, db=db) ############################ @@ -147,9 +153,13 @@ async def get_user_groups(user=Depends(get_verified_user)): @router.get("/permissions") -async def get_user_permissisions(request: Request, user=Depends(get_verified_user)): +async def get_user_permissisions( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, request.app.state.config.USER_PERMISSIONS, db=db ) return user_permissions @@ -216,6 +226,11 @@ class FeaturesPermissions(BaseModel): web_search: bool = True image_generation: bool = True code_interpreter: bool = True + memories: bool = True + + +class SettingsPermissions(BaseModel): + interface: bool = True class UserPermissions(BaseModel): @@ -223,6 +238,7 @@ class UserPermissions(BaseModel): sharing: SharingPermissions chat: ChatPermissions features: FeaturesPermissions + settings: SettingsPermissions @router.get("/default/permissions", response_model=UserPermissions) @@ -240,6 +256,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_ "features": FeaturesPermissions( **request.app.state.config.USER_PERMISSIONS.get("features", {}) ), + "settings": SettingsPermissions( + **request.app.state.config.USER_PERMISSIONS.get("settings", {}) + ), } @@ -257,8 +276,10 @@ async def update_default_user_permissions( @router.get("/user/settings", response_model=Optional[UserSettings]) -async def get_user_settings_by_session_user(user=Depends(get_verified_user)): - user = Users.get_user_by_id(user.id) +async def get_user_settings_by_session_user( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + user = Users.get_user_by_id(user.id, db=db) if user: return user.settings else: @@ -275,12 +296,17 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)): @router.post("/user/settings/update", response_model=UserSettings) async def update_user_settings_by_session_user( - request: Request, form_data: UserSettings, user=Depends(get_verified_user) + request: Request, + form_data: UserSettings, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): updated_user_settings = form_data.model_dump() + ui_settings = updated_user_settings.get("ui") if ( user.role != "admin" - and "toolServers" in updated_user_settings.get("ui").keys() + and ui_settings is not None + and "toolServers" in ui_settings.keys() and not has_permission( user.id, "features.direct_tool_servers", @@ -290,7 +316,7 @@ async def update_user_settings_by_session_user( # If the user is not an admin and does not have permission to use tool servers, remove the key updated_user_settings["ui"].pop("toolServers", None) - user = Users.update_user_settings_by_id(user.id, updated_user_settings) + user = Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: return user.settings else: @@ -306,8 +332,17 @@ async def update_user_settings_by_session_user( @router.get("/user/status") -async def get_user_status_by_session_user(user=Depends(get_verified_user)): - user = Users.get_user_by_id(user.id) +async def get_user_status_by_session_user( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if not request.app.state.config.ENABLE_USER_STATUS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + user = Users.get_user_by_id(user.id, db=db) if user: return user else: @@ -324,11 +359,19 @@ async def get_user_status_by_session_user(user=Depends(get_verified_user)): @router.post("/user/status/update") async def update_user_status_by_session_user( - form_data: UserStatus, user=Depends(get_verified_user) + request: Request, + form_data: UserStatus, + user=Depends(get_verified_user), + db: Session = Depends(get_session), ): - user = Users.get_user_by_id(user.id) + if not request.app.state.config.ENABLE_USER_STATUS: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACTION_PROHIBITED, + ) + user = Users.get_user_by_id(user.id, db=db) if user: - user = Users.update_user_status_by_id(user.id, form_data) + user = Users.update_user_status_by_id(user.id, form_data, db=db) return user else: raise HTTPException( @@ -343,8 +386,10 @@ async def update_user_status_by_session_user( @router.get("/user/info", response_model=Optional[dict]) -async def get_user_info_by_session_user(user=Depends(get_verified_user)): - user = Users.get_user_by_id(user.id) +async def get_user_info_by_session_user( + user=Depends(get_verified_user), db: Session = Depends(get_session) +): + user = Users.get_user_by_id(user.id, db=db) if user: return user.info else: @@ -361,14 +406,16 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user)): @router.post("/user/info/update", response_model=Optional[dict]) async def update_user_info_by_session_user( - form_data: dict, user=Depends(get_verified_user) + form_data: dict, user=Depends(get_verified_user), db: Session = Depends(get_session) ): - user = Users.get_user_by_id(user.id) + user = Users.get_user_by_id(user.id, db=db) if user: if user.info is None: user.info = {} - user = Users.update_user_by_id(user.id, {"info": {**user.info, **form_data}}) + user = Users.update_user_by_id( + user.id, {"info": {**user.info, **form_data}}, db=db + ) if user: return user.info else: @@ -391,13 +438,16 @@ async def update_user_info_by_session_user( class UserActiveResponse(UserStatus): name: str profile_image_url: Optional[str] = None + groups: Optional[list] = [] is_active: bool model_config = ConfigDict(extra="allow") @router.get("/{user_id}", response_model=UserActiveResponse) -async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): +async def get_user_by_id( + user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): # Check if user_id is a shared chat # If it is, get the user_id from the chat if user_id.startswith("shared-"): @@ -411,13 +461,14 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.USER_NOT_FOUND, ) - user = Users.get_user_by_id(user_id) - + user = Users.get_user_by_id(user_id, db=db) if user: + groups = Groups.get_groups_by_member_id(user_id, db=db) return UserActiveResponse( **{ **user.model_dump(), - "is_active": Users.is_user_active(user_id), + "groups": [{"id": group.id, "name": group.name} for group in groups], + "is_active": Users.is_user_active(user_id, db=db), } ) else: @@ -428,8 +479,10 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): @router.get("/{user_id}/oauth/sessions") -async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)): - sessions = OAuthSessions.get_sessions_by_user_id(user_id) +async def get_user_oauth_sessions_by_id( + user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + sessions = OAuthSessions.get_sessions_by_user_id(user_id, db=db) if sessions and len(sessions) > 0: return sessions else: @@ -445,8 +498,10 @@ async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_use @router.get("/{user_id}/profile/image") -async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): - user = Users.get_user_by_id(user_id) +async def get_user_profile_image_by_id( + user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + user = Users.get_user_by_id(user_id, db=db) if user: if user.profile_image_url: # check if it's url or base64 @@ -460,11 +515,12 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u header, base64_data = user.profile_image_url.split(",", 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) + media_type = header.split(";")[0].lstrip("data:") return StreamingResponse( image_buffer, - media_type="image/png", - headers={"Content-Disposition": "inline; filename=image.png"}, + media_type=media_type, + headers={"Content-Disposition": "inline"}, ) except Exception as e: pass @@ -482,9 +538,11 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u @router.get("/{user_id}/active", response_model=dict) -async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): +async def get_user_active_status_by_id( + user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): return { - "active": Users.is_user_active(user_id), + "active": Users.is_user_active(user_id, db=db), } @@ -498,10 +556,11 @@ async def update_user_by_id( user_id: str, form_data: UserUpdateForm, session_user=Depends(get_admin_user), + db: Session = Depends(get_session), ): # Prevent modification of the primary admin user by other admins try: - first_user = Users.get_first_user() + first_user = Users.get_first_user(db=db) if first_user: if user_id == first_user.id: if session_user.id != user_id: @@ -525,11 +584,11 @@ async def update_user_by_id( detail="Could not verify primary admin status.", ) - user = Users.get_user_by_id(user_id) + user = Users.get_user_by_id(user_id, db=db) if user: if form_data.email.lower() != user.email: - email_user = Users.get_user_by_email(form_data.email.lower()) + email_user = Users.get_user_by_email(form_data.email.lower(), db=db) if email_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -543,9 +602,9 @@ async def update_user_by_id( raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.password) - Auths.update_user_password_by_id(user_id, hashed) + Auths.update_user_password_by_id(user_id, hashed, db=db) - Auths.update_email_by_id(user_id, form_data.email.lower()) + Auths.update_email_by_id(user_id, form_data.email.lower(), db=db) updated_user = Users.update_user_by_id( user_id, { @@ -554,6 +613,7 @@ async def update_user_by_id( "email": form_data.email.lower(), "profile_image_url": form_data.profile_image_url, }, + db=db, ) if updated_user: @@ -576,10 +636,12 @@ async def update_user_by_id( @router.delete("/{user_id}", response_model=bool) -async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): +async def delete_user_by_id( + user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): # Prevent deletion of the primary admin user try: - first_user = Users.get_first_user() + first_user = Users.get_first_user(db=db) if first_user and user_id == first_user.id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -593,7 +655,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): ) if user.id != user_id: - result = Auths.delete_auth_by_id(user_id) + result = Auths.delete_auth_by_id(user_id, db=db) if result: return True @@ -616,5 +678,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): @router.get("/{user_id}/groups") -async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)): - return Groups.get_groups_by_member_id(user_id) +async def get_user_groups_by_id( + user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) +): + return Groups.get_groups_by_member_id(user_id, db=db) diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index b2a44e5488c..22529ab1b9e 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -14,11 +14,9 @@ from open_webui.utils.pdf_generator import PDFGenerator from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.code_interpreter import execute_code_jupyter -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 638a89715a5..67e04e69c3b 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -47,13 +47,11 @@ from open_webui.env import ( GLOBAL_LOG_LEVEL, - SRC_LOG_LEVELS, ) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["SOCKET"]) REDIS = None @@ -248,7 +246,13 @@ def get_user_ids_from_room(room): active_session_ids = get_session_ids_from_room(room) active_user_ids = list( - set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids]) + set( + [ + SESSION_POOL.get(session_id)["id"] + for session_id in active_session_ids + if SESSION_POOL.get(session_id) is not None + ] + ) ) return active_user_ids diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 5739a8027a1..327348626a2 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -190,7 +190,11 @@ async def remove_user(self, document_id: str, user_id: str): async def remove_user_from_all_documents(self, user_id: str): if self._redis: - keys = await self._redis.keys(f"{self._redis_key_prefix}:*") + keys = [] + async for key in self._redis.scan_iter( + match=f"{self._redis_key_prefix}:*", count=100 + ): + keys.append(key) for key in keys: if key.endswith(":users"): await self._redis.srem(key, user_id) diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 4292e53827b..ce02105bfa1 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -33,11 +33,9 @@ from azure.identity import DefaultAzureCredential from azure.storage.blob import BlobServiceClient from azure.core.exceptions import ResourceNotFoundError -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) class StorageProvider(ABC): diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 3e314382815..d83226ffb71 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -8,11 +8,10 @@ from fastapi import Request from typing import Dict, List, Optional -from open_webui.env import SRC_LOG_LEVELS, REDIS_KEY_PREFIX +from open_webui.env import REDIS_KEY_PREFIX log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) # A dictionary to keep track of active tasks tasks: Dict[str, asyncio.Task] = {} diff --git a/backend/open_webui/test/apps/webui/routers/test_chats.py b/backend/open_webui/test/apps/webui/routers/test_chats.py deleted file mode 100644 index a36a01fb149..00000000000 --- a/backend/open_webui/test/apps/webui/routers/test_chats.py +++ /dev/null @@ -1,236 +0,0 @@ -import uuid - -from test.util.abstract_integration_test import AbstractPostgresTest -from test.util.mock_user import mock_webui_user - - -class TestChats(AbstractPostgresTest): - BASE_PATH = "/api/v1/chats" - - def setup_class(cls): - super().setup_class() - - def setup_method(self): - super().setup_method() - from open_webui.models.chats import ChatForm, Chats - - self.chats = Chats - self.chats.insert_new_chat( - "2", - ChatForm( - **{ - "chat": { - "name": "chat1", - "description": "chat1 description", - "tags": ["tag1", "tag2"], - "history": {"currentId": "1", "messages": []}, - } - } - ), - ) - - def test_get_session_user_chat_list(self): - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) - assert response.status_code == 200 - first_chat = response.json()[0] - assert first_chat["id"] is not None - assert first_chat["title"] == "New Chat" - assert first_chat["created_at"] is not None - assert first_chat["updated_at"] is not None - - def test_delete_all_user_chats(self): - with mock_webui_user(id="2"): - response = self.fast_api_client.delete(self.create_url("/")) - assert response.status_code == 200 - assert len(self.chats.get_chats()) == 0 - - def test_get_user_chat_list_by_user_id(self): - with mock_webui_user(id="3"): - response = self.fast_api_client.get(self.create_url("/list/user/2")) - assert response.status_code == 200 - first_chat = response.json()[0] - assert first_chat["id"] is not None - assert first_chat["title"] == "New Chat" - assert first_chat["created_at"] is not None - assert first_chat["updated_at"] is not None - - def test_create_new_chat(self): - with mock_webui_user(id="2"): - response = self.fast_api_client.post( - self.create_url("/new"), - json={ - "chat": { - "name": "chat2", - "description": "chat2 description", - "tags": ["tag1", "tag2"], - } - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["archived"] is False - assert data["chat"] == { - "name": "chat2", - "description": "chat2 description", - "tags": ["tag1", "tag2"], - } - assert data["user_id"] == "2" - assert data["id"] is not None - assert data["share_id"] is None - assert data["title"] == "New Chat" - assert data["updated_at"] is not None - assert data["created_at"] is not None - assert len(self.chats.get_chats()) == 2 - - def test_get_user_chats(self): - self.test_get_session_user_chat_list() - - def test_get_user_archived_chats(self): - self.chats.archive_all_chats_by_user_id("2") - from open_webui.internal.db import Session - - Session.commit() - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/all/archived")) - assert response.status_code == 200 - first_chat = response.json()[0] - assert first_chat["id"] is not None - assert first_chat["title"] == "New Chat" - assert first_chat["created_at"] is not None - assert first_chat["updated_at"] is not None - - def test_get_all_user_chats_in_db(self): - with mock_webui_user(id="4"): - response = self.fast_api_client.get(self.create_url("/all/db")) - assert response.status_code == 200 - assert len(response.json()) == 1 - - def test_get_archived_session_user_chat_list(self): - self.test_get_user_archived_chats() - - def test_archive_all_chats(self): - with mock_webui_user(id="2"): - response = self.fast_api_client.post(self.create_url("/archive/all")) - assert response.status_code == 200 - assert len(self.chats.get_archived_chats_by_user_id("2")) == 1 - - def test_get_shared_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - self.chats.update_chat_share_id_by_id(chat_id, chat_id) - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url(f"/share/{chat_id}")) - assert response.status_code == 200 - data = response.json() - assert data["id"] == chat_id - assert data["chat"] == { - "name": "chat1", - "description": "chat1 description", - "tags": ["tag1", "tag2"], - "history": {"currentId": "1", "messages": []}, - } - assert data["id"] == chat_id - assert data["share_id"] == chat_id - assert data["title"] == "New Chat" - - def test_get_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url(f"/{chat_id}")) - assert response.status_code == 200 - data = response.json() - assert data["id"] == chat_id - assert data["chat"] == { - "name": "chat1", - "description": "chat1 description", - "tags": ["tag1", "tag2"], - "history": {"currentId": "1", "messages": []}, - } - assert data["share_id"] is None - assert data["title"] == "New Chat" - assert data["user_id"] == "2" - - def test_update_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.post( - self.create_url(f"/{chat_id}"), - json={ - "chat": { - "name": "chat2", - "description": "chat2 description", - "tags": ["tag2", "tag4"], - "title": "Just another title", - } - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["id"] == chat_id - assert data["chat"] == { - "name": "chat2", - "title": "Just another title", - "description": "chat2 description", - "tags": ["tag2", "tag4"], - "history": {"currentId": "1", "messages": []}, - } - assert data["share_id"] is None - assert data["title"] == "Just another title" - assert data["user_id"] == "2" - - def test_delete_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.delete(self.create_url(f"/{chat_id}")) - assert response.status_code == 200 - assert response.json() is True - - def test_clone_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url(f"/{chat_id}/clone")) - - assert response.status_code == 200 - data = response.json() - assert data["id"] != chat_id - assert data["chat"] == { - "branchPointMessageId": "1", - "description": "chat1 description", - "history": {"currentId": "1", "messages": []}, - "name": "chat1", - "originalChatId": chat_id, - "tags": ["tag1", "tag2"], - "title": "Clone of New Chat", - } - assert data["share_id"] is None - assert data["title"] == "Clone of New Chat" - assert data["user_id"] == "2" - - def test_archive_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url(f"/{chat_id}/archive")) - assert response.status_code == 200 - - chat = self.chats.get_chat_by_id(chat_id) - assert chat.archived is True - - def test_share_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - with mock_webui_user(id="2"): - response = self.fast_api_client.post(self.create_url(f"/{chat_id}/share")) - assert response.status_code == 200 - - chat = self.chats.get_chat_by_id(chat_id) - assert chat.share_id is not None - - def test_delete_shared_chat_by_id(self): - chat_id = self.chats.get_chats()[0].id - share_id = str(uuid.uuid4()) - self.chats.update_chat_share_id_by_id(chat_id, share_id) - with mock_webui_user(id="2"): - response = self.fast_api_client.delete(self.create_url(f"/{chat_id}/share")) - assert response.status_code - - chat = self.chats.get_chat_by_id(chat_id) - assert chat.share_id is None diff --git a/backend/open_webui/test/util/abstract_integration_test.py b/backend/open_webui/test/util/abstract_integration_test.py deleted file mode 100644 index e8492befb64..00000000000 --- a/backend/open_webui/test/util/abstract_integration_test.py +++ /dev/null @@ -1,161 +0,0 @@ -import logging -import os -import time - -import docker -import pytest -from docker import DockerClient -from pytest_docker.plugin import get_docker_ip -from fastapi.testclient import TestClient -from sqlalchemy import text, create_engine - - -log = logging.getLogger(__name__) - - -def get_fast_api_client(): - from main import app - - with TestClient(app) as c: - return c - - -class AbstractIntegrationTest: - BASE_PATH = None - - def create_url(self, path="", query_params=None): - if self.BASE_PATH is None: - raise Exception("BASE_PATH is not set") - parts = self.BASE_PATH.split("/") - parts = [part.strip() for part in parts if part.strip() != ""] - path_parts = path.split("/") - path_parts = [part.strip() for part in path_parts if part.strip() != ""] - query_parts = "" - if query_params: - query_parts = "&".join( - [f"{key}={value}" for key, value in query_params.items()] - ) - query_parts = f"?{query_parts}" - return "/".join(parts + path_parts) + query_parts - - @classmethod - def setup_class(cls): - pass - - def setup_method(self): - pass - - @classmethod - def teardown_class(cls): - pass - - def teardown_method(self): - pass - - -class AbstractPostgresTest(AbstractIntegrationTest): - DOCKER_CONTAINER_NAME = "postgres-test-container-will-get-deleted" - docker_client: DockerClient - - @classmethod - def _create_db_url(cls, env_vars_postgres: dict) -> str: - host = get_docker_ip() - user = env_vars_postgres["POSTGRES_USER"] - pw = env_vars_postgres["POSTGRES_PASSWORD"] - port = 8081 - db = env_vars_postgres["POSTGRES_DB"] - return f"postgresql://{user}:{pw}@{host}:{port}/{db}" - - @classmethod - def setup_class(cls): - super().setup_class() - try: - env_vars_postgres = { - "POSTGRES_USER": "user", - "POSTGRES_PASSWORD": "example", - "POSTGRES_DB": "openwebui", - } - cls.docker_client = docker.from_env() - cls.docker_client.containers.run( - "postgres:16.2", - detach=True, - environment=env_vars_postgres, - name=cls.DOCKER_CONTAINER_NAME, - ports={5432: ("0.0.0.0", 8081)}, - command="postgres -c log_statement=all", - ) - time.sleep(0.5) - - database_url = cls._create_db_url(env_vars_postgres) - os.environ["DATABASE_URL"] = database_url - retries = 10 - db = None - while retries > 0: - try: - from open_webui.config import OPEN_WEBUI_DIR - - db = create_engine(database_url, pool_pre_ping=True) - db = db.connect() - log.info("postgres is ready!") - break - except Exception as e: - log.warning(e) - time.sleep(3) - retries -= 1 - - if db: - # import must be after setting env! - cls.fast_api_client = get_fast_api_client() - db.close() - else: - raise Exception("Could not connect to Postgres") - except Exception as ex: - log.error(ex) - cls.teardown_class() - pytest.fail(f"Could not setup test environment: {ex}") - - def _check_db_connection(self): - from open_webui.internal.db import Session - - retries = 10 - while retries > 0: - try: - Session.execute(text("SELECT 1")) - Session.commit() - break - except Exception as e: - Session.rollback() - log.warning(e) - time.sleep(3) - retries -= 1 - - def setup_method(self): - super().setup_method() - self._check_db_connection() - - @classmethod - def teardown_class(cls) -> None: - super().teardown_class() - cls.docker_client.containers.get(cls.DOCKER_CONTAINER_NAME).remove(force=True) - - def teardown_method(self): - from open_webui.internal.db import Session - - # rollback everything not yet committed - Session.commit() - - # truncate all tables - tables = [ - "auth", - "chat", - "chatidtag", - "document", - "memory", - "model", - "prompt", - "tag", - '"user"', - ] - for table in tables: - Session.execute(text(f"TRUNCATE TABLE {table}")) - Session.commit() diff --git a/backend/open_webui/test/util/mock_user.py b/backend/open_webui/test/util/mock_user.py deleted file mode 100644 index 7ce64dffa99..00000000000 --- a/backend/open_webui/test/util/mock_user.py +++ /dev/null @@ -1,45 +0,0 @@ -from contextlib import contextmanager - -from fastapi import FastAPI - - -@contextmanager -def mock_webui_user(**kwargs): - from open_webui.routers.webui import app - - with mock_user(app, **kwargs): - yield - - -@contextmanager -def mock_user(app: FastAPI, **kwargs): - from open_webui.utils.auth import ( - get_current_user, - get_verified_user, - get_admin_user, - get_current_user_by_api_key, - ) - from open_webui.models.users import User - - def create_user(): - user_parameters = { - "id": "1", - "name": "John Doe", - "email": "john.doe@openwebui.com", - "role": "user", - "profile_image_url": "/user.png", - "last_active_at": 1627351200, - "updated_at": 1627351200, - "created_at": 162735120, - **kwargs, - } - return User(**user_parameters) - - app.dependency_overrides = { - get_current_user: create_user, - get_verified_user: create_user, - get_admin_user: create_user, - get_current_user_by_api_key: create_user, - } - yield - app.dependency_overrides = {} diff --git a/backend/open_webui/tools/__init__.py b/backend/open_webui/tools/__init__.py new file mode 100644 index 00000000000..112324b5690 --- /dev/null +++ b/backend/open_webui/tools/__init__.py @@ -0,0 +1,6 @@ +""" +Open WebUI Tools Package. + +This package contains built-in tools that are automatically available +when native function calling is enabled. +""" diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py new file mode 100644 index 00000000000..eb3b7cfc9ff --- /dev/null +++ b/backend/open_webui/tools/builtin.py @@ -0,0 +1,1671 @@ +""" +Built-in tools for Open WebUI. + +These tools are automatically available when native function calling is enabled. + +IMPORTANT: DO NOT IMPORT THIS MODULE DIRECTLY IN OTHER PARTS OF THE CODEBASE. +""" + +import json +import logging +import time +import asyncio +from typing import Optional + +from fastapi import Request + +from open_webui.models.users import UserModel +from open_webui.routers.retrieval import search_web as _search_web +from open_webui.retrieval.utils import get_content_from_url +from open_webui.routers.images import ( + image_generations, + image_edits, + CreateImageForm, + EditImageForm, +) +from open_webui.routers.memories import ( + query_memory, + add_memory as _add_memory, + update_memory_by_id, + QueryMemoryForm, + AddMemoryForm, + MemoryUpdateModel, +) +from open_webui.models.notes import Notes +from open_webui.models.chats import Chats +from open_webui.models.channels import Channels, ChannelMember, Channel +from open_webui.models.messages import Messages, Message +from open_webui.models.groups import Groups + +log = logging.getLogger(__name__) + +MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000 + +# ============================================================================= +# TIME UTILITIES +# ============================================================================= + + +async def get_current_timestamp( + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the current Unix timestamp in seconds. + + :return: JSON with current_timestamp (seconds) and current_iso (ISO format) + """ + try: + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + return json.dumps( + { + "current_timestamp": int(now.timestamp()), + "current_iso": now.isoformat(), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"get_current_timestamp error: {e}") + return json.dumps({"error": str(e)}) + + +async def calculate_timestamp( + days_ago: int = 0, + weeks_ago: int = 0, + months_ago: int = 0, + years_ago: int = 0, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the current Unix timestamp, optionally adjusted by days, weeks, months, or years. + Use this to calculate timestamps for date filtering in search functions. + Examples: "last week" = weeks_ago=1, "3 days ago" = days_ago=3, "a year ago" = years_ago=1 + + :param days_ago: Number of days to subtract from current time (default: 0) + :param weeks_ago: Number of weeks to subtract from current time (default: 0) + :param months_ago: Number of months to subtract from current time (default: 0) + :param years_ago: Number of years to subtract from current time (default: 0) + :return: JSON with current_timestamp and calculated_timestamp (both in seconds) + """ + try: + import datetime + from dateutil.relativedelta import relativedelta + + now = datetime.datetime.now(datetime.timezone.utc) + current_ts = int(now.timestamp()) + + # Calculate the adjusted time + total_days = days_ago + (weeks_ago * 7) + adjusted = now - datetime.timedelta(days=total_days) + + # Handle months and years separately (variable length) + if months_ago > 0 or years_ago > 0: + adjusted = adjusted - relativedelta(months=months_ago, years=years_ago) + + adjusted_ts = int(adjusted.timestamp()) + + return json.dumps( + { + "current_timestamp": current_ts, + "current_iso": now.isoformat(), + "calculated_timestamp": adjusted_ts, + "calculated_iso": adjusted.isoformat(), + }, + ensure_ascii=False, + ) + except ImportError: + # Fallback without dateutil + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + current_ts = int(now.timestamp()) + total_days = days_ago + (weeks_ago * 7) + (months_ago * 30) + (years_ago * 365) + adjusted = now - datetime.timedelta(days=total_days) + adjusted_ts = int(adjusted.timestamp()) + return json.dumps( + { + "current_timestamp": current_ts, + "current_iso": now.isoformat(), + "calculated_timestamp": adjusted_ts, + "calculated_iso": adjusted.isoformat(), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"calculate_timestamp error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# WEB SEARCH TOOLS +# ============================================================================= + + +async def search_web( + query: str, + count: int = 5, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search the public web for information. Best for current events, external references, + or topics not covered in internal documents. If knowledge base tools are available, + consider checking those first for internal information. + + :param query: The search query to look up + :param count: Number of results to return (default: 5) + :return: JSON with search results containing title, link, and snippet for each result + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + engine = __request__.app.state.config.WEB_SEARCH_ENGINE + user = UserModel(**__user__) if __user__ else None + + results = _search_web(__request__, engine, query, user) + + # Limit results + results = results[:count] if results else [] + + return json.dumps( + [{"title": r.title, "link": r.link, "snippet": r.snippet} for r in results], + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"search_web error: {e}") + return json.dumps({"error": str(e)}) + + +async def fetch_url( + url: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Fetch and extract the main text content from a web page URL. + + :param url: The URL to fetch content from + :return: The extracted text content from the page + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + content, _ = await asyncio.to_thread(get_content_from_url, __request__, url) + + # Truncate if too long (avoid overwhelming context) + max_length = 50000 + if len(content) > max_length: + content = content[:max_length] + "\n\n[Content truncated...]" + + return content + except Exception as e: + log.exception(f"fetch_url error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# IMAGE GENERATION TOOLS +# ============================================================================= + + +async def generate_image( + prompt: str, + __request__: Request = None, + __user__: dict = None, + __event_emitter__: callable = None, + __chat_id__: str = None, + __message_id__: str = None, +) -> str: + """ + Generate an image based on a text prompt. + + :param prompt: A detailed description of the image to generate + :return: Confirmation that the image was generated, or an error message + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + images = await image_generations( + request=__request__, + form_data=CreateImageForm(prompt=prompt), + user=user, + ) + + # Prepare file entries for the images + image_files = [{"type": "image", "url": img["url"]} for img in images] + + # Persist files to DB if chat context is available + if __chat_id__ and __message_id__ and images: + image_files = Chats.add_message_files_by_id_and_message_id( + __chat_id__, + __message_id__, + image_files, + ) + + # Emit the images to the UI if event emitter is available + if __event_emitter__ and image_files: + await __event_emitter__( + { + "type": "chat:message:files", + "data": { + "files": image_files, + }, + } + ) + # Return a message indicating the image is already displayed + return json.dumps( + { + "status": "success", + "message": "The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.", + "images": images, + }, + ensure_ascii=False, + ) + + return json.dumps({"status": "success", "images": images}, ensure_ascii=False) + except Exception as e: + log.exception(f"generate_image error: {e}") + return json.dumps({"error": str(e)}) + + +async def edit_image( + prompt: str, + image_urls: list[str], + __request__: Request = None, + __user__: dict = None, + __event_emitter__: callable = None, + __chat_id__: str = None, + __message_id__: str = None, +) -> str: + """ + Edit existing images based on a text prompt. + + :param prompt: A description of the changes to make to the images + :param image_urls: A list of URLs of the images to edit + :return: Confirmation that the images were edited, or an error message + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + images = await image_edits( + request=__request__, + form_data=EditImageForm(prompt=prompt, image=image_urls), + user=user, + ) + + # Prepare file entries for the images + image_files = [{"type": "image", "url": img["url"]} for img in images] + + # Persist files to DB if chat context is available + if __chat_id__ and __message_id__ and images: + image_files = Chats.add_message_files_by_id_and_message_id( + __chat_id__, + __message_id__, + image_files, + ) + + # Emit the images to the UI if event emitter is available + if __event_emitter__ and image_files: + await __event_emitter__( + { + "type": "chat:message:files", + "data": { + "files": image_files, + }, + } + ) + # Return a message indicating the image is already displayed + return json.dumps( + { + "status": "success", + "message": "The edited image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.", + "images": images, + }, + ensure_ascii=False, + ) + + return json.dumps({"status": "success", "images": images}, ensure_ascii=False) + except Exception as e: + log.exception(f"edit_image error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# MEMORY TOOLS +# ============================================================================= + + +async def search_memories( + query: str, + count: int = 5, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search the user's stored memories for relevant information. + + :param query: The search query to find relevant memories + :param count: Number of memories to return (default 5) + :return: JSON with matching memories and their dates + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + results = await query_memory( + __request__, + QueryMemoryForm(content=query, k=count), + user, + ) + + if results and hasattr(results, "documents") and results.documents: + memories = [] + for doc_idx, doc in enumerate(results.documents[0]): + memory_id = None + if results.ids and results.ids[0]: + memory_id = results.ids[0][doc_idx] + created_at = "Unknown" + if results.metadatas and results.metadatas[0][doc_idx].get( + "created_at" + ): + created_at = time.strftime( + "%Y-%m-%d", + time.localtime(results.metadatas[0][doc_idx]["created_at"]), + ) + memories.append({"id": memory_id, "date": created_at, "content": doc}) + return json.dumps(memories, ensure_ascii=False) + else: + return json.dumps([]) + except Exception as e: + log.exception(f"search_memories error: {e}") + return json.dumps({"error": str(e)}) + + +async def add_memory( + content: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Store a new memory for the user. + + :param content: The memory content to store + :return: Confirmation that the memory was stored + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + memory = await _add_memory( + __request__, + AddMemoryForm(content=content), + user, + ) + + return json.dumps({"status": "success", "id": memory.id}, ensure_ascii=False) + except Exception as e: + log.exception(f"add_memory error: {e}") + return json.dumps({"error": str(e)}) + + +async def replace_memory_content( + memory_id: str, + content: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Update the content of an existing memory by its ID. + + :param memory_id: The ID of the memory to update + :param content: The new content for the memory + :return: Confirmation that the memory was updated + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + try: + user = UserModel(**__user__) if __user__ else None + + memory = await update_memory_by_id( + memory_id=memory_id, + request=__request__, + form_data=MemoryUpdateModel(content=content), + user=user, + ) + + return json.dumps( + {"status": "success", "id": memory.id, "content": memory.content}, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"replace_memory_content error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# NOTES TOOLS +# ============================================================================= + + +async def search_notes( + query: str, + count: int = 5, + start_timestamp: Optional[int] = None, + end_timestamp: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search the user's notes by title and content. + + :param query: The search query to find matching notes + :param count: Maximum number of results to return (default: 5) + :param start_timestamp: Only include notes updated after this Unix timestamp (seconds) + :param end_timestamp: Only include notes updated before this Unix timestamp (seconds) + :return: JSON with matching notes containing id, title, and content snippet + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + result = Notes.search_notes( + user_id=user_id, + filter={ + "query": query, + "user_id": user_id, + "group_ids": user_group_ids, + "permission": "read", + }, + skip=0, + limit=count * 3, # Fetch more for filtering + ) + + # Convert timestamps to nanoseconds for comparison + start_ts = start_timestamp * 1_000_000_000 if start_timestamp else None + end_ts = end_timestamp * 1_000_000_000 if end_timestamp else None + + notes = [] + for note in result.items: + # Apply date filters (updated_at is in nanoseconds) + if start_ts and note.updated_at < start_ts: + continue + if end_ts and note.updated_at > end_ts: + continue + + # Extract a snippet from the markdown content + content_snippet = "" + if note.data and note.data.get("content", {}).get("md"): + md_content = note.data["content"]["md"] + lower_content = md_content.lower() + lower_query = query.lower() + idx = lower_content.find(lower_query) + if idx != -1: + start = max(0, idx - 50) + end = min(len(md_content), idx + len(query) + 100) + content_snippet = ( + ("..." if start > 0 else "") + + md_content[start:end] + + ("..." if end < len(md_content) else "") + ) + else: + content_snippet = md_content[:150] + ( + "..." if len(md_content) > 150 else "" + ) + + notes.append( + { + "id": note.id, + "title": note.title, + "snippet": content_snippet, + "updated_at": note.updated_at, + } + ) + + if len(notes) >= count: + break + + return json.dumps(notes, ensure_ascii=False) + except Exception as e: + log.exception(f"search_notes error: {e}") + return json.dumps({"error": str(e)}) + + +async def view_note( + note_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the full content of a note by its ID. + + :param note_id: The ID of the note to retrieve + :return: JSON with the note's id, title, and full markdown content + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + note = Notes.get_note_by_id(note_id) + + if not note: + return json.dumps({"error": "Note not found"}) + + # Check access permission + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + from open_webui.utils.access_control import has_access + + if note.user_id != user_id and not has_access( + user_id, "read", note.access_control, user_group_ids + ): + return json.dumps({"error": "Access denied"}) + + # Extract markdown content + content = "" + if note.data and note.data.get("content", {}).get("md"): + content = note.data["content"]["md"] + + return json.dumps( + { + "id": note.id, + "title": note.title, + "content": content, + "updated_at": note.updated_at, + "created_at": note.created_at, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"view_note error: {e}") + return json.dumps({"error": str(e)}) + + +async def write_note( + title: str, + content: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Create a new note with the given title and content. + + :param title: The title of the new note + :param content: The markdown content for the note + :return: JSON with success status and new note id + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.notes import NoteForm + + user_id = __user__.get("id") + + form = NoteForm( + title=title, + data={"content": {"md": content}}, + access_control={}, # Private by default - only owner can access + ) + + new_note = Notes.insert_new_note(user_id, form) + + if not new_note: + return json.dumps({"error": "Failed to create note"}) + + return json.dumps( + { + "status": "success", + "id": new_note.id, + "title": new_note.title, + "created_at": new_note.created_at, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"write_note error: {e}") + return json.dumps({"error": str(e)}) + + +async def replace_note_content( + note_id: str, + content: str, + title: Optional[str] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Update the content of a note. Use this to modify task lists, add notes, or update content. + + :param note_id: The ID of the note to update + :param content: The new markdown content for the note + :param title: Optional new title for the note + :return: JSON with success status and updated note info + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.notes import NoteUpdateForm + + note = Notes.get_note_by_id(note_id) + + if not note: + return json.dumps({"error": "Note not found"}) + + # Check write permission + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + from open_webui.utils.access_control import has_access + + if note.user_id != user_id and not has_access( + user_id, "write", note.access_control, user_group_ids + ): + return json.dumps({"error": "Write access denied"}) + + # Build update form + update_data = {"data": {"content": {"md": content}}} + if title: + update_data["title"] = title + + form = NoteUpdateForm(**update_data) + updated_note = Notes.update_note_by_id(note_id, form) + + if not updated_note: + return json.dumps({"error": "Failed to update note"}) + + return json.dumps( + { + "status": "success", + "id": updated_note.id, + "title": updated_note.title, + "updated_at": updated_note.updated_at, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"replace_note_content error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# CHATS TOOLS +# ============================================================================= + + +async def search_chats( + query: str, + count: int = 5, + start_timestamp: Optional[int] = None, + end_timestamp: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search the user's previous chat conversations by title and message content. + + :param query: The search query to find matching chats + :param count: Maximum number of results to return (default: 5) + :param start_timestamp: Only include chats updated after this Unix timestamp (seconds) + :param end_timestamp: Only include chats updated before this Unix timestamp (seconds) + :return: JSON with matching chats containing id, title, updated_at, and content snippet + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + chats = Chats.get_chats_by_user_id_and_search_text( + user_id=user_id, + search_text=query, + include_archived=False, + skip=0, + limit=count * 3, # Fetch more for filtering + ) + + results = [] + for chat in chats: + # Apply date filters (updated_at is in seconds) + if start_timestamp and chat.updated_at < start_timestamp: + continue + if end_timestamp and chat.updated_at > end_timestamp: + continue + + # Find a matching message snippet + snippet = "" + messages = chat.chat.get("history", {}).get("messages", {}) + lower_query = query.lower() + + for msg_id, msg in messages.items(): + content = msg.get("content", "") + if isinstance(content, str) and lower_query in content.lower(): + idx = content.lower().find(lower_query) + start = max(0, idx - 50) + end = min(len(content), idx + len(query) + 100) + snippet = ( + ("..." if start > 0 else "") + + content[start:end] + + ("..." if end < len(content) else "") + ) + break + + if not snippet and lower_query in chat.title.lower(): + snippet = f"Title match: {chat.title}" + + results.append( + { + "id": chat.id, + "title": chat.title, + "snippet": snippet, + "updated_at": chat.updated_at, + } + ) + + if len(results) >= count: + break + + return json.dumps(results, ensure_ascii=False) + except Exception as e: + log.exception(f"search_chats error: {e}") + return json.dumps({"error": str(e)}) + + +async def view_chat( + chat_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the full conversation history of a chat by its ID. + + :param chat_id: The ID of the chat to retrieve + :return: JSON with the chat's id, title, and messages + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + chat = Chats.get_chat_by_id_and_user_id(chat_id, user_id) + + if not chat: + return json.dumps({"error": "Chat not found or access denied"}) + + # Extract messages from history + messages = [] + history = chat.chat.get("history", {}) + msg_dict = history.get("messages", {}) + + # Build message chain from currentId + current_id = history.get("currentId") + visited = set() + + while current_id and current_id not in visited: + visited.add(current_id) + msg = msg_dict.get(current_id) + if msg: + messages.append( + { + "role": msg.get("role", ""), + "content": msg.get("content", ""), + } + ) + current_id = msg.get("parentId") if msg else None + + # Reverse to get chronological order + messages.reverse() + + return json.dumps( + { + "id": chat.id, + "title": chat.title, + "messages": messages, + "updated_at": chat.updated_at, + "created_at": chat.created_at, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"view_chat error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# CHANNELS TOOLS +# ============================================================================= + + +async def search_channels( + query: str, + count: int = 5, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search for channels by name and description that the user has access to. + + :param query: The search query to find matching channels + :param count: Maximum number of results to return (default: 5) + :return: JSON with matching channels containing id, name, description, and type + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + # Get all channels the user has access to + all_channels = Channels.get_channels_by_user_id(user_id) + + # Filter by query + lower_query = query.lower() + matching_channels = [] + + for channel in all_channels: + name_match = lower_query in channel.name.lower() if channel.name else False + desc_match = lower_query in (channel.description or "").lower() + + if name_match or desc_match: + matching_channels.append( + { + "id": channel.id, + "name": channel.name, + "description": channel.description or "", + "type": channel.type or "public", + } + ) + + if len(matching_channels) >= count: + break + + return json.dumps(matching_channels, ensure_ascii=False) + except Exception as e: + log.exception(f"search_channels error: {e}") + return json.dumps({"error": str(e)}) + + +async def search_channel_messages( + query: str, + count: int = 10, + start_timestamp: Optional[int] = None, + end_timestamp: Optional[int] = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search for messages in channels the user is a member of, including thread replies. + + :param query: The search query to find matching messages + :param count: Maximum number of results to return (default: 10) + :param start_timestamp: Only include messages created after this Unix timestamp (seconds) + :param end_timestamp: Only include messages created before this Unix timestamp (seconds) + :return: JSON with matching messages containing channel info, message content, and thread context + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + # Get all channels the user has access to + user_channels = Channels.get_channels_by_user_id(user_id) + channel_ids = [c.id for c in user_channels] + channel_map = {c.id: c for c in user_channels} + + if not channel_ids: + return json.dumps([]) + + # Convert timestamps to nanoseconds (Message.created_at is in nanoseconds) + start_ts = start_timestamp * 1_000_000_000 if start_timestamp else None + end_ts = end_timestamp * 1_000_000_000 if end_timestamp else None + + # Search messages using the model method + matching_messages = Messages.search_messages_by_channel_ids( + channel_ids=channel_ids, + query=query, + start_timestamp=start_ts, + end_timestamp=end_ts, + limit=count, + ) + + results = [] + for msg in matching_messages: + channel = channel_map.get(msg.channel_id) + + # Extract snippet around the match + content = msg.content or "" + lower_query = query.lower() + idx = content.lower().find(lower_query) + if idx != -1: + start = max(0, idx - 50) + end = min(len(content), idx + len(query) + 100) + snippet = ( + ("..." if start > 0 else "") + + content[start:end] + + ("..." if end < len(content) else "") + ) + else: + snippet = content[:150] + ("..." if len(content) > 150 else "") + + results.append( + { + "channel_id": msg.channel_id, + "channel_name": channel.name if channel else "Unknown", + "message_id": msg.id, + "content_snippet": snippet, + "is_thread_reply": msg.parent_id is not None, + "parent_id": msg.parent_id, + "created_at": msg.created_at, + } + ) + + return json.dumps(results, ensure_ascii=False) + except Exception as e: + log.exception(f"search_channel_messages error: {e}") + return json.dumps({"error": str(e)}) + + +async def view_channel_message( + message_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the full content of a channel message by its ID, including thread replies. + + :param message_id: The ID of the message to retrieve + :return: JSON with the message content, channel info, and thread replies if any + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + message = Messages.get_message_by_id(message_id) + + if not message: + return json.dumps({"error": "Message not found"}) + + # Verify user has access to the channel + channel = Channels.get_channel_by_id(message.channel_id) + if not channel: + return json.dumps({"error": "Channel not found"}) + + # Check if user has access to the channel + user_channels = Channels.get_channels_by_user_id(user_id) + channel_ids = [c.id for c in user_channels] + + if message.channel_id not in channel_ids: + return json.dumps({"error": "Access denied"}) + + # Build response with thread information + result = { + "id": message.id, + "channel_id": message.channel_id, + "channel_name": channel.name, + "content": message.content, + "user_id": message.user_id, + "is_thread_reply": message.parent_id is not None, + "parent_id": message.parent_id, + "reply_count": message.reply_count, + "created_at": message.created_at, + "updated_at": message.updated_at, + } + + # Include user info if available + if message.user: + result["user_name"] = message.user.name + + return json.dumps(result, ensure_ascii=False) + except Exception as e: + log.exception(f"view_channel_message error: {e}") + return json.dumps({"error": str(e)}) + + +async def view_channel_thread( + parent_message_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get all messages in a channel thread, including the parent message and all replies. + + :param parent_message_id: The ID of the parent message that started the thread + :return: JSON with the parent message and all thread replies in chronological order + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + user_id = __user__.get("id") + + # Get the parent message + parent_message = Messages.get_message_by_id(parent_message_id) + + if not parent_message: + return json.dumps({"error": "Message not found"}) + + # Verify user has access to the channel + channel = Channels.get_channel_by_id(parent_message.channel_id) + if not channel: + return json.dumps({"error": "Channel not found"}) + + user_channels = Channels.get_channels_by_user_id(user_id) + channel_ids = [c.id for c in user_channels] + + if parent_message.channel_id not in channel_ids: + return json.dumps({"error": "Access denied"}) + + # Get all thread replies + thread_replies = Messages.get_thread_replies_by_message_id(parent_message_id) + + # Build the response + messages = [] + + # Add parent message first + messages.append( + { + "id": parent_message.id, + "content": parent_message.content, + "user_id": parent_message.user_id, + "user_name": parent_message.user.name if parent_message.user else None, + "is_parent": True, + "created_at": parent_message.created_at, + } + ) + + # Add thread replies (reverse to get chronological order) + for reply in reversed(thread_replies): + messages.append( + { + "id": reply.id, + "content": reply.content, + "user_id": reply.user_id, + "user_name": reply.user.name if reply.user else None, + "is_parent": False, + "reply_to_id": reply.reply_to_id, + "created_at": reply.created_at, + } + ) + + return json.dumps( + { + "channel_id": parent_message.channel_id, + "channel_name": channel.name, + "thread_id": parent_message_id, + "message_count": len(messages), + "messages": messages, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f"view_channel_thread error: {e}") + return json.dumps({"error": str(e)}) + + +# ============================================================================= +# KNOWLEDGE BASE TOOLS +# ============================================================================= + + +async def list_knowledge_bases( + count: int = 10, + skip: int = 0, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + List the user's accessible knowledge bases. + + :param count: Maximum number of KBs to return (default: 10) + :param skip: Number of results to skip for pagination (default: 0) + :return: JSON with KBs containing id, name, description, and file_count + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.knowledge import Knowledges + + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + result = Knowledges.search_knowledge_bases( + user_id, + filter={ + "query": "", + "user_id": user_id, + "group_ids": user_group_ids, + }, + skip=skip, + limit=count, + ) + + knowledge_bases = [] + for knowledge_base in result.items: + files = Knowledges.get_files_by_id(knowledge_base.id) + file_count = len(files) if files else 0 + + knowledge_bases.append( + { + "id": knowledge_base.id, + "name": knowledge_base.name, + "description": knowledge_base.description or "", + "file_count": file_count, + "updated_at": knowledge_base.updated_at, + } + ) + + return json.dumps(knowledge_bases, ensure_ascii=False) + except Exception as e: + log.exception(f"list_knowledge_bases error: {e}") + return json.dumps({"error": str(e)}) + + +async def search_knowledge_bases( + query: str, + count: int = 5, + skip: int = 0, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search the user's accessible knowledge bases by name and description. + + :param query: The search query to find matching knowledge bases + :param count: Maximum number of results to return (default: 5) + :param skip: Number of results to skip for pagination (default: 0) + :return: JSON with matching KBs containing id, name, description, and file_count + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.knowledge import Knowledges + + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + result = Knowledges.search_knowledge_bases( + user_id, + filter={ + "query": query, + "user_id": user_id, + "group_ids": user_group_ids, + }, + skip=skip, + limit=count, + ) + + knowledge_bases = [] + for knowledge_base in result.items: + files = Knowledges.get_files_by_id(knowledge_base.id) + file_count = len(files) if files else 0 + + knowledge_bases.append( + { + "id": knowledge_base.id, + "name": knowledge_base.name, + "description": knowledge_base.description or "", + "file_count": file_count, + "updated_at": knowledge_base.updated_at, + } + ) + + return json.dumps(knowledge_bases, ensure_ascii=False) + except Exception as e: + log.exception(f"search_knowledge_bases error: {e}") + return json.dumps({"error": str(e)}) + + +async def search_knowledge_files( + query: str, + knowledge_id: Optional[str] = None, + count: int = 5, + skip: int = 0, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search files across knowledge bases the user has access to. + + :param query: The search query to find matching files by filename + :param knowledge_id: Optional KB id to limit search to a specific knowledge base + :param count: Maximum number of results to return (default: 5) + :param skip: Number of results to skip for pagination (default: 0) + :return: JSON with matching files containing id, filename, and updated_at + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.knowledge import Knowledges + + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + if knowledge_id: + result = Knowledges.search_files_by_id( + knowledge_id=knowledge_id, + user_id=user_id, + filter={"query": query}, + skip=skip, + limit=count, + ) + else: + result = Knowledges.search_knowledge_files( + filter={ + "query": query, + "user_id": user_id, + "group_ids": user_group_ids, + }, + skip=skip, + limit=count, + ) + + files = [] + for file in result.items: + file_info = { + "id": file.id, + "filename": file.filename, + "updated_at": file.updated_at, + } + if hasattr(file, "collection") and file.collection: + file_info["knowledge_id"] = file.collection.get("id", "") + file_info["knowledge_name"] = file.collection.get("name", "") + files.append(file_info) + + return json.dumps(files, ensure_ascii=False) + except Exception as e: + log.exception(f"search_knowledge_files error: {e}") + return json.dumps({"error": str(e)}) + + +async def view_knowledge_file( + file_id: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Get the full content of a file from a knowledge base. + + :param file_id: The ID of the file to retrieve + :return: JSON with the file's id, filename, and full text content + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.files import Files + from open_webui.models.knowledge import Knowledges + from open_webui.utils.access_control import has_access + + user_id = __user__.get("id") + user_role = __user__.get("role", "user") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + file = Files.get_file_by_id(file_id) + if not file: + return json.dumps({"error": "File not found"}) + + # Check access via any KB containing this file + knowledges = Knowledges.get_knowledges_by_file_id(file_id) + has_knowledge_access = False + knowledge_info = None + + for knowledge_base in knowledges: + if ( + user_role == "admin" + or knowledge_base.user_id == user_id + or has_access( + user_id, "read", knowledge_base.access_control, user_group_ids + ) + ): + has_knowledge_access = True + knowledge_info = {"id": knowledge_base.id, "name": knowledge_base.name} + break + + if not has_knowledge_access: + if file.user_id != user_id and user_role != "admin": + return json.dumps({"error": "Access denied"}) + + content = "" + if file.data: + content = file.data.get("content", "") + + result = { + "id": file.id, + "filename": file.filename, + "content": content, + "updated_at": file.updated_at, + "created_at": file.created_at, + } + if knowledge_info: + result["knowledge_id"] = knowledge_info["id"] + result["knowledge_name"] = knowledge_info["name"] + + return json.dumps(result, ensure_ascii=False) + except Exception as e: + log.exception(f"view_knowledge_file error: {e}") + return json.dumps({"error": str(e)}) + + +async def query_knowledge_files( + query: str, + knowledge_ids: Optional[list[str]] = None, + count: int = 5, + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: list[dict] = None, +) -> str: + """ + Search knowledge base files using semantic/vector search. This should be your first + choice for finding information before searching the web. Searches across collections (KBs), + individual files, and notes that the user has access to. + + :param query: The search query to find semantically relevant content + :param knowledge_ids: Optional list of KB ids to limit search to specific knowledge bases + :param count: Maximum number of results to return (default: 5) + :return: JSON with relevant chunks containing content, source filename, and relevance score + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.notes import Notes + from open_webui.retrieval.utils import query_collection + from open_webui.utils.access_control import has_access + + user_id = __user__.get("id") + user_role = __user__.get("role", "user") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + embedding_function = __request__.app.state.EMBEDDING_FUNCTION + if not embedding_function: + return json.dumps({"error": "Embedding function not configured"}) + + collection_names = [] + note_results = [] # Notes aren't vectorized, handle separately + + # If model has attached knowledge, use those + if __model_knowledge__: + for item in __model_knowledge__: + item_type = item.get("type") + item_id = item.get("id") + + if item_type == "collection": + # Knowledge base - use KB ID as collection name + knowledge = Knowledges.get_knowledge_by_id(item_id) + if knowledge and ( + user_role == "admin" + or knowledge.user_id == user_id + or has_access( + user_id, "read", knowledge.access_control, user_group_ids + ) + ): + collection_names.append(item_id) + + elif item_type == "file": + # Individual file - use file-{id} as collection name + file = Files.get_file_by_id(item_id) + if file and (user_role == "admin" or file.user_id == user_id): + collection_names.append(f"file-{item_id}") + + elif item_type == "note": + # Note - always return full content as context + note = Notes.get_note_by_id(item_id) + if note and ( + user_role == "admin" + or note.user_id == user_id + or has_access(user_id, "read", note.access_control) + ): + content = note.data.get("content", {}).get("md", "") + note_results.append( + { + "content": content, + "source": note.title, + "note_id": note.id, + "type": "note", + } + ) + + elif knowledge_ids: + # User specified specific KBs + for knowledge_id in knowledge_ids: + knowledge = Knowledges.get_knowledge_by_id(knowledge_id) + if knowledge and ( + user_role == "admin" + or knowledge.user_id == user_id + or has_access( + user_id, "read", knowledge.access_control, user_group_ids + ) + ): + collection_names.append(knowledge_id) + else: + # No model knowledge and no specific IDs - search all accessible KBs + result = Knowledges.search_knowledge_bases( + user_id, + filter={ + "query": "", + "user_id": user_id, + "group_ids": user_group_ids, + }, + skip=0, + limit=50, + ) + collection_names = [knowledge_base.id for knowledge_base in result.items] + + chunks = [] + + # Add note results first + chunks.extend(note_results) + + # Query vector collections if any + if collection_names: + query_results = await query_collection( + collection_names=collection_names, + queries=[query], + embedding_function=embedding_function, + k=count, + ) + + if query_results and "documents" in query_results: + documents = query_results.get("documents", [[]])[0] + metadatas = query_results.get("metadatas", [[]])[0] + distances = query_results.get("distances", [[]])[0] + + for idx, doc in enumerate(documents): + chunk_info = { + "content": doc, + "source": metadatas[idx].get( + "source", metadatas[idx].get("name", "Unknown") + ), + "file_id": metadatas[idx].get("file_id", ""), + } + if idx < len(distances): + chunk_info["distance"] = distances[idx] + chunks.append(chunk_info) + + # Limit to requested count + chunks = chunks[:count] + + return json.dumps(chunks, ensure_ascii=False) + except Exception as e: + log.exception(f"query_knowledge_files error: {e}") + return json.dumps({"error": str(e)}) + + +async def query_knowledge_bases( + query: str, + count: int = 5, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Search knowledge bases by semantic similarity to query. + Finds KBs whose name/description match the meaning of your query. + Use this to discover relevant knowledge bases before querying their files. + + :param query: Natural language query describing what you're looking for + :param count: Maximum results (default: 5) + :return: JSON with matching KBs (id, name, description, similarity) + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + import heapq + from open_webui.models.knowledge import Knowledges + from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION + from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT + + user_id = __user__.get("id") + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + query_embedding = await __request__.app.state.EMBEDDING_FUNCTION(query) + + # Min-heap of (distance, knowledge_base_id) - only holds top `count` results + top_results_heap = [] + seen_ids = set() + page_offset = 0 + page_size = 100 + + while True: + accessible_knowledge_bases = Knowledges.search_knowledge_bases( + user_id, + filter={"user_id": user_id, "group_ids": user_group_ids}, + skip=page_offset, + limit=page_size, + ) + + if not accessible_knowledge_bases.items: + break + + accessible_ids = [kb.id for kb in accessible_knowledge_bases.items] + + search_results = VECTOR_DB_CLIENT.search( + collection_name=KNOWLEDGE_BASES_COLLECTION, + vectors=[query_embedding], + filter={"knowledge_base_id": {"$in": accessible_ids}}, + limit=count, + ) + + if search_results and search_results.ids and search_results.ids[0]: + result_ids = search_results.ids[0] + result_distances = ( + search_results.distances[0] + if search_results.distances + else [0] * len(result_ids) + ) + + for knowledge_base_id, distance in zip(result_ids, result_distances): + if knowledge_base_id in seen_ids: + continue + seen_ids.add(knowledge_base_id) + + if len(top_results_heap) < count: + heapq.heappush(top_results_heap, (distance, knowledge_base_id)) + elif distance > top_results_heap[0][0]: + heapq.heapreplace( + top_results_heap, (distance, knowledge_base_id) + ) + + page_offset += page_size + if len(accessible_knowledge_bases.items) < page_size: + break + if page_offset >= MAX_KNOWLEDGE_BASE_SEARCH_ITEMS: + break + + # Sort by distance descending (best first) and fetch KB details + sorted_results = sorted(top_results_heap, key=lambda x: x[0], reverse=True) + + matching_knowledge_bases = [] + for distance, knowledge_base_id in sorted_results: + knowledge_base = Knowledges.get_knowledge_by_id(knowledge_base_id) + if knowledge_base: + matching_knowledge_bases.append( + { + "id": knowledge_base.id, + "name": knowledge_base.name, + "description": knowledge_base.description or "", + "similarity": round(distance, 4), + } + ) + + return json.dumps(matching_knowledge_bases, ensure_ascii=False) + + except Exception as e: + log.exception(f"query_knowledge_bases error: {e}") + return json.dumps({"error": str(e)}) diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control.py index 97d0b414913..7784f6efd7b 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control.py @@ -28,6 +28,7 @@ def fill_missing_permissions( def get_permissions( user_id: str, default_permissions: Dict[str, Any], + db: Optional[Any] = None, ) -> Dict[str, Any]: """ Get all permissions for a user by combining the permissions of all groups the user is a member of. @@ -53,7 +54,7 @@ def combine_permissions( ) # Use the most permissive value (True > False) return permissions - user_groups = Groups.get_groups_by_member_id(user_id) + user_groups = Groups.get_groups_by_member_id(user_id, db=db) # Deep copy default permissions to avoid modifying the original dict permissions = json.loads(json.dumps(default_permissions)) @@ -72,6 +73,7 @@ def has_permission( user_id: str, permission_key: str, default_permissions: Dict[str, Any] = {}, + db: Optional[Any] = None, ) -> bool: """ Check if a user has a specific permission by checking the group permissions @@ -92,7 +94,7 @@ def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool: permission_hierarchy = permission_key.split(".") # Retrieve user group permissions - user_groups = Groups.get_groups_by_member_id(user_id) + user_groups = Groups.get_groups_by_member_id(user_id, db=db) for group in user_groups: if get_permission(group.permissions or {}, permission_hierarchy): @@ -127,6 +129,7 @@ def has_access( access_control: Optional[dict] = None, user_group_ids: Optional[Set[str]] = None, strict: bool = True, + db: Optional[Any] = None, ) -> bool: if access_control is None: if strict: @@ -135,7 +138,7 @@ def has_access( return True if user_group_ids is None: - user_groups = Groups.get_groups_by_member_id(user_id) + user_groups = Groups.get_groups_by_member_id(user_id, db=db) user_group_ids = {group.id for group in user_groups} permitted_ids = get_permitted_group_and_user_ids(type, access_control) @@ -152,10 +155,10 @@ def has_access( # Get all users with access to a resource def get_users_with_access( - type: str = "write", access_control: Optional[dict] = None + type: str = "write", access_control: Optional[dict] = None, db: Optional[Any] = None ) -> list[UserModel]: if access_control is None: - result = Users.get_users(filter={"roles": ["!pending"]}) + result = Users.get_users(filter={"roles": ["!pending"]}, db=db) return result.get("users", []) permitted_ids = get_permitted_group_and_user_ids(type, access_control) @@ -167,8 +170,8 @@ def get_users_with_access( user_ids_with_access = set(permitted_user_ids) - group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids) + group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids, db=db) for user_ids in group_user_ids_map.values(): user_ids_with_access.update(user_ids) - return Users.get_users_by_user_ids(list(user_ids_with_access)) + return Users.get_users_by_user_ids(list(user_ids_with_access), db=db) diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 23fe517150a..c1f6910ddb4 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -24,6 +24,8 @@ from open_webui.utils.access_control import has_permission from open_webui.models.users import Users +from open_webui.models.auths import Auths + from open_webui.constants import ERROR_MESSAGES @@ -37,7 +39,6 @@ WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY, STATIC_DIR, - SRC_LOG_LEVELS, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ) @@ -46,7 +47,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OAUTH"]) SESSION_SECRET = WEBUI_SECRET_KEY ALGORITHM = "HS256" @@ -230,6 +230,10 @@ async def is_valid_token(request, decoded) -> bool: async def invalidate_token(request, token): decoded = decode_token(token) + # If token is invalid/expired, nothing to revoke + if not decoded: + return + # Require Redis to store revoked tokens if request.app.state.redis: jti = decoded.get("jti") @@ -273,6 +277,10 @@ async def get_current_user( response: Response, background_tasks: BackgroundTasks, auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), + # NOTE: We intentionally do NOT use Depends(get_session) here. + # Sessions are managed internally with short-lived context managers. + # This ensures connections are released immediately after auth queries, + # not held for the entire request duration (e.g., during 30+ second LLM calls). ): token = None @@ -367,6 +375,7 @@ async def get_current_user( def get_current_user_by_api_key(request, api_key: str): + # Each function call manages its own short-lived session internally user = Users.get_user_by_api_key(api_key) if user is None: @@ -415,3 +424,37 @@ def get_admin_user(user=Depends(get_current_user)): detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) return user + + +def create_admin_user(email: str, password: str, name: str = "Admin"): + """ + Create an admin user from environment variables. + Used for headless/automated deployments. + Returns the created user or None if creation failed. + """ + + if not email or not password: + return None + + if Users.has_users(): + log.debug("Users already exist, skipping admin creation") + return None + + log.info(f"Creating admin account from environment variables: {email}") + try: + hashed = get_password_hash(password) + user = Auths.insert_new_auth( + email=email.lower(), + password=hashed, + name=name, + role="admin", + ) + if user: + log.info(f"Admin account created successfully: {email}") + return user + else: + log.error("Failed to create admin account from environment variables") + return None + except Exception as e: + log.error(f"Error creating admin account: {e}") + return None diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 8b6a0b9da24..be700dda761 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -55,12 +55,11 @@ process_filter_functions, ) -from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL +from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) async def generate_direct_chat_completion( @@ -167,6 +166,7 @@ async def generate_chat_completion( form_data: dict, user: Any, bypass_filter: bool = False, + bypass_system_prompt: bool = False, ): log.debug(f"generate_chat_completion: {form_data}") if BYPASS_MODEL_ACCESS_CONTROL: @@ -238,7 +238,11 @@ async def stream_wrapper(stream): yield chunk response = await generate_chat_completion( - request, form_data, user, bypass_filter=True + request, + form_data, + user, + bypass_filter=True, + bypass_system_prompt=bypass_system_prompt, ) return StreamingResponse( stream_wrapper(response.body_iterator), @@ -249,7 +253,11 @@ async def stream_wrapper(stream): return { **( await generate_chat_completion( - request, form_data, user, bypass_filter=True + request, + form_data, + user, + bypass_filter=True, + bypass_system_prompt=bypass_system_prompt, ) ), "selected_model_id": selected_model_id, @@ -268,6 +276,7 @@ async def stream_wrapper(stream): form_data=form_data, user=user, bypass_filter=bypass_filter, + bypass_system_prompt=bypass_system_prompt, ) if form_data.get("stream"): response.headers["content-type"] = "text/event-stream" @@ -284,6 +293,7 @@ async def stream_wrapper(stream): form_data=form_data, user=user, bypass_filter=bypass_filter, + bypass_system_prompt=bypass_system_prompt, ) @@ -311,7 +321,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any): try: data = await process_pipeline_outlet_filter(request, data, user, models) except Exception as e: - return Exception(f"Error: {e}") + raise Exception(f"Error: {e}") metadata = { "chat_id": data["chat_id"], @@ -347,7 +357,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any): ) return result except Exception as e: - return Exception(f"Error: {e}") + raise Exception(f"Error: {e}") async def chat_action(request: Request, action_id: str, form_data: dict, user: Any): @@ -443,6 +453,6 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A data = action(**params) except Exception as e: - return Exception(f"Error: {e}") + raise Exception(f"Error: {e}") return data diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index f3dcbb81fbd..e89b970cb66 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -8,10 +8,8 @@ import websockets from pydantic import BaseModel -from open_webui.env import SRC_LOG_LEVELS logger = logging.getLogger(__name__) -logger.setLevel(SRC_LOG_LEVELS["MAIN"]) class ResultModel(BaseModel): diff --git a/backend/open_webui/utils/db/access_control.py b/backend/open_webui/utils/db/access_control.py new file mode 100644 index 00000000000..75bd337f8c7 --- /dev/null +++ b/backend/open_webui/utils/db/access_control.py @@ -0,0 +1,124 @@ +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON +from sqlalchemy.dialects.postgresql import JSONB + + +from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func + + +def has_permission(db, DocumentModel, 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( + DocumentModel.access_control["read"]["group_ids"].contains(gid) + ) + elif dialect_name == "postgresql": + group_read_conditions.append( + cast( + DocumentModel.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(DocumentModel.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( + DocumentModel.access_control["write"]["group_ids"].contains(gid) + ) + elif dialect_name == "postgresql": + group_write_conditions.append( + cast( + DocumentModel.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(DocumentModel.access_control.isnot(None)) + write_exclusions.append(cast(DocumentModel.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( + [ + DocumentModel.access_control.is_(None), + cast(DocumentModel.access_control, String) == "null", + ] + ) + + # User-level permission (owner has all permissions) + if user_id: + conditions.append(DocumentModel.user_id == user_id) + + # Group-level permission + if group_ids: + group_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_conditions.append( + DocumentModel.access_control[permission]["group_ids"].contains(gid) + ) + elif dialect_name == "postgresql": + group_conditions.append( + cast( + DocumentModel.access_control[permission]["group_ids"], + JSONB, + ).contains([gid]) + ) + conditions.append(or_(*group_conditions)) + + if conditions: + query = query.filter(or_(*conditions)) + + return query diff --git a/backend/open_webui/utils/embeddings.py b/backend/open_webui/utils/embeddings.py index 49ce72c3c54..43cbc56e5fc 100644 --- a/backend/open_webui/utils/embeddings.py +++ b/backend/open_webui/utils/embeddings.py @@ -6,7 +6,7 @@ from open_webui.models.users import UserModel from open_webui.models.models import Models from open_webui.utils.models import check_model_access -from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL +from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL from open_webui.routers.openai import embeddings as openai_embeddings from open_webui.routers.ollama import ( @@ -20,7 +20,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) async def generate_embeddings( diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 4f9564b7d4e..a37ecf31c6e 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -10,7 +10,13 @@ Request, UploadFile, ) +from typing import Optional +from pathlib import Path +from open_webui.storage.provider import Storage + +from open_webui.models.chats import Chats +from open_webui.models.files import Files from open_webui.routers.files import upload_file_handler import mimetypes @@ -18,24 +24,57 @@ import io import re +import requests BASE64_IMAGE_URL_PREFIX = re.compile(r"data:image/\w+;base64,", re.IGNORECASE) MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE) +def get_image_base64_from_url(url: str) -> Optional[str]: + try: + if url.startswith("http"): + # Download the image from the URL + response = requests.get(url) + response.raise_for_status() + image_data = response.content + encoded_string = base64.b64encode(image_data).decode("utf-8") + content_type = response.headers.get("Content-Type", "image/png") + return f"data:{content_type};base64,{encoded_string}" + else: + file = Files.get_file_by_id(url) + + if not file: + return None + + file_path = Storage.get_file(file.path) + file_path = Path(file_path) + + if file_path.is_file(): + with open(file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode("utf-8") + content_type, _ = mimetypes.guess_type(file_path.name) + return f"data:{content_type};base64,{encoded_string}" + else: + return None + + except Exception as e: + return None + + def get_image_url_from_base64(request, base64_image_string, metadata, user): if BASE64_IMAGE_URL_PREFIX.match(base64_image_string): image_url = "" # Extract base64 image data from the line image_data, content_type = get_image_data(base64_image_string) if image_data is not None: - image_url = upload_image( + _, image_url = upload_image( request, image_data, content_type, metadata, user, ) + return image_url return None @@ -113,3 +152,26 @@ def get_file_url_from_base64(request, base64_file_string, metadata, user): elif "data:audio/wav;base64" in base64_file_string: return get_audio_url_from_base64(request, base64_file_string, metadata, user) return None + + +def get_image_base64_from_file_id(id: str) -> Optional[str]: + file = Files.get_file_by_id(id) + if not file: + return None + + try: + file_path = Storage.get_file(file.path) + file_path = Path(file_path) + + # Check if the file already exists in the cache + if file_path.is_file(): + import base64 + + with open(file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode("utf-8") + content_type, _ = mimetypes.guess_type(file_path.name) + return f"data:{content_type};base64,{encoded_string}" + else: + return None + except Exception as e: + return None diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 663b4e3fb7d..37349d29023 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -6,10 +6,8 @@ get_function_module_from_cache, ) from open_webui.models.functions import Functions -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) def get_function_module(request, function_id, load_from_db=True): diff --git a/backend/open_webui/utils/groups.py b/backend/open_webui/utils/groups.py index 0f15f27e2cd..26fc5d8434f 100644 --- a/backend/open_webui/utils/groups.py +++ b/backend/open_webui/utils/groups.py @@ -7,6 +7,7 @@ def apply_default_group_assignment( default_group_id: str, user_id: str, + db=None, ) -> None: """ Apply default group assignment to a user if default_group_id is provided. @@ -17,7 +18,7 @@ def apply_default_group_assignment( """ if default_group_id: try: - Groups.add_users_to_group(default_group_id, [user_id]) + Groups.add_users_to_group(default_group_id, [user_id], db=db) except Exception as e: log.error( f"Failed to add user {user_id} to default group {default_group_id}: {e}" diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 506723bc929..3c402cbc17f 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -9,11 +9,9 @@ from typing import Optional import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client) -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["COMFYUI"]) default_headers = {"User-Agent": "Mozilla/5.0"} @@ -66,8 +64,8 @@ def get_history(prompt_id, base_url, api_key): return json.loads(response.read()) -def get_images(ws, prompt, client_id, base_url, api_key): - prompt_id = queue_prompt(prompt, client_id, base_url, api_key)["prompt_id"] +def get_images(ws, workflow, client_id, base_url, api_key): + prompt_id = queue_prompt(workflow, client_id, base_url, api_key)["prompt_id"] output_images = [] while True: out = ws.recv() @@ -81,9 +79,12 @@ def get_images(ws, prompt, client_id, base_url, api_key): continue # previews are binary data history = get_history(prompt_id, base_url, api_key)[prompt_id] - for o in history["outputs"]: - for node_id in history["outputs"]: - node_output = history["outputs"][node_id] + for node_id in history["outputs"]: + node_output = history["outputs"][node_id] + if node_id in workflow and workflow[node_id].get("class_type") in [ + "SaveImage", + "PreviewImage", + ]: if "images" in node_output: for image in node_output["images"]: url = get_image_url( diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 540527bf823..4af3064235b 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -6,11 +6,13 @@ from loguru import logger from opentelemetry import trace from open_webui.env import ( - AUDIT_UVICORN_LOGGER_NAMES, + ENABLE_AUDIT_STDOUT, + ENABLE_AUDIT_LOGS_FILE, + AUDIT_LOGS_FILE_PATH, AUDIT_LOG_FILE_ROTATION_SIZE, AUDIT_LOG_LEVEL, - AUDIT_LOGS_FILE_PATH, GLOBAL_LOG_LEVEL, + AUDIT_UVICORN_LOGGER_NAMES, ENABLE_OTEL, ENABLE_OTEL_LOGS, ) @@ -130,9 +132,11 @@ def start_logger(): sys.stdout, level=GLOBAL_LOG_LEVEL, format=stdout_format, - filter=lambda record: "auditable" not in record["extra"], + filter=lambda record: ( + "auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True + ), ) - if AUDIT_LOG_LEVEL != "NONE": + if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE: try: logger.add( AUDIT_LOGS_FILE_PATH, diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 140d2bc85d9..fe2d7e5dc1d 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -44,6 +44,7 @@ process_web_search, SearchForm, ) +from open_webui.utils.tools import get_builtin_tools from open_webui.routers.images import ( image_generations, CreateImageForm, @@ -60,6 +61,7 @@ from open_webui.utils.files import ( convert_markdown_base64_images, get_file_url_from_base64, + get_image_base64_from_url, get_image_url_from_base64, ) @@ -91,7 +93,11 @@ convert_logit_bias_input_to_json, get_content_from_message, ) -from open_webui.utils.tools import get_tools, get_updated_tool_function +from open_webui.utils.tools import ( + get_tools, + get_updated_tool_function, + has_tool_server_access, +) from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -110,7 +116,6 @@ CODE_INTERPRETER_BLOCKED_MODULES, ) from open_webui.env import ( - SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION, CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, @@ -118,13 +123,13 @@ BYPASS_MODEL_ACCESS_CONTROL, ENABLE_REALTIME_CHAT_SAVE, ENABLE_QUERIES_CACHE, + RAG_SYSTEM_CONTEXT, ) from open_webui.constants import TASKS logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) DEFAULT_REASONING_TAGS = [ @@ -141,6 +146,196 @@ DEFAULT_CODE_INTERPRETER_TAGS = [("", "")] +def get_citation_source_from_tool_result( + tool_name: str, tool_params: dict, tool_result: str, tool_id: str = "" +) -> list[dict]: + """ + Parse a tool's result and convert it to source dicts for citation display. + + Follows the source format conventions from get_sources_from_items: + - source: file/item info object with id, name, type + - document: list of document contents + - metadata: list of metadata objects with source, file_id, name fields + + Returns a list of sources (usually one, but query_knowledge_files may return multiple). + """ + try: + if tool_name == "search_web": + # Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}] + results = json.loads(tool_result) + documents = [] + metadata = [] + + for result in results: + title = result.get("title", "") + link = result.get("link", "") + snippet = result.get("snippet", "") + + documents.append(f"{title}\n{snippet}") + metadata.append( + { + "source": link, + "name": title, + "url": link, + } + ) + + return [ + { + "source": {"name": "search_web", "id": "search_web"}, + "document": documents, + "metadata": metadata, + } + ] + + elif tool_name == "view_knowledge_file": + file_data = json.loads(tool_result) + filename = file_data.get("filename", "Unknown File") + file_id = file_data.get("id", "") + knowledge_name = file_data.get("knowledge_name", "") + + return [ + { + "source": { + "id": file_id, + "name": filename, + "type": "file", + }, + "document": [file_data.get("content", "")], + "metadata": [ + { + "file_id": file_id, + "name": filename, + "source": filename, + **( + {"knowledge_name": knowledge_name} + if knowledge_name + else {} + ), + } + ], + } + ] + + elif tool_name == "query_knowledge_files": + chunks = json.loads(tool_result) + + # Group chunks by source for better citation display + # Each unique source becomes a separate source entry + sources_by_file = {} + + for chunk in chunks: + source_name = chunk.get("source", "Unknown") + file_id = chunk.get("file_id", "") + note_id = chunk.get("note_id", "") + chunk_type = chunk.get("type", "file") + content = chunk.get("content", "") + + # Use file_id or note_id as the key + key = file_id or note_id or source_name + + if key not in sources_by_file: + sources_by_file[key] = { + "source": { + "id": file_id or note_id, + "name": source_name, + "type": chunk_type, + }, + "document": [], + "metadata": [], + } + + sources_by_file[key]["document"].append(content) + sources_by_file[key]["metadata"].append( + { + "file_id": file_id, + "name": source_name, + "source": source_name, + **({"note_id": note_id} if note_id else {}), + } + ) + + # Return all grouped sources as a list + if sources_by_file: + return list(sources_by_file.values()) + + # Empty result fallback + return [] + + else: + # Fallback for other tools + return [ + { + "source": { + "name": tool_name, + "type": "tool", + "id": tool_id or tool_name, + }, + "document": [str(tool_result)], + "metadata": [{"source": tool_name, "name": tool_name}], + } + ] + except Exception as e: + log.exception(f"Error parsing tool result for {tool_name}: {e}") + return [ + { + "source": {"name": tool_name, "type": "tool"}, + "document": [str(tool_result)], + "metadata": [{"source": tool_name}], + } + ] + + +def apply_source_context_to_messages( + request: Request, + messages: list, + sources: list, + user_message: str, +) -> list: + """ + Build source context from citation sources and apply to messages. + Uses RAG template to format context for model consumption. + """ + if not sources or not user_message: + return messages + + context_string = "" + citation_idx = {} + + for source in sources: + for doc, meta in zip(source.get("document", []), source.get("metadata", [])): + src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A" + if src_id not in citation_idx: + citation_idx[src_id] = len(citation_idx) + 1 + src_name = source.get("source", {}).get("name") + context_string += ( + f'{doc}\n" + ) + + context_string = context_string.strip() + if not context_string: + return messages + + if RAG_SYSTEM_CONTEXT: + return add_or_update_system_message( + rag_template( + request.app.state.config.RAG_TEMPLATE, context_string, user_message + ), + messages, + append=True, + ) + else: + return add_or_update_user_message( + rag_template( + request.app.state.config.RAG_TEMPLATE, context_string, user_message + ), + messages, + append=False, + ) + + def process_tool_result( request, tool_function_name, @@ -345,7 +540,7 @@ def get_tools_function_calling_payload(messages, task_model_id, content): sources = [] specs = [tool["spec"] for tool in tools.values()] - tools_specs = json.dumps(specs) + tools_specs = json.dumps(specs, ensure_ascii=False) if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != "": template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE @@ -716,17 +911,20 @@ async def chat_web_search_handler( return form_data -def get_last_images(message_list): +def get_images_from_messages(message_list): images = [] + for message in reversed(message_list): - images_flag = False + + message_images = [] for file in message.get("files", []): if file.get("type") == "image": - images.append(file.get("url")) - images_flag = True + message_images.append(file.get("url")) + elif file.get("content_type", "").startswith("image/"): + message_images.append(file.get("url")) - if images_flag: - break + if message_images: + images.append(message_images) return images @@ -752,15 +950,60 @@ def get_image_urls(delta_images, request, metadata, user) -> list[str]: return image_urls +def add_file_context(messages: list, chat_id: str, user) -> list: + """ + Add file URLs to messages for native function calling. + """ + if not chat_id or chat_id.startswith("local:"): + return messages + + chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) + if not chat: + return messages + + history = chat.chat.get("history", {}) + stored_messages = get_message_list( + history.get("messages", {}), history.get("currentId") + ) + + def format_file_tag(file): + attrs = f'type="{file.get("type", "file")}" url="{file["url"]}"' + if file.get("content_type"): + attrs += f' content_type="{file["content_type"]}"' + if file.get("name"): + attrs += f' name="{file["name"]}"' + return f"" + + for message, stored_message in zip(messages, stored_messages): + files_with_urls = [ + file for file in stored_message.get("files", []) if file.get("url") + ] + if not files_with_urls: + continue + + file_tags = [format_file_tag(file) for file in files_with_urls] + file_context = ( + "\n" + "\n".join(file_tags) + "\n\n\n" + ) + + content = message.get("content", "") + if isinstance(content, list): + message["content"] = [{"type": "text", "text": file_context}] + content + else: + message["content"] = file_context + content + + return messages + + async def chat_image_generation_handler( request: Request, form_data: dict, extra_params: dict, user ): metadata = extra_params.get("__metadata__", {}) chat_id = metadata.get("chat_id", None) - if not chat_id: - return form_data + __event_emitter__ = extra_params.get("__event_emitter__", None) - __event_emitter__ = extra_params["__event_emitter__"] + if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: + return form_data if chat_id.startswith("local:"): message_list = form_data.get("messages", []) @@ -780,7 +1023,16 @@ async def chat_image_generation_handler( user_message = get_last_user_message(message_list) prompt = user_message - input_images = get_last_images(message_list) + message_images = get_images_from_messages(message_list) + + # Limit to first 2 sets of images + # We may want to change this in the future to allow more images + input_images = [] + for idx, images in enumerate(message_images): + if idx >= 2: + break + for image in images: + input_images.append(image) system_message_content = "" @@ -790,6 +1042,10 @@ async def chat_image_generation_handler( images = await image_edits( request=request, form_data=EditImageForm(**{"prompt": prompt, "image": input_images}), + metadata={ + "chat_id": metadata.get("chat_id", None), + "message_id": metadata.get("message_id", None), + }, user=user, ) @@ -815,7 +1071,7 @@ async def chat_image_generation_handler( } ) - system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated." + system_message_content = "The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated." except Exception as e: log.debug(e) @@ -874,6 +1130,10 @@ async def chat_image_generation_handler( images = await image_generations( request=request, form_data=CreateImageForm(**{"prompt": prompt}), + metadata={ + "chat_id": metadata.get("chat_id", None), + "message_id": metadata.get("message_id", None), + }, user=user, ) @@ -1090,15 +1350,55 @@ def apply_params_to_form_data(form_data, model): if "logit_bias" in params and params["logit_bias"] is not None: try: - form_data["logit_bias"] = json.loads( - convert_logit_bias_input_to_json(params["logit_bias"]) - ) + logit_bias = convert_logit_bias_input_to_json(params["logit_bias"]) + + if logit_bias: + form_data["logit_bias"] = json.loads(logit_bias) except Exception as e: log.exception(f"Error parsing logit_bias: {e}") return form_data +async def convert_url_images_to_base64(form_data): + messages = form_data.get("messages", []) + + for message in messages: + content = message.get("content") + if not isinstance(content, list): + continue + + new_content = [] + + for item in content: + if not isinstance(item, dict) or item.get("type") != "image_url": + new_content.append(item) + continue + + image_url = item.get("image_url", {}).get("url", "") + if image_url.startswith("data:image/"): + new_content.append(item) + continue + + try: + base64_data = await asyncio.to_thread( + get_image_base64_from_url, image_url + ) + new_content.append( + { + "type": "image_url", + "image_url": {"url": base64_data}, + } + ) + except Exception as e: + log.debug(f"Error converting image URL to base64: {e}") + new_content.append(item) + + message["content"] = new_content + + return form_data + + async def process_chat_payload(request, form_data, user, metadata, model): # Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling @@ -1116,6 +1416,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): except: pass + form_data = await convert_url_images_to_base64(form_data) + event_emitter = get_event_emitter(metadata) event_caller = get_event_call(metadata) @@ -1137,6 +1439,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): "__oauth_token__": oauth_token, "__request__": request, "__model__": model, + "__chat_id__": metadata.get("chat_id"), + "__message_id__": metadata.get("message_id"), } # Initialize events to store additional event to be sent to the client # Initialize contexts and citation @@ -1180,7 +1484,10 @@ async def process_chat_payload(request, form_data, user, metadata, model): user_message = get_last_user_message(form_data["messages"]) model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False) - if model_knowledge: + if ( + model_knowledge + and metadata.get("params", {}).get("function_calling") != "native" + ): await event_emitter( { "type": "status", @@ -1246,7 +1553,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): except Exception as e: raise Exception(f"{e}") - features = form_data.pop("features", None) + features = form_data.pop("features", None) or {} + extra_params["__features__"] = features if features: if "voice" in features and features["voice"]: if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None: @@ -1261,19 +1569,25 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) if "memory" in features and features["memory"]: - form_data = await chat_memory_handler( - request, form_data, extra_params, user - ) + # Skip forced memory injection when native FC is enabled - model can use memory tools + if metadata.get("params", {}).get("function_calling") != "native": + form_data = await chat_memory_handler( + request, form_data, extra_params, user + ) if "web_search" in features and features["web_search"]: - form_data = await chat_web_search_handler( - request, form_data, extra_params, user - ) + # Skip forced RAG web search when native FC is enabled - model can use web_search tool + if metadata.get("params", {}).get("function_calling") != "native": + form_data = await chat_web_search_handler( + request, form_data, extra_params, user + ) if "image_generation" in features and features["image_generation"]: - form_data = await chat_image_generation_handler( - request, form_data, extra_params, user - ) + # Skip forced image generation when native FC is enabled - model can use generate_image tool + if metadata.get("params", {}).get("function_calling") != "native": + form_data = await chat_image_generation_handler( + request, form_data, extra_params, user + ) if "code_interpreter" in features and features["code_interpreter"]: form_data["messages"] = add_or_update_user_message( @@ -1353,6 +1667,13 @@ async def process_chat_payload(request, form_data, user, metadata, model): log.error(f"MCP server with id {server_id} not found") continue + # Check access control for MCP server + if not has_tool_server_access(user, mcp_server_connection): + log.warning( + f"Access denied to MCP server {server_id} for user {user.id}" + ) + continue + auth_type = mcp_server_connection.get("auth_type", "") headers = {} if auth_type == "bearer": @@ -1484,6 +1805,36 @@ async def tool_function(**kwargs): if mcp_clients: metadata["mcp_clients"] = mcp_clients + # Inject builtin tools for native function calling based on enabled features and model capability + # Check if builtin_tools capability is enabled for this model (defaults to True if not specified) + builtin_tools_enabled = ( + model.get("info", {}) + .get("meta", {}) + .get("capabilities", {}) + .get("builtin_tools", True) + ) + if ( + metadata.get("params", {}).get("function_calling") == "native" + and builtin_tools_enabled + ): + # Add file context to user messages + chat_id = metadata.get("chat_id") + form_data["messages"] = add_file_context( + form_data.get("messages", []), chat_id, user + ) + builtin_tools = get_builtin_tools( + request, + { + **extra_params, + "__event_emitter__": event_emitter, + }, + features, + model, + ) + for name, tool_dict in builtin_tools.items(): + if name not in tools_dict: + tools_dict[name] = tool_dict + if tools_dict: if metadata.get("params", {}).get("function_calling") == "native": # If the function calling is native, then call the tools function calling handler @@ -1492,6 +1843,7 @@ async def tool_function(**kwargs): {"type": "function", "function": tool.get("spec", {})} for tool in tools_dict.values() ] + else: # If the function calling is not native, then call the tools function calling handler try: @@ -1502,54 +1854,28 @@ async def tool_function(**kwargs): except Exception as e: log.exception(e) - try: - form_data, flags = await chat_completion_files_handler( - request, form_data, extra_params, user - ) - sources.extend(flags.get("sources", [])) - except Exception as e: - log.exception(e) - - # If context is not empty, insert it into the messages - if len(sources) > 0: - context_string = "" - citation_idx_map = {} - - for source in sources: - if "document" in source: - for document_text, document_metadata in zip( - source["document"], source["metadata"] - ): - source_name = source.get("source", {}).get("name", None) - source_id = ( - document_metadata.get("source", None) - or source.get("source", {}).get("id", None) - or "N/A" - ) - - if source_id not in citation_idx_map: - citation_idx_map[source_id] = len(citation_idx_map) + 1 - - context_string += ( - f'{document_text}\n" - ) - - context_string = context_string.strip() - if prompt is None: - raise Exception("No user message found") + # Check if file context extraction is enabled for this model (default True) + file_context_enabled = ( + model.get("info", {}) + .get("meta", {}) + .get("capabilities", {}) + .get("file_context", True) + ) - if context_string != "": - form_data["messages"] = add_or_update_user_message( - rag_template( - request.app.state.config.RAG_TEMPLATE, - context_string, - prompt, - ), - form_data["messages"], - append=False, + if file_context_enabled: + try: + form_data, flags = await chat_completion_files_handler( + request, form_data, extra_params, user ) + sources.extend(flags.get("sources", [])) + except Exception as e: + log.exception(e) + + # If context is not empty, insert it into the messages + if sources and prompt: + form_data["messages"] = apply_source_context_to_messages( + request, form_data["messages"], sources, prompt + ) # If there are citations, add them to the data_items sources = [ @@ -2546,8 +2872,42 @@ async def flush_pending_delta_data(threshold: int = 0): continue delta = choices[0].get("delta", {}) - delta_tool_calls = delta.get("tool_calls", None) + # Handle delta annotations + annotations = delta.get("annotations") + if annotations: + for annotation in annotations: + if ( + annotation.get("type") == "url_citation" + and "url_citation" in annotation + ): + url_citation = annotation[ + "url_citation" + ] + + url = url_citation.get("url", "") + title = url_citation.get("title", url) + + await event_emitter( + { + "type": "source", + "data": { + "source": { + "name": title, + "url": url, + }, + "document": [title], + "metadata": [ + { + "source": url, + "name": title, + } + ], + }, + } + ) + + delta_tool_calls = delta.get("tool_calls", None) if delta_tool_calls: for delta_tool_call in delta_tool_calls: tool_call_index = delta_tool_call.get( @@ -2606,6 +2966,29 @@ async def flush_pending_delta_data(threshold: int = 0): "arguments" ] += delta_arguments + # Emit pending tool calls in real-time + if response_tool_calls: + # Flush any pending text first + await flush_pending_delta_data() + + pending_content_blocks = content_blocks + [ + { + "type": "tool_calls", + "content": response_tool_calls, + "pending": True, + } + ] + await event_emitter( + { + "type": "chat:completion", + "data": { + "content": serialize_content_blocks( + pending_content_blocks + ), + }, + } + ) + image_urls = get_image_urls( delta.get("images", []), request, metadata, user ) @@ -2686,7 +3069,17 @@ async def flush_pending_delta_data(threshold: int = 0): if ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION: value = convert_markdown_base64_images( - request, value, metadata, user + request, + value, + { + "chat_id": metadata.get( + "chat_id", None + ), + "message_id": metadata.get( + "message_id", None + ), + }, + user, ) content = f"{content}{value}" @@ -2809,6 +3202,7 @@ async def flush_pending_delta_data(threshold: int = 0): await stream_body_handler(response, form_data) tool_call_retries = 0 + tool_call_sources = [] # Track citation sources from tool results while ( len(tool_calls) > 0 @@ -2943,6 +3337,27 @@ async def flush_pending_delta_data(threshold: int = 0): ) ) + # Extract citation sources from tool results + if ( + tool_function_name + in [ + "search_web", + "view_knowledge_file", + "query_knowledge_files", + ] + and tool_result + ): + try: + citation_sources = get_citation_source_from_tool_result( + tool_name=tool_function_name, + tool_params=tool_function_params, + tool_result=tool_result, + tool_id=tool.get("tool_id", "") if tool else "", + ) + tool_call_sources.extend(citation_sources) + except Exception as e: + log.exception(f"Error extracting citation source: {e}") + results.append( { "tool_call_id": tool_call_id, @@ -2968,6 +3383,22 @@ async def flush_pending_delta_data(threshold: int = 0): } ) + # Emit citation sources for UI display + for source in tool_call_sources: + await event_emitter({"type": "source", "data": source}) + + # Apply source context to messages for model + if tool_call_sources: + user_msg = get_last_user_message(form_data["messages"]) + if user_msg: + form_data["messages"] = apply_source_context_to_messages( + request, + form_data["messages"], + tool_call_sources, + user_msg, + ) + tool_call_sources.clear() + await event_emitter( { "type": "chat:completion", @@ -2994,6 +3425,7 @@ async def flush_pending_delta_data(threshold: int = 0): request, new_form_data, user, + bypass_system_prompt=True, ) if isinstance(res, StreamingResponse): @@ -3173,6 +3605,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): request, new_form_data, user, + bypass_system_prompt=True, ) if isinstance(res, StreamingResponse): diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 5e3f3c4834f..e293f3d2576 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -9,13 +9,13 @@ from typing import Callable, Optional, Sequence, Union import json import aiohttp +import mimeparse import collections.abc -from open_webui.env import SRC_LOG_LEVELS, CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE +from open_webui.env import CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) def deep_update(d, u): @@ -373,6 +373,34 @@ def sanitize_filename(file_name): return final_file_name +def sanitize_text_for_db(text: str) -> str: + """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" + if not isinstance(text, str): + return text + # Remove null bytes + text = text.replace("\x00", "").replace("\u0000", "") + # Remove invalid UTF-8 surrogate characters that can cause encoding errors + # This handles cases where binary data or encoding issues introduced surrogates + try: + text = text.encode("utf-8", errors="surrogatepass").decode( + "utf-8", errors="ignore" + ) + except (UnicodeEncodeError, UnicodeDecodeError): + pass + return text + + +def sanitize_data_for_db(obj): + """Recursively sanitize all strings in a data structure for database storage.""" + if isinstance(obj, str): + return sanitize_text_for_db(obj) + elif isinstance(obj, dict): + return {k: sanitize_data_for_db(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [sanitize_data_for_db(v) for v in obj] + return obj + + def extract_folders_after_data_docs(path): # Convert the path to a Path object if it's not already path = Path(path) @@ -522,16 +550,18 @@ def parse_ollama_modelfile(model_text): return data -def convert_logit_bias_input_to_json(user_input): - logit_bias_pairs = user_input.split(",") - logit_bias_json = {} - for pair in logit_bias_pairs: - token, bias = pair.split(":") - token = str(token.strip()) - bias = int(bias.strip()) - bias = 100 if bias > 100 else -100 if bias < -100 else bias - logit_bias_json[token] = bias - return json.dumps(logit_bias_json) +def convert_logit_bias_input_to_json(user_input) -> Optional[str]: + if user_input: + logit_bias_pairs = user_input.split(",") + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(":") + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) + return None def freeze(value): @@ -577,6 +607,41 @@ def wrapper(*args, **kwargs): return decorator +def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[str]: + """ + Strictly match the mime type with the supported mime types. + + :param supported: The supported mime types. + :param header: The header to match. + :return: The matched mime type or None if no match is found. + """ + + try: + if isinstance(supported, str): + supported = supported.split(",") + + supported = [s for s in supported if s.strip() and "/" in s] + + if len(supported) == 0: + # Default to common types if none are specified + supported = ["audio/*", "video/webm"] + + match = mimeparse.best_match(supported, header) + if not match: + return None + + _, _, match_params = mimeparse.parse_mime_type(match) + _, _, header_params = mimeparse.parse_mime_type(header) + for k, v in match_params.items(): + if header_params.get(k) != v: + return None + + return match + except Exception as e: + log.exception(f"Failed to match mime type {header}: {e}") + return None + + def extract_urls(text: str) -> list[str]: # Regex pattern to match URLs url_pattern = re.compile( @@ -624,14 +689,17 @@ async def yield_safe_stream_chunks(): yield line else: yield b"data: {}" + yield b"\n" else: # Normal mode: check if line exceeds limit if len(line) > max_buffer_size: skip_mode = True yield b"data: {}" + yield b"\n" log.info(f"Skip mode triggered, line size: {len(line)}") else: yield line + yield b"\n" # Save the last incomplete fragment buffer = lines[-1] @@ -646,5 +714,6 @@ async def yield_safe_stream_chunks(): # Process remaining buffer data if buffer and not skip_mode: yield buffer + yield b"\n" return yield_safe_stream_chunks() diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index fbd10893826..b3a332adeeb 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -28,13 +28,12 @@ DEFAULT_ARENA_MODEL, ) -from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL +from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL from open_webui.models.users import UserModel logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) async def fetch_ollama_models(request: Request, user: UserModel = None): @@ -338,7 +337,7 @@ def get_function_module_by_id(function_id): return models -def check_model_access(user, model): +def check_model_access(user, model, db=None): if model.get("arena"): if not has_access( user.id, @@ -346,29 +345,38 @@ def check_model_access(user, model): access_control=model.get("info", {}) .get("meta", {}) .get("access_control", {}), + db=db, ): raise Exception("Model not found") else: - model_info = Models.get_model_by_id(model.get("id")) + model_info = Models.get_model_by_id(model.get("id"), db=db) if not model_info: raise Exception("Model not found") elif not ( user.id == model_info.user_id or has_access( - user.id, type="read", access_control=model_info.access_control + user.id, type="read", access_control=model_info.access_control, db=db ) ): raise Exception("Model not found") -def get_filtered_models(models, user): +def get_filtered_models(models, user, db=None): # Filter out models that the user does not have access to if ( user.role == "user" or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL) ) and not BYPASS_MODEL_ACCESS_CONTROL: + model_ids = [model["id"] for model in models if not model.get("arena")] + model_infos = { + model_info.id: model_info + for model_info in Models.get_models_by_ids(model_ids) + } + filtered_models = [] - 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, db=db) + } for model in models: if model.get("arena"): if has_access( @@ -382,7 +390,7 @@ def get_filtered_models(models, user): filtered_models.append(model) continue - model_info = Models.get_model_by_id(model["id"]) + model_info = model_infos.get(model["id"], None) if model_info: if ( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 61c98ca744c..c849eb25a82 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -55,6 +55,7 @@ OAUTH_ALLOWED_DOMAINS, OAUTH_UPDATE_PICTURE_ON_LOGIN, OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, + OAUTH_AUDIENCE, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, @@ -100,11 +101,10 @@ class OAuthClientInformationFull(OAuthClientMetadata): server_metadata: Optional[OAuthMetadata] = None # Fetched from the OAuth server -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) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OAUTH"]) auth_manager_config = AppConfig() auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE @@ -126,6 +126,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): auth_manager_config.WEBHOOK_URL = WEBHOOK_URL auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN +auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE FERNET = None @@ -245,10 +246,66 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]: return parsed, base_url -def get_discovery_urls(server_url) -> list[str]: - parsed, base_url = get_parsed_and_base_url(server_url) +async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: + """ + https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization + """ + + authorization_servers = [] + try: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.post( + server_url, + json={"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}, + headers={"Content-Type": "application/json"}, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + if response.status == 401: + match = re.search( + r'resource_metadata="([^"]+)"', + response.headers.get("WWW-Authenticate", ""), + ) + if match: + resource_metadata_url = match.group(1) + log.debug( + f"Found resource_metadata URL: {resource_metadata_url}" + ) + + # Step 2: Fetch Protected Resource metadata + async with session.get( + resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL + ) as resource_response: + if resource_response.status == 200: + resource_metadata = await resource_response.json() + + # Step 3: Extract authorization_servers + servers = resource_metadata.get( + "authorization_servers", [] + ) + if servers: + authorization_servers = servers + log.debug( + f"Discovered authorization servers: {servers}" + ) + except Exception as e: + log.debug(f"MCP Protected Resource discovery failed: {e}") + + discovery_urls = [] + for auth_server in authorization_servers: + auth_server = auth_server.rstrip("/") + discovery_urls.extend( + [ + f"{auth_server}/.well-known/oauth-authorization-server", + f"{auth_server}/.well-known/openid-configuration", + ] + ) + + return discovery_urls - urls = [] + +async def get_discovery_urls(server_url) -> list[str]: + urls = await get_authorization_server_discovery_urls(server_url) + parsed, base_url = get_parsed_and_base_url(server_url) if parsed.path and parsed.path != "/": # Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery @@ -302,7 +359,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( ) # Attempt to fetch OAuth server metadata to get registration endpoint & scopes - discovery_urls = get_discovery_urls(oauth_server_url) + discovery_urls = await get_discovery_urls(oauth_server_url) for url in discovery_urls: async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( @@ -449,6 +506,50 @@ def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): } return self.clients[client_id] + def ensure_client_from_config(self, client_id): + """ + Lazy-load an OAuth client from the current TOOL_SERVER_CONNECTIONS + config if it hasn't been registered on this node yet. + """ + if client_id in self.clients: + return self.clients[client_id]["client"] + + try: + connections = getattr(self.app.state.config, "TOOL_SERVER_CONNECTIONS", []) + except Exception: + connections = [] + + for connection in connections or []: + if connection.get("type", "openapi") != "mcp": + continue + if connection.get("auth_type", "none") != "oauth_2.1": + continue + + server_id = connection.get("info", {}).get("id") + if not server_id: + continue + + expected_client_id = f"mcp:{server_id}" + if client_id != expected_client_id: + continue + + oauth_client_info = connection.get("info", {}).get("oauth_client_info", "") + if not oauth_client_info: + continue + + try: + oauth_client_info = decrypt_data(oauth_client_info) + return self.add_client( + expected_client_id, OAuthClientInformationFull(**oauth_client_info) + )["client"] + except Exception as e: + log.error( + f"Failed to lazily add OAuth client {expected_client_id} from config: {e}" + ) + continue + + return None + def remove_client(self, client_id): if client_id in self.clients: del self.clients[client_id] @@ -532,22 +633,29 @@ async def _preflight_authorization_url( return True def get_client(self, client_id): + if client_id not in self.clients: + self.ensure_client_from_config(client_id) + client = self.clients.get(client_id) return client["client"] if client else None def get_client_info(self, client_id): + if client_id not in self.clients: + self.ensure_client_from_config(client_id) + client = self.clients.get(client_id) return client["client_info"] if client else None def get_server_metadata_url(self, client_id): - if client_id in self.clients: - client = self.clients[client_id] - return ( - client._server_metadata_url - if hasattr(client, "_server_metadata_url") - else None - ) - return None + client = self.get_client(client_id) + if not client: + return None + + return ( + client._server_metadata_url + if hasattr(client, "_server_metadata_url") + else None + ) async def get_oauth_token( self, user_id: str, client_id: str, force_refresh: bool = False @@ -716,10 +824,13 @@ async def _perform_token_refresh(self, session) -> dict: return None async def handle_authorize(self, request, client_id: str) -> RedirectResponse: - client = self.get_client(client_id) + client = self.get_client(client_id) or self.ensure_client_from_config(client_id) if client is None: raise HTTPException(404) client_info = self.get_client_info(client_id) + if client_info is None: + # ensure_client_from_config registers client_info too + client_info = self.get_client_info(client_id) if client_info is None: raise HTTPException(404) @@ -730,7 +841,7 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: return await client.authorize_redirect(request, redirect_uri_str) async def handle_callback(self, request, client_id: str, user_id: str, response): - client = self.get_client(client_id) + client = self.get_client(client_id) or self.ensure_client_from_config(client_id) if client is None: raise HTTPException(404) @@ -738,16 +849,22 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) try: client_info = self.get_client_info(client_id) - auth_params = {} - if ( - client_info - and hasattr(client_info, "client_id") - and hasattr(client_info, "client_secret") - ): - auth_params["client_id"] = client_info.client_id - auth_params["client_secret"] = client_info.client_secret + # Note: Do NOT pass client_id/client_secret explicitly here. + # The Authlib client already has these configured during add_client(). + # Passing them again causes Authlib to concatenate them (e.g., "ID1,ID1"), + # which results in 401 errors from the token endpoint. (Fix for #19823) + token = await client.authorize_access_token(request) + + # Validate that we received a proper token response + # If token exchange failed (e.g., 401), we may get an error response instead + if token and not token.get("access_token"): + error_desc = token.get( + "error_description", token.get("error", "Unknown error") + ) + error_message = f"Token exchange failed: {error_desc}" + log.error(f"Invalid token response for client_id {client_id}: {token}") + token = None - token = await client.authorize_access_token(request, **auth_params) if token: try: # Add timestamp for tracking @@ -777,7 +894,8 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) error_message = "Failed to store OAuth session server-side" log.error(f"Failed to store OAuth session server-side: {e}") else: - error_message = "Failed to obtain OAuth token" + if not error_message: + error_message = "Failed to obtain OAuth token" log.warning(error_message) except Exception as e: error_message = _build_oauth_callback_error_message(e) @@ -1073,7 +1191,7 @@ def get_user_role(self, user, user_data): return role - def update_user_groups(self, user, user_data, default_permissions): + def update_user_groups(self, user, user_data, default_permissions, db=None): log.debug("Running OAUTH Group management") oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM @@ -1102,8 +1220,10 @@ def update_user_groups(self, user, user_data, default_permissions): else: user_oauth_groups = [] - user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) - all_available_groups: list[GroupModel] = Groups.get_all_groups() + user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id( + user.id, db=db + ) + all_available_groups: list[GroupModel] = Groups.get_all_groups(db=db) # Create groups if they don't exist and creation is enabled if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: @@ -1129,7 +1249,7 @@ def update_user_groups(self, user, user_data, default_permissions): ) # Use determined creator ID (admin or fallback to current user) created_group = Groups.insert_new_group( - creator_id, new_group_form + creator_id, new_group_form, db=db ) if created_group: log.info( @@ -1147,7 +1267,7 @@ def update_user_groups(self, user, user_data, default_permissions): # Refresh the list of all available groups if any were created if groups_created: - all_available_groups = Groups.get_all_groups() + all_available_groups = Groups.get_all_groups(db=db) log.debug("Refreshed list of all available groups after creation.") log.debug(f"Oauth Groups claim: {oauth_claim}") @@ -1168,7 +1288,7 @@ def update_user_groups(self, user, user_data, default_permissions): log.debug( f"Removing user from group {group_model.name} as it is no longer in their oauth groups" ) - Groups.remove_users_from_group(group_model.id, [user.id]) + Groups.remove_users_from_group(group_model.id, [user.id], db=db) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions @@ -1183,6 +1303,7 @@ def update_user_groups(self, user, user_data, default_permissions): permissions=group_permissions, ), overwrite=False, + db=db, ) # Add user to new groups @@ -1198,7 +1319,7 @@ def update_user_groups(self, user, user_data, default_permissions): f"Adding user to group {group_model.name} as it was found in their oauth groups" ) - Groups.add_users_to_group(group_model.id, [user.id]) + Groups.add_users_to_group(group_model.id, [user.id], db=db) # In case a group is created, but perms are never assigned to the group by hitting "save" group_permissions = group_model.permissions @@ -1213,6 +1334,7 @@ def update_user_groups(self, user, user_data, default_permissions): permissions=group_permissions, ), overwrite=False, + db=db, ) async def _process_picture_url( @@ -1270,9 +1392,14 @@ async def handle_login(self, request, provider): client = self.get_client(provider) if client is None: raise HTTPException(404) - return await client.authorize_redirect(request, redirect_uri) - async def handle_callback(self, request, provider, response): + kwargs = {} + if auth_manager_config.OAUTH_AUDIENCE: + kwargs["audience"] = auth_manager_config.OAUTH_AUDIENCE + + return await client.authorize_redirect(request, redirect_uri, **kwargs) + + async def handle_callback(self, request, provider, response, db=None): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) @@ -1397,20 +1524,20 @@ async def handle_callback(self, request, provider, response): raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists - user = Users.get_user_by_oauth_sub(provider, sub) + user = Users.get_user_by_oauth_sub(provider, sub, db=db) if not user: # If the user does not exist, check if merging is enabled if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: # Check if the user exists by email - user = Users.get_user_by_email(email) + user = Users.get_user_by_email(email, db=db) if user: # Update the user with the new oauth sub - Users.update_user_oauth_by_id(user.id, provider, sub) + Users.update_user_oauth_by_id(user.id, provider, sub, db=db) if user: determined_role = self.get_user_role(user, user_data) if user.role != determined_role: - Users.update_user_role_by_id(user.id, determined_role) + Users.update_user_role_by_id(user.id, determined_role, db=db) # Update the user object in memory as well, # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below user.role = determined_role @@ -1427,14 +1554,14 @@ async def handle_callback(self, request, provider, response): ) if processed_picture_url != user.profile_image_url: Users.update_user_profile_image_url_by_id( - user.id, processed_picture_url + user.id, processed_picture_url, db=db ) log.debug(f"Updated profile picture for user {user.email}") else: # If the user does not exist, check if signups are enabled if auth_manager_config.ENABLE_OAUTH_SIGNUP: # Check if an existing user with the same email already exists - existing_user = Users.get_user_by_email(email) + existing_user = Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) @@ -1465,6 +1592,7 @@ async def handle_callback(self, request, provider, response): profile_image_url=picture_url, role=self.get_user_role(None, user_data), oauth=oauth_data, + db=db, ) if auth_manager_config.WEBHOOK_URL: @@ -1480,8 +1608,7 @@ async def handle_callback(self, request, provider, response): ) apply_default_group_assignment( - request.app.state.config.DEFAULT_GROUP_ID, - user.id, + request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db ) else: @@ -1502,6 +1629,7 @@ async def handle_callback(self, request, provider, response): user=user, user_data=user_data, default_permissions=request.app.state.config.USER_PERMISSIONS, + db=db, ) except Exception as e: @@ -1552,15 +1680,16 @@ async def handle_callback(self, request, provider, response): token["expires_at"] = datetime.now().timestamp() + token["expires_in"] # Clean up any existing sessions for this user/provider first - sessions = OAuthSessions.get_sessions_by_user_id(user.id) + sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db) for session in sessions: if session.provider == provider: - OAuthSessions.delete_session_by_id(session.id) + OAuthSessions.delete_session_by_id(session.id, db=db) session = OAuthSessions.create_session( user_id=user.id, provider=provider, token=token, + db=db, ) response.set_cookie( diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index bf372e0e76e..458687b3714 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -6,6 +6,7 @@ ) from typing import Callable, Optional +import copy import json @@ -286,6 +287,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: Returns: dict: A modified payload compatible with the Ollama API. """ + openai_payload = copy.deepcopy(openai_payload) ollama_payload = {} # Mapping basic model and message details diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 51c3f4f5f7f..965b0a688fb 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -7,12 +7,11 @@ import tempfile import logging -from open_webui.env import SRC_LOG_LEVELS, PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS +from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE from open_webui.models.functions import Functions from open_webui.models.tools import Tools log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) def extract_frontmatter(content): @@ -265,6 +264,10 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): def install_frontmatter_requirements(requirements: str): + if OFFLINE_MODE: + log.info("Offline mode enabled, skipping installation of requirements.") + return + if requirements: try: req_list = [req.strip() for req in requirements.split(",")] diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index cc29ce66837..2040633a7bf 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -7,6 +7,7 @@ from open_webui.env import ( REDIS_CLUSTER, + REDIS_SOCKET_CONNECT_TIMEOUT, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_MAX_RETRY_COUNT, REDIS_SENTINEL_PORT, @@ -41,6 +42,38 @@ def __getattr__(self, item): return orig_attr if self._async_mode: + if inspect.isasyncgenfunction(orig_attr): + + def _wrapped_iter(*args, **kwargs): + async def _iter(): + for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT): + try: + method = getattr(self._master(), item) + async for value in method(*args, **kwargs): + yield value + return + except ( + redis.exceptions.ConnectionError, + redis.exceptions.ReadOnlyError, + ) as e: + if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1: + log.debug( + "Redis sentinel fail-over (%s). Retry %s/%s", + type(e).__name__, + i + 1, + REDIS_SENTINEL_MAX_RETRY_COUNT, + ) + continue + log.error( + "Redis operation failed after %s retries: %s", + REDIS_SENTINEL_MAX_RETRY_COUNT, + e, + ) + raise e from e + + return _iter() + + return _wrapped_iter async def _wrapped(*args, **kwargs): for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT): @@ -162,6 +195,7 @@ def get_redis_connection( username=redis_config["username"], password=redis_config["password"], decode_responses=decode_responses, + socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, @@ -188,6 +222,7 @@ def get_redis_connection( username=redis_config["username"], password=redis_config["password"], decode_responses=decode_responses, + socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 7f90e963301..ecedd595a7f 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -8,12 +8,10 @@ from open_webui.utils.misc import get_last_user_message, get_messages_content -from open_webui.env import SRC_LOG_LEVELS from open_webui.config import DEFAULT_RAG_TEMPLATE log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) def get_task_model_id( diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 0ba42efd4b8..dbc4ebb2cb9 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -28,10 +28,8 @@ from open_webui.utils.telemetry.constants import SPAN_REDIS_TYPE, SpanAttributes -from open_webui.env import SRC_LOG_LEVELS logger = logging.getLogger(__name__) -logger.setLevel(SRC_LOG_LEVELS["MAIN"]) def requests_hook(span: Span, request: PreparedRequest): diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index d935ddaafaf..f129f5f0023 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -141,9 +141,12 @@ def observe_active_users( def observe_total_registered_users( options: metrics.CallbackOptions, ) -> Sequence[metrics.Observation]: + # IMPORTANT: Use get_num_users() for efficient COUNT(*) query. + # Do NOT use len(get_users()["users"]) - it loads ALL user records into memory, + # causing connection pool exhaustion on high-latency databases (e.g., Aurora). return [ metrics.Observation( - value=len(Users.get_users()["users"]), + value=Users.get_num_users() or 0, ) ] diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 2baff503ee0..6cb6c4b856d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -37,18 +37,46 @@ from open_webui.utils.misc import is_string_allowed from open_webui.models.tools import Tools from open_webui.models.users import UserModel +from open_webui.models.groups import Groups from open_webui.utils.plugin import load_tool_module_by_id +from open_webui.utils.access_control import has_access +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( - SRC_LOG_LEVELS, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ) +from open_webui.tools.builtin import ( + search_web, + fetch_url, + generate_image, + edit_image, + search_memories, + add_memory, + replace_memory_content, + get_current_timestamp, + calculate_timestamp, + search_notes, + search_chats, + search_channels, + search_channel_messages, + view_note, + view_chat, + view_channel_message, + view_channel_thread, + replace_note_content, + write_note, + list_knowledge_bases, + search_knowledge_bases, + query_knowledge_bases, + search_knowledge_files, + query_knowledge_files, + view_knowledge_file, +) import copy log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) def get_async_tool_function_and_apply_extra_params( @@ -106,15 +134,114 @@ def get_updated_tool_function(function: Callable, extra_params: dict): return function +def has_tool_server_access( + user: UserModel, server_connection: dict, user_group_ids: set = None +) -> bool: + """Check if user has access to a tool server (MCP or OpenAPI).""" + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return True + + if user_group_ids is None: + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + + access_control = server_connection.get("config", {}).get("access_control", None) + return has_access(user.id, "read", access_control, user_group_ids) + + async def get_tools( request: Request, tool_ids: list[str], user: UserModel, extra_params: dict ) -> dict[str, dict]: + """Load tools for the given tool_ids, checking access control.""" tools_dict = {} + # Get user's group memberships for access control checks + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + for tool_id in tool_ids: tool = Tools.get_tool_by_id(tool_id) - if tool is None: + if tool: + # Check access control for local tools + if ( + not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + and tool.user_id != user.id + and not has_access(user.id, "read", tool.access_control, user_group_ids) + ): + log.warning(f"Access denied to tool {tool_id} for user {user.id}") + continue + + module = request.app.state.TOOLS.get(tool_id, None) + if module is None: + module, _ = load_tool_module_by_id(tool_id) + request.app.state.TOOLS[tool_id] = module + + __user__ = { + **extra_params["__user__"], + } + + # Set valves for the tool + if hasattr(module, "valves") and hasattr(module, "Valves"): + valves = Tools.get_tool_valves_by_id(tool_id) or {} + module.valves = module.Valves(**valves) + if hasattr(module, "UserValves"): + __user__["valves"] = module.UserValves( # type: ignore + **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) + ) + + for spec in tool.specs: + # TODO: Fix hack for OpenAI API + # Some times breaks OpenAI but others don't. Leaving the comment + for val in spec.get("parameters", {}).get("properties", {}).values(): + if val.get("type") == "str": + val["type"] = "string" + + # Remove internal reserved parameters (e.g. __id__, __user__) + spec["parameters"]["properties"] = { + key: val + for key, val in spec["parameters"]["properties"].items() + if not key.startswith("__") + } + + # convert to function that takes only model params and inserts custom params + function_name = spec["name"] + tool_function = getattr(module, function_name) + callable = get_async_tool_function_and_apply_extra_params( + tool_function, + { + **extra_params, + "__id__": tool_id, + "__user__": __user__, + }, + ) + # TODO: Support Pydantic models as parameters + if callable.__doc__ and callable.__doc__.strip() != "": + s = re.split(":(param|return)", callable.__doc__, 1) + spec["description"] = s[0] + else: + spec["description"] = function_name + + tool_dict = { + "tool_id": tool_id, + "callable": callable, + "spec": spec, + # Misc info + "metadata": { + "file_handler": hasattr(module, "file_handler") + and module.file_handler, + "citation": hasattr(module, "citation") and module.citation, + }, + } + + # Handle function name collisions + while function_name in tools_dict: + log.warning( + f"Tool {function_name} already exists in another tools!" + ) + # Prepend tool ID to function name + function_name = f"{tool_id}_{function_name}" + + tools_dict[function_name] = tool_dict + else: if tool_id.startswith("server:"): splits = tool_id.split(":") @@ -149,6 +276,15 @@ async def get_tools( ] ) + # Check access control for tool server + if not has_tool_server_access( + user, tool_server_connection, user_group_ids + ): + log.warning( + f"Access denied to tool server {server_id} for user {user.id}" + ) + continue + specs = tool_server_data.get("specs", []) function_name_filter_list = tool_server_connection.get( "config", {} @@ -243,81 +379,116 @@ async def tool_function(**kwargs): else: continue - else: - continue - else: - module = request.app.state.TOOLS.get(tool_id, None) - if module is None: - module, _ = load_tool_module_by_id(tool_id) - request.app.state.TOOLS[tool_id] = module + return tools_dict - __user__ = { - **extra_params["__user__"], - } - # Set valves for the tool - if hasattr(module, "valves") and hasattr(module, "Valves"): - valves = Tools.get_tool_valves_by_id(tool_id) or {} - module.valves = module.Valves(**valves) - if hasattr(module, "UserValves"): - __user__["valves"] = module.UserValves( # type: ignore - **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) - ) +def get_builtin_tools( + request: Request, extra_params: dict, features: dict = None, model: dict = None +) -> dict[str, dict]: + """ + Get built-in tools for native function calling. + Only returns tools when BOTH the global config is enabled AND the model capability allows it. + """ + tools_dict = {} + builtin_functions = [] + features = features or {} + model = model or {} + + # Helper to get model capabilities (defaults to True if not specified) + def get_model_capability(name: str, default: bool = True) -> bool: + return ( + model.get("info", {}) + .get("meta", {}) + .get("capabilities", {}) + .get(name, default) + ) - for spec in tool.specs: - # TODO: Fix hack for OpenAI API - # Some times breaks OpenAI but others don't. Leaving the comment - for val in spec.get("parameters", {}).get("properties", {}).values(): - if val.get("type") == "str": - val["type"] = "string" + # Time utilities - always available for date calculations + builtin_functions.extend([get_current_timestamp, calculate_timestamp]) - # Remove internal reserved parameters (e.g. __id__, __user__) - spec["parameters"]["properties"] = { - key: val - for key, val in spec["parameters"]["properties"].items() - if not key.startswith("__") - } + # Knowledge base tools - conditional injection based on model knowledge + # If model has attached knowledge (any type), only provide query_knowledge_files + # Otherwise, provide all KB browsing tools + model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", []) + if model_knowledge: + # Model has attached knowledge - only allow semantic search within it + builtin_functions.append(query_knowledge_files) + else: + # No model knowledge - allow full KB browsing + builtin_functions.extend( + [ + list_knowledge_bases, + search_knowledge_bases, + query_knowledge_bases, + search_knowledge_files, + query_knowledge_files, + view_knowledge_file, + ] + ) - # convert to function that takes only model params and inserts custom params - function_name = spec["name"] - tool_function = getattr(module, function_name) - callable = get_async_tool_function_and_apply_extra_params( - tool_function, - { - **extra_params, - "__id__": tool_id, - "__user__": __user__, - }, - ) + # Chats tools - search and fetch user's chat history + builtin_functions.extend([search_chats, view_chat]) + + # Add memory tools if enabled for this chat + if features.get("memory"): + builtin_functions.extend([search_memories, add_memory, replace_memory_content]) + + # Add web search tools if enabled globally AND model has web_search capability + if getattr( + request.app.state.config, "ENABLE_WEB_SEARCH", False + ) and get_model_capability("web_search"): + builtin_functions.extend([search_web, fetch_url]) + + # Add image generation/edit tools if enabled globally AND model has image_generation capability + if getattr( + request.app.state.config, "ENABLE_IMAGE_GENERATION", False + ) and get_model_capability("image_generation"): + builtin_functions.append(generate_image) + if getattr( + request.app.state.config, "ENABLE_IMAGE_EDIT", False + ) and get_model_capability("image_generation"): + builtin_functions.append(edit_image) + + # Notes tools - search, view, create, and update user's notes (if notes enabled globally) + if getattr(request.app.state.config, "ENABLE_NOTES", False): + builtin_functions.extend( + [search_notes, view_note, write_note, replace_note_content] + ) - # TODO: Support Pydantic models as parameters - if callable.__doc__ and callable.__doc__.strip() != "": - s = re.split(":(param|return)", callable.__doc__, 1) - spec["description"] = s[0] - else: - spec["description"] = function_name + # Channels tools - search channels and messages (if channels enabled globally) + if getattr(request.app.state.config, "ENABLE_CHANNELS", False): + builtin_functions.extend( + [ + search_channels, + search_channel_messages, + view_channel_thread, + view_channel_message, + ] + ) - tool_dict = { - "tool_id": tool_id, - "callable": callable, - "spec": spec, - # Misc info - "metadata": { - "file_handler": hasattr(module, "file_handler") - and module.file_handler, - "citation": hasattr(module, "citation") and module.citation, - }, - } + for func in builtin_functions: + callable = get_async_tool_function_and_apply_extra_params( + func, + { + "__request__": request, + "__user__": extra_params.get("__user__", {}), + "__event_emitter__": extra_params.get("__event_emitter__"), + "__chat_id__": extra_params.get("__chat_id__"), + "__message_id__": extra_params.get("__message_id__"), + "__model_knowledge__": model_knowledge, + }, + ) - # Handle function name collisions - while function_name in tools_dict: - log.warning( - f"Tool {function_name} already exists in another tools!" - ) - # Prepend tool ID to function name - function_name = f"{tool_id}_{function_name}" + # Generate spec from function + pydantic_model = convert_function_to_pydantic_model(func) + spec = convert_pydantic_model_to_openai_function_spec(pydantic_model) - tools_dict[function_name] = tool_dict + tools_dict[func.__name__] = { + "tool_id": f"builtin:{func.__name__}", + "callable": callable, + "spec": spec, + "type": "builtin", + } return tools_dict diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 4424c651ac7..eb2688851ff 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -3,10 +3,9 @@ import aiohttp from open_webui.config import WEBUI_FAVICON_URL -from open_webui.env import SRC_LOG_LEVELS, VERSION +from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["WEBHOOK"]) async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool: @@ -51,7 +50,9 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b payload = {**event_data} log.debug(f"payload: {payload}") - async with aiohttp.ClientSession(trust_env=True) as session: + async with aiohttp.ClientSession( + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + ) as session: async with session.post(url, json=payload) as r: r_text = await r.text() r.raise_for_status() diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 022f88ee725..272b8dc0d42 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -1,22 +1,22 @@ # Minimal requirements for backend to run # WIP: use this as a reference to build a minimal docker image -fastapi==0.123.0 -uvicorn[standard]==0.37.0 +fastapi==0.128.0 +uvicorn[standard]==0.40.0 pydantic==2.12.5 -python-multipart==0.0.20 +python-multipart==0.0.21 itsdangerous==2.2.0 -python-socketio==5.15.0 +python-socketio==5.16.0 python-jose==3.5.0 cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.10.1 -authlib==1.6.5 +authlib==1.6.6 requests==2.32.5 -aiohttp==3.12.15 +aiohttp==3.13.2 async-timeout aiocache aiofiles @@ -24,29 +24,32 @@ starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 -sqlalchemy==2.0.38 +sqlalchemy==2.0.45 alembic==1.17.2 peewee==3.18.3 peewee-migrate==1.14.3 -pycrdt==0.12.25 +pycrdt==0.12.44 redis -APScheduler==3.10.4 -RestrictedPython==8.0 +APScheduler==3.11.2 +RestrictedPython==8.1 loguru==0.7.3 asgiref==3.11.0 newrelic -mcp==1.22.0 +mcp==1.25.0 openai -langchain==0.3.27 -langchain-community==0.3.29 +langchain==1.2.0 +langchain-community==0.4.1 +langchain-classic==1.0.1 +langchain-text-splitters==1.1.0 + fake-useragent==2.2.0 -chromadb==1.1.0 -black==25.11.0 +chromadb==1.4.0 +black==25.12.0 pydub chardet==5.2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index fd547383d72..51f0a8a1ae8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,102 +1,104 @@ -fastapi==0.123.0 -uvicorn[standard]==0.37.0 +fastapi==0.128.0 +uvicorn[standard]==0.40.0 pydantic==2.12.5 -python-multipart==0.0.20 +python-multipart==0.0.21 itsdangerous==2.2.0 -python-socketio==5.15.0 +python-socketio==5.16.0 python-jose==3.5.0 cryptography bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.10.1 -authlib==1.6.5 +authlib==1.6.6 requests==2.32.5 -aiohttp==3.12.15 +aiohttp==3.13.2 async-timeout aiocache aiofiles starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 +python-mimeparse==2.0.0 -sqlalchemy==2.0.38 +sqlalchemy==2.0.45 alembic==1.17.2 peewee==3.18.3 peewee-migrate==1.14.3 -pycrdt==0.12.25 +pycrdt==0.12.44 redis -APScheduler==3.10.4 -RestrictedPython==8.0 +APScheduler==3.11.2 +RestrictedPython==8.1 loguru==0.7.3 asgiref==3.11.0 # AI libraries tiktoken -mcp==1.22.0 +mcp==1.25.0 openai anthropic -google-genai==1.52.0 -google-generativeai==0.8.5 +google-genai==1.56.0 -langchain==0.3.27 -langchain-community==0.3.29 +langchain==1.2.0 +langchain-community==0.4.1 +langchain-classic==1.0.1 +langchain-text-splitters==1.1.0 fake-useragent==2.2.0 -chromadb==1.1.0 -weaviate-client==4.17.0 -opensearch-py==2.8.0 +chromadb==1.4.0 +weaviate-client==4.19.2 +opensearch-py==3.1.0 transformers==4.57.3 -sentence-transformers==5.1.2 +sentence-transformers==5.2.0 accelerate pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.1 ftfy==6.3.1 chardet==5.2.0 -pypdf==6.4.0 -fpdf2==2.8.2 -pymdown-extensions==10.17.2 -docx2txt==0.8 +pypdf==6.5.0 +fpdf2==2.8.5 +pymdown-extensions==10.20 +docx2txt==0.9 python-pptx==1.0.2 -unstructured==0.18.21 +unstructured==0.18.24 msoffcrypto-tool==5.4.2 -nltk==3.9.1 +nltk==3.9.2 Markdown==3.10 pypandoc==1.16.2 -pandas==2.2.3 +pandas==2.3.3 openpyxl==3.1.5 pyxlsb==1.0.10 -xlrd==2.0.1 +xlrd==2.0.2 validators==0.35.0 psutil sentencepiece soundfile==0.13.1 -pillow==11.3.0 -opencv-python-headless==4.11.0.86 +pillow==12.1.0 +opencv-python-headless==4.12.0.88 rapidocr-onnxruntime==1.4.4 rank-bm25==0.2.2 -onnxruntime==1.20.1 -faster-whisper==1.1.1 +onnxruntime==1.23.2 +faster-whisper==1.2.1 -black==25.11.0 -youtube-transcript-api==1.2.2 +black==25.12.0 +youtube-transcript-api==1.2.3 pytube==15.0.0 pydub -ddgs==9.9.2 +ddgs==9.10.0 azure-ai-documentintelligence==1.0.2 -azure-identity==1.25.0 -azure-storage-blob==12.24.1 +azure-identity==1.25.1 +azure-storage-blob==12.27.1 azure-search-documents==11.6.0 ## Google Drive @@ -105,26 +107,26 @@ google-auth-httplib2 google-auth-oauthlib googleapis-common-protos==1.72.0 -google-cloud-storage==2.19.0 +google-cloud-storage==3.7.0 ## Databases pymongo -psycopg2-binary==2.9.10 -pgvector==0.4.1 +psycopg2-binary==2.9.11 +pgvector==0.4.2 -PyMySQL==1.1.1 -boto3==1.41.5 +PyMySQL==1.1.2 +boto3==1.42.21 -pymilvus==2.6.4 -qdrant-client==1.14.3 -playwright==1.56.0 # Caution: version must match docker-compose.playwright.yaml -elasticsearch==9.1.0 +pymilvus==2.6.6 +qdrant-client==1.16.2 +playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary +elasticsearch==9.2.1 pinecone==6.0.2 -oracledb==3.2.0 +oracledb==3.4.1 av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720 -colbert-ai==0.2.21 +colbert-ai==0.2.22 ## Tests @@ -136,18 +138,17 @@ pytest-docker~=3.2.5 ldap3==2.9.1 ## Firecrawl -firecrawl-py==4.10.0 +firecrawl-py==4.12.0 ## Trace -newrelic -opentelemetry-api==1.38.0 -opentelemetry-sdk==1.38.0 -opentelemetry-exporter-otlp==1.38.0 -opentelemetry-instrumentation==0.59b0 -opentelemetry-instrumentation-fastapi==0.59b0 -opentelemetry-instrumentation-sqlalchemy==0.59b0 -opentelemetry-instrumentation-redis==0.59b0 -opentelemetry-instrumentation-requests==0.59b0 -opentelemetry-instrumentation-logging==0.59b0 -opentelemetry-instrumentation-httpx==0.59b0 -opentelemetry-instrumentation-aiohttp-client==0.59b0 +opentelemetry-api==1.39.1 +opentelemetry-sdk==1.39.1 +opentelemetry-exporter-otlp==1.39.1 +opentelemetry-instrumentation==0.60b1 +opentelemetry-instrumentation-fastapi==0.60b1 +opentelemetry-instrumentation-sqlalchemy==0.60b1 +opentelemetry-instrumentation-redis==0.60b1 +opentelemetry-instrumentation-requests==0.60b1 +opentelemetry-instrumentation-logging==0.60b1 +opentelemetry-instrumentation-httpx==0.60b1 +opentelemetry-instrumentation-aiohttp-client==0.60b1 diff --git a/banner.png b/banner.png new file mode 100644 index 00000000000..270f76e7e00 Binary files /dev/null and b/banner.png differ diff --git a/demo.gif b/demo.gif deleted file mode 100644 index 6e56b74a0ab..00000000000 Binary files a/demo.gif and /dev/null differ diff --git a/demo.png b/demo.png new file mode 100644 index 00000000000..a38ddaf6ab4 Binary files /dev/null and b/demo.png differ diff --git a/docker-compose.playwright.yaml b/docker-compose.playwright.yaml index fa2b49ff9a2..e00a28df588 100644 --- a/docker-compose.playwright.yaml +++ b/docker-compose.playwright.yaml @@ -1,8 +1,8 @@ services: playwright: - image: mcr.microsoft.com/playwright:v1.49.1-noble # Version must match requirements.txt + image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt container_name: playwright - command: npx -y playwright@1.49.1 run-server --port 3000 --host 0.0.0.0 + command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0 open-webui: environment: diff --git a/kubernetes/helm/README.md b/kubernetes/helm/README.md deleted file mode 100644 index 5737007d964..00000000000 --- a/kubernetes/helm/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Helm Charts -Open WebUI Helm Charts are now hosted in a separate repo, which can be found here: https://github.com/open-webui/helm-charts - -The charts are released at https://helm.openwebui.com. \ No newline at end of file diff --git a/kubernetes/manifest/base/kustomization.yaml b/kubernetes/manifest/base/kustomization.yaml deleted file mode 100644 index 61500f87c51..00000000000 --- a/kubernetes/manifest/base/kustomization.yaml +++ /dev/null @@ -1,8 +0,0 @@ -resources: - - open-webui.yaml - - ollama-service.yaml - - ollama-statefulset.yaml - - webui-deployment.yaml - - webui-service.yaml - - webui-ingress.yaml - - webui-pvc.yaml diff --git a/kubernetes/manifest/base/ollama-service.yaml b/kubernetes/manifest/base/ollama-service.yaml deleted file mode 100644 index 8bab65b59ef..00000000000 --- a/kubernetes/manifest/base/ollama-service.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: ollama-service - namespace: open-webui -spec: - selector: - app: ollama - ports: - - protocol: TCP - port: 11434 - targetPort: 11434 \ No newline at end of file diff --git a/kubernetes/manifest/base/ollama-statefulset.yaml b/kubernetes/manifest/base/ollama-statefulset.yaml deleted file mode 100644 index cd1144caf9d..00000000000 --- a/kubernetes/manifest/base/ollama-statefulset.yaml +++ /dev/null @@ -1,41 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: ollama - namespace: open-webui -spec: - serviceName: "ollama" - replicas: 1 - selector: - matchLabels: - app: ollama - template: - metadata: - labels: - app: ollama - spec: - containers: - - name: ollama - image: ollama/ollama:latest - ports: - - containerPort: 11434 - resources: - requests: - cpu: "2000m" - memory: "2Gi" - limits: - cpu: "4000m" - memory: "4Gi" - nvidia.com/gpu: "0" - volumeMounts: - - name: ollama-volume - mountPath: /root/.ollama - tty: true - volumeClaimTemplates: - - metadata: - name: ollama-volume - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 30Gi \ No newline at end of file diff --git a/kubernetes/manifest/base/open-webui.yaml b/kubernetes/manifest/base/open-webui.yaml deleted file mode 100644 index 9c1a599f326..00000000000 --- a/kubernetes/manifest/base/open-webui.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: open-webui \ No newline at end of file diff --git a/kubernetes/manifest/base/webui-deployment.yaml b/kubernetes/manifest/base/webui-deployment.yaml deleted file mode 100644 index 79a0a9a23c9..00000000000 --- a/kubernetes/manifest/base/webui-deployment.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: open-webui-deployment - namespace: open-webui -spec: - replicas: 1 - selector: - matchLabels: - app: open-webui - template: - metadata: - labels: - app: open-webui - spec: - containers: - - name: open-webui - image: ghcr.io/open-webui/open-webui:main - ports: - - containerPort: 8080 - resources: - requests: - cpu: "500m" - memory: "500Mi" - limits: - cpu: "1000m" - memory: "1Gi" - env: - - name: OLLAMA_BASE_URL - value: "http://ollama-service.open-webui.svc.cluster.local:11434" - tty: true - volumeMounts: - - name: webui-volume - mountPath: /app/backend/data - volumes: - - name: webui-volume - persistentVolumeClaim: - claimName: open-webui-pvc \ No newline at end of file diff --git a/kubernetes/manifest/base/webui-ingress.yaml b/kubernetes/manifest/base/webui-ingress.yaml deleted file mode 100644 index dc0b53ccd45..00000000000 --- a/kubernetes/manifest/base/webui-ingress.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: open-webui-ingress - namespace: open-webui - #annotations: - # Use appropriate annotations for your Ingress controller, e.g., for NGINX: - # nginx.ingress.kubernetes.io/rewrite-target: / -spec: - rules: - - host: open-webui.minikube.local - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: open-webui-service - port: - number: 8080 diff --git a/kubernetes/manifest/base/webui-pvc.yaml b/kubernetes/manifest/base/webui-pvc.yaml deleted file mode 100644 index 97fb761d422..00000000000 --- a/kubernetes/manifest/base/webui-pvc.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - app: open-webui - name: open-webui-pvc - namespace: open-webui -spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 2Gi \ No newline at end of file diff --git a/kubernetes/manifest/base/webui-service.yaml b/kubernetes/manifest/base/webui-service.yaml deleted file mode 100644 index d73845f00a8..00000000000 --- a/kubernetes/manifest/base/webui-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: open-webui-service - namespace: open-webui -spec: - type: NodePort # Use LoadBalancer if you're on a cloud that supports it - selector: - app: open-webui - ports: - - protocol: TCP - port: 8080 - targetPort: 8080 - # If using NodePort, you can optionally specify the nodePort: - # nodePort: 30000 \ No newline at end of file diff --git a/kubernetes/manifest/gpu/kustomization.yaml b/kubernetes/manifest/gpu/kustomization.yaml deleted file mode 100644 index c0d39fbfaab..00000000000 --- a/kubernetes/manifest/gpu/kustomization.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - ../base - -patches: -- path: ollama-statefulset-gpu.yaml diff --git a/kubernetes/manifest/gpu/ollama-statefulset-gpu.yaml b/kubernetes/manifest/gpu/ollama-statefulset-gpu.yaml deleted file mode 100644 index 3e42443656d..00000000000 --- a/kubernetes/manifest/gpu/ollama-statefulset-gpu.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: ollama - namespace: open-webui -spec: - selector: - matchLabels: - app: ollama - serviceName: "ollama" - template: - spec: - containers: - - name: ollama - resources: - limits: - nvidia.com/gpu: "1" diff --git a/package-lock.json b/package-lock.json index 1572d432408..057a856e2ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.41", + "version": "0.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.41", + "version": "0.7.2", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -62,11 +62,12 @@ "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", - "jspdf": "^3.0.0", + "jspdf": "^4.0.0", "katex": "^0.16.22", "kokoro-js": "^1.1.1", "leaflet": "^1.9.4", "lowlight": "^3.3.0", + "mammoth": "^1.11.0", "marked": "^9.1.0", "mermaid": "^11.10.1", "paneforge": "^0.0.6", @@ -96,7 +97,9 @@ "vega": "^6.2.0", "vega-lite": "^6.4.1", "vite-plugin-static-copy": "^2.2.0", + "xlsx": "^0.18.5", "y-prosemirror": "^1.3.7", + "y-protocols": "^1.0.7", "yaml": "^2.7.1", "yjs": "^13.6.27" }, @@ -126,7 +129,7 @@ "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", - "vite": "^5.4.14", + "vite": "^5.4.21", "vitest": "^1.6.1" }, "engines": { @@ -215,13 +218,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -4216,6 +4216,12 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -4604,6 +4610,15 @@ "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", "integrity": "sha512-zCcqCJoNLvdeF41asAK71XPlwSPieeRDsE09albBunJEksuYPYNillKNQjf8p5BqSoTKTuKrW3lUm3MNodUC4g==" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@xyflow/svelte": { "version": "0.1.19", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.19.tgz", @@ -4661,6 +4676,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4880,18 +4904,6 @@ "node": ">= 4.0.0" } }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" - } - }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -4941,7 +4953,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -5164,18 +5175,6 @@ "node": "10.* || >= 12.*" } }, - "node_modules/btoa": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", - "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", - "license": "(MIT OR Apache-2.0)", - "bin": { - "btoa": "bin/btoa.js" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -5321,6 +5320,19 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -5784,6 +5796,15 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/coincident": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz", @@ -5934,8 +5955,7 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cose-base": { "version": "1.0.3", @@ -6859,6 +6879,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -6939,6 +6965,15 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7594,6 +7629,23 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -7761,6 +7813,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -8496,6 +8557,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", @@ -8579,6 +8646,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-arrayish": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", @@ -8777,8 +8850,7 @@ "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, "node_modules/isexe": { "version": "2.0.0", @@ -8945,14 +9017,13 @@ } }, "node_modules/jspdf": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", - "integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", + "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.26.7", - "atob": "^2.1.2", - "btoa": "^1.2.1", + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { @@ -8977,6 +9048,18 @@ "verror": "1.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/katex": { "version": "0.16.22", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", @@ -9134,6 +9217,15 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.29.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", @@ -9630,6 +9722,17 @@ "integrity": "sha512-qtzLbJE8hq7VabR3mISmVGtoXP8KGc2Z/AT8OuqlYD7JTR3oqrgwdjnk07wpj1twXxYmgDXgoKVWUG/fReSzHg==", "license": "Apache-2.0" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -9670,6 +9773,51 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mammoth/node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/mammoth/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/markdown-it": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", @@ -10181,6 +10329,12 @@ "integrity": "sha512-TwaE51xV9q2y8pM61q73rbywJnusw9ivTEHAJ39GVWNZqxCoDBpe/tQkh/w9S+o/g+zS7YeeL0I/2mEWd+dgyA==", "license": "MIT" }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -10269,6 +10423,12 @@ "quansync": "^0.2.7" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/paneforge": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-0.0.6.tgz", @@ -10361,7 +10521,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10762,8 +10921,7 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "node_modules/promise-map-series": { "version": "0.3.0", @@ -11205,7 +11363,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -11227,11 +11384,6 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -11527,8 +11679,7 @@ "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -11964,6 +12115,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -12153,6 +12310,18 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -12231,7 +12400,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -12976,6 +13144,12 @@ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "license": "MIT" }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "license": "MIT" + }, "node_modules/underscore.string": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", @@ -13042,8 +13216,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/utrie": { "version": "1.0.2", @@ -13585,9 +13758,9 @@ } }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -14466,6 +14639,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -14578,6 +14769,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -14590,6 +14802,15 @@ "node": ">=18" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -14641,11 +14862,10 @@ } }, "node_modules/y-protocols": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", - "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.85" }, diff --git a/package.json b/package.json index ae4bc3f8cad..bc71db855e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.41", + "version": "0.7.2", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -47,7 +47,7 @@ "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", - "vite": "^5.4.14", + "vite": "^5.4.21", "vitest": "^1.6.1" }, "type": "module", @@ -106,11 +106,12 @@ "i18next-resources-to-backend": "^1.2.0", "idb": "^7.1.1", "js-sha256": "^0.10.1", - "jspdf": "^3.0.0", + "jspdf": "^4.0.0", "katex": "^0.16.22", "kokoro-js": "^1.1.1", "leaflet": "^1.9.4", "lowlight": "^3.3.0", + "mammoth": "^1.11.0", "marked": "^9.1.0", "mermaid": "^11.10.1", "paneforge": "^0.0.6", @@ -140,7 +141,9 @@ "vega": "^6.2.0", "vega-lite": "^6.4.1", "vite-plugin-static-copy": "^2.2.0", + "xlsx": "^0.18.5", "y-prosemirror": "^1.3.7", + "y-protocols": "^1.0.7", "yaml": "^2.7.1", "yjs": "^13.6.27" }, diff --git a/pyproject.toml b/pyproject.toml index 2ba2664ab2a..cbffd30aaa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,113 +6,115 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.123.0", - "uvicorn[standard]==0.37.0", + "fastapi==0.128.0", + "uvicorn[standard]==0.40.0", "pydantic==2.12.5", - "python-multipart==0.0.20", + "python-multipart==0.0.21", "itsdangerous==2.2.0", - "python-socketio==5.15.0", + "python-socketio==5.16.0", "python-jose==3.5.0", "cryptography", "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.10.1", - "authlib==1.6.5", + "authlib==1.6.6", "requests==2.32.5", - "aiohttp==3.12.15", + "aiohttp==3.13.2", "async-timeout", "aiocache", "aiofiles", "starlette-compress==1.6.1", "httpx[socks,http2,zstd,cli,brotli]==0.28.1", "starsessions[redis]==2.2.1", + "python-mimeparse==2.0.0", - "sqlalchemy==2.0.38", + "sqlalchemy==2.0.45", "alembic==1.17.2", "peewee==3.18.3", "peewee-migrate==1.14.3", - "pycrdt==0.12.25", + "pycrdt==0.12.44", "redis", - "APScheduler==3.10.4", - "RestrictedPython==8.0", + "APScheduler==3.11.2", + "RestrictedPython==8.1", "loguru==0.7.3", "asgiref==3.11.0", "newrelic", "tiktoken", - "mcp==1.22.0", + "mcp==1.25.0", "openai", "anthropic", - "google-genai==1.52.0", - "google-generativeai==0.8.5", + "google-genai==1.56.0", - "langchain==0.3.27", - "langchain-community==0.3.29", + "langchain==1.2.0", + "langchain-community==0.4.1", + "langchain-classic==1.0.1", + "langchain-text-splitters==1.1.0", "fake-useragent==2.2.0", - "chromadb==1.0.20", - "opensearch-py==2.8.0", - "PyMySQL==1.1.1", - "boto3==1.41.5", + "chromadb==1.4.0", + "opensearch-py==3.1.0", + "PyMySQL==1.1.2", + "boto3==1.42.21", "transformers==4.57.3", - "sentence-transformers==5.1.2", + "sentence-transformers==5.2.0", "accelerate", - "pyarrow==20.0.0", + "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.1", "ftfy==6.3.1", "chardet==5.2.0", - "pypdf==6.4.0", - "fpdf2==2.8.2", - "pymdown-extensions==10.17.2", - "docx2txt==0.8", + "pypdf==6.5.0", + "fpdf2==2.8.5", + "pymdown-extensions==10.20", + "docx2txt==0.9", "python-pptx==1.0.2", - "unstructured==0.18.21", + "unstructured==0.18.24", "msoffcrypto-tool==5.4.2", - "nltk==3.9.1", + "nltk==3.9.2", "Markdown==3.10", "pypandoc==1.16.2", - "pandas==2.2.3", + "pandas==2.3.3", "openpyxl==3.1.5", "pyxlsb==1.0.10", - "xlrd==2.0.1", + "xlrd==2.0.2", "validators==0.35.0", "psutil", "sentencepiece", "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", - "pillow==11.3.0", - "opencv-python-headless==4.11.0.86", + "pillow==12.1.0", + "opencv-python-headless==4.12.0.88", "rapidocr-onnxruntime==1.4.4", "rank-bm25==0.2.2", - "onnxruntime==1.20.1", - "faster-whisper==1.1.1", + "onnxruntime==1.23.2", + "faster-whisper==1.2.1", - "black==25.11.0", - "youtube-transcript-api==1.2.2", + "black==25.12.0", + "youtube-transcript-api==1.2.3", "pytube==15.0.0", "pydub", - "ddgs==9.9.2", + "ddgs==9.10.0", "google-api-python-client", "google-auth-httplib2", "google-auth-oauthlib", "googleapis-common-protos==1.72.0", - "google-cloud-storage==2.19.0", + "google-cloud-storage==3.7.0", - "azure-identity==1.25.0", - "azure-storage-blob==12.24.1", + "azure-identity==1.25.1", + "azure-storage-blob==12.27.1", "ldap3==2.9.1", ] @@ -131,30 +133,31 @@ classifiers = [ [project.optional-dependencies] postgres = [ - "psycopg2-binary==2.9.10", - "pgvector==0.4.1", + "psycopg2-binary==2.9.11", + "pgvector==0.4.2", ] all = [ "pymongo", - "psycopg2-binary==2.9.9", - "pgvector==0.4.0", + "psycopg2-binary==2.9.11", + "pgvector==0.4.2", "moto[s3]>=5.0.26", "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", "pytest~=8.3.2", "pytest-docker~=3.2.5", - "playwright==1.56.0", - "elasticsearch==9.1.0", + "playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary + "elasticsearch==9.2.1", - "qdrant-client==1.14.3", - "weaviate-client==4.17.0", - "pymilvus==2.6.4", + "qdrant-client==1.16.2", + + "weaviate-client==4.19.2", + "pymilvus==2.6.6", "pinecone==6.0.2", - "oracledb==3.2.0", - "colbert-ai==0.2.21", + "oracledb==3.4.1", + "colbert-ai==0.2.22", - "firecrawl-py==4.10.0", + "firecrawl-py==4.12.0", "azure-search-documents==11.6.0", ] diff --git a/src/app.css b/src/app.css index fc093e5a6a6..8d2d6972f4c 100644 --- a/src/app.css +++ b/src/app.css @@ -628,7 +628,7 @@ input[type='number'] { /* Table styling for tiptap editors */ .tiptap table { - @apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full; + @apply w-full text-sm text-start text-gray-500 dark:text-gray-400 max-w-full; } .tiptap thead { @@ -641,7 +641,7 @@ input[type='number'] { } .tiptap th { - @apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850; + @apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850; } .tiptap td { @@ -803,3 +803,7 @@ body { position: relative; z-index: 0; } + +#note-content-container .ProseMirror { + padding-bottom: 2rem; /* space for the bottom toolbar */ +} diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 5450479af52..1fd22494b51 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -423,6 +423,19 @@ export const updateUserProfile = async (token: string, profile: object) => { return res; }; +export const updateUserTimezone = async (token: string, timezone: string) => { + await fetch(`${WEBUI_API_BASE_URL}/auths/update/timezone`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ timezone }) + }).catch((err) => { + console.error('Failed to update timezone:', err); + }); +}; + export const updateUserPassword = async (token: string, password: string, newPassword: string) => { let error = null; diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 0731b2ea9fa..225d8cd7cf4 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -491,6 +491,44 @@ export const getChannelThreadMessages = async ( return res; }; +export const getMessageData = async ( + token: string = '', + channel_id: string, + message_id: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/data`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + type MessageForm = { temp_id?: string; reply_to_id?: string; @@ -725,3 +763,155 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess return res; }; + +// Webhook API functions + +type WebhookForm = { + name: string; + profile_image_url?: string; +}; + +export const getChannelWebhooks = async (token: string = '', channel_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createChannelWebhook = async ( + token: string = '', + channel_id: string, + formData: WebhookForm +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ ...formData }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateChannelWebhook = async ( + token: string = '', + channel_id: string, + webhook_id: string, + formData: WebhookForm +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/update`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ ...formData }) + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteChannelWebhook = async ( + token: string = '', + channel_id: string, + webhook_id: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/delete`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 010c80a56f1..b33072e8906 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -142,6 +142,10 @@ export const getChatList = async ( throw error; } + if (!res) { + return []; + } + return res.map((chat) => ({ ...chat, time_range: getTimeRange(chat.updated_at) @@ -1166,3 +1170,103 @@ export const archiveAllChats = async (token: string) => { return res; }; +export const exportChatStats = async (token: string, page: number = 1, params: object = {}) => { + let error = null; + + const searchParams = new URLSearchParams(); + searchParams.append('page', `${page}`); + + if (params) { + for (const [key, value] of Object.entries(params)) { + searchParams.append(key, `${value}`); + } + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const exportSingleChatStats = async (token: string, chatId: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export/${chatId}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const downloadChatStats = async ( + token: string = '', + updated_at: number | null = null +): Promise<[Response | null, AbortController]> => { + const controller = new AbortController(); + let error = null; + + let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`; + if (updated_at) url += `&updated_at=${updated_at}`; + + const res = await fetch(url, { + signal: controller.signal, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }).catch((err) => { + console.error(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return [res, controller]; +}; diff --git a/src/lib/apis/evaluations/index.ts b/src/lib/apis/evaluations/index.ts index 1f48c7bfbf5..a3af6e80bbf 100644 --- a/src/lib/apis/evaluations/index.ts +++ b/src/lib/apis/evaluations/index.ts @@ -93,6 +93,74 @@ export const getAllFeedbacks = async (token: string = '') => { return res; }; +export const getLeaderboard = async (token: string = '', query: string = '') => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/evaluations/leaderboard?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getModelHistory = async (token: string = '', modelId: string, days: number = 30) => { + let error = null; + + const searchParams = new URLSearchParams(); + searchParams.append('days', days.toString()); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/evaluations/leaderboard/${encodeURIComponent(modelId)}/history?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => { let error = null; diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts index 8351393e3c8..44af669fa1a 100644 --- a/src/lib/apis/files/index.ts +++ b/src/lib/apis/files/index.ts @@ -1,16 +1,26 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; import { splitStream } from '$lib/utils'; -export const uploadFile = async (token: string, file: File, metadata?: object | null) => { +export const uploadFile = async ( + token: string, + file: File, + metadata?: object | null, + process?: boolean | null +) => { const data = new FormData(); data.append('file', file); if (metadata) { data.append('metadata', JSON.stringify(metadata)); } + const searchParams = new URLSearchParams(); + if (process !== undefined && process !== null) { + searchParams.append('process', String(process)); + } + let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/files/?${searchParams.toString()}`, { method: 'POST', headers: { Accept: 'application/json', @@ -242,7 +252,7 @@ export const getFileContentById = async (id: string) => { }) .then(async (res) => { if (!res.ok) throw await res.json(); - return await res.blob(); + return await res.arrayBuffer(); }) .catch((err) => { error = err.detail; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index e865e9ba0e7..8f35fbf8815 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -1,10 +1,7 @@ -import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +import { WEBUI_BASE_URL } from '$lib/constants'; import { convertOpenApiToToolPayload } from '$lib/utils'; import { getOpenAIModelsDirect } from './openai'; -import { parse } from 'yaml'; -import { toast } from 'svelte-sonner'; - export const getModels = async ( token: string = '', connections: object | null = null, @@ -316,7 +313,7 @@ export const getToolServerData = async (token: string, url: string) => { // Check if URL ends with .yaml or .yml to determine format if (url.toLowerCase().endsWith('.yaml') || url.toLowerCase().endsWith('.yml')) { if (!res.ok) throw await res.text(); - const text = await res.text(); + const [text, { parse }] = await Promise.all([res.text(), import('yaml')]); return parse(text); } else { if (!res.ok) throw await res.json(); @@ -382,6 +379,13 @@ export const getToolServersData = async (servers: object[]) => { } if (res) { + if (!res.paths) { + return { + error: 'Invalid OpenAPI spec', + url: server?.url + }; + } + const { openapi, info, specs } = { openapi: res, info: res.info, diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index c01c986a2a3..dc9dd8b88a4 100644 --- a/src/lib/apis/knowledge/index.ts +++ b/src/lib/apis/knowledge/index.ts @@ -38,10 +38,13 @@ export const createNewKnowledge = async ( return res; }; -export const getKnowledgeBases = async (token: string = '') => { +export const getKnowledgeBases = async (token: string = '', page: number | null = null) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, { + const searchParams = new URLSearchParams(); + if (page) searchParams.append('page', page.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', @@ -69,10 +72,20 @@ export const getKnowledgeBases = async (token: string = '') => { return res; }; -export const getKnowledgeBaseList = async (token: string = '') => { +export const searchKnowledgeBases = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + page: number | null = null +) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, { + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (page) searchParams.append('page', page.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', @@ -100,6 +113,55 @@ export const getKnowledgeBaseList = async (token: string = '') => { return res; }; +export const searchKnowledgeFiles = async ( + token: string, + query?: string | null = null, + viewOption?: string | null = null, + orderBy?: string | null = null, + direction?: string | null = null, + page: number = 1 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (orderBy) searchParams.append('order_by', orderBy); + if (direction) searchParams.append('direction', direction); + searchParams.append('page', page.toString()); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/knowledge/search/files?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getKnowledgeById = async (token: string, id: string) => { let error = null; @@ -132,6 +194,56 @@ export const getKnowledgeById = async (token: string, id: string) => { return res; }; +export const searchKnowledgeFilesById = async ( + token: string, + id: string, + query?: string | null = null, + viewOption?: string | null = null, + orderBy?: string | null = null, + direction?: string | null = null, + page: number = 1 +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (orderBy) searchParams.append('order_by', orderBy); + if (direction) searchParams.append('direction', direction); + searchParams.append('page', page.toString()); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + type KnowledgeUpdateForm = { name?: string; description?: string; @@ -373,3 +485,29 @@ export const reindexKnowledgeFiles = async (token: string) => { return res; }; + +export const exportKnowledgeById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/${id}/export`, { + method: 'GET', + headers: { + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.blob(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 61794f6766a..55f9427e0da 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -91,6 +91,65 @@ export const getNotes = async (token: string = '', raw: boolean = false) => { return grouped; }; +export const searchNotes = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + permission: string | null = null, + sortKey: string | null = null, + page: number | null = null +) => { + let error = null; + const searchParams = new URLSearchParams(); + + if (query !== null) { + searchParams.append('query', query); + } + + if (viewOption !== null) { + searchParams.append('view_option', viewOption); + } + + if (permission !== null) { + searchParams.append('permission', permission); + } + + if (sortKey !== null) { + searchParams.append('order_by', sortKey); + } + + if (page !== null) { + searchParams.append('page', `${page}`); + } + + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${searchParams.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getNoteList = async (token: string = '', page: number | null = null) => { let error = null; const searchParams = new URLSearchParams(); @@ -99,7 +158,7 @@ export const getNoteList = async (token: string = '', page: number | null = null searchParams.append('page', `${page}`); } - const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index 276fad145d1..8cf7438ad31 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -103,7 +103,7 @@ export const getOpenAIUrls = async (token: string = '') => { throw error; } - return res.OPENAI_API_BASE_URLS; + return res?.OPENAI_API_BASE_URLS ?? []; }; export const updateOpenAIUrls = async (token: string = '', urls: string[]) => { @@ -170,7 +170,7 @@ export const getOpenAIKeys = async (token: string = '') => { throw error; } - return res.OPENAI_API_KEYS; + return res?.OPENAI_API_KEYS ?? []; }; export const updateOpenAIKeys = async (token: string = '', keys: string[]) => { diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index 75065910d60..a84e7b68225 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -327,10 +327,21 @@ export const processYoutubeVideo = async (token: string, url: string) => { return res; }; -export const processWeb = async (token: string, collection_name: string, url: string) => { +export const processWeb = async ( + token: string, + collection_name: string, + url: string, + process: boolean = true +) => { let error = null; - const res = await fetch(`${RETRIEVAL_API_BASE_URL}/process/web`, { + const searchParams = new URLSearchParams(); + + if (!process) { + searchParams.append('process', 'false'); + } + + const res = await fetch(`${RETRIEVAL_API_BASE_URL}/process/web?${searchParams.toString()}`, { method: 'POST', headers: { Accept: 'application/json', diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index d0d1c638a7b..fa8381f7f9c 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -23,6 +23,13 @@ changelog = await getChangelog(); }; + const closeModal = async () => { + localStorage.version = $config.version; + await settings.set({ ...$settings, ...{ version: $config.version } }); + await updateUserSettings(localStorage.token, { ui: $settings }); + show = false; + }; + $: if (show) { init(); } @@ -36,14 +43,7 @@ {$WEBUI_NAME} -
{#if selectedTab === 'leaderboard'} - - {:else if selectedTab === 'feedbacks'} + + {:else if selectedTab === 'feedback'} {/if}
diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index 3782e7614f1..0ec5678f0e3 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -80,11 +80,7 @@ } }; - $: if (page) { - getFeedbacks(); - } - - $: if (orderBy && direction) { + $: if (orderBy && direction && page) { getFeedbacks(); } @@ -179,7 +175,7 @@
{#if (items ?? []).length === 0}
- {$i18n.t('No feedbacks found')} + {$i18n.t('No feedback found')}
{:else} - {#if total > 0 && $config?.features?.enable_community_sharing} -
-
- {$i18n.t('Help us create the best community leaderboard by sharing your feedback history!')} -
- -
- - - -
-
- {/if} - {#if total > 30} {/if} diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte index 36d85772294..e16e62c98cf 100644 --- a/src/lib/components/admin/Evaluations/Leaderboard.svelte +++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte @@ -1,559 +1,208 @@ - +
-
-
- {$i18n.t('Leaderboard')} -
- -
- {rankedModels.length} -
-
- -
- -
-
- -
- { - loadEmbeddingModel(); - }} - /> -
-
+
+ {$i18n.t('Leaderboard')} + {rankedModels.length}
+ +
+ + +
+
-
- {#if loadingLeaderboard} -
-
- -
+
+ {#if loading} +
+
{/if} - {#if (rankedModels ?? []).length === 0} -
- {$i18n.t('No models found')} -
- {:else} + + {#if !rankedModels.length && !loading} +
{$i18n.t('No models found')}
+ {:else if rankedModels.length}
- - - - - - + + {#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col} + + {/each} - - {#each sortedModels as model, modelIdx (model.id)} + + {#each sortedModels as model, idx (model.id)} openLeaderboardModelModal(model)} + class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" + on:click={() => openModal(model)} > - - - - - - - {/each} @@ -562,8 +211,8 @@ {/if} -
-
+
+
ⓘ {$i18n.t( 'The evaluation leaderboard is based on the Elo rating system and is updated in real-time.' diff --git a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte index b3d78497971..fc3ec6eb105 100644 --- a/src/lib/components/admin/Evaluations/LeaderboardModal.svelte +++ b/src/lib/components/admin/Evaluations/LeaderboardModal.svelte @@ -1,37 +1,63 @@ - + {#if model}
@@ -42,9 +68,42 @@
-
+ +
+
+
+ {$i18n.t('Activity')} +
+
+ {#each TIME_RANGES as range} + + {/each} +
+
+ +
+ +
+
+ {$i18n.t('Tags')} +
{#if topTags.length} -
+
{#each topTags as tagInfo} {tagInfo.tag} {tagInfo.count} @@ -52,9 +111,10 @@ {/each}
{:else} - - + - {/if}
+
- - - - - - - - - - - - - - - - - - - - - - + + +
- + + + + + + + + + + + + + + {#each filteredSettings as tab (tab.id)} + + {/each}
{#if selectedTab === 'general'}
+
+
+
+ {$i18n.t('API Timeout')} +
+ +
+
+
@@ -728,11 +742,26 @@ > -
+
+
+ + {$i18n.t('Markdown Header Text Splitter')} + +
+
+ +
+
+
@@ -769,6 +798,35 @@
+ + {#if RAGConfig.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER} +
+
+
+
+ + {$i18n.t('Chunk Min Size Target')} + +
+
+ +
+
+
+
+ {/if} {/if}
@@ -928,24 +986,24 @@
- {#if RAG_EMBEDDING_ENGINE === 'ollama' || RAG_EMBEDDING_ENGINE === 'openai' || RAG_EMBEDDING_ENGINE === 'azure_openai'} -
-
- {$i18n.t('Embedding Batch Size')} -
+
+
+ {$i18n.t('Embedding Batch Size')} +
-
- -
+
+
+
+ {#if RAG_EMBEDDING_ENGINE === 'ollama' || RAG_EMBEDDING_ENGINE === 'openai' || RAG_EMBEDDING_ENGINE === 'azure_openai'}
+ {#if adminConfig.SHOW_ADMIN_DETAILS} +
+
+
{$i18n.t('Admin Contact Email')}
+
+ +
+ +
+
+ {/if} +
{$i18n.t('Pending User Overlay Title')} @@ -684,6 +701,30 @@
+ {#if adminConfig.ENABLE_FOLDERS} +
+
+
+ {$i18n.t('Folder Max File Count')} +
+
+ +
+ +
+ +
+ {$i18n.t('Maximum number of files allowed per folder.')} +
+
+ {/if} +
{$i18n.t('Notes')} ({$i18n.t('Beta')}) @@ -700,6 +741,14 @@
+
+
+ {$i18n.t('Memories')} ({$i18n.t('Beta')}) +
+ + +
+
{$i18n.t('User Webhooks')} @@ -708,6 +757,14 @@
+
+
+ {$i18n.t('User Status')} +
+ + +
+
{$i18n.t('Response Watermark')} diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 1c1fc6512b4..d107e09f704 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -17,6 +17,7 @@ } from '$lib/apis/models'; import { copyToClipboard } from '$lib/utils'; import { page } from '$app/stores'; + import { updateUserSettings } from '$lib/apis/users'; import { getModels } from '$lib/apis'; import Search from '$lib/components/icons/Search.svelte'; @@ -218,6 +219,19 @@ saveAs(blob, `${model.id}-${Date.now()}.json`); }; + const pinModelHandler = async (modelId) => { + let pinnedModels = $settings?.pinnedModels ?? []; + + if (pinnedModels.includes(modelId)) { + pinnedModels = pinnedModels.filter((id) => id !== modelId); + } else { + pinnedModels = [...new Set([...pinnedModels, modelId])]; + } + + settings.set({ ...$settings, pinnedModels: pinnedModels }); + await updateUserSettings(localStorage.token, { ui: $settings }); + }; + onMount(async () => { await init(); const id = $page.url.searchParams.get('id'); @@ -427,6 +441,9 @@ hideHandler={() => { hideModelHandler(model); }} + pinModelHandler={() => { + pinModelHandler(model.id); + }} copyLinkHandler={() => { copyLinkHandler(model); }} diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte index 1c96ef8127b..016bf68f07a 100644 --- a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte +++ b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte @@ -453,6 +453,11 @@ $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) ) ); + + ollamaModels = await getOllamaModels(localStorage.token, urlIdx).catch((error) => { + toast.error(`${error}`); + return null; + }); }; const cancelUpdateModelHandler = async (model: string) => { diff --git a/src/lib/components/admin/Settings/Models/ModelMenu.svelte b/src/lib/components/admin/Settings/Models/ModelMenu.svelte index b7e694b1658..d4cd48a37dd 100644 --- a/src/lib/components/admin/Settings/Models/ModelMenu.svelte +++ b/src/lib/components/admin/Settings/Models/ModelMenu.svelte @@ -13,8 +13,10 @@ import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte'; import Download from '$lib/components/icons/Download.svelte'; import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte'; + import Pin from '$lib/components/icons/Pin.svelte'; + import PinSlash from '$lib/components/icons/PinSlash.svelte'; - import { config } from '$lib/stores'; + import { config, settings } from '$lib/stores'; import Link from '$lib/components/icons/Link.svelte'; const i18n = getContext('i18n'); @@ -24,6 +26,7 @@ export let exportHandler: Function; export let hideHandler: Function; + export let pinModelHandler: Function; export let copyLinkHandler: Function; export let cloneHandler: Function; @@ -104,6 +107,27 @@
+ { + pinModelHandler(model?.id); + }} + > + {#if ($settings?.pinnedModels ?? []).includes(model?.id)} + + {:else} + + {/if} + +
+ {#if ($settings?.pinnedModels ?? []).includes(model?.id)} + {$i18n.t('Hide from Sidebar')} + {:else} + {$i18n.t('Keep in Sidebar')} + {/if} +
+
+ { diff --git a/src/lib/components/admin/Settings/Pipelines.svelte b/src/lib/components/admin/Settings/Pipelines.svelte index 18446da7ddc..81ecfe22182 100644 --- a/src/lib/components/admin/Settings/Pipelines.svelte +++ b/src/lib/components/admin/Settings/Pipelines.svelte @@ -47,7 +47,7 @@ if (pipeline && (pipeline?.valves ?? false)) { for (const property in valves_spec.properties) { if (valves_spec.properties[property]?.type === 'array') { - valves[property] = valves[property].split(',').map((v) => v.trim()); + valves[property] = (valves[property] ?? '').split(',').map((v) => v.trim()); } } diff --git a/src/lib/components/admin/Settings/WebSearch.svelte b/src/lib/components/admin/Settings/WebSearch.svelte index 17191ac2165..e91a110f814 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -43,29 +43,48 @@ const submitHandler = async () => { // Convert domain filter string to array before sending - if (webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST) { + if ( + typeof webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST === 'string' && + webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST + ) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.split(',') .map((domain) => domain.trim()) .filter((domain) => domain.length > 0); - } else { + } else if (!Array.isArray(webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST)) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = []; } // Convert Youtube loader language string to array before sending - if (webConfig.YOUTUBE_LOADER_LANGUAGE) { + if ( + typeof webConfig.YOUTUBE_LOADER_LANGUAGE === 'string' && + webConfig.YOUTUBE_LOADER_LANGUAGE + ) { webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.split(',') .map((lang) => lang.trim()) .filter((lang) => lang.length > 0); - } else { + } else if (!Array.isArray(webConfig.YOUTUBE_LOADER_LANGUAGE)) { webConfig.YOUTUBE_LOADER_LANGUAGE = []; } + // Convert numeric timeout values to strings (backend expects strings) + if (typeof webConfig.FIRECRAWL_TIMEOUT === 'number') { + webConfig.FIRECRAWL_TIMEOUT = webConfig.FIRECRAWL_TIMEOUT.toString(); + } + if (typeof webConfig.PLAYWRIGHT_TIMEOUT === 'number') { + webConfig.PLAYWRIGHT_TIMEOUT = webConfig.PLAYWRIGHT_TIMEOUT.toString(); + } + const res = await updateRAGConfig(localStorage.token, { web: webConfig }); - webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.join(','); - webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.join(','); + // Convert arrays back to strings for display + if (Array.isArray(webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST)) { + webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.join(','); + } + if (Array.isArray(webConfig.YOUTUBE_LOADER_LANGUAGE)) { + webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.join(','); + } }; onMount(async () => { @@ -75,11 +94,31 @@ webConfig = res.web; // Convert array back to comma-separated string for display - if (webConfig?.WEB_SEARCH_DOMAIN_FILTER_LIST) { + if (Array.isArray(webConfig?.WEB_SEARCH_DOMAIN_FILTER_LIST)) { webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST.join(','); + } else if (!webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST) { + webConfig.WEB_SEARCH_DOMAIN_FILTER_LIST = ''; } - webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.join(','); + if (Array.isArray(webConfig?.YOUTUBE_LOADER_LANGUAGE)) { + webConfig.YOUTUBE_LOADER_LANGUAGE = webConfig.YOUTUBE_LOADER_LANGUAGE.join(','); + } else if (!webConfig.YOUTUBE_LOADER_LANGUAGE) { + webConfig.YOUTUBE_LOADER_LANGUAGE = ''; + } + + // Convert timeout strings to numbers for number input fields + if (webConfig.FIRECRAWL_TIMEOUT && typeof webConfig.FIRECRAWL_TIMEOUT === 'string') { + const parsed = parseInt(webConfig.FIRECRAWL_TIMEOUT); + if (!isNaN(parsed)) { + webConfig.FIRECRAWL_TIMEOUT = parsed; + } + } + if (webConfig.PLAYWRIGHT_TIMEOUT && typeof webConfig.PLAYWRIGHT_TIMEOUT === 'string') { + const parsed = parseInt(webConfig.PLAYWRIGHT_TIMEOUT); + if (!isNaN(parsed)) { + webConfig.PLAYWRIGHT_TIMEOUT = parsed; + } + } } }); @@ -189,7 +228,7 @@ {:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
-
+
{$i18n.t('Searxng Query URL')}
@@ -206,6 +245,24 @@
+
+
+ {$i18n.t('Searxng search language (all, en, es, de, fr, etc.)')} +
+ +
+
+ +
+
+
{:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'}
@@ -451,6 +508,24 @@ {:else if webConfig.WEB_SEARCH_ENGINE === 'jina'}
+
+ {$i18n.t('Jina API Base URL')} +
+ +
+
+ +
+
+
+ +
{$i18n.t('Jina API Key')}
@@ -610,19 +685,24 @@ bind:value={webConfig.FIRECRAWL_API_KEY} />
-
- {:else if webConfig.WEB_SEARCH_ENGINE === 'ddgs' || webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'} -
-
- {$i18n.t('Concurrent Requests')} -
- +
+
+ {$i18n.t('Firecrawl Timeout (s)')} +
+ +
+
+ +
+
+
{:else if webConfig.WEB_SEARCH_ENGINE === 'external'}
@@ -656,6 +736,36 @@
{/if} + + {#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'} +
+
+
+ {$i18n.t('DDGS Backend')} +
+ +
+
+ +
+
+
+
+ {/if} {/if} {#if webConfig.ENABLE_WEB_SEARCH} @@ -673,6 +783,27 @@ required />
+ +
+
+ + {$i18n.t('Concurrent Requests')} + +
+ + +
@@ -767,6 +898,19 @@
{#if webConfig.WEB_LOADER_ENGINE === '' || webConfig.WEB_LOADER_ENGINE === 'safe_web'} +
+
+ {$i18n.t('Timeout')} +
+
+ +
+
+
{$i18n.t('Verify SSL Certificate')} diff --git a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte index c0896a17b90..791aa9f0fcf 100644 --- a/src/lib/components/admin/Users/Groups/EditGroupModal.svelte +++ b/src/lib/components/admin/Users/Groups/EditGroupModal.svelte @@ -8,6 +8,7 @@ import General from './General.svelte'; import Permissions from './Permissions.svelte'; import Users from './Users.svelte'; + import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions'; import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte'; import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; @@ -36,63 +37,7 @@ export let description = ''; export let data = {}; - export let permissions = { - workspace: { - models: false, - knowledge: false, - prompts: false, - tools: false, - models_import: false, - models_export: false, - prompts_import: false, - prompts_export: false, - tools_import: false, - tools_export: false - }, - sharing: { - models: false, - public_models: false, - knowledge: false, - public_knowledge: false, - prompts: false, - public_prompts: false, - tools: false, - public_tools: false, - notes: false, - public_notes: false - }, - chat: { - controls: true, - valves: true, - system_prompt: true, - params: true, - file_upload: true, - delete: true, - delete_message: true, - continue_response: true, - regenerate_response: true, - rate_response: true, - edit: true, - share: true, - export: true, - stt: true, - tts: true, - call: true, - multiple_models: true, - temporary: true, - temporary_enforced: false - }, - features: { - api_keys: false, - notes: true, - channels: true, - folders: true, - direct_tool_servers: false, - web_search: true, - image_generation: true, - code_interpreter: true - } - }; + export let permissions = DEFAULT_PERMISSIONS; const submitHandler = async () => { loading = true; @@ -114,7 +59,15 @@ if (group) { name = group.name; description = group.description; - permissions = group?.permissions ?? {}; + const loadedPermissions = group?.permissions ?? {}; + // Create fresh object from defaults, then overlay loaded values + permissions = { + workspace: { ...DEFAULT_PERMISSIONS.workspace, ...loadedPermissions.workspace }, + sharing: { ...DEFAULT_PERMISSIONS.sharing, ...loadedPermissions.sharing }, + chat: { ...DEFAULT_PERMISSIONS.chat, ...loadedPermissions.chat }, + features: { ...DEFAULT_PERMISSIONS.features, ...loadedPermissions.features }, + settings: { ...DEFAULT_PERMISSIONS.settings, ...loadedPermissions.settings } + }; data = group?.data ?? {}; userCount = group?.member_count ?? 0; diff --git a/src/lib/components/admin/Users/Groups/General.svelte b/src/lib/components/admin/Users/Groups/General.svelte index 745611ec1f1..1922f001384 100644 --- a/src/lib/components/admin/Users/Groups/General.svelte +++ b/src/lib/components/admin/Users/Groups/General.svelte @@ -2,7 +2,6 @@ import { getContext } from 'svelte'; import Textarea from '$lib/components/common/Textarea.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - import Switch from '$lib/components/common/Switch.svelte'; const i18n = getContext('i18n'); @@ -73,21 +72,30 @@
- {$i18n.t('Allow Group Sharing')} + {$i18n.t('Who can share to this group')}
- { - if (data?.config?.share) { - data.config.share = e.detail; + const value = e.target.value; + let shareValue; + if (value === 'false') { + shareValue = false; + } else if (value === 'true') { + shareValue = true; } else { - data.config = { ...(data?.config ?? {}), share: e.detail }; + shareValue = value; } + data.config = { ...(data?.config ?? {}), share: shareValue }; }} - /> + > + + + +
diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 58c3547d336..3284f6171c4 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -5,64 +5,7 @@ import Switch from '$lib/components/common/Switch.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; - // Default values for permissions - const DEFAULT_PERMISSIONS = { - workspace: { - models: false, - knowledge: false, - prompts: false, - tools: false, - models_import: false, - models_export: false, - prompts_import: false, - prompts_export: false, - tools_import: false, - tools_export: false - }, - sharing: { - models: false, - public_models: false, - knowledge: false, - public_knowledge: false, - prompts: false, - public_prompts: false, - tools: false, - public_tools: false, - notes: false, - public_notes: false - }, - chat: { - controls: true, - valves: true, - system_prompt: true, - params: true, - file_upload: true, - delete: true, - delete_message: true, - continue_response: true, - regenerate_response: true, - rate_response: true, - edit: true, - share: true, - export: true, - stt: true, - tts: true, - call: true, - multiple_models: true, - temporary: true, - temporary_enforced: false - }, - features: { - api_keys: false, - notes: true, - channels: true, - folders: true, - direct_tool_servers: false, - web_search: true, - image_generation: true, - code_interpreter: true - } - }; + import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions'; export let permissions = {}; export let defaultPermissions = {}; @@ -79,7 +22,8 @@ workspace: { ...defaults.workspace, ...obj.workspace }, sharing: { ...defaults.sharing, ...obj.sharing }, chat: { ...defaults.chat, ...obj.chat }, - features: { ...defaults.features, ...obj.features } + features: { ...defaults.features, ...obj.features }, + settings: { ...defaults.settings, ...obj.settings } }; } @@ -838,5 +782,43 @@
{/if}
+ +
+
+
+ {$i18n.t('Memories')} +
+ +
+ {#if defaultPermissions?.features?.memories && !permissions.features.memories} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+
+ +
+ +
+
{$i18n.t('Settings Permissions')}
+ +
+
+
+ {$i18n.t('Interface Settings Access')} +
+ +
+ {#if defaultPermissions?.settings?.interface && !permissions.settings.interface} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ab544e5c8ac..76f398d71cd 100644 --- a/src/lib/components/admin/Users/Groups/Users.svelte +++ b/src/lib/components/admin/Users/Groups/Users.svelte @@ -30,7 +30,7 @@ let total = null; let query = ''; - let orderBy = `group_id:${groupId}`; // default sort key + let orderBy = 'created_at'; // default sort key let direction = 'desc'; // default sort order let page = 1; @@ -42,6 +42,7 @@ orderBy = key; direction = 'asc'; } + page = 1; }; const getUserList = async () => { @@ -75,7 +76,6 @@ }); } - page = 1; getUserList(); }; diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index e51241d77db..97b647ba56c 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -339,7 +339,7 @@
- {#each users as user, userIdx} + {#each users as user, userIdx (user.id)}
setSortKey('rating')} - > -
- {$i18n.t('RK')} - {#if orderBy === 'rating'} - - {#if direction === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('name')} - > -
- {$i18n.t('Model')} - {#if orderBy === 'name'} - - {#if direction === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('rating')} - > -
- {$i18n.t('Rating')} - {#if orderBy === 'rating'} - - {#if direction === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('won')} - > -
- {$i18n.t('Won')} - {#if orderBy === 'won'} - - {#if direction === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
setSortKey('lost')} - > -
- {$i18n.t('Lost')} - {#if orderBy === 'lost'} - - {#if direction === 'asc'} - - {:else} - - {/if} - - {:else} - - {/if} -
-
toggleSort(col.key)} + > +
+ {$i18n.t(col.label)} + {#if orderBy === col.key} + {#if direction === 'asc'}{:else}{/if} + {:else} + + {/if} +
+
-
- {model?.rating !== '-' ? modelIdx + 1 : '-'} -
+
+ {model.rating !== '-' ? idx + 1 : '-'} +
-
- {model.name} -
- -
- {model.name} -
+ {model.name} + {model.name}
+ {model.rating} -
- {#if model.stats.won === '-'} - - - {:else} - - {model.stats.won} - {/if} -
+
+ {#if model.stats.won === '-'}-{:else} + + {model.stats.won} + {/if} -
- {#if model.stats.lost === '-'} - - - {:else} - - {model.stats.lost} - {/if} -
+
+ {#if model.stats.lost === '-'}-{:else} + + {model.stats.lost} + {/if}
+ + + {#if expanded} +
+
+ +
+
{$i18n.t('Name')}
+ +
+
+ + + + + + +
+
+
+ {/if} + diff --git a/src/lib/components/channel/WebhooksModal.svelte b/src/lib/components/channel/WebhooksModal.svelte new file mode 100644 index 00000000000..c095e38018d --- /dev/null +++ b/src/lib/components/channel/WebhooksModal.svelte @@ -0,0 +1,181 @@ + + + + +{#if channel} + +
+
+
+
+
{$i18n.t('Webhooks')}
+ {webhooks.length} +
+ + +
+ + +
+ +
+
{ + e.preventDefault(); + saveHandler(); + }} + > + {#if isLoading} +
+ +
+ {:else if webhooks.length > 0} +
+ {#each webhooks as webhook (webhook.id)} + { + selectedWebhookId = selectedWebhookId === webhook.id ? null : webhook.id; + }} + onDelete={() => { + showDeleteConfirmDialog = true; + }} + onUpdate={(changes) => { + pendingChanges[webhook.id] = changes; + }} + /> + {/each} +
+ {:else} +
+ {$i18n.t('No webhooks yet')} +
+ {/if} + +
+ +
+
+
+
+
+{/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 7f80bca6015..ff41dfe472a 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -53,7 +53,8 @@ getPromptVariables, processDetails, removeAllDetails, - getCodeBlockContents + getCodeBlockContents, + isYoutubeUrl } from '$lib/utils'; import { AudioQueue } from '$lib/utils/audio'; @@ -389,6 +390,9 @@ if (autoScroll) { scrollToBottom('smooth'); } + } else if (type === 'chat:message:favorite') { + // Update message favorite status + message.favorite = data.favorite; } else if (type === 'chat:title') { chatTitle.set(data); currentChatPage.set(1); @@ -758,7 +762,7 @@ fileItem.id = uploadedFile.id; fileItem.size = file.size; fileItem.collection_name = uploadedFile?.meta?.collection_name; - fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`; + fileItem.url = `${uploadedFile.id}`; files = files; toast.success($i18n.t('File uploaded successfully')); @@ -773,69 +777,55 @@ } }; - const uploadWeb = async (url) => { - console.log(url); + const uploadWeb = async (urls) => { + if (!Array.isArray(urls)) { + urls = [urls]; + } - const fileItem = { + // Create file items first + const fileItems = urls.map((url) => ({ type: 'text', name: url, collection_name: '', status: 'uploading', - url: url, + context: 'full', + url, error: '' - }; + })); - try { - files = [...files, fileItem]; - const res = await processWeb(localStorage.token, '', url); + // Display all items at once + files = [...files, ...fileItems]; - if (res) { - fileItem.status = 'uploaded'; - fileItem.collection_name = res.collection_name; - fileItem.file = { - ...res.file, - ...fileItem.file - }; + for (const fileItem of fileItems) { + try { + const res = isYoutubeUrl(fileItem.url) + ? await processYoutubeVideo(localStorage.token, fileItem.url) + : await processWeb(localStorage.token, '', fileItem.url); + + if (res) { + fileItem.status = 'uploaded'; + fileItem.collection_name = res.collection_name; + fileItem.file = { + ...res.file, + ...fileItem.file + }; + } - files = files; + files = [...files]; + } catch (e) { + files = files.filter((f) => f.name !== url); + toast.error(`${e}`); } - } catch (e) { - // Remove the failed doc from the files array - files = files.filter((f) => f.name !== url); - toast.error(JSON.stringify(e)); } }; - const uploadYoutubeTranscription = async (url) => { - console.log(url); + const onUpload = async (event) => { + const { type, data } = event; - const fileItem = { - type: 'text', - name: url, - collection_name: '', - status: 'uploading', - context: 'full', - url: url, - error: '' - }; - - try { - files = [...files, fileItem]; - const res = await processYoutubeVideo(localStorage.token, url); - - if (res) { - fileItem.status = 'uploaded'; - fileItem.collection_name = res.collection_name; - fileItem.file = { - ...res.file, - ...fileItem.file - }; - files = files; - } - } catch (e) { - // Remove the failed doc from the files array - files = files.filter((f) => f.name !== url); - toast.error(`${e}`); + if (type === 'google-drive') { + await uploadGoogleDriveFile(data); + } else if (type === 'web') { + await uploadWeb(data); } }; @@ -915,10 +905,16 @@ } } + if ($user?.role !== 'admin' && !$user?.permissions?.chat?.temporary) { + await temporaryChatEnabled.set(false); + } + const availableModels = $models .filter((m) => !(m?.info?.meta?.hidden ?? false)) .map((m) => m.id); + const defaultModels = $config?.default_models ? $config?.default_models.split(',') : []; + if ($page.url.searchParams.get('models') || $page.url.searchParams.get('model')) { const urlModels = ( $page.url.searchParams.get('models') || @@ -927,8 +923,8 @@ )?.split(','); if (urlModels.length === 1) { - const m = $models.find((m) => m.id === urlModels[0]); - if (!m) { + if (!$models.find((m) => m.id === urlModels[0])) { + // Model not found; open model selector and prefill const modelSelectorButton = document.getElementById('model-selector-0-button'); if (modelSelectorButton) { modelSelectorButton.click(); @@ -942,37 +938,51 @@ } } } else { + // Model found; set it as selected selectedModels = urlModels; } } else { + // Multiple models; set as selected selectedModels = urlModels; } + // Unavailable models filtering selectedModels = selectedModels.filter((modelId) => $models.map((m) => m.id).includes(modelId) ); } else { if ($selectedFolder?.data?.model_ids) { + // Set from folder model IDs selectedModels = $selectedFolder?.data?.model_ids; } else { if (sessionStorage.selectedModels) { + // Set from session storage (temporary selection) selectedModels = JSON.parse(sessionStorage.selectedModels); sessionStorage.removeItem('selectedModels'); } else { if ($settings?.models) { + // Set from user settings selectedModels = $settings?.models; - } else if ($config?.default_models) { - console.log($config?.default_models.split(',') ?? ''); - selectedModels = $config?.default_models.split(','); + } else if (defaultModels && defaultModels.length > 0) { + // Set from default models + selectedModels = defaultModels; } } } + // Unavailable & hidden models filtering selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId)); } + // Ensure at least one model is selected if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) { if (availableModels.length > 0) { + if (defaultModels && defaultModels.length > 0) { + // Set from default models + selectedModels = defaultModels.filter((modelId) => availableModels.includes(modelId)); + } + + // Set to first available model selectedModels = [availableModels?.at(0) ?? '']; } else { selectedModels = ['']; @@ -1003,9 +1013,7 @@ params = {}; if ($page.url.searchParams.get('youtube')) { - uploadYoutubeTranscription( - `https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}` - ); + await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`); } if ($page.url.searchParams.get('load-url')) { @@ -1111,7 +1119,7 @@ if (history.currentId) { for (const message of Object.values(history.messages)) { - if (message.role === 'assistant') { + if (message && message.role === 'assistant') { message.done = true; } } @@ -1609,8 +1617,10 @@ const _files = JSON.parse(JSON.stringify(files)); chatFiles.push( - ..._files.filter((item) => - ['doc', 'text', 'file', 'note', 'chat', 'folder', 'collection'].includes(item.type) + ..._files.filter( + (item) => + ['doc', 'text', 'note', 'chat', 'folder', 'collection'].includes(item.type) || + (item.type === 'file' && !(item?.content_type ?? '').startsWith('image/')) ) ); chatFiles = chatFiles.filter( @@ -1737,11 +1747,18 @@ if (model) { // If there are image files, check if model is vision capable + // Skip this check if image generation is enabled, as images may be for editing or are generated outputs in the history const hasImages = createMessagesList(_history, parentId).some((message) => - message.files?.some((file) => file.type === 'image') + message.files?.some( + (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + ) ); - if (hasImages && !(model.info?.meta?.capabilities?.vision ?? true)) { + if ( + hasImages && + !(model.info?.meta?.capabilities?.vision ?? true) && + !imageGenerationEnabled + ) { toast.error( $i18n.t('Model {{modelName}} is not vision capable', { modelName: model.name ?? model.id @@ -1832,8 +1849,10 @@ let files = JSON.parse(JSON.stringify(chatFiles)); files.push( - ...(userMessage?.files ?? []).filter((item) => - ['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type) + ...(userMessage?.files ?? []).filter( + (item) => + ['doc', 'text', 'note', 'chat', 'collection'].includes(item.type) || + (item.type === 'file' && !(item?.content_type ?? '').startsWith('image/')) ) ); // Remove duplicates @@ -1880,30 +1899,33 @@ ].filter((message) => message); messages = messages - .map((message, idx, arr) => ({ - role: message.role, - ...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) && - message.role === 'user' - ? { - content: [ - { - type: 'text', - text: message?.merged?.content ?? message.content - }, - ...message.files - .filter((file) => file.type === 'image') - .map((file) => ({ + .map((message, idx, arr) => { + const imageFiles = (message?.files ?? []).filter( + (file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/') + ); + + return { + role: message.role, + ...(message.role === 'user' && imageFiles.length > 0 + ? { + content: [ + { + type: 'text', + text: message?.merged?.content ?? message.content + }, + ...imageFiles.map((file) => ({ type: 'image_url', image_url: { url: file.url } })) - ] - } - : { - content: message?.merged?.content ?? message.content - }) - })) + ] + } + : { + content: message?.merged?.content ?? message.content + }) + }; + }) .filter((message) => message?.role === 'user' || message?.content?.trim()); const toolIds = []; @@ -1958,6 +1980,7 @@ id: responseMessageId, parent_id: userMessage?.id ?? null, + parent_message: userMessage, background_tasks: { ...(!$temporaryChatEnabled && @@ -2081,8 +2104,10 @@ const responseMessage = history.messages[history.currentId]; // Set all response messages to done - for (const messageId of history.messages[responseMessage.parentId].childrenIds) { - history.messages[messageId].done = true; + if (responseMessage.parentId && history.messages[responseMessage.parentId]) { + for (const messageId of history.messages[responseMessage.parentId].childrenIds) { + history.messages[messageId].done = true; + } } history.messages[history.currentId] = responseMessage; @@ -2138,6 +2163,11 @@ if (history.currentId) { let userMessage = history.messages[message.parentId]; + if (!userMessage) { + toast.error($i18n.t('Parent message not found')); + return; + } + if (autoScroll) { scrollToBottom(); } @@ -2204,14 +2234,17 @@ generating = true; const [res, controller] = await generateMoACompletion( localStorage.token, - message.model, - history.messages[message.parentId].content, + message.model ?? '', + message.parentId ? history.messages[message.parentId].content : '', responses ); if (res && res.ok && res.body && generating) { - generationController = controller; - const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks); + generationController = controller as AbortController; + const textStream = await createOpenAITextStream( + res.body, + Boolean($settings?.splitLargeChunks ?? false) + ); for await (const update of textStream) { const { value, done, sources, error, usage } = update; if (error || done) { @@ -2298,7 +2331,7 @@ }; const MAX_DRAFT_LENGTH = 5000; - let saveDraftTimeout = null; + let saveDraftTimeout: ReturnType | null = null; const saveDraft = async (draft, chatId = null) => { if (saveDraftTimeout) { @@ -2377,7 +2410,7 @@
@@ -2522,22 +2555,12 @@ {generating} {stopResponse} {createMessagePair} + {onUpload} onChange={(data) => { if (!$temporaryChatEnabled) { saveDraft(data, $chatId); } }} - on:upload={async (e) => { - const { type, data } = e.detail; - - if (type === 'web') { - await uploadWeb(data); - } else if (type === 'youtube') { - await uploadYoutubeTranscription(data); - } else if (type === 'google-drive') { - await uploadGoogleDriveFile(data); - } - }} on:submit={async (e) => { clearDraft(); if (e.detail || files.length > 0) { @@ -2574,20 +2597,12 @@ {stopResponse} {createMessagePair} {onSelect} + {onUpload} onChange={(data) => { if (!$temporaryChatEnabled) { saveDraft(data); } }} - on:upload={async (e) => { - const { type, data } = e.detail; - - if (type === 'web') { - await uploadWeb(data); - } else if (type === 'youtube') { - await uploadYoutubeTranscription(data); - } - }} on:submit={async (e) => { clearDraft(); if (e.detail || files.length > 0) { diff --git a/src/lib/components/chat/ChatControls/Embeds.svelte b/src/lib/components/chat/ChatControls/Embeds.svelte index 81cf20d01f5..126124bc69b 100644 --- a/src/lib/components/chat/ChatControls/Embeds.svelte +++ b/src/lib/components/chat/ChatControls/Embeds.svelte @@ -1,10 +1,42 @@ - {#if $embed} @@ -40,7 +72,11 @@
{/if} - +
{/if} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index c59b21734d1..21c9640de9d 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1,14 +1,22 @@ @@ -57,15 +62,16 @@ {$i18n.t('Webpage URLs')} - user settings > config default + const getVoiceId = () => { + // Check for model-specific TTS voice first + if (model?.info?.meta?.tts?.voice) { + return model.info.meta.tts.voice; + } + // Fall back to user settings or config default + if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) { + return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice; + } + return $config?.audio?.tts?.voice; + }; + const speakSpeechSynthesisHandler = (content) => { if ($showCallOverlay) { return new Promise((resolve) => { @@ -372,12 +385,8 @@ if (voices.length > 0) { clearInterval(getVoicesLoop); - const voice = - voices - ?.filter( - (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice) - ) - ?.at(0) ?? undefined; + const voiceId = getVoiceId(); + const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined; currentUtterance = new SpeechSynthesisUtterance(content); currentUtterance.rate = $settings.audio?.tts?.playbackRate ?? 1; @@ -471,7 +480,7 @@ const url = await $TTSWorker .generate({ text: content, - voice: $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice + voice: getVoiceId() }) .catch((error) => { console.error(error); @@ -482,16 +491,12 @@ audioCache.set(content, new Audio(url)); } } else if ($config.audio.tts.engine !== '') { - const res = await synthesizeOpenAISpeech( - localStorage.token, - $settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice - ? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice) - : $config?.audio?.tts?.voice, - content - ).catch((error) => { - console.error(error); - return null; - }); + const res = await synthesizeOpenAISpeech(localStorage.token, getVoiceId(), content).catch( + (error) => { + console.error(error); + return null; + } + ); if (res) { const blob = await res.blob(); diff --git a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte index d8a9e1e91af..f4bd5b83f84 100644 --- a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte +++ b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte @@ -28,9 +28,6 @@ await Promise.all([ (async () => { prompts.set(await getPrompts(localStorage.token)); - })(), - (async () => { - knowledge.set(await getKnowledgeBases(localStorage.token)); })() ]); loading = false; @@ -103,7 +100,6 @@ bind:this={suggestionElement} {query} bind:filteredItems - knowledge={$knowledge ?? []} onSelect={(e) => { const { type, data } = e; @@ -114,13 +110,6 @@ type: 'file', data: data }); - } else if (type === 'youtube') { - insertTextHandler(''); - - onUpload({ - type: 'youtube', - data: data - }); } else if (type === 'web') { insertTextHandler(''); diff --git a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte index 77e5c168049..3ec7bbc6c6a 100644 --- a/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/Commands/Knowledge.svelte @@ -1,19 +1,21 @@ -
- {$i18n.t('Knowledge')} -
- {#if filteredItems.length > 0 || query.startsWith('http')} {#each filteredItems as item, idx} + {#if idx === 0 || item?.type !== items[idx - 1]?.type} +
+ {#if item?.type === 'folder'} + {$i18n.t('Folders')} + {:else if item?.type === 'collection'} + {$i18n.t('Collections')} + {:else if item?.type === 'file'} + {$i18n.t('Files')} + {/if} +
+ {/if} + {#if !['youtube', 'web'].includes(item.type)} - - {/if} + + + {#if ($chats ?? []).length > 0} {}; let loaded = false; - let items = []; let selectedIdx = 0; - onMount(async () => { - if ($knowledge === null) { - await knowledge.set(await getKnowledgeBases(localStorage.token)); - } + let selectedItem = null; - let legacy_documents = $knowledge - .filter((item) => item?.meta?.document) - .map((item) => ({ - ...item, - type: 'file' - })); - - let legacy_collections = - legacy_documents.length > 0 - ? [ - { - name: 'All Documents', - legacy: true, - type: 'collection', - description: 'Deprecated (legacy collection), please create a new knowledge base.', - title: $i18n.t('All Documents'), - collection_names: legacy_documents.map((item) => item.id) - }, - - ...legacy_documents - .reduce((a, item) => { - return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])]; - }, []) - .map((tag) => ({ - name: tag, - legacy: true, - type: 'collection', - description: 'Deprecated (legacy collection), please create a new knowledge base.', - collection_names: legacy_documents - .filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag)) - .map((item) => item.id) - })) - ] - : []; - - let collections = $knowledge - .filter((item) => !item?.meta?.document) - .map((item) => ({ - ...item, - type: 'collection' - })); - ``; - let collection_files = - $knowledge.length > 0 - ? [ - ...$knowledge - .reduce((a, item) => { - return [ - ...new Set([ - ...a, - ...(item?.files ?? []).map((file) => ({ - ...file, - collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT - })) - ]) - ]; - }, []) - .map((file) => ({ - ...file, - name: file?.meta?.name, - description: `${file?.collection?.name} - ${file?.collection?.description}`, - knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE - type: 'file' - })) - ] - : []; - - items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map( - (item) => { - return { - ...item, - ...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {}) - }; + let selectedFileItemsPage = 1; + + let selectedFileItems = null; + let selectedFileItemsTotal = null; + + let selectedFileItemsLoading = false; + let selectedFileAllItemsLoaded = false; + + $: if (selectedItem) { + initSelectedFileItems(); + } + + const initSelectedFileItems = async () => { + selectedFileItemsPage = 1; + selectedFileItems = null; + selectedFileItemsTotal = null; + selectedFileAllItemsLoaded = false; + selectedFileItemsLoading = false; + await tick(); + await getSelectedFileItemsPage(); + }; + + const loadMoreSelectedFileItems = async () => { + if (selectedFileAllItemsLoaded) return; + selectedFileItemsPage += 1; + await getSelectedFileItemsPage(); + }; + + const getSelectedFileItemsPage = async () => { + if (!selectedItem) return; + selectedFileItemsLoading = true; + + const res = await searchKnowledgeFilesById( + localStorage.token, + selectedItem.id, + null, + null, + null, + null, + selectedFileItemsPage + ).catch(() => { + return null; + }); + + if (res) { + selectedFileItemsTotal = res.total; + const pageItems = res.items; + + if ((pageItems ?? []).length === 0) { + selectedFileAllItemsLoaded = true; + } else { + selectedFileAllItemsLoaded = false; + } + + if (selectedFileItems) { + selectedFileItems = [...selectedFileItems, ...pageItems]; + } else { + selectedFileItems = pageItems; } - ); + } + + selectedFileItemsLoading = false; + return res; + }; + let page = 1; + let items = null; + let total = null; + + let itemsLoading = false; + let allItemsLoaded = false; + + $: if (loaded) { + init(); + } + + const init = async () => { + reset(); await tick(); + await getItemsPage(); + }; + + const reset = () => { + page = 1; + items = null; + total = null; + allItemsLoaded = false; + itemsLoading = false; + }; + + const loadMoreItems = async () => { + if (allItemsLoaded) return; + page += 1; + await getItemsPage(); + }; + const getItemsPage = async () => { + itemsLoading = true; + const res = await getKnowledgeBases(localStorage.token, page).catch(() => { + return null; + }); + + if (res) { + console.log(res); + total = res.total; + const pageItems = res.items; + + if ((pageItems ?? []).length === 0) { + allItemsLoaded = true; + } else { + allItemsLoaded = false; + } + + if (items) { + items = [...items, ...pageItems]; + } else { + items = pageItems; + } + } + + itemsLoading = false; + return res; + }; + + onMount(async () => { + await tick(); loaded = true; }); -{#if loaded} +{#if loaded && items !== null}
- {#each items as item, idx} - + + +
- - {/each} + + {#if selectedItem && selectedItem.id === item.id} +
+ {#if selectedFileItems === null && selectedFileItemsTotal === null} +
+ +
+ {:else if selectedFileItemsTotal === 0} +
+ {$i18n.t('No files in this knowledge base.')} +
+ {:else} + {#each selectedFileItems as file, fileIdx (file.id)} + + {/each} + + {#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading} + { + if (!selectedFileItemsLoading) { + await loadMoreSelectedFileItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} + {/if} +
+ {/if} + {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} + {/if} {:else}
diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index 784514679c1..20579689a53 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -77,7 +77,15 @@ let _messages = []; let message = history.messages[history.currentId]; + const visitedMessageIds = new Set(); + while (message && (messagesCount !== null ? _messages.length <= messagesCount : true)) { + if (visitedMessageIds.has(message.id)) { + console.warn('Circular dependency detected in message history', message.id); + break; + } + visitedMessageIds.add(message.id); + _messages.unshift({ ...message }); message = message.parentId !== null ? history.messages[message.parentId] : null; } @@ -346,6 +354,10 @@ }; const saveMessage = async (messageId, message) => { + if (!history.messages?.[messageId]) { + return; + } + history.messages[messageId] = message; await updateChat(); }; diff --git a/src/lib/components/chat/Messages/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte index 5bd7c2222b7..2db74581bdc 100644 --- a/src/lib/components/chat/Messages/Citations.svelte +++ b/src/lib/components/chat/Messages/Citations.svelte @@ -1,11 +1,14 @@ @@ -123,7 +154,21 @@
- {$i18n.t('Content')} + {#if document.source?.url?.includes('http')} + {@const snippetUrl = getTextFragmentUrl(document)} + {#if snippetUrl} + {$i18n.t('Content')} + {:else} + {$i18n.t('Content')} + {/if} + {:else} + {$i18n.t('Content')} + {/if} {#if showRelevance && document.distance !== undefined} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index a5e97ed9e8a..022bcfb2cb4 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -4,6 +4,7 @@ import type { Token } from 'marked'; import { getContext } from 'svelte'; + import { goto } from '$app/navigation'; const i18n = getContext('i18n'); @@ -24,6 +25,27 @@ export let tokens: Token[]; export let sourceIds = []; export let onSourceClick: Function = () => {}; + + /** + * Handle link clicks - intercept same-origin app URLs for in-app navigation + */ + const handleLinkClick = (e: MouseEvent, href: string) => { + try { + const url = new URL(href, window.location.origin); + // Check if same origin and an in-app route + if ( + url.origin === window.location.origin && + (url.pathname.startsWith('/notes/') || + url.pathname.startsWith('/c/') || + url.pathname.startsWith('/channels/')) + ) { + e.preventDefault(); + goto(url.pathname + url.search + url.hash); + } + } catch { + // Invalid URL, let browser handle it + } + }; {#each tokens as token, tokenIdx (tokenIdx)} @@ -33,11 +55,23 @@ {:else if token.type === 'link'} {#if token.tokens} - + handleLinkClick(e, token.href)} + > {:else} - {token.text} + handleLinkClick(e, token.href)}>{token.text} {/if} {:else if token.type === 'image'} {token.text} @@ -75,12 +109,11 @@ `${token.escapedText}` ) || ''} {:else if token.type === 'citation'} - - + {#if (sourceIds ?? []).length > 0} + + {:else} + + {/if} {:else if token.type === 'text'} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte index bd02408e36f..ac2b84cdcdc 100644 --- a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte @@ -39,37 +39,45 @@ }; -{#if (token?.ids ?? []).length == 1} - -{:else} - - - + + - - {getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))} - +{(token?.ids ?? []).length - 1} - - - - -
- {#each token.ids as sourceId} -
- -
- {/each} -
-
-
+
+ {#each token.citationIdentifiers ?? token.ids as identifier} + {@const id = + typeof identifier === 'string' ? parseInt(identifier.split('#')[0]) : identifier} +
+ +
+ {/each} +
+ + + {/if} +{:else} + {token.raw} {/if} diff --git a/src/lib/components/chat/Messages/Message.svelte b/src/lib/components/chat/Messages/Message.svelte index e34e1cd54d2..d9ca32492a0 100644 --- a/src/lib/components/chat/Messages/Message.svelte +++ b/src/lib/components/chat/Messages/Message.svelte @@ -100,29 +100,31 @@ {topPadding} /> {:else} - + {#key messageId} + + {/key} {/if} {/if}
diff --git a/src/lib/components/chat/Messages/MultiResponseMessages.svelte b/src/lib/components/chat/Messages/MultiResponseMessages.svelte index 5b5000e8e01..73460dff9fd 100644 --- a/src/lib/components/chat/Messages/MultiResponseMessages.svelte +++ b/src/lib/components/chat/Messages/MultiResponseMessages.svelte @@ -250,7 +250,7 @@ class="flex gap-2 scrollbar-none overflow-x-auto w-fit text-center font-medium bg-transparent pt-1 text-sm" > {#each Object.keys(groupedMessageIds) as modelIdx} - {#if groupedMessageIdsIdx[modelIdx] !== undefined && groupedMessageIds[modelIdx].messageIds.length > 0} + {#if groupedMessageIdsIdx[modelIdx] !== undefined && (groupedMessageIds[modelIdx]?.messageIds ?? []).length > 0} @@ -283,16 +283,12 @@
{#if selectedModelIdx !== null} - {@const _messageId = - groupedMessageIds[selectedModelIdx].messageIds[ - groupedMessageIdsIdx[selectedModelIdx] - ]} {#key history.currentId} {#if message} user settings > config default + const getVoiceId = () => { + // Check for model-specific TTS voice first + if (model?.info?.meta?.tts?.voice) { + return model.info.meta.tts.voice; + } + // Fall back to user settings or config default + if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) { + return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice; + } + return $config?.audio?.tts?.voice; + }; + if ($config.audio.tts.engine === '') { let voices = []; const getVoicesLoop = setInterval(() => { @@ -215,12 +227,8 @@ if (voices.length > 0) { clearInterval(getVoicesLoop); - const voice = - voices - ?.filter( - (v) => v.voiceURI === ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice) - ) - ?.at(0) ?? undefined; + const voiceId = getVoiceId(); + const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined; console.log(voice); @@ -266,7 +274,9 @@ return; } - console.debug('Prepared message content for TTS', messageContentParts); + const voiceId = getVoiceId(); + console.debug('Prepared message content for TTS', messageContentParts, 'voice:', voiceId); + if ($settings.audio?.tts?.engine === 'browser-kokoro') { if (!$TTSWorker) { await TTSWorker.set( @@ -282,7 +292,7 @@ const url = await $TTSWorker .generate({ text: sentence, - voice: $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice + voice: voiceId }) .catch((error) => { console.error(error); @@ -299,19 +309,15 @@ } } else { for (const [idx, sentence] of messageContentParts.entries()) { - const res = await synthesizeOpenAISpeech( - localStorage.token, - $settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice - ? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice) - : $config?.audio?.tts?.voice, - sentence - ).catch((error) => { - console.error(error); - toast.error(`${error}`); + const res = await synthesizeOpenAISpeech(localStorage.token, voiceId, sentence).catch( + (error) => { + console.error(error); + toast.error(`${error}`); - speaking = false; - loadingSpeech = false; - }); + speaking = false; + loadingSpeech = false; + } + ); if (res && speaking) { const blob = await res.blob(); @@ -390,28 +396,6 @@ await tick(); }; - const generateImage = async (message: MessageType) => { - generatingImage = true; - const res = await imageGenerations(localStorage.token, message.content).catch((error) => { - toast.error(`${error}`); - }); - console.log(res); - - if (res) { - const files = res.map((image) => ({ - type: 'image', - url: `${image.url}` - })); - - saveMessage(message.id, { - ...message, - files: files - }); - } - - generatingImage = false; - }; - let feedbackLoading = false; const feedbackHandler = async (rating: number | null = null, details: object | null = null) => { @@ -666,10 +650,13 @@ {/if} {#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0} -
+
{#each message.files as file}
- {#if file.type === 'image'} + {#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')} {message.content} {:else}
@@ -824,6 +811,7 @@ @@ -1088,73 +1076,6 @@ {/if} - {#if $config?.features.enable_image_generation && ($user?.role === 'admin' || $user?.permissions?.features?.image_generation) && !readOnly} - - - - {/if} - {#if message.usage} - - - {/each} - {/if} + {#each model?.actions ?? [] as action} + + + + {/each} {/if} {/if} {/if} diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index f6e431e532d..417c6737f60 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -188,11 +188,18 @@
{#if edit !== true} {#if message.files} -
+
{#each message.files as file} + {@const fileUrl = + file.url?.startsWith('data') || file.url?.startsWith('http') + ? file.url + : `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
- {#if file.type === 'image'} - + {#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')} + {:else} 0}
{#each editedFiles as file, fileIdx} - {#if file.type === 'image'} + {#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')} + {@const fileUrl = + file.url?.startsWith('data') || file.url?.startsWith('http') + ? file.url + : `${WEBUI_API_BASE_URL}/files/${file.url}${file?.content_type ? '/content' : ''}`}
input diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index 3306f160573..3cfbf756d84 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -80,6 +80,7 @@ src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${item.model.id}&lang=${$i18n.language}`} alt="Model" class="rounded-full size-5 flex items-center" + loading="lazy" />
diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte index 64e79dbcbb6..5f795a67091 100644 --- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte @@ -5,9 +5,10 @@ import { getContext } from 'svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Pin from '$lib/components/icons/Pin.svelte'; + import PinSlash from '$lib/components/icons/PinSlash.svelte'; + import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte'; import Link from '$lib/components/icons/Link.svelte'; - import Eye from '$lib/components/icons/Eye.svelte'; - import EyeSlash from '$lib/components/icons/EyeSlash.svelte'; import { settings } from '$lib/stores'; const i18n = getContext('i18n'); @@ -63,9 +64,9 @@ }} > {#if ($settings?.pinnedModels ?? []).includes(model?.id)} - + {:else} - + {/if}
diff --git a/src/lib/components/chat/Overview/Node.svelte b/src/lib/components/chat/Overview/Node.svelte index 4e599740c02..9e6cf356a44 100644 --- a/src/lib/components/chat/Overview/Node.svelte +++ b/src/lib/components/chat/Overview/Node.svelte @@ -44,7 +44,7 @@ {:else}
diff --git a/src/lib/components/chat/Overview/View.svelte b/src/lib/components/chat/Overview/View.svelte index dc1276abec4..b3164e6e788 100644 --- a/src/lib/components/chat/Overview/View.svelte +++ b/src/lib/components/chat/Overview/View.svelte @@ -75,6 +75,8 @@ Object.keys(history.messages).forEach((id) => { const message = history.messages[id]; + if (!message) return; + const level = message.parentId ? (positionMap.get(message.parentId)?.level ?? -1) + 1 : 0; if (!layerWidths[level]) layerWidths[level] = 0; @@ -88,6 +90,8 @@ // Adjust positions based on siblings count to centralize vertical spacing Object.keys(history.messages).forEach((id) => { const pos = positionMap.get(id); + if (!pos) return; + const x = direction === 'vertical' ? pos.position * siblingOffset : pos.level * levelOffset; const y = direction === 'vertical' ? pos.level * levelOffset : pos.position * siblingOffset; diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index d0b3a19a726..fc898b45d9b 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -54,6 +54,7 @@ export let codeInterpreterEnabled = false; export let webSearchEnabled = false; + export let onUpload: Function = (e) => {}; export let onSelect = (e) => {}; export let onChange = (e) => {}; @@ -216,9 +217,7 @@ {createMessagePair} placeholder={$i18n.t('How can I help you today?')} {onChange} - on:upload={(e) => { - dispatch('upload', e.detail); - }} + {onUpload} on:submit={(e) => { dispatch('submit', e.detail); }} diff --git a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte index fb4efddabb8..e6916507e73 100644 --- a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte +++ b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte @@ -1,6 +1,8 @@ - + + +
+ {#if completed} +
+
{$i18n.t('Sync Complete!')}
+
+ {$i18n.t('Your usage stats have been successfully synced.')} +
+ + + +
+ +
+
+ {:else if error} +
+
{$i18n.t('Sync Failed')}
+
+ {errorMessage || $i18n.t('There was an error syncing your stats. Please try again.')} +
+ +
+ +
+
+ {:else} +
+
{$i18n.t('Sync Usage Stats')}
+ +
+ +
+
+ {$i18n.t('Do you want to sync your usage stats with Open WebUI Community?')} +
+ +
+ {$i18n.t( + 'Participate in community leaderboards and evaluations! Syncing aggregated usage stats helps drive research and improvements to Open WebUI. Your privacy is paramount: no message content is ever shared.' + )} +
+ +
+
+ {$i18n.t('What is shared:')} +
+
    +
  • {$i18n.t('Open WebUI version')}
  • +
  • {$i18n.t('Model names and usage frequency')}
  • +
  • {$i18n.t('Message counts and response timestamps')}
  • +
  • {$i18n.t('Content lengths (character counts only)')}
  • +
  • {$i18n.t('User ratings (thumbs up/down)')}
  • +
+ +
+ {$i18n.t('What is NOT shared:')} +
+
    +
  • {$i18n.t('Your message text or inputs')}
  • +
  • {$i18n.t('Model responses or outputs')}
  • +
  • {$i18n.t('Uploaded files or images')}
  • +
+
+ + {#if eventData?.lastSyncedChatUpdatedAt} +
+ + + +
+ {/if} + + {#if syncing} +
+
+
+ {downloading ? $i18n.t('Downloading stats...') : $i18n.t('Syncing stats...')} +
+
+ {#if total > 0} + {processedItemsCount}/{total} + {/if} +
+
+
+ {#if total > 0} +
+ {:else} +
+ {/if} +
+
+ {/if} + +
+
+ +
+ + + + +
+
+ {/if} +
+
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index 6e7825ba3f1..b9cdac92ac5 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -487,6 +487,17 @@ ); } + if (tab.id === 'interface') { + return $user?.role === 'admin' || ($user?.permissions?.settings?.interface ?? true); + } + + if (tab.id === 'personalization') { + return ( + $config?.features?.enable_memories && + ($user?.role === 'admin' || ($user?.permissions?.features?.memories ?? true)) + ); + } + return true; }); }; diff --git a/src/lib/components/common/Collapsible.svelte b/src/lib/components/common/Collapsible.svelte index 8f788c6d89a..69262328b89 100644 --- a/src/lib/components/common/Collapsible.svelte +++ b/src/lib/components/common/Collapsible.svelte @@ -206,7 +206,7 @@ /> {/if} {:else if typeof file === 'object'} - {#if file.type === 'image' && file.url} + {#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url} { - document.body.removeChild(popupElement); + if (popupElement && popupElement.parentNode) { + try { + popupElement.parentNode.removeChild(popupElement); + } catch (err) { + console.warn('Failed to remove popupElement:', err); + } + } + document.body.style.overflow = 'unset'; }); diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte new file mode 100644 index 00000000000..ecc5cc9cf8d --- /dev/null +++ b/src/lib/components/common/DropdownOptions.svelte @@ -0,0 +1,62 @@ + + + + +
+ {items.find((item) => item.value === value)?.label ?? placeholder} + +
+
+ + +
+ {#each items as item} + + {/each} +
+
+
diff --git a/src/lib/components/common/FileItem.svelte b/src/lib/components/common/FileItem.svelte index 2925c836224..c6e4a785b49 100644 --- a/src/lib/components/common/FileItem.svelte +++ b/src/lib/components/common/FileItem.svelte @@ -1,13 +1,15 @@ + + +
+
+
+ {$i18n.t('Input')} +
+ +
+ +
+
+ { + value = content.md; + inputContent = content; + + onChange(content); + }} + json={true} + value={inputContent?.json} + html={inputContent?.html} + richText={$settings?.richTextInput ?? true} + messageInput={true} + showFormattingToolbar={$settings?.showFormattingToolbar ?? false} + floatingMenuPlacement={'top-start'} + insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} + {autocomplete} + {generateAutoCompletion} + /> +
+
+
+
diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 9f352c1b022..f7447551f81 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -169,7 +169,7 @@ export let documentId = ''; - export let className = 'input-prose'; + export let className = 'input-prose min-h-fit h-full'; export let placeholder = $i18n.t('Type here...'); let _placeholder = placeholder; @@ -416,7 +416,7 @@ }; export const setText = (text: string) => { - if (!editor) return; + if (!editor || !editor.view) return; text = text.replaceAll('\n\n', '\n'); // reset the editor content @@ -448,11 +448,13 @@ } selectNextTemplate(editor.view.state, editor.view.dispatch); + + // Ensure the editor is still valid before trying to focus focus(); }; export const insertContent = (content) => { - if (!editor) return; + if (!editor || !editor.view) return; const { state, view } = editor; const { schema, tr } = state; @@ -466,7 +468,7 @@ }; export const replaceVariables = (variables) => { - if (!editor) return; + if (!editor || !editor.view) return; const { state, view } = editor; const { doc } = state; @@ -509,11 +511,16 @@ }; export const focus = () => { - if (editor) { + if (editor && editor.view) { + // Check if the editor is destroyed + if (editor.isDestroyed) { + return; + } + try { - editor.view?.focus(); + editor.view.focus(); // Scroll to the current selection - editor.view?.dispatch(editor.view.state.tr.scrollIntoView()); + editor.view.dispatch(editor.view.state.tr.scrollIntoView()); } catch (e) { // sometimes focusing throws an error, ignore console.warn('Error focusing editor', e); @@ -753,6 +760,14 @@ placement: 'top', theme: 'transparent', offset: [0, 2] + }, + shouldShow: ({ editor, view, state, oldState, from, to }) => { + // safety check + if (!editor || !editor.view || editor.isDestroyed) { + return false; + } + // default logic + return from !== to; } }), FloatingMenu.configure({ @@ -763,6 +778,14 @@ placement: floatingMenuPlacement, theme: 'transparent', offset: [-12, 4] + }, + shouldShow: ({ editor, view, state, oldState }) => { + // safety check + if (!editor || !editor.view || editor.isDestroyed) { + return false; + } + // default logic + return editor.isActive('paragraph'); } }) ] @@ -1156,7 +1179,6 @@
diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts index 7c7b7a48d97..e261af01a1f 100644 --- a/src/lib/components/common/RichTextInput/Collaboration.ts +++ b/src/lib/components/common/RichTextInput/Collaboration.ts @@ -8,7 +8,6 @@ import { prosemirrorJSONToYDoc } from 'y-prosemirror'; import type { Socket } from 'socket.io-client'; -import type { Awareness } from 'y-protocols/awareness'; import type { SessionUser } from '$lib/stores'; import { Editor, Extension } from '@tiptap/core'; import { keymap } from 'prosemirror-keymap'; @@ -72,7 +71,8 @@ export class SocketIOCollaborationProvider { }) ]; - plugins.push(yCursorPlugin(this.awareness as unknown as Awareness)); + // @ts-ignore + plugins.push(yCursorPlugin(this.awareness)); return plugins; } @@ -131,9 +131,20 @@ export class SocketIOCollaborationProvider { const isEmptyEditor = !this.editor?.getText().trim(); if (isEmptyEditor && this.editor) { if (this.initialContent && (data?.sessions ?? ['']).length === 1) { - const editorYdoc = prosemirrorJSONToYDoc(this.editor.schema, this.initialContent); - if (editorYdoc) { - Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); + // Check if initialContent is HTML (string) or JSON (object) + if (typeof this.initialContent === 'string') { + // HTML content - let the editor parse it, then sync to Yjs + this.editor.commands.setContent(this.initialContent); + // The Yjs plugin will automatically sync the content + } else { + // JSON content - use the existing approach + const editorYdoc = prosemirrorJSONToYDoc( + this.editor.schema, + this.initialContent + ); + if (editorYdoc) { + Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); + } } } } else { diff --git a/src/lib/components/icons/ArrowDownTray.svelte b/src/lib/components/icons/ArrowDownTray.svelte new file mode 100644 index 00000000000..55620e9feaf --- /dev/null +++ b/src/lib/components/icons/ArrowDownTray.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/src/lib/components/icons/Expand.svelte b/src/lib/components/icons/Expand.svelte new file mode 100644 index 00000000000..e11230aa37e --- /dev/null +++ b/src/lib/components/icons/Expand.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/icons/Glasses.svelte b/src/lib/components/icons/Glasses.svelte index 939924f7079..3d05cf70a26 100644 --- a/src/lib/components/icons/Glasses.svelte +++ b/src/lib/components/icons/Glasses.svelte @@ -11,13 +11,35 @@ fill="none" stroke="currentColor" viewBox="0 0 24 24" - > + + + + + + /> + + + + + + + + + + diff --git a/src/lib/components/icons/PagePlus.svelte b/src/lib/components/icons/PagePlus.svelte new file mode 100644 index 00000000000..c69816dd8e5 --- /dev/null +++ b/src/lib/components/icons/PagePlus.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/lib/components/layout/ArchivedChatsModal.svelte b/src/lib/components/layout/ArchivedChatsModal.svelte index 791aa8c9fe2..ec6a74a045d 100644 --- a/src/lib/components/layout/ArchivedChatsModal.svelte +++ b/src/lib/components/layout/ArchivedChatsModal.svelte @@ -1,5 +1,7 @@ - +
@@ -210,6 +218,7 @@ placeholder={`${$i18n.t('new-channel')}`} autocomplete="off" required={type !== 'dm'} + max="100" />
@@ -240,6 +249,22 @@
{/if} + {#if edit} +
+
{$i18n.t('Webhooks')}
+ + +
+ {/if} +
{#if edit} - - + {#if note?.write_access} + {#if editor} +
+
+ + + +
-
- {/if} + {/if} - - - + }} + > + + + - - - + }} + > + + + + {/if} { @@ -1071,11 +1080,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, }} >
- + {#if note?.write_access} + + {:else} +
+ {$i18n.t('Read-Only Access')} +
+ {/if} {#if editor}
@@ -1130,7 +1141,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{#if editing} @@ -1145,10 +1156,10 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, bind:this={inputElement} bind:editor id={`note-${note.id}`} - className="input-prose-sm px-0.5" + className="input-prose-sm px-0.5 h-[calc(100%-2rem)]" json={true} bind:value={note.data.content.json} - html={note.data?.content?.html} + html={editorHtml} documentId={`note:${note.id}`} collaboration={true} socket={$socket} @@ -1158,7 +1169,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, image={true} {files} placeholder={$i18n.t('Write something...')} - editable={versionIdx === null && !editing} + editable={versionIdx === null && !editing && note?.write_access} onSelectionUpdate={({ editor }) => { const { from, to } = editor.state.selection; const selectedText = editor.state.doc.textBetween(from, to, ' '); @@ -1243,8 +1254,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{/if}
-
-
+
+
{#if recording}
{:else} +
+ + {#if editing} + + {:else} + { + enhanceNoteHandler(); + }} + onChat={() => { + showPanel = true; + selectedPanel = 'chat'; + }} + > +
+ +
+
+ {/if} +
+
{ displayMediaRecord = false; @@ -1324,40 +1368,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
- -
- - {#if editing} - - {:else} - { - enhanceNoteHandler(); - }} - onChat={() => { - showPanel = true; - selectedPanel = 'chat'; - }} - > -
- -
-
- {/if} -
-
{/if}
diff --git a/src/lib/components/notes/NoteEditor/Controls.svelte b/src/lib/components/notes/NoteEditor/Controls.svelte index 675056ee413..3d80425f760 100644 --- a/src/lib/components/notes/NoteEditor/Controls.svelte +++ b/src/lib/components/notes/NoteEditor/Controls.svelte @@ -68,7 +68,7 @@ {/each}
- {#each files.filter((file) => file.type === 'image') as file, fileIdx} + {#each files.filter((file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) as file, fileIdx} import { marked } from 'marked'; - import { toast } from 'svelte-sonner'; import fileSaver from 'file-saver'; - import Fuse from 'fuse.js'; const { saveAs } = fileSaver; @@ -25,17 +23,16 @@ } } + import { onMount, getContext, onDestroy } from 'svelte'; + + const i18n = getContext('i18n'); // Assuming $i18n.languages is an array of language codes $: loadLocale($i18n.languages); - import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { onMount, getContext, onDestroy } from 'svelte'; import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; - - import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes'; + import { createNewNote, deleteNoteById, getNoteList, searchNotes } from '$lib/apis/notes'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; - import { downloadPdf, createNoteHandler } from './utils'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; @@ -48,58 +45,31 @@ import NoteMenu from './Notes/NoteMenu.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import XMark from '../icons/XMark.svelte'; + import DropdownOptions from '../common/DropdownOptions.svelte'; + import Loader from '../common/Loader.svelte'; - const i18n = getContext('i18n'); let loaded = false; let importFiles = ''; - let query = ''; - - let noteItems = []; - let fuse = null; - let selectedNote = null; + let showDeleteConfirm = false; + let notes = {}; - $: if (fuse) { - notes = groupNotes( - query - ? fuse.search(query).map((e) => { - return e.item; - }) - : noteItems - ); - } - let showDeleteConfirm = false; + let items = null; + let total = null; - const groupNotes = (res) => { - console.log(res); - if (!Array.isArray(res)) { - return {}; // or throw new Error("Notes response is not an array") - } + let query = ''; - // Build the grouped object - const grouped: Record = {}; - for (const note of res) { - const timeRange = getTimeRange(note.updated_at / 1000000000); - if (!grouped[timeRange]) { - grouped[timeRange] = []; - } - grouped[timeRange].push({ - ...note, - timeRange - }); - } - return grouped; - }; + let sortKey = null; + let displayOption = null; + let viewOption = null; + let permission = null; - const init = async () => { - noteItems = await getNotes(localStorage.token, true); + let page = 1; - fuse = new Fuse(noteItems, { - keys: ['title'] - }); - }; + let itemsLoading = false; + let allItemsLoaded = false; const downloadHandler = async (type) => { if (type === 'txt') { @@ -173,6 +143,101 @@ } }; + const reset = () => { + page = 1; + items = null; + total = null; + allItemsLoaded = false; + itemsLoading = false; + notes = {}; + }; + + const loadMoreItems = async () => { + if (allItemsLoaded) return; + page += 1; + await getItemsPage(); + }; + + const init = async () => { + reset(); + await getItemsPage(); + }; + + $: if ( + loaded && + query !== undefined && + sortKey !== undefined && + permission !== undefined && + viewOption !== undefined + ) { + init(); + } + + const getItemsPage = async () => { + itemsLoading = true; + + if (viewOption === 'created') { + permission = null; + } + + const res = await searchNotes( + localStorage.token, + query, + viewOption, + permission, + sortKey, + page + ).catch(() => { + return []; + }); + + if (res) { + console.log(res); + total = res.total; + const pageItems = res.items; + + if ((pageItems ?? []).length === 0) { + allItemsLoaded = true; + } else { + allItemsLoaded = false; + } + + if (items) { + items = [...items, ...pageItems]; + } else { + items = pageItems; + } + } + + itemsLoading = false; + return res; + }; + + const groupNotes = (res) => { + if (!Array.isArray(res)) { + return []; // Return empty array for invalid input + } + + // Build the grouped object while tracking order + const grouped: Record = {}; + const orderedKeys: string[] = []; + + for (const note of res) { + const timeRange = getTimeRange(note.updated_at / 1000000000); + if (!grouped[timeRange]) { + grouped[timeRange] = []; + orderedKeys.push(timeRange); + } + grouped[timeRange].push({ + ...note, + timeRange + }); + } + + // Return as array of [timeRange, notes] to preserve insertion order + return orderedKeys.map((key) => [key, grouped[key]] as [string, any[]]); + }; + let dragged = false; const onDragOver = (e) => { @@ -205,6 +270,18 @@ dragged = false; }; + onMount(async () => { + viewOption = localStorage?.noteViewOption ?? null; + displayOption = localStorage?.noteDisplayOption ?? null; + + loaded = true; + + const dropzoneElement = document.getElementById('notes-container'); + dropzoneElement?.addEventListener('dragover', onDragOver); + dropzoneElement?.addEventListener('drop', onDrop); + dropzoneElement?.addEventListener('dragleave', onDragLeave); + }); + onDestroy(() => { console.log('destroy'); const dropzoneElement = document.getElementById('notes-container'); @@ -215,17 +292,6 @@ dropzoneElement?.removeEventListener('dragleave', onDragLeave); } }); - - onMount(async () => { - await init(); - loaded = true; - - const dropzoneElement = document.getElementById('notes-container'); - - dropzoneElement?.addEventListener('dragover', onDragOver); - dropzoneElement?.addEventListener('drop', onDrop); - dropzoneElement?.addEventListener('dragleave', onDragLeave); - }); @@ -236,7 +302,7 @@ -
+
{#if loaded} -
-
+
+
+
+
+ {$i18n.t('Notes')} +
+ +
+ {total} +
+
+ +
+ +
+
+
+ +
+
@@ -277,194 +376,303 @@ {/if}
-
-
- {#if Object.keys(notes).length > 0} -
- {#each Object.keys(notes) as timeRange} -
- {$i18n.t(timeRange)} -
+
+
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ { + if (value) { + localStorage.noteViewOption = value; + } else { + delete localStorage.noteViewOption; + } + }} + /> + + {#if [null, 'shared'].includes(viewOption)} + + {/if} +
+
+ +
+ { + if (displayOption) { + localStorage.noteDisplayOption = displayOption; + } else { + delete localStorage.noteDisplayOption; + } + }} + /> +
+
+ + {#if items !== null && total !== null} + {#if (items ?? []).length > 0} + {@const groupedNotes = groupNotes(items)} -
- {#each notes[timeRange] as note, idx (note.id)} +
+
+ {#each groupedNotes as [timeRange, notesList], idx}
- - - + {/if} {/each} -
- {/each} -
- {:else} -
-
-
- {$i18n.t('No Notes')} -
-
- {$i18n.t('Create your first note by clicking on the plus button below.')} + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
+
+ {:else} +
+
+
+ {$i18n.t('No Notes')} +
+ +
+ {$i18n.t('Create your first note by clicking on the plus button below.')} +
+ {/if} + {:else} +
+
{/if}
- -
-
- - - - - -
-
- - {:else}
- +
{/if}
diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts index 5d398ebaf24..052c48a4411 100644 --- a/src/lib/components/notes/utils.ts +++ b/src/lib/components/notes/utils.ts @@ -107,7 +107,7 @@ export const downloadPdf = async (note) => { pdf.save(`${note.title}.pdf`); }; -export const createNoteHandler = async (title: string, content?: string) => { +export const createNoteHandler = async (title: string, md?: string, html?: string) => { // $i18n.t('New Note'), const res = await createNewNote(localStorage.token, { // YYYY-MM-DD @@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => { data: { content: { json: null, - html: content ?? '', - md: content ?? '' + html: html || md || '', + md: md || '' } }, meta: null, diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index d77ac02066c..e93faa15542 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -1,6 +1,4 @@ @@ -123,7 +155,7 @@
- {filteredItems.length} + {total}
@@ -192,11 +224,11 @@
- {#if (filteredItems ?? []).length !== 0} - -
- {#each filteredItems as item} - + {#if items !== null && total !== null} + {#if (items ?? []).length !== 0} + +
+ {#each items as item} - - {/each} -
- {:else} -
-
-
😕
-
{$i18n.t('No knowledge found')}
-
- {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} + {/each} +
+ + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} + {:else} +
+
+
😕
+
{$i18n.t('No knowledge found')}
+
+ {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} +
+ {/if} + {:else} +
+
{/if}
diff --git a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte index 2e729f4968e..3373e5a660c 100644 --- a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte @@ -1,11 +1,13 @@ @@ -50,16 +54,16 @@
{ - dispatch('upload', { type: 'files' }); + onUpload({ type: 'files' }); }} > @@ -67,9 +71,9 @@ { - dispatch('upload', { type: 'directory' }); + onUpload({ type: 'directory' }); }} > @@ -83,9 +87,9 @@ className="w-full" > { - dispatch('sync', { type: 'directory' }); + onSync(); }} > @@ -94,9 +98,19 @@ { + onUpload({ type: 'web' }); + }} + > + +
{$i18n.t('Add webpage')}
+
+ + { - dispatch('upload', { type: 'text' }); + onUpload({ type: 'text' }); }} > diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte index eed0a95c81f..9d421302349 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/Files.svelte @@ -1,45 +1,105 @@ -
- {#each files as file} -
- { - if (file.status === 'uploading') { - return; - } - - dispatch('click', file.id); +
+ {#each files as file (file?.id ?? file?.itemId ?? file?.tempId)} +
+ + + {#if knowledge?.write_access} +
+ + + +
+ {/if}
{/each}
diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index b66517d2665..44f91be079f 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -24,6 +24,7 @@ import { getModels } from '$lib/apis'; import { getGroups } from '$lib/apis/groups'; + import { updateUserSettings } from '$lib/apis/users'; import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils'; @@ -43,6 +44,7 @@ import ViewSelector from './common/ViewSelector.svelte'; import TagSelector from './common/TagSelector.svelte'; import Pagination from '../common/Pagination.svelte'; + import Badge from '$lib/components/common/Badge.svelte'; let shiftKey = false; @@ -68,13 +70,18 @@ let models = null; let total = null; + let searchDebounceTimer; + $: if ( page !== undefined && query !== undefined && selectedTag !== undefined && viewOption !== undefined ) { - getModelList(); + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + getModelList(); + }, 300); } const getModelList = async () => { @@ -210,6 +217,19 @@ saveAs(blob, `${model.id}-${Date.now()}.json`); }; + const pinModelHandler = async (modelId) => { + let pinnedModels = $settings?.pinnedModels ?? []; + + if (pinnedModels.includes(modelId)) { + pinnedModels = pinnedModels.filter((id) => id !== modelId); + } else { + pinnedModels = [...new Set([...pinnedModels, modelId])]; + } + + settings.set({ ...$settings, pinnedModels: pinnedModels }); + await updateUserSettings(localStorage.token, { ui: $settings }); + }; + onMount(async () => { viewOption = localStorage.workspaceViewOption ?? ''; page = 1; @@ -381,6 +401,7 @@ class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent" bind:value={query} placeholder={$i18n.t('Search Models')} + maxlength="500" /> {#if query} @@ -430,213 +451,227 @@
- {#if (models ?? []).length !== 0} -
- {#each models as model (model.id)} - - -
{ - if ( - $user?.role === 'admin' || - model.user_id === $user?.id || - model.access_control.write.group_ids.some((wg) => groupIds.includes(wg)) - ) { - goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`); - } - }} - > -
-
-
-
- modelfile profile + {#if models !== null} + {#if (models ?? []).length !== 0} +
+ {#each models as model (model.id)} + + +
{ + if (model.write_access) { + goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`); + } + }} + > +
+
+
+
+ modelfile profile +
-
-
-
-
-
- - - {model.name} - - - -
-
-
-
-
- {#if shiftKey} - - + + + + + + {:else} + { + goto( + `/workspace/models/edit?id=${encodeURIComponent(model.id)}` + ); + }} + shareHandler={() => { + shareModelHandler(model); + }} + cloneHandler={() => { + cloneModelHandler(model); + }} + exportHandler={() => { + exportModelHandler(model); + }} + hideHandler={() => { hideModelHandler(model); }} - > - {#if model?.meta?.hidden} - - {:else} - - {/if} - - - - - - - {:else} - { - goto( - `/workspace/models/edit?id=${encodeURIComponent(model.id)}` +
+ +
+
+ {/if} +
+
+ {/if} + + {#if model.write_access} +
-
+ /> + + + {/if}
+
- -
-
+
+ {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User') + ) + })} +
+ -
- -
- {$i18n.t('By {{name}}', { - name: capitalizeFirstLetter( - model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User') - ) - })} -
-
- -
·
- - -
-
- {#if (model?.meta?.description ?? '').trim()} - {model?.meta?.description} - {:else} - {model.id} - {/if} +
·
+ + +
+
+ {#if (model?.meta?.description ?? '').trim()} + {model?.meta?.description} + {:else} + {model.id} + {/if} +
-
- + +
-
- {/each} -
+ {/each} +
- {#if total > 30} - - {/if} - {:else} -
-
-
😕
-
{$i18n.t('No models found')}
-
- {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} + {#if total > 30} + + {/if} + {:else} +
+
+
😕
+
{$i18n.t('No models found')}
+
+ {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} +
+ {/if} + {:else} +
+
{/if}
diff --git a/src/lib/components/workspace/Models/Capabilities.svelte b/src/lib/components/workspace/Models/Capabilities.svelte index f1e9741dfef..57810c2303d 100644 --- a/src/lib/components/workspace/Models/Capabilities.svelte +++ b/src/lib/components/workspace/Models/Capabilities.svelte @@ -15,6 +15,10 @@ label: $i18n.t('File Upload'), description: $i18n.t('Model accepts file inputs') }, + file_context: { + label: $i18n.t('File Context'), + description: $i18n.t('Inject file content into conversation context') + }, web_search: { label: $i18n.t('Web Search'), description: $i18n.t('Model can search the web for information') @@ -40,10 +44,17 @@ status_updates: { label: $i18n.t('Status Updates'), description: $i18n.t('Displays status updates (e.g., web search progress) in the response') + }, + builtin_tools: { + label: $i18n.t('Builtin Tools'), + description: $i18n.t( + 'Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)' + ) } }; export let capabilities: { + file_context?: boolean; vision?: boolean; file_upload?: boolean; web_search?: boolean; @@ -52,7 +63,16 @@ usage?: boolean; citations?: boolean; status_updates?: boolean; + builtin_tools?: boolean; } = {}; + + // Hide file_context when file_upload is disabled + $: visibleCapabilities = Object.keys(capabilityLabels).filter((cap) => { + if (cap === 'file_context' && !capabilities.file_upload) { + return false; + } + return true; + });
@@ -60,7 +80,7 @@
{$i18n.t('Capabilities')}
- {#each Object.keys(capabilityLabels) as capability} + {#each visibleCapabilities as capability}
-
{$i18n.t('Default Filters')}
+
{$i18n.t('Default Filters')}
diff --git a/src/lib/components/workspace/Models/Knowledge.svelte b/src/lib/components/workspace/Models/Knowledge.svelte index 618c56c7b2c..22cac5cd17d 100644 --- a/src/lib/components/workspace/Models/Knowledge.svelte +++ b/src/lib/components/workspace/Models/Knowledge.svelte @@ -2,7 +2,7 @@ import { getContext, onMount } from 'svelte'; import { config, knowledge, settings, user } from '$lib/stores'; - import Selector from './Knowledge/Selector.svelte'; + import KnowledgeSelector from './Knowledge/KnowledgeSelector.svelte'; import FileItem from '$lib/components/common/FileItem.svelte'; import { getKnowledgeBases } from '$lib/apis/knowledge'; @@ -20,6 +20,10 @@ let filesInputElement = null; let inputFiles = null; + $: if (selectedItems === null) { + selectedItems = []; + } + const uploadFileHandler = async (file, fullContext: boolean = false) => { if ($user?.role !== 'admin' && !($user?.permissions?.chat?.file_upload ?? true)) { toast.error($i18n.t('You do not have permission to upload files.')); @@ -80,7 +84,7 @@ fileItem.id = uploadedFile.id; fileItem.collection_name = uploadedFile?.meta?.collection_name || uploadedFile?.collection_name; - fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`; + fileItem.url = `${uploadedFile.id}`; selectedItems = selectedItems; } else { @@ -128,9 +132,6 @@ }; onMount(async () => { - if (!$knowledge) { - knowledge.set(await getKnowledgeBases(localStorage.token)); - } loaded = true; }); @@ -190,8 +191,7 @@ {#if loaded}
- { const item = e.detail; @@ -210,7 +210,7 @@ > {$i18n.t('Select Knowledge')}
- + {#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload} +
+ {/each} + {/if} +
+ +
+ diff --git a/src/lib/components/workspace/Models/Knowledge/Selector.svelte b/src/lib/components/workspace/Models/Knowledge/Selector.svelte deleted file mode 100644 index 29c1ea7d5e3..00000000000 --- a/src/lib/components/workspace/Models/Knowledge/Selector.svelte +++ /dev/null @@ -1,227 +0,0 @@ - - - { - if (e.detail === false) { - onClose(); - query = ''; - } - }} -> - - -
- -
-
-
- -
- -
-
- -
- {#if filteredItems.length === 0} -
- {$i18n.t('No knowledge found')} -
- {:else} - {#each filteredItems as item} - { - dispatch('select', item); - }} - > -
-
- {#if item.legacy} -
- Legacy -
- {:else if item?.meta?.document} -
- Document -
- {:else if item?.type === 'file'} -
- File -
- {:else if item?.type === 'note'} -
- Note -
- {:else} -
- Collection -
- {/if} - -
- {decodeString(item?.name)} -
-
- -
- {item?.description} -
-
-
- {/each} - {/if} -
-
-
-
diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 906bed422f6..e26d3d02ccf 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -2,12 +2,11 @@ import { toast } from 'svelte-sonner'; import { onMount, getContext, tick } from 'svelte'; - import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores'; + import { models, tools, functions, user } from '$lib/stores'; import { WEBUI_BASE_URL } from '$lib/constants'; import { getTools } from '$lib/apis/tools'; import { getFunctions } from '$lib/apis/functions'; - import { getKnowledgeBases } from '$lib/apis/knowledge'; import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; import Tags from '$lib/components/common/Tags.svelte'; @@ -93,6 +92,7 @@ let defaultFilterIds = []; let capabilities = { + file_context: true, vision: true, file_upload: true, web_search: true, @@ -100,25 +100,14 @@ code_interpreter: true, citations: true, status_updates: true, - usage: undefined + usage: undefined, + builtin_tools: true }; let defaultFeatureIds = []; let actionIds = []; let accessControl = {}; - - const addUsage = (base_model_id) => { - const baseModel = $models.find((m) => m.id === base_model_id); - - if (baseModel) { - if (baseModel.owned_by === 'openai') { - capabilities.usage = baseModel?.meta?.capabilities?.usage ?? false; - } else { - delete capabilities.usage; - } - capabilities = capabilities; - } - }; + let tts = { voice: '' }; const submitHandler = async () => { loading = true; @@ -206,6 +195,18 @@ } } + if (tts.voice !== '') { + if (!info.meta.tts) info.meta.tts = {}; + info.meta.tts.voice = tts.voice; + } else { + if (info.meta.tts?.voice) { + delete info.meta.tts.voice; + if (Object.keys(info.meta.tts).length === 0) { + delete info.meta.tts; + } + } + } + info.params.system = system.trim() === '' ? null : system; info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null; Object.keys(info.params).forEach((key) => { @@ -223,7 +224,6 @@ onMount(async () => { await tools.set(await getTools(localStorage.token)); await functions.set(await getFunctions(localStorage.token)); - await knowledgeCollections.set([...(await getKnowledgeBases(localStorage.token))]); // Scroll to top 'workspace-container' element const workspaceContainer = document.getElementById('workspace-container'); @@ -288,6 +288,7 @@ capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) }; defaultFeatureIds = model?.meta?.defaultFeatureIds ?? []; + tts = { voice: model?.meta?.tts?.voice ?? '' }; if ('access_control' in model) { accessControl = model.access_control; @@ -363,7 +364,16 @@ on:change={() => { let reader = new FileReader(); reader.onload = (event) => { - let originalImageUrl = `${event.target.result}`; + let originalImageUrl = `${event.target?.result}`; + + // For animated formats (gif, webp), skip resizing to preserve animation + const fileType = (inputFiles[0] as any)?.['type']; + if (fileType === 'image/gif' || fileType === 'image/webp') { + info.meta.profile_image_url = originalImageUrl; + inputFiles = null; + filesInputElement.value = ''; + return; + } const img = new Image(); img.src = originalImageUrl; @@ -397,7 +407,7 @@ ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight); // Get the base64 representation of the compressed image - const compressedSrc = canvas.toDataURL(); + const compressedSrc = canvas.toDataURL('image/webp', 0.8); // Display the compressed image info.meta.profile_image_url = compressedSrc; @@ -411,12 +421,12 @@ inputFiles && inputFiles.length > 0 && ['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/svg+xml'].includes( - inputFiles[0]['type'] + (inputFiles[0] as any)?.['type'] ) ) { reader.readAsDataURL(inputFiles[0]); } else { - console.log(`Unsupported File Type '${inputFiles[0]['type']}'.`); + console.log(`Unsupported File Type '${(inputFiles[0] as any)?.['type']}'.`); inputFiles = null; } }} @@ -429,199 +439,196 @@ submitHandler(); }} > -
-
- + +
+ - - - -
- -
- - -
-
-
-
- -
-
-
-
-
- -
-
-
+
+
+
+
+ +
+
+ +
+
+
+ +
+
-
- -
-
+
+ {/if} - {#if preset}
-
- {$i18n.t('Base Model (From)')} -
+
+
+ {$i18n.t('Description')} +
-
- + {#if !enableDescription} + {$i18n.t('Default')} + {:else} + {$i18n.t('Custom')} + {/if} +
-
- {/if} -
-
-
- {$i18n.t('Description')} -
- - + {#if enableDescription} +