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

Open WebUI (OVINC-CN)

-官方文档: [Open WebUI Documentation](https://docs.openwebui.com/). -官方更新日志: [CHANGELOG.md](./CHANGELOG.md) +

+ 基于 Open WebUI 的增强版:集成计费、支付与企业级用户管理 +

-## 部署方式 +

+ + Release + + + License + + + GHCR + +

-部署二开版本只需要替换镜像和版本,其他的部署与官方版本没有差别,版本号请在 [Release](https://github.com/ovinc-cn/openwebui/releases/latest) 中查看 +
+
-``` -ghcr.io/ovinc-cn/openwebui:<版本号> -``` +> ⚠️ **注意**:此仓库的 `dev` 分支是开发分支,可能包含不稳定功能。生产环境请务必使用 [Release](https://github.com/ovinc-cn/openwebui/releases) 中的正式版本。 +> 本项目是 [Open WebUI](https://github.com/open-webui/open-webui) 的定制分支,与官方团队无关联。 -## 拓展特性 +## 📖 简介 -完整特性请看更新日志 [CHANGELOG_EXTRA.md](./CHANGELOG_EXTRA.md) +这是一个社区驱动的 Open WebUI 增强版本,旨在为个人开发者和中小团队提供开箱即用的**运营化解决方案**。我们在原版强大的对话功能基础上,补充了计费、支付、用户验证等商业化闭环所需的关键特性。 -### 积分报表 +## ✨ 核心特性 -![usage panel](./docs/usage_panel.png) +| 功能模块 | 说明 | +| :------------------------ | :------------------------------------------------------------------- | +| 💰 **灵活计费** | 支持按 **Token** 或 **请求次数** 计费,实时扣费并在对话中显示详情。 | +| 💳 **支付集成** | 原生支持**易支付**和**支付宝**(当面付/订单码),轻松实现自助充值。 | +| 📊 **数据报表** | 内置全局积分报表与用户消费记录,运营数据一目了然。 | +| 🔐 **用户管理** | 支持**邮箱验证**注册与**兑换码**系统,有效控制用户准入与权益分发。 | +| ⚙️ **自定义定价** | 支持对特定模型、搜索工具等进行精细化的自定义倍率或额外收费配置。 | +| 🎨 **自定义 LOGO & 名称** | 支持自定义 LOGO 和名称,详情参考 [BRANDING.md](./docs/BRANDING.md)。 | -### 全局积分设置 +完整特性列表请参阅 [CHANGELOG_EXTRA.md](./CHANGELOG_EXTRA.md)。 -![credit config](./docs/credit_config.png) +## 📸 功能预览 -### 用户积分管理与充值 +
+点击展开查看更多截图 -![user credit](./docs/user_credit.png) +### 积分报表与全局设置 -### 按照 Token 或请求次数计费,并在对话 Usage 中显示扣费详情 +| 积分报表 | 全局设置 | +| :------------------------------------: | :----------------------------------------: | +| ![usage panel](./docs/usage_panel.png) | ![credit config](./docs/credit_config.png) | -![usage](./docs/usage.png) +### 用户充值与计费详情 -### 兑换码 +| 用户充值 | 计费详情 | +| :------------------------------------: | :------------------------: | +| ![user credit](./docs/user_credit.png) | ![usage](./docs/usage.png) | -![redemption code](./docs/redemption.png) +### 兑换码与注册验证 -### 支持注册邮箱验证 +| 兑换码 | 邮箱验证 | +| :---------------------------------------: | :-----------------------------------: | +| ![redemption code](./docs/redemption.png) | ![email](./docs/sign_verify_user.png) | -![email](./docs/sign_verify_user.png) +
-## 拓展配置 +## 🚀 快速部署 -### 支付宝当面付/订单码支付 +部署本版本非常简单,只需替换官方镜像即可。 -推荐使用 [“订单码支付”](https://open.alipay.com/api/detail?code=I1080300001000068149&index=0) 功能,[“当面付”](https://open.alipay.com/api/detail?code=I1080300001000041016&index=0) 处于产品调整中,后续支付宝可能会下线这个支付方式 +```bash +# 拉取最新镜像(请将 <版本号> 替换为具体版本,如 v0.3.0) +docker pull ghcr.io/ovinc-cn/openwebui:<版本号> -配置网关地址和授权回调地址为 `https://example.com/api/v1/credit/callback/alipay` 其中 `example.com` 替换为你的 WebUI 地址 +# 启动容器 (示例) +docker run -d -p 3000:8080 \ + --add-host=host.docker.internal:host-gateway \ + -v open-webui:/app/backend/data \ + --name open-webui \ + --restart always \ + ghcr.io/ovinc-cn/openwebui:<版本号> +``` -使用支付宝密钥工具生成的私钥无法直接使用,需要通过 “格式转换” 转换为 PKCS1 格式,转换后工具会提示 “已转换为 PKCS1 格式” +查看最新版本:[Releases](https://github.com/ovinc-cn/openwebui/releases/latest) -![alipay_private_key](docs/alipay_private_key.png) +## ⚙️ 进阶配置 -### 兑换码功能 +### 1. 支付宝支付配置 -需要使用 Redis 避免被多次兑换 +推荐使用 [“订单码支付”](https://open.alipay.com/api/detail?code=I1080300001000068149&index=0)。 -``` -REDIS_URL=redis://:@:6379/0 -``` +1. **回调地址配置**: + 在支付宝后台配置网关地址和授权回调地址为: + `https://your-domain.com/api/v1/credit/callback/alipay` + _(将 `your-domain.com` 替换为你的实际域名)_ -### 自定义价格配置 +2. **私钥格式转换**: + 支付宝工具生成的私钥需转换为 **PKCS1** 格式。 -可以对请求 Body 中的任何匹配的内容额外计费,例如 OpenAI 和 Gemini 原生网页搜索 -此部分配置较为复杂,如有需要可以提 ISSUE 单获取支持,或者使用 LLM 生成,Prompt 为 "参考这个例子,生成一个 XXX 的配置",并提供下面的例子 +### 2. 自定义价格配置 + +(管理员面板 - 设置- 积分 - 自定义价格配置) +支持对请求 Body 内容进行正则匹配计费(如对 Web Search 额外收费)。 ```json [ { - "name": "web_search", // 计费名称,使用纯英文和下划线 - "path": "$.tools[*].type", // python jsonpath_ng 兼容的解析路径 - "exists": false, // 是否检测到 path 就计费,优先级高于 value 匹配 - "value": "web_search_preview", // 匹配的值 - "cost": 1000000 // 1M 次请求的价格 + "name": "web_search", // 计费项名称 + "path": "$.tools[*].type", // JSONPath 路径 + "exists": false, // 是否仅检测存在性 + "value": "web_search_preview", // 匹配值 + "cost": 1000000 // 价格 (1M 次请求) } ] ``` -### HTTP Client Read Buffer Size +### 3. 性能调优 -当有遇到 `Chunk too big` 报错时,可以适当调节这里的大小 +如果遇到 `Chunk too big` 错误,可调整 HTTP Client Read Buffer: -``` -# 默认是 64KB +```env +# 默认 64KB,可根据需要增加 AIOHTTP_CLIENT_READ_BUFFER_SIZE=65536 ``` - -### 注册邮箱验证 - -![verify email](./docs/signup_verify.png) - -请在管理端打开注册邮箱验证,配置 WebUI URL,同时配置如下环境变量 - -``` -# 缓存 -REDIS_URL=redis://:@:6379/0 - -# 邮件相关 -SMTP_HOST=smtp.email.qq.com -SMTP_PORT=465 -SMTP_USERNAME=example@qq.com -SMTP_PASSWORD=password -``` - -### 品牌/LOGO定制能力说明 - -本项目尊重并遵守 [Open WebUI License](https://docs.openwebui.com/license) 的品牌保护条款;我们鼓励社区用户尽量保留原有 Open WebUI 品牌,支持开源生态! - -如需自定义品牌标识(如 LOGO、名称等): - -- 请务必确保您的实际部署满足 License 所要求的用户规模、授权条件等(详见 [官方说明#9](https://docs.openwebui.com/license#9-what-about-forks-can-i-start-one-and-remove-all-open-webui-mentions))。 -- 未经授权的商用或大规模去除品牌属于违规,由使用者自行承担法律风险。 -- 具体自定义方法见 [docs/BRANDING.md](./docs/BRANDING.md)。 diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index a226fdafae..763cad1616 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -51,81 +51,6 @@ def filter(self, record: logging.LogRecord) -> bool: #################################### -# Function to run the alembic migrations -def run_migrations(): - log.info("Running migrations") - try: - from alembic import command - from alembic.config import Config - - alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") - - # Set the script location dynamically - migrations_path = OPEN_WEBUI_DIR / "migrations" - alembic_cfg.set_main_option("script_location", str(migrations_path)) - - command.upgrade(alembic_cfg, "head") - except Exception as e: - log.exception(f"Error running migrations: {e}") - - -run_migrations() - - -def run_extra_migrations(): - """ - Only create table or index is allowed here. - """ - custom_migrations = [ - {"base": "3781e22d8b01", "upgrade_to": "1403e6d80d1d"}, - {"base": "d31026856c01", "upgrade_to": "97c08d196e3d"}, - ] - log.info("Running extra migrations") - # do migrations - try: - # load version from db - current_version = Session.execute( - text("SELECT version_num FROM alembic_version") - ).scalar_one() - - # init alembic - from alembic import command - from alembic.config import Config - - alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") - migrations_path = OPEN_WEBUI_DIR / "migrations" - alembic_cfg.set_main_option("script_location", str(migrations_path)) - - # do migrations - for migration in custom_migrations: - try: - command.stamp(alembic_cfg, migration["base"]) - command.upgrade(alembic_cfg, migration["upgrade_to"]) - except Exception as err: - err = str(err) - if err.index("already exists") != -1 or err.index("duplicate") != -1: - log.info( - "skip migrate %s to %s: already exists", - migration["base"], - migration["upgrade_to"], - ) - continue - log.warning( - "failed to migrate %s to %s: %s", - migration["base"], - migration["upgrade_to"], - err, - ) - - # stamp to current version - command.stamp(alembic_cfg, current_version) - except Exception as e: - log.exception("Error running extra migrations: %s", e) - - -run_extra_migrations() - - class Config(Base): __tablename__ = "config" @@ -680,6 +605,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() @@ -1181,6 +1112,12 @@ def feishu_oauth_register(oauth: OAuth): os.environ.get("SMTP_PASSWORD", ""), ) +SMTP_SENT_FROM = PersistentConfig( + "SMTP_SENT_FROM", + "ui.smtp.sent_from", + os.environ.get("SMTP_SENT_FROM", ""), +) + DEFAULT_LOCALE = PersistentConfig( "DEFAULT_LOCALE", "ui.default_locale", @@ -1324,7 +1261,6 @@ def feishu_oauth_register(oauth: OAuth): os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT", "False").lower() == "true" ) - USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING = ( os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "False").lower() == "true" @@ -1339,7 +1275,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" ) @@ -1363,7 +1299,6 @@ def feishu_oauth_register(oauth: OAuth): == "true" ) - USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING = ( os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING", "False").lower() == "true" @@ -1376,10 +1311,8 @@ def feishu_oauth_register(oauth: OAuth): == "true" ) - 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 = ( @@ -1965,7 +1898,6 @@ class BannerModel(BaseModel): #### Output: """ - VOICE_MODE_PROMPT_TEMPLATE = PersistentConfig( "VOICE_MODE_PROMPT_TEMPLATE", "task.voice.prompt_template", @@ -2539,6 +2471,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", @@ -2809,6 +2747,13 @@ 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", "rag.text_splitter", @@ -2906,7 +2851,6 @@ class BannerModel(BaseModel): os.getenv("ENABLE_RAG_LOCAL_WEB_FETCH", "False").lower() == "true" ) - DEFAULT_WEB_FETCH_FILTER_LIST = [ "!169.254.169.254", "!fd00:ec2::254", @@ -2925,7 +2869,6 @@ class BannerModel(BaseModel): WEB_FETCH_FILTER_LIST = list(set(DEFAULT_WEB_FETCH_FILTER_LIST + web_fetch_filter_list)) - YOUTUBE_LOADER_LANGUAGE = PersistentConfig( "YOUTUBE_LOADER_LANGUAGE", "rag.youtube_loader_language", @@ -2988,7 +2931,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")), ) WEB_LOADER_ENGINE = PersistentConfig( @@ -3003,6 +2946,13 @@ 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", "rag.web.loader.ssl_verification", @@ -3027,6 +2977,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", @@ -3456,10 +3412,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( @@ -3485,12 +3447,10 @@ class BannerModel(BaseModel): except json.JSONDecodeError: images_openai_params = {} - IMAGES_OPENAI_API_PARAMS = PersistentConfig( "IMAGES_OPENAI_API_PARAMS", "image_generation.openai.params", images_openai_params ) - IMAGES_GEMINI_API_BASE_URL = PersistentConfig( "IMAGES_GEMINI_API_BASE_URL", "image_generation.gemini.api_base_url", @@ -3558,7 +3518,6 @@ class BannerModel(BaseModel): os.getenv("IMAGES_EDIT_GEMINI_API_KEY", GEMINI_API_KEY), ) - IMAGES_EDIT_COMFYUI_BASE_URL = PersistentConfig( "IMAGES_EDIT_COMFYUI_BASE_URL", "images.edit.comfyui.base_url", @@ -3576,10 +3535,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, ) #################################### @@ -3726,7 +3691,6 @@ class BannerModel(BaseModel): audio_tts_openai_params, ) - AUDIO_TTS_API_KEY = PersistentConfig( "AUDIO_TTS_API_KEY", "audio.tts.api_key", @@ -3882,6 +3846,13 @@ class BannerModel(BaseModel): # Credit and Usage #################################### + +CREDIT_NO_CHARGE_EMPTY_RESPONSE = PersistentConfig( + "CREDIT_NO_CHARGE_EMPTY_RESPONSE", + "credit.no_charge_empty_response", + os.environ.get("CREDIT_NO_CHARGE_EMPTY_RESPONSE", "True").lower() == "true", +) + CREDIT_NO_CREDIT_MSG = PersistentConfig( "CREDIT_NO_CREDIT_MSG", "credit.no_credit_msg", diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index cafa09aa32..57a6cda1ef 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -85,32 +85,6 @@ 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"]) WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") if WEBUI_NAME != "Open WebUI": @@ -366,6 +340,11 @@ def parse_section(section): except Exception: DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 +# Enable public visibility of active user count (when disabled, only admins can see it) +ENABLE_PUBLIC_ACTIVE_USERS_COUNT = ( + os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true" +) + RESET_CONFIG_ON_START = ( os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" ) @@ -397,6 +376,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 #################################### @@ -441,7 +427,13 @@ 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 = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN) +except Exception as e: + log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}") + PASSWORD_VALIDATION_REGEX_PATTERN = re.compile( + "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$" + ) BYPASS_MODEL_ACCESS_CONTROL = ( @@ -548,6 +540,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 @@ -622,9 +618,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) diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index ef2ee7595b..8f1357681f 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -27,7 +27,7 @@ ) from open_webui.utils.tools import get_tools -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 ( openai_chat_chunk_message_template, @@ -41,7 +41,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 b6913d87b0..a5eecd6605 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -9,7 +9,6 @@ OPEN_WEBUI_DIR, DATABASE_URL, DATABASE_SCHEMA, - SRC_LOG_LEVELS, DATABASE_POOL_MAX_OVERFLOW, DATABASE_POOL_RECYCLE, DATABASE_POOL_SIZE, @@ -25,7 +24,6 @@ from typing_extensions import Self log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["DB"]) class JSONField(types.TypeDecorator): @@ -92,8 +90,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(): diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py index 554a5effdd..80b1aab8ff 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 22cea03d82..e00f2595fb 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -202,6 +202,7 @@ FIRECRAWL_API_KEY, WEB_LOADER_ENGINE, WEB_LOADER_CONCURRENT_REQUESTS, + WEB_LOADER_TIMEOUT, WHISPER_MODEL, WHISPER_VAD_FILTER, WHISPER_LANGUAGE, @@ -217,6 +218,7 @@ RAG_RERANKING_MODEL, RAG_EXTERNAL_RERANKER_URL, RAG_EXTERNAL_RERANKER_API_KEY, + RAG_EXTERNAL_RERANKER_TIMEOUT, RAG_RERANKING_MODEL_AUTO_UPDATE, RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_BATCH_SIZE, @@ -253,6 +255,7 @@ MINERU_API_MODE, MINERU_API_URL, MINERU_API_KEY, + MINERU_API_TIMEOUT, MINERU_PARAMS, DATALAB_MARKER_USE_LLM, EXTERNAL_DOCUMENT_LOADER_URL, @@ -287,6 +290,7 @@ SERPAPI_API_KEY, SERPAPI_ENGINE, SEARXNG_QUERY_URL, + SEARXNG_LANGUAGE, YACY_QUERY_URL, YACY_USERNAME, YACY_PASSWORD, @@ -423,6 +427,7 @@ AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, AppConfig, reset_config, + CREDIT_NO_CHARGE_EMPTY_RESPONSE, CREDIT_NO_CREDIT_MSG, USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, USAGE_DEFAULT_ENCODING_MODEL, @@ -443,6 +448,7 @@ SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, + SMTP_SENT_FROM, USAGE_CALCULATE_MINIMUM_COST, EZFP_PAY_PRIORITY, USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE, @@ -456,6 +462,7 @@ ALIPAY_CALLBACK_HOST, ) from open_webui.env import ( + ENABLE_CUSTOM_MODEL_FALLBACK, LICENSE_KEY, AUDIT_EXCLUDED_PATHS, AUDIT_LOG_LEVEL, @@ -468,7 +475,6 @@ GLOBAL_LOG_LEVEL, MAX_BODY_LOG_SIZE, SAFE_MODE, - SRC_LOG_LEVELS, VERSION, DEPLOYMENT_ID, INSTANCE_ID, @@ -493,6 +499,7 @@ AIOHTTP_CLIENT_SESSION_SSL, ENABLE_STAR_SESSIONS_MIDDLEWARE, BASE_DIR, + ENABLE_PUBLIC_ACTIVE_USERS_COUNT, ) from open_webui.utils.models import ( @@ -549,7 +556,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) class SPAStaticFiles(StaticFiles): @@ -763,6 +769,7 @@ async def lifespan(app: FastAPI): app.state.config.SMTP_PORT = SMTP_PORT app.state.config.SMTP_USERNAME = SMTP_USERNAME app.state.config.SMTP_PASSWORD = SMTP_PASSWORD +app.state.config.SMTP_SENT_FROM = SMTP_SENT_FROM app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ( @@ -902,6 +909,7 @@ 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 @@ -919,6 +927,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 @@ -945,6 +954,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 = ( @@ -957,6 +967,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 @@ -1016,6 +1027,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 @@ -1051,6 +1063,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( @@ -1227,6 +1240,7 @@ async def lifespan(app: FastAPI): # Usage ######################################## +app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE = CREDIT_NO_CHARGE_EMPTY_RESPONSE app.state.config.CREDIT_NO_CREDIT_MSG = CREDIT_NO_CREDIT_MSG app.state.config.CREDIT_EXCHANGE_RATIO = CREDIT_EXCHANGE_RATIO app.state.config.CREDIT_DEFAULT_CREDIT = CREDIT_DEFAULT_CREDIT @@ -1598,6 +1612,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") @@ -1615,7 +1630,6 @@ async def chat_completion( raise e else: model = model_item - model_info = None request.state.direct = True request.state.model = model @@ -1624,6 +1638,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" @@ -1644,6 +1678,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", []), @@ -1668,15 +1703,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", {}) + 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 @@ -1916,6 +1974,7 @@ async def get_app_config(request: Request): "enable_signup_verify": app.state.config.ENABLE_SIGNUP_VERIFY, "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, @@ -2092,10 +2151,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") diff --git a/backend/open_webui/migrate.py b/backend/open_webui/migrate.py new file mode 100644 index 0000000000..241d49b13c --- /dev/null +++ b/backend/open_webui/migrate.py @@ -0,0 +1,77 @@ +from open_webui.env import OPEN_WEBUI_DIR, log +from open_webui.internal.db import Session +from sqlalchemy import text + + +# Function to run the alembic migrations +def run_migrations(): + log.info("Running migrations") + try: + from alembic import command + from alembic.config import Config + + alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") + + # Set the script location dynamically + migrations_path = OPEN_WEBUI_DIR / "migrations" + alembic_cfg.set_main_option("script_location", str(migrations_path)) + + command.upgrade(alembic_cfg, "head") + except Exception as e: + log.exception(f"Error running migrations: {e}") + + +def run_extra_migrations(): + """ + Only create table or index is allowed here. + """ + custom_migrations = [ + {"base": "3781e22d8b01", "upgrade_to": "1403e6d80d1d"}, + {"base": "d31026856c01", "upgrade_to": "97c08d196e3d"}, + ] + log.info("Running extra migrations") + # do migrations + try: + # load version from db + current_version = Session.execute( + text("SELECT version_num FROM alembic_version") + ).scalar_one() + + # init alembic + from alembic import command + from alembic.config import Config + + alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") + migrations_path = OPEN_WEBUI_DIR / "migrations" + alembic_cfg.set_main_option("script_location", str(migrations_path)) + + # do migrations + for migration in custom_migrations: + try: + command.stamp(alembic_cfg, migration["base"]) + command.upgrade(alembic_cfg, migration["upgrade_to"]) + except Exception as err: + err = str(err) + if err.index("already exists") != -1 or err.index("duplicate") != -1: + log.info( + "skip migrate %s to %s: already exists", + migration["base"], + migration["upgrade_to"], + ) + continue + log.warning( + "failed to migrate %s to %s: %s", + migration["base"], + migration["upgrade_to"], + err, + ) + + # stamp to current version + command.stamp(alembic_cfg, current_version) + except Exception as e: + log.exception("Error running extra migrations: %s", e) + + +if __name__ == "__main__": + run_migrations() + run_extra_migrations() 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 0000000000..59fe57a421 --- /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 0000000000..181b280666 --- /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 0000000000..20f4a6d7b6 --- /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 8b03580e6c..cb6c057c88 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -4,12 +4,10 @@ from open_webui.internal.db import Base, get_db 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 diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 754f6e3dfa..362222a284 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -10,7 +10,18 @@ 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 +148,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" @@ -642,6 +688,135 @@ def get_channel_by_id(self, id: str) -> Optional[ChannelModel]: channel = db.query(Channel).filter(Channel.id == id).first() return ChannelModel.model_validate(channel) if channel else None + def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]: + with get_db() as db: + channel_files = ( + db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() + ) + channel_ids = [cf.channel_id for cf in channel_files] + channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all() + return [ChannelModel.model_validate(channel) for channel in channels] + + def get_channels_by_file_id_and_user_id( + self, file_id: str, user_id: str + ) -> list[ChannelModel]: + with get_db() as db: + # 1. Determine which channels have this file + channel_file_rows = ( + db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() + ) + channel_ids = [row.channel_id for row in channel_file_rows] + + if not channel_ids: + return [] + + # 2. Load all channel rows that still exist + channels = ( + db.query(Channel) + .filter( + Channel.id.in_(channel_ids), + Channel.deleted_at.is_(None), + Channel.archived_at.is_(None), + ) + .all() + ) + if not channels: + return [] + + # Preload user's group membership + user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)] + + allowed_channels = [] + + for channel in channels: + # --- Case A: group or dm => user must be an active member --- + if channel.type in ["group", "dm"]: + membership = ( + db.query(ChannelMember) + .filter( + ChannelMember.channel_id == channel.id, + ChannelMember.user_id == user_id, + ChannelMember.is_active.is_(True), + ) + .first() + ) + if membership: + allowed_channels.append(ChannelModel.model_validate(channel)) + continue + + # --- Case B: standard channel => rely on ACL permissions --- + query = db.query(Channel).filter(Channel.id == channel.id) + + query = self._has_permission( + db, + query, + {"user_id": user_id, "group_ids": user_group_ids}, + permission="read", + ) + + allowed = query.first() + if allowed: + allowed_channels.append(ChannelModel.model_validate(allowed)) + + return allowed_channels + + def get_channel_by_id_and_user_id( + self, id: str, user_id: str + ) -> Optional[ChannelModel]: + with get_db() as db: + # Fetch the channel + channel: Channel = ( + db.query(Channel) + .filter( + Channel.id == id, + Channel.deleted_at.is_(None), + Channel.archived_at.is_(None), + ) + .first() + ) + + if not channel: + return None + + # If the channel is a group or dm, read access requires membership (active) + if channel.type in ["group", "dm"]: + membership = ( + db.query(ChannelMember) + .filter( + ChannelMember.channel_id == id, + ChannelMember.user_id == user_id, + ChannelMember.is_active.is_(True), + ) + .first() + ) + if membership: + return ChannelModel.model_validate(channel) + else: + return None + + # For channels that are NOT group/dm, fall back to ACL-based read access + query = db.query(Channel).filter(Channel.id == id) + + # Determine user groups + user_group_ids = [ + group.id for group in Groups.get_groups_by_member_id(user_id) + ] + + # Apply ACL rules + query = self._has_permission( + db, + query, + {"user_id": user_id, "group_ids": user_group_ids}, + permission="read", + ) + + channel_allowed = query.first() + return ( + ChannelModel.model_validate(channel_allowed) + if channel_allowed + else None + ) + def update_channel_by_id( self, id: str, form_data: ChannelForm ) -> Optional[ChannelModel]: @@ -663,6 +838,65 @@ def update_channel_by_id( db.commit() return ChannelModel.model_validate(channel) if channel else None + def add_file_to_channel_by_id( + self, channel_id: str, file_id: str, user_id: str + ) -> Optional[ChannelFileModel]: + with get_db() as db: + channel_file = ChannelFileModel( + **{ + "id": str(uuid.uuid4()), + "channel_id": channel_id, + "file_id": file_id, + "user_id": user_id, + "created_at": int(time.time()), + "updated_at": int(time.time()), + } + ) + + try: + result = ChannelFile(**channel_file.model_dump()) + db.add(result) + db.commit() + db.refresh(result) + if result: + return ChannelFileModel.model_validate(result) + else: + return None + except Exception: + return None + + def set_file_message_id_in_channel_by_id( + self, channel_id: str, file_id: str, message_id: str + ) -> bool: + try: + with get_db() as db: + channel_file = ( + db.query(ChannelFile) + .filter_by(channel_id=channel_id, file_id=file_id) + .first() + ) + if not channel_file: + return False + + channel_file.message_id = message_id + channel_file.updated_at = int(time.time()) + + db.commit() + return True + except Exception: + return False + + def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool: + try: + with get_db() as db: + db.query(ChannelFile).filter_by( + channel_id=channel_id, file_id=file_id + ).delete() + db.commit() + return True + except Exception: + return False + def delete_channel_by_id(self, id: str): with get_db() as db: db.query(Channel).filter(Channel.id == id).delete() diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 187a4522c9..d821985a4e 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -7,10 +7,20 @@ from open_webui.internal.db import Base, get_db from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders -from open_webui.env import SRC_LOG_LEVELS +from 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 +30,6 @@ #################### log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Chat(Base): @@ -75,6 +84,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 +167,53 @@ 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 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): """ @@ -310,7 +384,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", {}) @@ -675,14 +749,31 @@ 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]: + def get_chats_by_user_id( + self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None + ) -> ChatListResponse: with get_db() as db: - all_chats = ( + query = ( db.query(Chat) .filter_by(user_id=user_id) .order_by(Chat.updated_at.desc()) ) - return [ChatModel.model_validate(chat) for chat in all_chats] + + total = query.count() + + if skip is not None: + query = query.offset(skip) + if limit is not None: + query = query.limit(limit) + + all_chats = query.all() + + return ChatListResponse( + **{ + "items": [ChatModel.model_validate(chat) for chat in all_chats], + "total": total, + } + ) def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: with get_db() as db: @@ -713,7 +804,7 @@ def get_chats_by_user_id_and_search_text( """ 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( @@ -1170,5 +1261,93 @@ 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 + ) -> Optional[list[ChatFileModel]]: + if not file_ids: + return None + + chat_message_file_ids = [ + item.id + for item in self.get_chat_files_by_chat_id_and_message_id( + chat_id, message_id + ) + ] + # Remove duplicates and existing file_ids + file_ids = list( + set( + [ + file_id + for file_id in file_ids + if file_id and file_id not in chat_message_file_ids + ] + ) + ) + if not file_ids: + return None + + try: + with get_db() as db: + now = int(time.time()) + + chat_files = [ + ChatFileModel( + id=str(uuid.uuid4()), + user_id=user_id, + chat_id=chat_id, + message_id=message_id, + file_id=file_id, + created_at=now, + updated_at=now, + ) + for file_id in file_ids + ] + + results = [ + ChatFile(**chat_file.model_dump()) for chat_file in chat_files + ] + + db.add_all(results) + db.commit() + + return chat_files + except Exception: + return None + + def get_chat_files_by_chat_id_and_message_id( + self, chat_id: str, message_id: str + ) -> list[ChatFileModel]: + with get_db() as db: + all_chat_files = ( + db.query(ChatFile) + .filter_by(chat_id=chat_id, message_id=message_id) + .order_by(ChatFile.created_at.asc()) + .all() + ) + return [ + ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files + ] + + def delete_chat_file(self, chat_id: str, file_id: str) -> bool: + try: + with get_db() as db: + db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete() + db.commit() + return True + except Exception: + return False + + def get_shared_chats_by_file_id(self, file_id: str) -> list[ChatModel]: + with get_db() as db: + # Join Chat and ChatFile tables to get shared chats associated with the file_id + all_chats = ( + db.query(Chat) + .join(ChatFile, Chat.id == ChatFile.chat_id) + .filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None)) + .all() + ) + + return [ChatModel.model_validate(chat) for chat in all_chats] + Chats = ChatTable() diff --git a/backend/open_webui/models/credits.py b/backend/open_webui/models/credits.py index 0718da07ac..12ed4030d8 100644 --- a/backend/open_webui/models/credits.py +++ b/backend/open_webui/models/credits.py @@ -280,7 +280,7 @@ def get_ticket_by_id(self, id: str) -> Optional[TradeTicketModel]: return None def get_ticket_by_time( - self, start_time: int, end_time: int + self, start_time: int, end_time: int, user_ids: Optional[List[str]] = None ) -> list[TradeTicketModel]: try: with get_db() as db: @@ -288,8 +288,10 @@ def get_ticket_by_time( db.query(TradeTicket) .filter(TradeTicket.created_at >= start_time) .filter(TradeTicket.created_at < end_time) - .order_by(TradeTicket.created_at.asc()) ) + if user_ids: + logs = logs.filter(TradeTicket.user_id.in_(user_ids)) + logs = logs.order_by(TradeTicket.created_at.asc()) return [TradeTicketModel.model_validate(log) for log in logs] except Exception: return [] @@ -345,7 +347,7 @@ def get_credit_log_by_page( return [CreditLogSimpleModel.model_validate(log) for log in all_logs] def get_log_by_time( - self, start_time: int, end_time: int + self, start_time: int, end_time: int, user_ids: Optional[List[str]] = None ) -> list[CreditLogSimpleModel]: try: with get_db() as db: @@ -353,8 +355,10 @@ def get_log_by_time( db.query(CreditLog) .filter(CreditLog.created_at >= start_time) .filter(CreditLog.created_at < end_time) - .order_by(CreditLog.created_at.asc()) ) + if user_ids: + logs = logs.filter(CreditLog.user_id.in_(user_ids)) + logs = logs.order_by(CreditLog.created_at.asc()) return [CreditLogSimpleModel.model_validate(log) for log in logs] except Exception: return [] diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 5a91804b56..39e22ff2d9 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -6,12 +6,10 @@ from open_webui.internal.db import Base, get_db 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 +60,13 @@ class FeedbackResponse(BaseModel): updated_at: int +class FeedbackIdResponse(BaseModel): + id: str + user_id: str + created_at: int + updated_at: int + + class RatingData(BaseModel): rating: Optional[str | int] = None model_id: Optional[str] = None diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 1ed743df87..9d4e8fb054 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -3,12 +3,10 @@ from typing import Optional from open_webui.internal.db import Base, JSONField, get_db -from open_webui.env import SRC_LOG_LEVELS 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 +81,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,6 +102,11 @@ 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: @@ -238,6 +241,7 @@ def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]: 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) @@ -249,6 +253,7 @@ def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]: 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: @@ -260,6 +265,7 @@ def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel] 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: diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 6e1735ecea..0043dd3644 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -9,11 +9,9 @@ from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func from open_webui.internal.db import Base, get_db -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 91736f949a..19ad985d0c 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -4,12 +4,10 @@ from open_webui.internal.db import Base, JSONField, get_db 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 diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index a7900e2c78..da94287111 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -5,7 +5,6 @@ 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 FileMetadataResponse @@ -26,7 +25,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # UserGroup DB Schema diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 2c72401181..d8af004338 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -5,11 +5,15 @@ 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 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 +25,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 +132,7 @@ class KnowledgeResponse(KnowledgeModel): class KnowledgeUserResponse(KnowledgeUserModel): - files: Optional[list[FileMetadataResponse | dict]] = None + pass class KnowledgeForm(BaseModel): @@ -135,6 +141,20 @@ 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 @@ -162,12 +182,13 @@ def insert_new_knowledge( except Exception: return None - def get_knowledge_bases(self) -> list[KnowledgeUserModel]: + def get_knowledge_bases( + self, skip: int = 0, limit: int = 30 + ) -> list[KnowledgeUserModel]: with get_db() as db: 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 [] @@ -186,6 +207,126 @@ def get_knowledge_bases(self) -> list[KnowledgeUserModel]: ) return knowledge_bases + def search_knowledge_bases( + self, user_id: str, filter: dict, skip: int = 0, limit: int = 30 + ) -> KnowledgeListResponse: + try: + with get_db() as db: + query = db.query(Knowledge, User).outerjoin( + User, User.id == Knowledge.user_id + ) + + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + Knowledge.name.ilike(f"%{query_key}%"), + Knowledge.description.ilike(f"%{query_key}%"), + ) + ) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(Knowledge.user_id == user_id) + elif view_option == "shared": + query = query.filter(Knowledge.user_id != user_id) + + query = has_permission(db, Knowledge, query, filter) + + query = query.order_by(Knowledge.updated_at.desc()) + + total = query.count() + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + knowledge_bases = [] + for knowledge_base, user in items: + knowledge_bases.append( + KnowledgeUserModel.model_validate( + { + **KnowledgeModel.model_validate( + knowledge_base + ).model_dump(), + "user": ( + UserModel.model_validate(user).model_dump() + if user + else None + ), + } + ) + ) + + return KnowledgeListResponse(items=knowledge_bases, total=total) + except Exception as e: + print(e) + return KnowledgeListResponse(items=[], total=0) + + def search_knowledge_files( + self, filter: dict, skip: int = 0, limit: int = 30 + ) -> KnowledgeFileListResponse: + """ + Scalable version: search files across all knowledge bases the user has + READ access to, without loading all KBs or using large IN() lists. + """ + try: + with get_db() as db: + # Base query: join Knowledge → KnowledgeFile → File + query = ( + db.query(File, User) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id) + .outerjoin(User, User.id == KnowledgeFile.user_id) + ) + + # Apply access-control directly to the joined query + # This makes the database handle filtering, even with 10k+ KBs + query = has_permission(db, Knowledge, query, filter) + + # Apply filename search + if filter: + q = filter.get("query") + if q: + query = query.filter(File.filename.ilike(f"%{q}%")) + + # Order by file changes + query = query.order_by(File.updated_at.desc()) + + # Count before pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + rows = query.all() + + items = [] + for file, user in rows: + items.append( + FileUserResponse( + **FileModel.model_validate(file).model_dump(), + user=( + UserResponse( + **UserModel.model_validate(user).model_dump() + ) + if user + else None + ), + ) + ) + + return KnowledgeFileListResponse(items=items, total=total) + + except Exception as e: + print("search_knowledge_files error:", e) + return KnowledgeFileListResponse(items=[], total=0) + def check_access_by_user_id(self, id, user_id, permission="write") -> bool: knowledge = self.get_knowledge_by_id(id) if not knowledge: @@ -217,6 +358,21 @@ def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: except Exception: return None + def get_knowledge_by_id_and_user_id( + self, id: str, user_id: str + ) -> Optional[KnowledgeModel]: + knowledge = self.get_knowledge_by_id(id) + if not knowledge: + return None + + if knowledge.user_id == user_id: + return knowledge + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} + if has_access(user_id, "write", knowledge.access_control, user_group_ids): + return knowledge + return None + def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: try: with get_db() as db: @@ -232,6 +388,88 @@ def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: except Exception: return [] + def search_files_by_id( + self, + knowledge_id: str, + user_id: str, + filter: dict, + skip: int = 0, + limit: int = 30, + ) -> KnowledgeFileListResponse: + try: + with get_db() as db: + query = ( + db.query(File, User) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .outerjoin(User, User.id == KnowledgeFile.user_id) + .filter(KnowledgeFile.knowledge_id == knowledge_id) + ) + + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter(or_(File.filename.ilike(f"%{query_key}%"))) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(KnowledgeFile.user_id == user_id) + elif view_option == "shared": + query = query.filter(KnowledgeFile.user_id != user_id) + + order_by = filter.get("order_by") + direction = filter.get("direction") + + if order_by == "name": + if direction == "asc": + query = query.order_by(File.filename.asc()) + else: + query = query.order_by(File.filename.desc()) + elif order_by == "created_at": + if direction == "asc": + query = query.order_by(File.created_at.asc()) + else: + query = query.order_by(File.created_at.desc()) + elif order_by == "updated_at": + if direction == "asc": + query = query.order_by(File.updated_at.asc()) + else: + query = query.order_by(File.updated_at.desc()) + else: + query = query.order_by(File.updated_at.desc()) + + else: + query = query.order_by(File.updated_at.desc()) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + files = [] + for file, user in items: + files.append( + FileUserResponse( + **FileModel.model_validate(file).model_dump(), + user=( + UserResponse( + **UserModel.model_validate(user).model_dump() + ) + if user + else None + ), + ) + ) + + return KnowledgeFileListResponse(items=files, total=total) + except Exception as e: + print(e) + return KnowledgeFileListResponse(items=[], total=0) + def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: try: with get_db() as db: diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 98be21463d..5b068b6449 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -9,7 +9,7 @@ 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 +108,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] diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 3f7eb7bee7..5feb044cba 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -3,7 +3,6 @@ from typing import Optional from open_webui.internal.db import Base, JSONField, get_db -from open_webui.env import SRC_LOG_LEVELS from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, Users, UserResponse @@ -19,7 +18,6 @@ from open_webui.utils.access_control import has_access log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### @@ -394,6 +392,14 @@ def get_model_by_id(self, id: str) -> Optional[ModelModel]: except Exception: return None + def get_models_by_ids(self, ids: list[str]) -> list[ModelModel]: + try: + with get_db() as db: + models = db.query(Model).filter(Model.id.in_(ids)).all() + return [ModelModel.model_validate(model) for model in models] + except Exception: + return [] + def toggle_model_by_id(self, id: str) -> Optional[ModelModel]: with get_db() as db: try: diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index af75fab598..cfeddf4a8c 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -7,12 +7,15 @@ from open_webui.internal.db import Base, get_db from open_webui.models.groups import Groups from open_webui.utils.access_control import has_access -from open_webui.models.users import Users, UserResponse +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,7 +78,138 @@ 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, @@ -110,73 +244,115 @@ def get_notes( notes = query.all() return [NoteModel.model_validate(note) for note in notes] - def get_notes_by_user_id( - self, - user_id: str, - skip: Optional[int] = None, - limit: Optional[int] = None, - ) -> list[NoteModel]: + def search_notes( + self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 + ) -> NoteListResponse: with get_db() as db: - query = db.query(Note).filter(Note.user_id == user_id) - query = query.order_by(Note.updated_at.desc()) + query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) + if filter: + query_key = filter.get("query") + if query_key: + query = query.filter( + or_( + Note.title.ilike(f"%{query_key}%"), + cast(Note.data["content"]["md"], Text).ilike( + f"%{query_key}%" + ), + ) + ) - 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 + ), + ) + ) - def get_notes_by_permission( + return NoteListResponse(items=notes, total=total) + + def get_notes_by_user_id( self, user_id: str, - permission: str = "write", + permission: str = "read", skip: Optional[int] = None, limit: Optional[int] = None, ) -> list[NoteModel]: with get_db() as db: - user_groups = Groups.get_groups_by_member_id(user_id) - user_group_ids = {group.id for group in user_groups} - - # Order newest-first. We stream to keep memory usage low. - query = ( - db.query(Note) - .order_by(Note.updated_at.desc()) - .execution_options(stream_results=True) - .yield_per(256) - ) + user_group_ids = [ + group.id for group in Groups.get_groups_by_member_id(user_id) + ] - results: list[NoteModel] = [] - n_skipped = 0 - - for note in query: - # Fast-pass #1: owner - if note.user_id == user_id: - permitted = True - # Fast-pass #2: public/open - elif note.access_control is None: - # Technically this should mean public access for both read and write, but we'll only do read for now - # We might want to change this behavior later - permitted = permission == "read" - else: - permitted = has_access( - user_id, permission, note.access_control, user_group_ids - ) - - if not permitted: - continue - - # Apply skip AFTER permission filtering so it counts only accessible notes - if skip and n_skipped < skip: - n_skipped += 1 - continue + 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: diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index d07faad35e..8b0334ed19 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -9,13 +9,12 @@ 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 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 diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index e1cbb68a0b..499f3859dc 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -6,12 +6,10 @@ from open_webui.internal.db import Base, get_db -from open_webui.env import SRC_LOG_LEVELS from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 7f6c7fd3f5..fff53a7e94 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -6,7 +6,6 @@ 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 +13,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) #################### # Tools DB Schema diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 4a73ae62d7..6be042f7b1 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -5,11 +5,11 @@ 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 diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index 998afd36f6..e1371be288 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 68ed66162b..39644caddb 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 1346cd065c..b0bc1dd068 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 logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) known_source_ext = [ "go", @@ -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 360af804c7..617be8e87a 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 6a2d235559..68570757c8 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 15a3d7f97f..f298de80b4 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 cba602ed87..faf7b4452e 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 7ec888437a..2a8c0329d7 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 822cb3e3dd..d567bf4fe5 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 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,7 @@ def predict( f"{self.url}", headers=headers, json=payload, + timeout=self.timeout, ) r.raise_for_status() diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 1e4a17b07c..5cfa659f79 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 @@ -35,7 +38,6 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader from open_webui.env import ( - SRC_LOG_LEVELS, OFFLINE_MODE, ENABLE_FORWARD_USER_INFO_HEADERS, ) @@ -48,7 +50,6 @@ from open_webui.utils.credit.utils import check_credit_by_user_id log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) from typing import Any @@ -1214,7 +1215,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 1fdb064c51..69d894afde 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): diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 3dae4672f3..23e4bbd03e 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): diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index cd2ceed795..203a36141e 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" diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index b714588bdc..3f5c3463f0 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): diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 85c2ef009d..2f4677995a 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): diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index 5bef0d9ea7..94d09dabf5 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): diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index ea43297499..ce7095bea2 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): diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index e9fa03d459..fdc8f9d897 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: diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py index e2a7adfd8b..95fc5d3f24 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): diff --git a/backend/open_webui/retrieval/web/azure.py b/backend/open_webui/retrieval/web/azure.py index 814cf4b63c..3859ccc9b7 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 0a3ba4621c..4c9822b900 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 f26da36f84..7e3c9b0a40 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 7bea575620..e047602b36 100644 --- a/backend/open_webui/retrieval/web/brave.py +++ b/backend/open_webui/retrieval/web/brave.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_brave( diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index e4cf9d00ec..0303b4e303 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( diff --git a/backend/open_webui/retrieval/web/exa.py b/backend/open_webui/retrieval/web/exa.py index 927adef413..df9554fab2 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 13f6a5aa68..527c918a47 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 2d9b104bca..82635aa8ca 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 69de24711a..96fa8c98cd 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 a87293db5c..bcc5794027 100644 --- a/backend/open_webui/retrieval/web/jina_search.py +++ b/backend/open_webui/retrieval/web/jina_search.py @@ -2,11 +2,9 @@ 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]: diff --git a/backend/open_webui/retrieval/web/kagi.py b/backend/open_webui/retrieval/web/kagi.py index 0b69da8bce..f0303acf69 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 d298b0ee51..d48f7aeef8 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 a199a14389..71bd9d5124 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 4e046668fa..aae802b432 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 97961f478b..5c591ff64f 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 d7704638c2..caf781c5df 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 15e3c098a9..b3d4eb8795 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 8762210bfd..bb421b500f 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 685e34375d..5a745e304e 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 a9b473eb04..68843eba85 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 d4dbda57ca..97db858724 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 af7957c4fc..d8747c3ade 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 bfd102afa6..6d9ff89a87 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 bdbde0b3a9..eb01976e95 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -33,6 +33,7 @@ PLAYWRIGHT_WS_URL, PLAYWRIGHT_TIMEOUT, WEB_LOADER_ENGINE, + WEB_LOADER_TIMEOUT, FIRECRAWL_API_BASE_URL, FIRECRAWL_API_KEY, TAVILY_API_KEY, @@ -41,11 +42,9 @@ 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): @@ -674,6 +673,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 diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py index bc61425cbc..2419717b24 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 9c84f9c704..0816cdcde3 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -33,6 +33,7 @@ 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.headers import include_user_info_headers from open_webui.config import ( @@ -48,7 +49,6 @@ ENV, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, - SRC_LOG_LEVELS, DEVICE_TYPE, ENABLE_FORWARD_USER_INFO_HEADERS, ) @@ -63,7 +63,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) @@ -1155,17 +1154,9 @@ def transcription( stt_supported_content_types = getattr( request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] - ) + ) or ["audio/*", "video/webm"] - 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 7895524c3f..83e390fa6c 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -41,7 +41,10 @@ WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, ENABLE_INITIAL_ADMIN_SIGNUP, - SRC_LOG_LEVELS, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, + REDIS_CLUSTER, ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse, Response, JSONResponse @@ -73,7 +76,11 @@ from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.groups import apply_default_group_assignment -from open_webui.utils.redis import get_redis_client +from open_webui.utils.redis import ( + get_redis_client, + get_redis_connection, + get_sentinels_from_env, +) from open_webui.utils.rate_limit import RateLimiter @@ -87,7 +94,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 @@ -297,13 +303,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( @@ -311,15 +315,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): @@ -329,13 +340,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}") @@ -386,16 +397,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, @@ -983,6 +994,11 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)): "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP_VERIFY": request.app.state.config.ENABLE_SIGNUP_VERIFY, "SIGNUP_EMAIL_DOMAIN_WHITELIST": request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, + "SMTP_HOST": request.app.state.config.SMTP_HOST, + "SMTP_PORT": request.app.state.config.SMTP_PORT, + "SMTP_USERNAME": request.app.state.config.SMTP_USERNAME, + "SMTP_PASSWORD": request.app.state.config.SMTP_PASSWORD, + "SMTP_SENT_FROM": request.app.state.config.SMTP_SENT_FROM, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, @@ -1007,6 +1023,11 @@ class AdminConfig(BaseModel): ENABLE_SIGNUP: bool ENABLE_SIGNUP_VERIFY: bool = Field(default=False) SIGNUP_EMAIL_DOMAIN_WHITELIST: str = Field(default="") + SMTP_HOST: str + SMTP_PORT: str + SMTP_USERNAME: str + SMTP_PASSWORD: str + SMTP_SENT_FROM: str ENABLE_API_KEYS: bool ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool API_KEYS_ALLOWED_ENDPOINTS: str @@ -1028,6 +1049,19 @@ class AdminConfig(BaseModel): async def update_admin_config( request: Request, form_data: AdminConfig, user=Depends(get_admin_user) ): + # verify redis status + if form_data.ENABLE_SIGNUP_VERIFY: + # check redis + _redis = get_redis_connection( + redis_url=REDIS_URL, + redis_sentinels=get_sentinels_from_env( + REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT + ), + redis_cluster=REDIS_CLUSTER, + ) + if not _redis: + raise HTTPException(status_code=400, detail="Redis is not configured.") + request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP @@ -1035,6 +1069,11 @@ async def update_admin_config( request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST = ( form_data.SIGNUP_EMAIL_DOMAIN_WHITELIST ) + request.app.state.config.SMTP_HOST = form_data.SMTP_HOST + request.app.state.config.SMTP_PORT = form_data.SMTP_PORT + request.app.state.config.SMTP_USERNAME = form_data.SMTP_USERNAME + request.app.state.config.SMTP_PASSWORD = form_data.SMTP_PASSWORD + request.app.state.config.SMTP_SENT_FROM = form_data.SMTP_SENT_FROM request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ( @@ -1081,6 +1120,11 @@ async def update_admin_config( "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP_VERIFY": request.app.state.config.ENABLE_SIGNUP_VERIFY, "SIGNUP_EMAIL_DOMAIN_WHITELIST": request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, + "SMTP_HOST": request.app.state.config.SMTP_HOST, + "SMTP_PORT": request.app.state.config.SMTP_PORT, + "SMTP_USERNAME": request.app.state.config.SMTP_USERNAME, + "SMTP_PASSWORD": request.app.state.config.SMTP_PASSWORD, + "SMTP_SENT_FROM": request.app.state.config.SMTP_SENT_FROM, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 0dff67da3e..4cd116424f 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks from pydantic import BaseModel - +from pydantic import field_validator from open_webui.socket.main import ( emit_to_users, @@ -39,9 +39,10 @@ ) +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.utils.models import ( @@ -62,10 +63,24 @@ from open_webui.utils.channels import extract_mentions, replace_mentions 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,7 +95,11 @@ 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), +): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -132,7 +151,11 @@ async def get_channels(request: Request, user=Depends(get_verified_user)): @router.get("/list", response_model=list[ChannelModel]) -async def get_all_channels(user=Depends(get_verified_user)): +async def get_all_channels( + request: Request, + user=Depends(get_verified_user), +): + check_channels_access(request) if user.role == "admin": return Channels.get_channels() return Channels.get_channels_by_user_id(user.id) @@ -145,8 +168,11 @@ async def get_all_channels(user=Depends(get_verified_user)): @router.get("/users/{user_id}", response_model=Optional[ChannelModel]) async def get_dm_channel_by_user_id( - request: Request, user_id: str, user=Depends(get_verified_user) + request: Request, + user_id: str, + user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -214,8 +240,11 @@ async def get_dm_channel_by_user_id( @router.post("/create", response_model=Optional[ChannelModel]) async def create_new_channel( - request: Request, form_data: CreateChannelForm, user=Depends(get_verified_user) + request: Request, + form_data: CreateChannelForm, + user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -294,7 +323,12 @@ class ChannelFullResponse(ChannelResponse): @router.get("/{id}", response_model=Optional[ChannelFullResponse]) -async def get_channel_by_id(id: str, user=Depends(get_verified_user)): +async def get_channel_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), +): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -381,6 +415,7 @@ 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, @@ -388,6 +423,7 @@ async def get_channel_members_by_id( page: Optional[int] = 1, user=Depends(get_verified_user), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: @@ -470,10 +506,12 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -506,6 +544,7 @@ async def add_members_by_id( form_data: UpdateMembersForm, user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -554,6 +593,7 @@ async def remove_members_by_id( form_data: RemoveMembersForm, user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -591,8 +631,12 @@ async def remove_members_by_id( @router.post("/{id}/update", response_model=Optional[ChannelModel]) async def update_channel_by_id( - request: Request, id: str, form_data: ChannelForm, user=Depends(get_verified_user) + request: Request, + id: str, + form_data: ChannelForm, + user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -629,8 +673,11 @@ async def update_channel_by_id( @router.delete("/{id}/delete", response_model=bool) async def delete_channel_by_id( - request: Request, id: str, user=Depends(get_verified_user) + request: Request, + id: str, + user=Depends(get_verified_user), ): + check_channels_access(request) if user.role != "admin" and not has_permission( user.id, "features.channels", request.app.state.config.USER_PERMISSIONS ): @@ -666,13 +713,27 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -710,6 +771,10 @@ async def get_channel_messages( thread_replies[0].created_at if thread_replies else None ) + user = None + user_model = users.get(message.user_id) or None + if user_model: + user = UserNameResponse(**user_model.model_dump()) messages.append( MessageUserResponse( **{ @@ -717,7 +782,7 @@ async def get_channel_messages( "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()), + "user": user, } ) ) @@ -734,8 +799,12 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -768,12 +837,16 @@ async def get_pinned_channel_messages( user = Users.get_user_by_id(message.user_id) users[message.user_id] = user + user = None + user_model = users.get(message.user_id) or None + if user_model: + user = UserNameResponse(**user_model.model_dump()) messages.append( MessageWithReactionsResponse( **{ **message.model_dump(), "reactions": Messages.get_reactions_by_message_id(message.id), - "user": UserNameResponse(**users[message.user_id].model_dump()), + "user": user, } ) ) @@ -906,6 +979,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 = { @@ -1075,9 +1152,19 @@ async def post_new_message( background_tasks: BackgroundTasks, user=Depends(get_verified_user), ): + check_channels_access(request) try: message, channel = await new_message_handler(request, id, form_data, user) + try: + if files := message.data.get("files", []): + for file in files: + Channels.set_file_message_id_in_channel_by_id( + channel.id, file.get("id", ""), message.id + ) + except Exception as e: + log.debug(e) + active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") async def background_handler(): @@ -1108,10 +1195,14 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1142,7 +1233,7 @@ 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( @@ -1152,6 +1243,52 @@ async def get_channel_message( ) +############################ +# GetChannelMessageData +############################ + + +@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict]) +async def get_channel_message_data( + request: Request, + id: str, + message_id: str, + user=Depends(get_verified_user), +): + check_channels_access(request) + channel = Channels.get_channel_by_id(id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if channel.type in ["group", "dm"]: + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + message = Messages.get_message_by_id(message_id) + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if message.channel_id != id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + return message.data + + ############################ # PinChannelMessage ############################ @@ -1165,8 +1302,13 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1224,12 +1366,14 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1258,6 +1402,10 @@ async def get_channel_thread_messages( user = Users.get_user_by_id(message.user_id) users[message.user_id] = user + user = None + user_model = users.get(message.user_id) or None + if user_model: + user = UserNameResponse(**user_model.model_dump()) messages.append( MessageUserResponse( **{ @@ -1265,7 +1413,7 @@ async def get_channel_thread_messages( "reply_count": 0, "latest_reply_at": None, "reactions": Messages.get_reactions_by_message_id(message.id), - "user": UserNameResponse(**users[message.user_id].model_dump()), + "user": user, } ) ) @@ -1282,8 +1430,13 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1357,8 +1510,13 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1426,8 +1584,13 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( @@ -1498,8 +1661,12 @@ 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), ): + check_channels_access(request) channel = Channels.get_channel_by_id(id) if not channel: raise HTTPException( diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 78cd8bdb1a..8dde946a4d 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -3,10 +3,12 @@ from typing import Optional +from open_webui.utils.misc import get_message_list from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, ChatImportForm, + ChatUsageStatsListResponse, ChatsImportForm, ChatResponse, Chats, @@ -17,7 +19,6 @@ 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 +27,6 @@ from open_webui.utils.access_control import has_permission log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -66,6 +66,132 @@ def get_session_user_chat_list( ) +############################ +# GetChatUsageStats +# EXPERIMENTAL: may be removed in future releases +############################ + + +@router.get("/stats/usage", response_model=ChatUsageStatsListResponse) +def get_session_user_chat_usage_stats( + items_per_page: Optional[int] = 50, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + try: + limit = items_per_page + skip = (page - 1) * limit + + result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit) + + chats = result.items + total = result.total + + chat_stats = [] + for chat in chats: + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + + if messages_map and message_id: + try: + history_models = {} + history_message_count = len(messages_map) + history_user_messages = [] + history_assistant_messages = [] + + for message in messages_map.values(): + if message.get("role", "") == "user": + history_user_messages.append(message) + elif message.get("role", "") == "assistant": + history_assistant_messages.append(message) + model = message.get("model", None) + if model: + if model not in history_models: + history_models[model] = 0 + history_models[model] += 1 + + average_user_message_content_length = ( + sum( + len(message.get("content", "")) + for message in history_user_messages + ) + / len(history_user_messages) + if len(history_user_messages) > 0 + else 0 + ) + average_assistant_message_content_length = ( + sum( + len(message.get("content", "")) + for message in history_assistant_messages + ) + / len(history_assistant_messages) + if len(history_assistant_messages) > 0 + else 0 + ) + + response_times = [] + for message in history_assistant_messages: + user_message_id = message.get("parentId", None) + if user_message_id and user_message_id in messages_map: + user_message = messages_map[user_message_id] + response_time = message.get( + "timestamp", 0 + ) - user_message.get("timestamp", 0) + + response_times.append(response_time) + + average_response_time = ( + sum(response_times) / len(response_times) + if len(response_times) > 0 + else 0 + ) + + message_list = get_message_list(messages_map, message_id) + message_count = len(message_list) + + models = {} + for message in reversed(message_list): + if message.get("role") == "assistant": + model = message.get("model", None) + if model: + if model not in models: + models[model] = 0 + models[model] += 1 + + annotation = message.get("annotation", {}) + + chat_stats.append( + { + "id": chat.id, + "models": models, + "message_count": message_count, + "history_models": history_models, + "history_message_count": history_message_count, + "history_user_message_count": len(history_user_messages), + "history_assistant_message_count": len( + history_assistant_messages + ), + "average_response_time": average_response_time, + "average_user_message_content_length": average_user_message_content_length, + "average_assistant_message_content_length": average_assistant_message_content_length, + "tags": chat.meta.get("tags", []), + "last_message_at": message_list[-1].get("timestamp", None), + "updated_at": chat.updated_at, + "created_at": chat.created_at, + } + ) + except Exception as e: + pass + + return ChatUsageStatsListResponse(items=chat_stats, total=total) + + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # DeleteAllChats ############################ diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index fda4d96340..5ba0313975 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -18,7 +18,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 +31,6 @@ router = APIRouter() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) ############################ @@ -543,6 +541,7 @@ async def get_banners( class UsageConfigForm(BaseModel): + CREDIT_NO_CHARGE_EMPTY_RESPONSE: bool = Field(default=False) CREDIT_NO_CREDIT_MSG: str = Field(default="余额不足,请前往 设置-积分 充值") CREDIT_EXCHANGE_RATIO: float = Field(default=1, gt=0) CREDIT_DEFAULT_CREDIT: float = Field(default=0, ge=0) @@ -573,6 +572,7 @@ class UsageConfigForm(BaseModel): @router.get("/usage", response_model=UsageConfigForm) async def get_usage_config(request: Request, _=Depends(get_admin_user)): return { + "CREDIT_NO_CHARGE_EMPTY_RESPONSE": request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, "CREDIT_NO_CREDIT_MSG": request.app.state.config.CREDIT_NO_CREDIT_MSG, "CREDIT_EXCHANGE_RATIO": request.app.state.config.CREDIT_EXCHANGE_RATIO, "CREDIT_DEFAULT_CREDIT": request.app.state.config.CREDIT_DEFAULT_CREDIT, @@ -605,6 +605,9 @@ async def get_usage_config(request: Request, _=Depends(get_admin_user)): async def set_usage_config( request: Request, form_data: UsageConfigForm, _=Depends(get_admin_user) ): + request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE = ( + form_data.CREDIT_NO_CHARGE_EMPTY_RESPONSE + ) request.app.state.config.CREDIT_NO_CREDIT_MSG = form_data.CREDIT_NO_CREDIT_MSG request.app.state.config.CREDIT_EXCHANGE_RATIO = form_data.CREDIT_EXCHANGE_RATIO request.app.state.config.CREDIT_DEFAULT_CREDIT = form_data.CREDIT_DEFAULT_CREDIT @@ -652,6 +655,7 @@ async def set_usage_config( request.app.state.config.ALIPAY_PRODUCT_CODE = form_data.ALIPAY_PRODUCT_CODE return { + "CREDIT_NO_CHARGE_EMPTY_RESPONSE": request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, "CREDIT_NO_CREDIT_MSG": request.app.state.config.CREDIT_NO_CREDIT_MSG, "CREDIT_EXCHANGE_RATIO": request.app.state.config.CREDIT_EXCHANGE_RATIO, "CREDIT_DEFAULT_CREDIT": request.app.state.config.CREDIT_DEFAULT_CREDIT, diff --git a/backend/open_webui/routers/credit.py b/backend/open_webui/routers/credit.py index 6cb5d3ea1f..7a6e0be1b5 100644 --- a/backend/open_webui/routers/credit.py +++ b/backend/open_webui/routers/credit.py @@ -13,7 +13,13 @@ from pydantic import BaseModel, Field from open_webui.config import EZFP_CALLBACK_HOST, ALIPAY_APP_ID -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import ( + GLOBAL_LOG_LEVEL, + REDIS_URL, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_PORT, + REDIS_CLUSTER, +) from open_webui.models.credits import ( TradeTicketModel, TradeTickets, @@ -28,9 +34,10 @@ from open_webui.utils.credit.alipay import AlipayClient from open_webui.utils.credit.ezfp import ezfp_client from open_webui.utils.models import get_all_models +from open_webui.utils.redis import get_redis_connection, get_sentinels_from_env log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) +log.setLevel(GLOBAL_LOG_LEVEL) router = APIRouter() @@ -68,7 +75,7 @@ class DeleteLogsResponse(BaseModel): @router.delete("/logs") -async def update_model_price( +async def delete_credit_logs( form_data: DeleteLogsForm, _: UserModel = Depends(get_admin_user) ) -> DeleteLogsResponse: return DeleteLogsResponse( @@ -214,22 +221,43 @@ async def update_model_price( class StatisticRequest(BaseModel): start_time: int end_time: int + query: Optional[str] = None @router.post("/statistics") async def get_statistics( form_data: StatisticRequest, _: UserModel = Depends(get_admin_user) ): + # query user id + user_ids = [] + if form_data.query: + users = Users.get_users(filter={"query": form_data.query})["users"] + user_map = {user.id: user.name for user in users} + user_ids = list(user_map.keys()) + if not user_ids: + return { + "total_tokens": 0, + "total_credit": 0, + "model_cost_pie": [], + "model_token_pie": [], + "user_cost_pie": [], + "user_token_pie": [], + "total_payment": 0, + "user_payment_stats_x": [], + "user_payment_stats_y": [], + } + else: + users = Users.get_users()["users"] + user_map = {user.id: user.name for user in users} + # load credit data - logs = CreditLogs.get_log_by_time(form_data.start_time, form_data.end_time) + logs = CreditLogs.get_log_by_time( + form_data.start_time, form_data.end_time, user_ids + ) trade_logs = TradeTickets.get_ticket_by_time( - form_data.start_time, form_data.end_time + form_data.start_time, form_data.end_time, user_ids ) - # load user data - users = Users.get_users()["users"] - user_map = {user.id: user.name for user in users} - # build graph data total_tokens = 0 total_credit = 0 @@ -345,6 +373,17 @@ async def create_redemption_code( """ Create redemption codes """ + # check redis + _redis = get_redis_connection( + redis_url=REDIS_URL, + redis_sentinels=get_sentinels_from_env( + REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT + ), + redis_cluster=REDIS_CLUSTER, + ) + if not _redis: + raise HTTPException(status_code=500, detail="Redis connection failed.") + # create now = int(time.time()) if form_data.expired_at is not None: expired_at = datetime.datetime.fromtimestamp(form_data.expired_at) diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 3e5e14801c..cdcefe6ba7 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -4,6 +4,7 @@ from open_webui.models.users import Users, UserModel from open_webui.models.feedbacks import ( + FeedbackIdResponse, FeedbackModel, FeedbackResponse, FeedbackForm, @@ -64,6 +65,12 @@ async def get_all_feedbacks(user=Depends(get_admin_user)): return feedbacks +@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse]) +async def get_all_feedback_ids(user=Depends(get_admin_user)): + feedbacks = Feedbacks.get_all_feedbacks() + return feedbacks + + @router.delete("/feedbacks/all") async def delete_all_feedbacks(user=Depends(get_admin_user)): success = Feedbacks.delete_all_feedbacks() @@ -71,7 +78,7 @@ async def delete_all_feedbacks(user=Depends(get_admin_user)): @router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) -async def get_all_feedbacks(user=Depends(get_admin_user)): +async def export_all_feedbacks(user=Depends(get_admin_user)): feedbacks = Feedbacks.get_all_feedbacks() return feedbacks diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 8af921bc7a..723a150197 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -24,9 +24,9 @@ from fastapi.responses import FileResponse, StreamingResponse from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT +from open_webui.models.channels import Channels from open_webui.models.users import Users from open_webui.models.files import ( FileForm, @@ -34,11 +34,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 +47,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() @@ -73,9 +72,9 @@ def has_access_to_file( detail=ERROR_MESSAGES.NOT_FOUND, ) + # Check if the file is associated with any knowledge bases the user has access to knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id) user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - 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 @@ -91,6 +90,17 @@ def has_access_to_file( 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) + if access_type == "read" and channels: + return True + + # Check if the file is associated with any chats the user has access to + # TODO: Granular access control for chats + chats = Chats.get_shared_chats_by_file_id(file_id) + if chats: + return True + return False @@ -104,17 +114,9 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us if file.content_type: stt_supported_content_types = getattr( request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] - ) + ) or ["audio/*", "video/webm"] - if any( - fnmatch(file.content_type, content_type) - for content_type in ( - stt_supported_content_types - if stt_supported_content_types - and any(t.strip() for t in stt_supported_content_types) - else ["audio/*", "video/webm"] - ) - ): + if strict_match_mime_type(stt_supported_content_types, file.content_type): file_path = Storage.get_file(file_path) result = transcribe(request, file_path, file_metadata, user) @@ -138,6 +140,7 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us f"File type {file.content_type} is not provided, but trying to process anyway" ) 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( @@ -179,7 +182,7 @@ def upload_file_handler( user=Depends(get_verified_user), background_tasks: Optional[BackgroundTasks] = None, ): - log.info(f"file.content_type: {file.content_type}") + log.info(f"file.content_type: {file.content_type} {process}") if isinstance(metadata, str): try: @@ -247,6 +250,13 @@ def upload_file_handler( ), ) + if "channel_id" in file_metadata: + channel = Channels.get_channel_by_id_and_user_id( + file_metadata["channel_id"], user.id + ) + if channel: + Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id) + if process: if background_tasks and process_in_background: background_tasks.add_task( diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index fe2bf367bf..32911fa509 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -21,7 +21,6 @@ from open_webui.config import UPLOAD_DIR -from open_webui.env import SRC_LOG_LEVELS from open_webui.constants import ERROR_MESSAGES @@ -34,7 +33,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index c8f131553c..82321cb546 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -23,12 +23,10 @@ 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 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 7d2efcf899..423f6b1c67 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -17,11 +17,9 @@ 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 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 8aabf0f73b..91bc3f2d40 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -16,7 +16,9 @@ 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.headers import include_user_info_headers @@ -31,7 +33,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 +197,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 +381,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 [ @@ -510,14 +512,29 @@ 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, + ) + 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 image_generations( request: Request, form_data: CreateImageForm, + metadata: Optional[dict] = None, user=Depends(get_verified_user), ): # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default @@ -535,6 +552,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 +584,9 @@ async def image_generations( ), **( {} - if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL + if request.app.state.config.IMAGE_GENERATION_MODEL.startswith( + "gpt-image" + ) else {"response_format": "b64_json"} ), **( @@ -593,7 +615,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 +667,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 +678,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}) @@ -703,11 +733,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}) @@ -750,11 +780,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 +811,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 @@ -867,7 +900,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 +935,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 +990,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 +1072,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 3bfc961ac3..467f6f3896 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -4,7 +4,9 @@ from fastapi.concurrency import run_in_threadpool import logging +from open_webui.models.groups import Groups from open_webui.models.knowledge import ( + KnowledgeFileListResponse, Knowledges, KnowledgeForm, KnowledgeResponse, @@ -25,13 +27,11 @@ 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 +39,115 @@ # getKnowledgeBases ############################ +PAGE_ITEM_COUNT = 30 -@router.get("/", response_model=list[KnowledgeUserResponse]) -async def get_knowledge(user=Depends(get_verified_user)): - # Return knowledge bases with read access - knowledge_bases = [] - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - knowledge_bases = Knowledges.get_knowledge_bases() - else: - knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read") - return [ - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), - ) - for knowledge_base in knowledge_bases - ] +class KnowledgeAccessResponse(KnowledgeUserResponse): + write_access: Optional[bool] = False -@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") +class KnowledgeAccessListResponse(BaseModel): + items: list[KnowledgeAccessResponse] + total: int - return [ - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), - ) - for knowledge_base in knowledge_bases - ] + +@router.get("/", response_model=KnowledgeAccessListResponse) +async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)): + page = max(page, 1) + limit = PAGE_ITEM_COUNT + skip = (page - 1) * limit + + filter = {} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + result = Knowledges.search_knowledge_bases( + user.id, filter=filter, skip=skip, limit=limit + ) + + return KnowledgeAccessListResponse( + items=[ + KnowledgeAccessResponse( + **knowledge_base.model_dump(), + write_access=( + user.id == knowledge_base.user_id + or has_access(user.id, "write", knowledge_base.access_control) + ), + ) + for knowledge_base in result.items + ], + total=result.total, + ) + + +@router.get("/search", response_model=KnowledgeAccessListResponse) +async def search_knowledge_bases( + query: Optional[str] = None, + view_option: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + page = max(page, 1) + limit = PAGE_ITEM_COUNT + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + result = Knowledges.search_knowledge_bases( + user.id, filter=filter, skip=skip, limit=limit + ) + + return KnowledgeAccessListResponse( + items=[ + KnowledgeAccessResponse( + **knowledge_base.model_dump(), + write_access=( + user.id == knowledge_base.user_id + or has_access(user.id, "write", knowledge_base.access_control) + ), + ) + for knowledge_base in result.items + ], + total=result.total, + ) + + +@router.get("/search/files", response_model=KnowledgeFileListResponse) +async def search_knowledge_files( + query: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + page = max(page, 1) + limit = PAGE_ITEM_COUNT + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit) ############################ @@ -185,7 +259,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us class KnowledgeFilesResponse(KnowledgeResponse): - files: list[FileMetadataResponse] + files: Optional[list[FileMetadataResponse]] = None + write_access: Optional[bool] = False @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) @@ -201,7 +276,10 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Knowledges.get_file_metadatas_by_id(knowledge.id), + write_access=( + user.id == knowledge.user_id + or has_access(user.id, "write", knowledge.access_control) + ), ) else: raise HTTPException( @@ -264,6 +342,59 @@ async def update_knowledge_by_id( ) +############################ +# GetKnowledgeFilesById +############################ + + +@router.get("/{id}/files", response_model=KnowledgeFileListResponse) +async def get_knowledge_files_by_id( + id: str, + query: Optional[str] = None, + view_option: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), +): + + knowledge = Knowledges.get_knowledge_by_id(id=id) + if not knowledge: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if not ( + user.role == "admin" + or knowledge.user_id == user.id + or has_access(user.id, "read", knowledge.access_control) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + page = max(page, 1) + + limit = 30 + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + + return Knowledges.search_files_by_id( + id, user.id, filter=filter, skip=skip, limit=limit + ) + + ############################ # AddFileToKnowledge ############################ @@ -309,11 +440,6 @@ 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( @@ -321,6 +447,11 @@ def add_file_to_knowledge_by_id( ProcessFileForm(file_id=form_data.file_id, collection_name=id), user=user, ) + + # Add file to knowledge base + Knowledges.add_file_to_knowledge_by_id( + knowledge_id=id, file_id=form_data.file_id, user_id=user.id + ) except Exception as e: log.debug(e) raise HTTPException( diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index 8e45a14dfb..9bb1ef518d 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -7,11 +7,9 @@ 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 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index df5a7377dc..4475b2d78e 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -291,12 +291,15 @@ 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) + # Cache-control headers to prevent stale cached images + cache_headers = {"Cache-Control": "no-cache, must-revalidate"} + if model: if model.meta.profile_image_url: if model.meta.profile_image_url.startswith("http"): return Response( status_code=status.HTTP_302_FOUND, - headers={"Location": model.meta.profile_image_url}, + headers={"Location": model.meta.profile_image_url, **cache_headers}, ) elif model.meta.profile_image_url.startswith("data:image"): try: @@ -307,14 +310,17 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)): return StreamingResponse( image_buffer, media_type="image/png", - headers={"Content-Disposition": "inline; filename=image.png"}, + headers={ + "Content-Disposition": "inline; filename=image.png", + **cache_headers, + }, ) except Exception as e: pass - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers) else: - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers) ############################ diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 3858c4670f..ee0e46da29 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -8,20 +8,28 @@ 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 log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -30,9 +38,19 @@ ############################ -@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) +): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS ): @@ -41,6 +59,12 @@ async def get_notes(request: Request, user=Depends(get_verified_user)): detail=ERROR_MESSAGES.UNAUTHORIZED, ) + limit = None + skip = None + if page is not None: + limit = 60 + skip = (page - 1) * limit + notes = [ NoteUserResponse( **{ @@ -48,22 +72,21 @@ async def get_notes(request: Request, user=Depends(get_verified_user)): "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), } ) - for note in Notes.get_notes_by_permission(user.id, "write") + for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit) ] - 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), ): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS @@ -79,14 +102,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 - return notes + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + groups = Groups.get_groups_by_member_id(user.id) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + return Notes.search_notes(user.id, filter, skip=skip, limit=limit) ############################ @@ -98,7 +133,6 @@ async def get_note_list( async def create_new_note( request: Request, form_data: NoteForm, user=Depends(get_verified_user) ): - if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS ): @@ -122,7 +156,11 @@ async def create_new_note( ############################ -@router.get("/{id}", response_model=Optional[NoteModel]) +class NoteResponse(NoteModel): + write_access: bool = False + + +@router.get("/{id}", response_model=Optional[NoteResponse]) async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS @@ -146,7 +184,15 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - return note + write_access = ( + user.role == "admin" + or (user.id == note.user_id) + or has_access( + user.id, type="write", access_control=note.access_control, strict=False + ) + ) + + return NoteResponse(**note.model_dump(), write_access=write_access) ############################ diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index b640a0fe18..2a04210a62 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -60,7 +60,6 @@ ) from open_webui.env import ( ENV, - SRC_LOG_LEVELS, MODELS_CACHE_TTL, AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, @@ -70,7 +69,6 @@ from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OLLAMA"]) ########################################## @@ -1299,7 +1297,12 @@ async def generate_chat_completion( 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() diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index beb81f13f9..4428ebfba2 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -36,7 +36,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.credit.utils import check_credit_by_user_id from open_webui.utils.payload import ( @@ -55,7 +54,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OPENAI"]) ########################################## @@ -823,8 +821,13 @@ async def generate_chat_completion( # 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() @@ -900,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 f80ea91f84..7a42acffc1 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/retrieval.py b/backend/open_webui/routers/retrieval.py index b7ed993895..a2c1cc80d5 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,8 +29,11 @@ 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 @@ -84,6 +88,7 @@ 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 @@ -99,7 +104,6 @@ RAG_EMBEDDING_QUERY_PREFIX, ) from open_webui.env import ( - SRC_LOG_LEVELS, DEVICE_TYPE, DOCKER, SENTENCE_TRANSFORMERS_BACKEND, @@ -111,7 +115,6 @@ from open_webui.constants import ERROR_MESSAGES log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) ########################################## # @@ -148,9 +151,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,6 +181,7 @@ 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}") @@ -475,12 +484,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, "MINERU_API_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, @@ -507,6 +518,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "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, @@ -536,6 +548,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, "SOUGOU_API_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, @@ -565,6 +578,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 @@ -594,6 +608,7 @@ 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 @@ -656,6 +671,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,6 +679,7 @@ 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 @@ -877,6 +894,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 +936,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 +962,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( @@ -1024,6 +1053,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 @@ -1071,6 +1101,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 ) @@ -1145,12 +1177,14 @@ 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, @@ -1177,6 +1211,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, @@ -1206,6 +1241,7 @@ 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, @@ -1346,7 +1382,7 @@ def _get_docs_info(docs: list[Document]) -> str: 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 +1437,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 @@ -1557,6 +1594,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( @@ -1715,44 +1753,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 +1856,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") @@ -2066,16 +2115,38 @@ async def process_web_search( f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" ) - 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) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index c2ee4d1c35..bd2fd3d4f7 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -24,10 +24,8 @@ get_verified_user, ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) router = APIRouter() diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 60bca4d96c..4d4059da19 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -37,11 +37,9 @@ DEFAULT_VOICE_MODE_PROMPT_TEMPLATE, CREDIT_NO_CREDIT_MSG, ) -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 aa8d95943a..fdcaf266fa 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -27,13 +27,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() diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index ebfacf5d37..75a5f53a48 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -36,7 +36,7 @@ ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR +from open_webui.env import STATIC_DIR from open_webui.utils.auth import ( @@ -49,7 +49,6 @@ log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() @@ -418,6 +417,7 @@ 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") @@ -439,11 +439,12 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): ) user = Users.get_user_by_id(user_id) - if user: + groups = Groups.get_groups_by_member_id(user_id) return UserActiveResponse( **{ **user.model_dump(), + "groups": [{"id": group.id, "name": group.name} for group in groups], "is_active": Users.is_user_active(user_id), } ) diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index b2a44e5488..22529ab1b9 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 638a89715a..72b2761c64 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 diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 5739a8027a..327348626a 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 4292e53827..ce02105bfa 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 3e31438281..d83226ffb7 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/utils/auth.py b/backend/open_webui/utils/auth.py index 2435d0b2ef..3252b162ea 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -40,7 +40,6 @@ WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY, STATIC_DIR, - SRC_LOG_LEVELS, WEBUI_AUTH_TRUSTED_EMAIL_HEADER, FRONTEND_BUILD_DIR, REDIS_URL, @@ -56,7 +55,6 @@ from open_webui.utils.redis import get_redis_connection, get_sentinels_from_env log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["OAUTH"]) SESSION_SECRET = WEBUI_SECRET_KEY ALGORITHM = "HS256" diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 108112365b..15876a3fd3 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -57,12 +57,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( diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index f3dcbb81fb..e89b970cb6 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/credit/alipay.py b/backend/open_webui/utils/credit/alipay.py index 45ad765b17..94782115e4 100644 --- a/backend/open_webui/utils/credit/alipay.py +++ b/backend/open_webui/utils/credit/alipay.py @@ -22,11 +22,11 @@ ALIPAY_CALLBACK_HOST, ALIPAY_PRODUCT_CODE, ) -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import GLOBAL_LOG_LEVEL from open_webui.utils.credit.utils import check_amount logger = logging.getLogger(__name__) -logger.setLevel(SRC_LOG_LEVELS["MAIN"]) +logger.setLevel(GLOBAL_LOG_LEVEL) class AlipayClient: diff --git a/backend/open_webui/utils/credit/usage.py b/backend/open_webui/utils/credit/usage.py index ad97f2a26f..0a3c8fa761 100644 --- a/backend/open_webui/utils/credit/usage.py +++ b/backend/open_webui/utils/credit/usage.py @@ -2,7 +2,7 @@ import logging import time from decimal import Decimal -from typing import List, Union +from typing import List, Union, Tuple import tiktoken from fastapi import HTTPException @@ -10,12 +10,13 @@ from jsonpath_ng import parse as jsonpath_parse from open_webui.config import ( + CREDIT_NO_CHARGE_EMPTY_RESPONSE, USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, USAGE_DEFAULT_ENCODING_MODEL, USAGE_CALCULATE_MINIMUM_COST, USAGE_CUSTOM_PRICE_CONFIG, ) -from open_webui.env import SRC_LOG_LEVELS +from open_webui.env import GLOBAL_LOG_LEVEL from open_webui.models.credits import AddCreditForm, Credits, SetCreditFormDetail from open_webui.models.models import Models from open_webui.models.users import UserModel @@ -33,7 +34,7 @@ ) logger = logging.getLogger(__name__) -logger.setLevel(SRC_LOG_LEVELS["MAIN"]) +logger.setLevel(GLOBAL_LOG_LEVEL) class Calculator: @@ -72,7 +73,7 @@ def calculate_usage( response: Union[ChatCompletion, ChatCompletionChunk], model_prefix_to_remove: str = "", default_model_for_encoding: str = "gpt-4o", - ) -> (bool, CompletionUsage): + ) -> Tuple[bool, CompletionUsage]: try: # use provider usage if response.usage is not None: @@ -117,12 +118,16 @@ def calculate_usage( if choices: choice = choices[0] if isinstance(response, ChatCompletion): + content = choice.message.content or "" usage.completion_tokens = len( - encoder.encode(choice.message.content or "") + # strip to avoid empty token calculation + encoder.encode(content.lstrip("")) ) elif isinstance(response, ChatCompletionChunk): + content = choice.delta.content or "" + # strip to avoid empty token calculation usage.completion_tokens = len( - encoder.encode(choice.delta.content or "") + encoder.encode(content.lstrip("")) ) # total tokens @@ -155,6 +160,7 @@ def __init__( is_embedding: bool = False, ) -> None: self.is_error = False + self.empty_no_cost = CREDIT_NO_CHARGE_EMPTY_RESPONSE.value self.remote_id = "" self.user = user self.model_id = model_id @@ -211,6 +217,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): for k, v in self.custom_fees.items() }, "is_calculate": not self.is_official_usage, + "is_empty_response": self.is_empty_response, + "empty_no_cost": self.empty_no_cost, **self.usage.model_dump(exclude_unset=True, exclude_none=True), }, api_params={ @@ -234,6 +242,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.total_price, ) + @property + def is_empty_response(self) -> bool: + return self.usage.completion_tokens <= 0 + @property def prompt_unit_price(self) -> Decimal: if ( @@ -263,6 +275,8 @@ def completion_unit_price(self) -> Decimal: @property def prompt_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) cache_tokens = 0 # load from prompt_tokens_details or input_tokens_details if ( @@ -289,22 +303,32 @@ def prompt_price(self) -> Decimal: @property def completion_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) return self.completion_unit_price * self.usage.completion_tokens / 1000 / 1000 @property def request_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) return self.request_unit_price / 1000 / 1000 @property def feature_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) return get_feature_price(self.features) @property def custom_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) return Decimal(sum(v for _, v in self.custom_fees.items())) / 1000 / 1000 @property def total_price(self) -> Decimal: + if self.is_error or (self.is_empty_response and self.empty_no_cost): + return Decimal(0) if self.request_unit_price > 0: total_price = self.request_price + self.feature_price + self.custom_price else: @@ -338,6 +362,8 @@ def usage_with_cost(self) -> dict: }, "is_calculate": not self.is_official_usage, "is_error": self.is_error, + "is_empty_response": self.is_empty_response, + "empty_no_cost": self.empty_no_cost, }, **self.usage.model_dump(exclude_unset=True, exclude_none=True), } @@ -472,12 +498,16 @@ def _run(self, response: Union[dict, bytes, str]) -> None: model_prefix_to_remove=USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE.value, default_model_for_encoding=USAGE_DEFAULT_ENCODING_MODEL.value, ) + + # use official usage if is_official_usage: self.is_official_usage = True self.usage = usage return if self.is_official_usage: return + + # use calculated usage if self.is_stream: self.usage.prompt_tokens = usage.prompt_tokens self.usage.completion_tokens += usage.completion_tokens diff --git a/backend/open_webui/utils/credit/utils.py b/backend/open_webui/utils/credit/utils.py index e7690c96f2..8658587700 100644 --- a/backend/open_webui/utils/credit/utils.py +++ b/backend/open_webui/utils/credit/utils.py @@ -263,7 +263,9 @@ def calculate_image_token(model_id: str, image: ImageURL) -> int: if "," in image.url: image_data = image.url.split(",", 1)[1] else: - image_data = image.url + from open_webui.utils.files import get_image_base64_from_url + + image_data = get_image_base64_from_url(image.url) or image.url image_data = base64.b64decode(image_data.encode("utf-8")) image = Image.open(BytesIO(image_data)) 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 0000000000..d2e6151e5b --- /dev/null +++ b/backend/open_webui/utils/db/access_control.py @@ -0,0 +1,130 @@ +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 49ce72c3c5..43cbc56e5f 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 4f9564b7d4..a37ecf31c6 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 663b4e3fb7..37349d2902 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/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 506723bc92..c1293a0fc6 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"} diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 140d2bc85d..05f76b1724 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -60,6 +60,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, ) @@ -110,7 +111,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, @@ -124,7 +124,6 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MAIN"]) DEFAULT_REASONING_TAGS = [ @@ -345,7 +344,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 +715,18 @@ 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")) - if images_flag: - break + if message_images: + images.append(message_images) return images @@ -757,10 +757,10 @@ async def chat_image_generation_handler( ): 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 +780,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 +799,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 +828,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 +887,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 +1107,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 +1173,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) @@ -2686,7 +2745,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}" diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index b0fbce9a42..af48b71150 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,37 @@ 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] + + 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( @@ -656,14 +717,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] @@ -679,6 +743,7 @@ async def yield_safe_stream_chunks(): if buffer and not skip_mode: credit_deduct.run(response=buffer) yield buffer + yield b"\n" yield credit_deduct.usage_message diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index fbd1089382..431542d340 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): @@ -367,6 +366,12 @@ def get_filtered_models(models, user): 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)} for model in models: @@ -382,7 +387,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 61c98ca744..13108f78e5 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 @@ -449,6 +450,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,10 +577,14 @@ 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 @@ -716,10 +765,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 +782,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 +790,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 +835,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) @@ -1270,7 +1329,12 @@ 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) + + 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): if provider not in OAUTH_PROVIDERS: diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 51c3f4f5f7..5407a7cec8 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 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): diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index cc29ce6683..da6df2a7f9 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, @@ -162,6 +163,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 +190,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/smtp.py b/backend/open_webui/utils/smtp.py index fa66c9d4db..6b6043b4d3 100644 --- a/backend/open_webui/utils/smtp.py +++ b/backend/open_webui/utils/smtp.py @@ -2,23 +2,30 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from open_webui.config import SMTP_USERNAME, SMTP_HOST, SMTP_PORT, SMTP_PASSWORD +from open_webui.config import ( + SMTP_USERNAME, + SMTP_HOST, + SMTP_PORT, + SMTP_PASSWORD, + SMTP_SENT_FROM, +) def send_email(receiver: str, subject: str, body: str): message = MIMEMultipart() - message["From"] = SMTP_USERNAME.value + message["From"] = SMTP_SENT_FROM.value or SMTP_USERNAME.value message["To"] = receiver message["Subject"] = subject message.attach(MIMEText(body, "html")) - if SMTP_PORT.value == "587": - server = smtplib.SMTP(SMTP_HOST.value, int(SMTP_PORT.value)) + port = str(SMTP_PORT.value) + if port == "587": + server = smtplib.SMTP(SMTP_HOST.value, int(port)) server.starttls() - elif SMTP_PORT.value == "465": - server = smtplib.SMTP_SSL(SMTP_HOST.value, int(SMTP_PORT.value)) + elif port == "465": + server = smtplib.SMTP_SSL(SMTP_HOST.value, int(port)) else: - raise ValueError(f"Invalid SMTP port {SMTP_PORT.value}") + raise ValueError(f"Invalid SMTP port {port}") try: server.login(SMTP_USERNAME.value, SMTP_PASSWORD.value) diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 7f90e96330..ecedd595a7 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 0ba42efd4b..dbc4ebb2cb 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/tools.py b/backend/open_webui/utils/tools.py index 2baff503ee..f517e89999 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -39,7 +39,6 @@ from open_webui.models.users import UserModel from open_webui.utils.plugin import load_tool_module_by_id from open_webui.env import ( - SRC_LOG_LEVELS, AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, @@ -48,7 +47,6 @@ import copy log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["MODELS"]) def get_async_tool_function_and_apply_extra_params( diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index 4424c651ac..b617abc06c 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 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: diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index f22ad7f0cf..e2827ec659 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -1,13 +1,13 @@ # Minimal requirements for backend to run # WIP: use this as a reference to build a minimal docker image -fastapi==0.123.0 +fastapi==0.126.0 uvicorn[standard]==0.37.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.15.1 python-jose==3.5.0 cryptography bcrypt==5.0.0 @@ -16,7 +16,7 @@ PyJWT[crypto]==2.10.1 authlib==1.6.5 requests==2.32.5 -aiohttp==3.12.15 +aiohttp==3.13.2 async-timeout aiocache aiofiles @@ -24,28 +24,28 @@ 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.1 +RestrictedPython==8.1 loguru==0.7.3 asgiref==3.11.0 -mcp==1.22.0 +mcp==1.25.0 openai langchain==0.3.27 langchain-community==0.3.29 fake-useragent==2.2.0 -chromadb==1.1.0 -black==25.11.0 +chromadb==1.3.7 +black==25.12.0 pydub chardet==5.2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 609aab7789..bd58331a70 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,103 +1,106 @@ -fastapi==0.123.0 +fastapi==0.126.0 uvicorn[standard]==0.37.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.15.1 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.1 +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 +google-generativeai==0.8.6 -langchain==0.3.27 -langchain-community==0.3.29 +langchain==1.2.0 +langchain-community==0.4.1 +langchain-classic==1.0.0 +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.3.7 +weaviate-client==4.19.0 +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.19.1 +docx2txt==0.9 python-pptx==1.0.2 unstructured==0.18.21 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 jsonpath-ng soundfile==0.13.1 -pillow==11.3.0 -opencv-python-headless==4.11.0.86 +pillow==12.0.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 @@ -106,26 +109,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.14 -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.5 +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.0 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 @@ -137,20 +140,20 @@ pytest-docker~=3.2.5 ldap3==2.9.1 ## Firecrawl -firecrawl-py==4.10.0 +firecrawl-py==4.12.0 ## Trace -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 # Alipay alipay-sdk-python==3.7.796 diff --git a/backend/start.sh b/backend/start.sh index 31e87c9557..5729babe52 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -79,7 +79,12 @@ else ARGS=(--workers "$UVICORN_WORKERS") fi +# Do migrate before starting +echo "Running database migrations..." +"$PYTHON_CMD" -m open_webui.migrate + # Run uvicorn +echo "Starting Open WebUI on http://$HOST:$PORT with $UVICORN_WORKERS workers..." WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \ --host "$HOST" \ --port "$PORT" \ diff --git a/banner.png b/banner.png new file mode 100644 index 0000000000..270f76e7e0 Binary files /dev/null and b/banner.png differ diff --git a/demo.gif b/demo.gif deleted file mode 100644 index 6e56b74a0a..0000000000 Binary files a/demo.gif and /dev/null differ diff --git a/demo.png b/demo.png new file mode 100644 index 0000000000..a38ddaf6ab Binary files /dev/null and b/demo.png differ diff --git a/docker-compose.playwright.yaml b/docker-compose.playwright.yaml index fa2b49ff9a..e00a28df58 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/docs/BRANDING.md b/docs/BRANDING.md index a271a8f4b1..33a9c36f2d 100644 --- a/docs/BRANDING.md +++ b/docs/BRANDING.md @@ -1,10 +1,13 @@ +## 自定义品牌配置 + > [!WARNING] -> 本文档由本开源 Fork 维护,与 Open WebUI 官方无直接关联,仅为合规性用途说明,最终要求以官方最新 License 为准 -> 我们鼓励大家支持开源项目,保留 Open WebUI 的标识,非必要请勿配置下方的环境变量 -> 您应当通过购买商业授权的方式获取许可,从而使用自己的品牌名称或者 Logo,详见 [Open WebUI for Enterprises](https://docs.openwebui.com/enterprise) -> 如您通过下述方式移除 Open WebUI 标识,请确保满足 [License 条款9](https://docs.openwebui.com/license#9-what-about-forks-can-i-start-one-and-remove-all-open-webui-mentions) 相关条件。 +> 本项目是开源分发版本,与 Open WebUI 官方无直接关联 +> 我们鼓励大家支持开源项目,保留 Open WebUI 的标识 +> 如您通过下述方式移除 Open WebUI 标识,请确保满足 [Open WebUI License 条款9](https://docs.openwebui.com/license#9-what-about-forks-can-i-start-one-and-remove-all-open-webui-mentions) 相关条件 > 未经授权的大规模或商用修改属于违规,由使用者本人承担全部法律风险 -> 以下内容仅供小规模、实验或已授权用户自查合法合规性参考 +> 以下内容仅供小规模、实验或已授权用户使用 + +您可以通过设置以下环境变量来自定义系统的名称和 Logo ```bash # 配置为任意非空值即可 diff --git a/kubernetes/helm/README.md b/kubernetes/helm/README.md deleted file mode 100644 index 5737007d96..0000000000 --- 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 61500f87c5..0000000000 --- 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 8bab65b59e..0000000000 --- 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 cd1144caf9..0000000000 --- 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 9c1a599f32..0000000000 --- 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 79a0a9a23c..0000000000 --- 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 dc0b53ccd4..0000000000 --- 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 97fb761d42..0000000000 --- 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 d73845f00a..0000000000 --- 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 c0d39fbfaa..0000000000 --- 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 3e42443656..0000000000 --- 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 9ad49a6ab9..b19c20ee4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.41.1", + "version": "0.6.42.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.41.1", + "version": "0.6.42.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -69,6 +69,7 @@ "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", @@ -98,6 +99,7 @@ "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", "yaml": "^2.7.1", "yjs": "^13.6.27" @@ -179,22 +181,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, "node_modules/@azure/msal-browser": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", @@ -657,131 +643,6 @@ "node": ">=0.1.90" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", - "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@cypress/request": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", @@ -4606,6 +4467,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", @@ -4663,16 +4533,13 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "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": ">= 14" + "node": ">=0.8" } }, "node_modules/aggregate-error": { @@ -4943,7 +4810,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", @@ -5323,6 +5189,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", @@ -5786,6 +5665,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", @@ -5936,8 +5824,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", @@ -6028,31 +5915,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cypress": { "version": "13.15.0", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", @@ -6708,22 +6570,6 @@ "node": ">=0.10" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -6746,15 +6592,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -6861,6 +6698,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", @@ -6941,6 +6784,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", @@ -7785,6 +7637,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", @@ -8265,21 +8126,6 @@ "node": ">=12.0.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-entities": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", @@ -8348,22 +8194,6 @@ "entities": "^4.5.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", @@ -8378,22 +8208,6 @@ "node": ">=0.10" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -8520,6 +8334,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", @@ -8742,15 +8562,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -8801,8 +8612,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", @@ -8854,73 +8664,6 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, - "node_modules/jsdom": { - "version": "24.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz", - "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.4", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -9001,6 +8744,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", @@ -9158,6 +8913,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", @@ -9654,6 +9418,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", @@ -9694,6 +9469,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", @@ -10110,15 +9930,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10205,6 +10016,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", @@ -10293,6 +10110,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", @@ -10385,7 +10208,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" } @@ -10786,8 +10608,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", @@ -11229,7 +11050,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", @@ -11482,15 +11302,6 @@ "points-on-path": "^0.2.1" } }, - "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -11551,8 +11362,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", @@ -11940,21 +11750,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -11988,6 +11783,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", @@ -12177,6 +11978,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", @@ -12255,7 +12068,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" } @@ -12592,15 +12404,6 @@ "node": ">=12.0.0" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -12862,21 +12665,6 @@ "node": ">= 4.0.0" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -13000,6 +12788,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", @@ -13066,8 +12860,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", @@ -14351,21 +14144,6 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -14404,18 +14182,6 @@ "node": "*" } }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -14439,22 +14205,6 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/wheel": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", @@ -14490,6 +14240,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", @@ -14602,26 +14370,35 @@ } } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "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", - "optional": true, - "peer": true, + "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": ">=18" + "node": ">=0.8" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", "license": "MIT", - "optional": true, - "peer": true + "engines": { + "node": ">=4.0" + } }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", diff --git a/package.json b/package.json index a706cf2c93..f88a6d53cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.41.1", + "version": "0.6.42.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -113,6 +113,7 @@ "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", @@ -142,6 +143,7 @@ "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", "yaml": "^2.7.1", "yjs": "^13.6.27" diff --git a/pyproject.toml b/pyproject.toml index 10dd3259e4..f580c6623e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,112 +6,113 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.123.0", + "fastapi==0.126.0", "uvicorn[standard]==0.37.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.15.1", "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.1", + "RestrictedPython==8.1", "loguru==0.7.3", "asgiref==3.11.0", "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", + "google-generativeai==0.8.6", "langchain==0.3.27", "langchain-community==0.3.29", "fake-useragent==2.2.0", - "chromadb==1.0.20", - "opensearch-py==2.8.0", - "PyMySQL==1.1.1", - "boto3==1.41.5", + "chromadb==1.3.7", + "opensearch-py==3.1.0", + "PyMySQL==1.1.2", + "boto3==1.42.14", "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.19.1", + "docx2txt==0.9", "python-pptx==1.0.2", "unstructured==0.18.21", "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.0.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", ] @@ -130,8 +131,8 @@ classifiers = [ [project.optional-dependencies] postgres = [ - "psycopg2-binary==2.9.10", - "pgvector==0.4.1", + "psycopg2-binary==2.9.11", + "pgvector==0.4.2", ] all = [ @@ -143,17 +144,18 @@ all = [ "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.0", - "qdrant-client==1.14.3", - "weaviate-client==4.17.0", + "qdrant-client==1.16.2", "pymilvus==2.6.4", + "weaviate-client==4.19.0", + "pymilvus==2.6.5", "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 fc093e5a6a..897dbdc3b7 100644 --- a/src/app.css +++ b/src/app.css @@ -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/channels/index.ts b/src/lib/apis/channels/index.ts index 0731b2ea9f..44817e97ef 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; diff --git a/src/lib/apis/files/index.ts b/src/lib/apis/files/index.ts index 8351393e3c..07042c4ade 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', diff --git a/src/lib/apis/knowledge/index.ts b/src/lib/apis/knowledge/index.ts index c01c986a2a..9656a232ea 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; diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 61794f6766..55f9427e0d 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/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index 75065910d6..a84e7b6822 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/admin/Settings/Credit.svelte b/src/lib/components/admin/Settings/Credit.svelte index c5d27388db..f9791a42a7 100644 --- a/src/lib/components/admin/Settings/Credit.svelte +++ b/src/lib/components/admin/Settings/Credit.svelte @@ -3,6 +3,7 @@ import { getUsageConfig, setUsageConfig } from '$lib/apis/configs'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; + import Switch from '$lib/components/common/Switch.svelte'; const i18n = getContext('i18n'); @@ -36,6 +37,12 @@
{$i18n.t('Credit')}

+
+
+ {$i18n.t('No Charge When Empty Response')} +
+ +
{$i18n.t('No Credit Message')}
diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 57a4f7b5f1..fa68783afa 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -667,11 +667,18 @@
- +
+
+ {$i18n.t('API Timeout')} +
+ +
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index 9370758e80..62bbbfa042 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -337,6 +337,85 @@ {$i18n.t('After verify, will auto change to role User')} + {#if adminConfig.ENABLE_SIGNUP_VERIFY} +
+
+ {$i18n.t('SMTP Host')} +
+ +
+ +
+
+ +
+
+ {$i18n.t('SMTP Port')} +
+ +
+ +
+
+ +
+
+ {$i18n.t('SMTP Sent From')} +
+
+ {$i18n.t('If empty, will use username as the from address')} +
+ +
+ +
+
+ +
+
+ {$i18n.t('SMTP Username')} +
+ +
+ +
+
+ +
+
+ {$i18n.t('SMTP Password')} +
+ +
+ +
+
+ {/if} +
{$i18n.t('Sign Up Email Domain Whitelist')} diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte index 1c96ef8127..016bf68f07 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/Pipelines.svelte b/src/lib/components/admin/Settings/Pipelines.svelte index 18446da7dd..81ecfe2218 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 17191ac216..6c9035f3c9 100644 --- a/src/lib/components/admin/Settings/WebSearch.svelte +++ b/src/lib/components/admin/Settings/WebSearch.svelte @@ -189,7 +189,7 @@ {:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
-
+
{$i18n.t('Searxng Query URL')}
@@ -206,6 +206,24 @@
+
+
+ {$i18n.t('Searxng search language (all, en, es, de, fr, etc.)')} +
+ +
+
+ +
+
+
{:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'}
@@ -611,19 +629,6 @@ />
- {:else if webConfig.WEB_SEARCH_ENGINE === 'ddgs' || webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'} -
-
- {$i18n.t('Concurrent Requests')} -
- - -
{:else if webConfig.WEB_SEARCH_ENGINE === 'external'}
@@ -673,6 +678,27 @@ required />
+ +
+
+ + {$i18n.t('Concurrent Requests')} + +
+ + +
@@ -767,6 +793,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/Credit.svelte b/src/lib/components/admin/Users/Credit.svelte index 978de31d5c..d5c6d6fa56 100644 --- a/src/lib/components/admin/Users/Credit.svelte +++ b/src/lib/components/admin/Users/Credit.svelte @@ -56,6 +56,7 @@ let endTime = new Date(); let startTime = new Date(); + startTime.setDate(endTime.getDate() - 7); const mergeData = (data: Array) => { let sorted = data.sort((a, b) => b.value - a.value); @@ -68,10 +69,16 @@ let dateRangeInput; let fp; - const doQuery = async (startTime: Date, endTime: Date) => { + let query = ''; + $: if (query !== undefined) { + doQuery(); + } + + const doQuery = async () => { const data = await getCreditStats(localStorage.token, { start_time: Math.round(startTime.getTime() / 1000), - end_time: Math.round(endTime.getTime() / 1000) + end_time: Math.round(endTime.getTime() / 1000), + query: query }).catch((error) => { toast.error(`${error}`); return null; @@ -308,14 +315,10 @@ locale = Mandarin; } - const end = new Date(); - const start = new Date(); - start.setDate(end.getDate() - 7); - const minDays = new Date(); - minDays.setDate(end.getDate() - 180); + minDays.setDate(endTime.getDate() - 180); const tomorrow = new Date(); - tomorrow.setDate(end.getDate() + 1); + tomorrow.setDate(endTime.getDate() + 1); fp = flatpickr(dateRangeInput, { locale: locale, @@ -324,7 +327,7 @@ enableTime: true, animate: true, allowInput: true, - defaultDate: [start, end], + defaultDate: [startTime, endTime], defaultHour: 0, maxDate: tomorrow, minDate: minDays, @@ -333,73 +336,101 @@ time_24hr: true, onChange: async (selectedDates, _) => { if (selectedDates.length === 2) { - await doQuery(selectedDates[0], selectedDates[1]); + startTime = selectedDates[0]; + endTime = selectedDates[1]; + await doQuery(); } } }); - await doQuery(start, end); - return () => { fp.destroy(); }; }); -
+
- {$i18n.t('Credit Statistics')} -
- -
- +
+ {$i18n.t('Credit Statistics')} +
-
-
- {$i18n.t('Total Payment')} -
{statsData.total_payment ?? 0}
-
-
- {$i18n.t('Total Credit Cost')} -
{statsData.total_credit ?? 0}
-
-
- {$i18n.t('Total Token Cost')} -
{statsData.total_tokens ?? 0}
+
+
+
+
+ + + +
+ +
+
-
-
-
-
-
+
+
+ +
+
+ {$i18n.t('Total Payment')} +
{statsData.total_payment ?? 0}
+
+
+ {$i18n.t('Total Credit Cost')} +
{statsData.total_credit ?? 0}
+
+
+ {$i18n.t('Total Token Cost')} +
{statsData.total_tokens ?? 0}
+
+
+ +
+
+
+
+
diff --git a/src/lib/components/admin/Users/CreditLog.svelte b/src/lib/components/admin/Users/CreditLog.svelte index 84593f99c0..d78677717b 100644 --- a/src/lib/components/admin/Users/CreditLog.svelte +++ b/src/lib/components/admin/Users/CreditLog.svelte @@ -13,17 +13,8 @@ let limit = 30; let total = null; - $: if (page) { - doQuery(); - } - let query = ''; - $: if (query !== undefined) { - page = 1; - doQuery(); - } - let logs = []; const doQuery = async () => { const data = await listAllCreditLog(localStorage.token, page, limit, query).catch((error) => { @@ -41,7 +32,7 @@ return new Date(t * 1000).toLocaleString(); }; - const formatDesc = (log: Log): string => { + const formatDesc = (log): string => { const usage = log?.detail?.usage ?? {}; if (usage && Object.keys(usage).length > 0) { if (usage.total_price !== undefined && usage.total_price !== null) { @@ -59,9 +50,9 @@ let showDeleteLogModal = false; - onMount(async () => { - await doQuery(); - }); + $: if (page !== undefined || query !== undefined) { + doQuery(); + }
@@ -158,7 +149,7 @@ {$i18n.t('Model')} - {$i18n.t('Desc')} + {$i18n.t('Description')} diff --git a/src/lib/components/admin/Users/Groups/Users.svelte b/src/lib/components/admin/Users/Groups/Users.svelte index ab544e5c8a..76f398d71c 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/RedemptionCodes.svelte b/src/lib/components/admin/Users/RedemptionCodes.svelte index 497f8d2231..394c415f2a 100644 --- a/src/lib/components/admin/Users/RedemptionCodes.svelte +++ b/src/lib/components/admin/Users/RedemptionCodes.svelte @@ -25,15 +25,6 @@ let showEditModal = false; let selectedCode: any = null; - $: if (page) { - loadCodes(); - } - - $: if (keyword !== undefined) { - page = 1; - loadCodes(); - } - const loadCodes = async () => { try { const data = await getRedemptionCodes(localStorage.token, page, limit, keyword); @@ -116,9 +107,9 @@ return code.replace(/(.{4})(.*)(.{4})/, '$1****$3'); }; - onMount(async () => { - await loadCodes(); - }); + $: if (page !== undefined || keyword !== undefined) { + loadCodes(); + } - {#each users as user, userIdx} + {#each users as user, userIdx (user.id)} - - {/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/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte index 5bd7c2222b..2799059b07 100644 --- a/src/lib/components/chat/Messages/Citations.svelte +++ b/src/lib/components/chat/Messages/Citations.svelte @@ -1,11 +1,14 @@ -{#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.ids as sourceId} +
+ +
+ {/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 e34e1cd54d..d9ca32492a 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 5b5000e8e0..73460dff9f 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} f.type === 'image').length > 0} -
+
{#each message.files as file}
{#if file.type === 'image'} @@ -824,6 +827,7 @@ @@ -1461,37 +1465,35 @@ {/if} {/if} - {#if isLastMessage} - {#each model?.actions ?? [] as action} - - - - {/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 f6e431e532..fd2b3fb27a 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} diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index d0b3a19a72..fc898b45d9 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 fb4efddabb..e6916507e7 100644 --- a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte +++ b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte @@ -1,6 +1,8 @@ - diff --git a/src/lib/components/common/DropdownOptions.svelte b/src/lib/components/common/DropdownOptions.svelte new file mode 100644 index 0000000000..ecc5cc9cf8 --- /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 2925c83622..c6e4a785b4 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 9f352c1b02..e4d6ddde7a 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; @@ -1156,7 +1156,5 @@
diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts index 7c7b7a48d9..087545d527 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; } diff --git a/src/lib/components/icons/Expand.svelte b/src/lib/components/icons/Expand.svelte new file mode 100644 index 0000000000..e11230aa37 --- /dev/null +++ b/src/lib/components/icons/Expand.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/icons/PagePlus.svelte b/src/lib/components/icons/PagePlus.svelte new file mode 100644 index 0000000000..c69816dd8e --- /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 791aa8c9fe..ec6a74a045 100644 --- a/src/lib/components/layout/ArchivedChatsModal.svelte +++ b/src/lib/components/layout/ArchivedChatsModal.svelte @@ -1,5 +1,7 @@ @@ -73,12 +78,7 @@ /> - { - dispatch('change', state); - }} -> + @@ -352,7 +352,7 @@
{$i18n.t('Sign Out')}
- {#if showActiveUsers && usage} + {#if showActiveUsers && ($config?.features?.enable_public_active_users_count || role === 'admin') && usage} {#if usage?.user_count}
@@ -364,7 +364,9 @@
{ - getUsageInfo(); + if ($config?.features?.enable_public_active_users_count || role === 'admin') { + getUsageInfo(); + } }} >
diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index f49d8bb7d0..37620f75e1 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -24,7 +24,7 @@ import { compressImage, copyToClipboard, splitStream, convertHeicToJpeg } from '$lib/utils'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; - import { uploadFile } from '$lib/apis/files'; + import { getFileById, uploadFile } from '$lib/apis/files'; import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai'; import { @@ -157,6 +157,16 @@ if (res) { note = res; files = res.data.files || []; + + if (note?.write_access) { + $socket?.emit('join-note', { + note_id: id, + auth: { + token: localStorage.token + } + }); + $socket?.on('note-events', noteEventHandler); + } } else { goto('/'); return; @@ -416,11 +426,7 @@ ${content} const uploadedFile = await uploadFile(localStorage.token, file, metadata); if (uploadedFile) { - console.log('File upload completed:', { - id: uploadedFile.id, - name: fileItem.name, - collection: uploadedFile?.meta?.collection_name - }); + console.log('File upload completed:', uploadedFile); if (uploadedFile.error) { console.warn('File upload warning:', uploadedFile.error); @@ -428,12 +434,15 @@ ${content} } fileItem.status = 'uploaded'; - fileItem.file = uploadedFile; + fileItem.file = await getFileById(localStorage.token, uploadedFile.id).catch((e) => { + toast.error(`${e}`); + return null; + }); 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}`; files = files; } else { @@ -781,13 +790,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, onMount(async () => { await tick(); - $socket?.emit('join-note', { - note_id: id, - auth: { - token: localStorage.token - } - }); - $socket?.on('note-events', noteEventHandler); if ($settings?.models) { selectedModelId = $settings?.models[0]; @@ -956,69 +958,71 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, {/if}
- {#if editor} -
-
- - - + {#if note?.write_access} + {#if editor} +
+
+ + + +
-
- {/if} + {/if} - - - + }} + > + + + - - - + }} + > + + + + {/if} { @@ -1071,11 +1075,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 +1136,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{#if editing} @@ -1145,7 +1151,7 @@ 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} @@ -1158,7 +1164,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 +1249,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 +1363,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/Notes.svelte b/src/lib/components/notes/Notes.svelte index 2b377bda6c..3d0ceb60b7 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -1,9 +1,7 @@ @@ -236,7 +297,7 @@ -
+
{#if loaded} -
-
+
+
+
+
+ {$i18n.t('Notes')} +
+ +
+ {total} +
+
+ +
+ +
+
+
+ +
+
@@ -277,194 +371,305 @@ {/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; + } + }} + /> +
+
-
- {#each notes[timeRange] as note, idx (note.id)} + {#if items !== null && total !== null} + {#if (items ?? []).length > 0} + {@const notes = groupNotes(items)} + +
+
+ {#each Object.keys(notes) as timeRange, 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 5d398ebaf2..052c48a441 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 d77ac02066..8f8d5c1db7 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -1,6 +1,4 @@ @@ -123,7 +132,7 @@
- {filteredItems.length} + {total}
@@ -192,11 +201,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 2e729f4968..3373e5a660 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 eed0a95c81..9d42130234 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 b66517d266..7f42c2da4d 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -68,13 +68,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 () => { @@ -381,6 +386,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 +436,221 @@
- {#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 ( + $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 +
-
-
-
-
-
- - - {model.name} - - +
+
+
+
+ + + {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)}` - ); - }} - shareHandler={() => { - shareModelHandler(model); - }} - cloneHandler={() => { - cloneModelHandler(model); - }} - exportHandler={() => { - exportModelHandler(model); - }} - hideHandler={() => { - hideModelHandler(model); - }} - copyLinkHandler={() => { - copyLinkHandler(model); - }} - deleteHandler={() => { - selectedModel = model; - showModelDeleteConfirm = true; - }} - onClose={() => {}} - > -
- -
-
- {/if} +
+ +
+
+ {/if} +
-
- + + { + toggleModelById(localStorage.token, model.id); + _models.set( + await getModels( + localStorage.token, + $config?.features?.enable_direct_connections && + ($settings?.directConnections ?? null) + ) + ); + }} + /> + + +
-
-
- -
- {$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} +
+ +
+ {$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} +
+
+
+
-
- {/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/Knowledge.svelte b/src/lib/components/workspace/Models/Knowledge.svelte index 618c56c7b2..eb19d767cf 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'; @@ -80,7 +80,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 +128,6 @@ }; onMount(async () => { - if (!$knowledge) { - knowledge.set(await getKnowledgeBases(localStorage.token)); - } loaded = true; }); @@ -190,8 +187,7 @@ {#if loaded}
- { const item = e.detail; @@ -210,7 +206,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 29c1ea7d5e..0000000000 --- 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 4ece131b92..6224649178 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'; @@ -119,19 +118,6 @@ 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; - } - }; - const submitHandler = async () => { loading = true; @@ -235,7 +221,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'); @@ -389,7 +374,7 @@ on:change={() => { let reader = new FileReader(); reader.onload = (event) => { - let originalImageUrl = `${event.target.result}`; + let originalImageUrl = `${event.target?.result}`; const img = new Image(); img.src = originalImageUrl; @@ -437,12 +422,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; } }} @@ -557,15 +542,15 @@
-
+