diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cacf29521..d0730963ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,79 @@ 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.41] - 2025-12-02 + +### Added + +- 🚦 Sign-in rate limiting was implemented to protect against brute force attacks, limiting login attempts to 15 per 3-minute window per email address using Redis with automatic fallback to in-memory storage when Redis is unavailable. [Commit](https://github.com/open-webui/open-webui/commit/7b166370432414ce8f186747fb098e0c70fb2d6b) +- 📂 Administrators can now globally disable the folders feature and control user-level folder permissions through the admin panel, enabling minimalist interface configurations for deployments that don't require workspace organization features. [#19529](https://github.com/open-webui/open-webui/pull/19529), [#19210](https://github.com/open-webui/open-webui/discussions/19210), [#18459](https://github.com/open-webui/open-webui/discussions/18459), [#18299](https://github.com/open-webui/open-webui/discussions/18299) +- 👥 Group channels were introduced as a new channel type enabling membership-based collaboration spaces where users explicitly join as members rather than accessing through permissions, with support for public or private visibility, automatic member inclusion from specified user groups, member role tracking with invitation metadata, and post-creation member management allowing channel managers to add or remove members through the channel info modal. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4), [Commit](https://github.com/open-webui/open-webui/commit/3f1d9ccbf8443a2fa5278f36202bad930a216680) +- 💬 Direct Message channels were introduced with a dedicated channel type selector and multi-user member selection interface, enabling private conversations between specific users without requiring full channel visibility. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386) +- 📨 Direct Message channels now support a complete user-to-user messaging system with member-based access control, automatic deduplication for one-on-one conversations, optional channel naming, and distinct visual presentation using participant avatars instead of channel icons. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f) +- 🙈 Users can now hide Direct Message channels from their sidebar while preserving message history, with automatic reactivation when new messages arrive from other participants, providing a cleaner interface for managing active conversations. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f) +- ☑️ A comprehensive user selection component was added to the channel creation modal, featuring search functionality, sortable user lists, pagination support, and multi-select checkboxes for building Direct Message participant lists. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f) +- 🔴 Channel unread message count tracking was implemented with visual badge indicators in the sidebar, automatically updating counts in real-time and marking messages as read when users view channels, with join/leave functionality to manage membership status. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386) +- 📌 Message pinning functionality was added to channels, allowing users to pin important messages for easy reference with visual highlighting, a dedicated pinned messages modal accessible from the navbar, and complete backend support for tracking pinned status, pin timestamp, and the user who pinned each message. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386), [Commit](https://github.com/open-webui/open-webui/commit/aae2fce17355419d9c29f8100409108037895201) +- 🟢 Direct Message channels now display an active status indicator for one-on-one conversations, showing a green dot when the other participant is currently online or a gray dot when offline. [Commit](https://github.com/open-webui/open-webui/commit/4b6773885cd7527c5a56b963781dac5e95105eec), [Commit](https://github.com/open-webui/open-webui/commit/39645102d14f34e71b34e5ddce0625790be33f6f) +- 🆔 Users can now start Direct Message conversations directly from user profile previews by clicking the "Message" button, enabling quick access to private messaging without navigating away from the current channel. [Commit](https://github.com/open-webui/open-webui/commit/a0826ec9fedb56320532616d568fa59dda831d4e) +- ⚡ Channel messages now appear instantly when sent using optimistic UI rendering, displaying with a pending state while the server confirms delivery, providing a more responsive messaging experience. [Commit](https://github.com/open-webui/open-webui/commit/25994dd3da90600401f53596d4e4fb067c1b8eaa) +- 👍 Channel message reactions now display the names of users who reacted when hovering over the emoji, showing up to three names with a count for additional reactors. [Commit](https://github.com/open-webui/open-webui/commit/05e79bdd0c7af70b631e958924e3656db1013b80) +- 🛠️ Channel creators can now edit and delete their own group and DM channels without requiring administrator privileges, enabling users to manage the channels they create independently. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4) +- 🔌 A new API endpoint was added to directly get or create a Direct Message channel with a specific user by their ID, streamlining programmatic DM channel creation for integrations and frontend workflows. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4) +- 💭 Users can now set a custom status with an emoji and message that displays in profile previews, the sidebar user menu, and Direct Message channel items in the sidebar, with the ability to clear status at any time, providing visibility into availability or current focus similar to team communication platforms. [Commit](https://github.com/open-webui/open-webui/commit/51621ba91a982e52da168ce823abffd11ad3e4fa), [Commit](https://github.com/open-webui/open-webui/commit/f5e8d4d5a004115489c35725408b057e24dfe318) +- 📤 A group export API endpoint was added, enabling administrators to export complete group data including member lists for backup and migration purposes. [Commit](https://github.com/open-webui/open-webui/commit/09b6ea38c579659f8ca43ae5ea3746df3ac561ad) +- 📡 A new API endpoint was added to retrieve all users belonging to a specific group, enabling programmatic access to group membership information for administrative workflows. [Commit](https://github.com/open-webui/open-webui/commit/01868e856a10f474f74fbd1b4425dafdf949222f) +- 👁️ The admin user list now displays an active status indicator next to each user, showing a visual green dot for users who have been active within the last three minutes. [Commit](https://github.com/open-webui/open-webui/commit/1b095d12ff2465b83afa94af89ded9593f8a8655) +- 🔑 The admin user edit modal now displays OAuth identity information with a per-provider breakdown, showing each linked identity provider and its associated subject identifier separately. [#19573](https://github.com/open-webui/open-webui/pull/19573) +- 🧩 OAuth role claim parsing now respects the "OAUTH_ROLES_SEPARATOR" configuration, enabling proper parsing of roles returned as comma-separated strings and providing consistent behavior with group claim handling. [#19514](https://github.com/open-webui/open-webui/pull/19514) +- 🎛️ Channel feature access can now be controlled through both the "USER_PERMISSIONS_FEATURES_CHANNELS" environment variable and group permission toggles in the admin panel, allowing administrators to restrict channel functionality for specific users or groups while defaulting to enabled for all users. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4) +- 🎨 The model editor interface was refined with access control settings moved to a dedicated modal, group member counts now displayed when configuring permissions, reorganized layout with improved visual hierarchy, and redesigned prompt suggestions cards with tooltips for field guidance. [Commit](https://github.com/open-webui/open-webui/commit/e65d92fc6f49da5ca059e1c65a729e7973354b99), [Commit](https://github.com/open-webui/open-webui/commit/9d39b9b42c653ee2acf2674b2df343ecbceb4954) +- 🏗️ Knowledge base file management was rebuilt with a dedicated database table replacing the previous JSON array storage, enabling pagination support for large knowledge bases, significantly faster file listing performance, and more reliable file-knowledge base relationship tracking. [Commit](https://github.com/open-webui/open-webui/commit/d19023288e2ca40f86e2dc3fd9f230540f3e70d7) +- ☁️ Azure Document Intelligence model selection was added, allowing administrators to specify which model to use for document processing via the "DOCUMENT_INTELLIGENCE_MODEL" environment variable or admin UI setting, with "prebuilt-layout" as the default. [#19692](https://github.com/open-webui/open-webui/pull/19692), [Docs:#872](https://github.com/open-webui/docs/pull/872) +- 🚀 Milvus multitenancy vector database performance was improved by removing manual flush calls after upsert operations, eliminating rate limit errors and reducing load on etcd and MinIO/S3 storage by allowing Milvus to manage segment persistence automatically via its WAL and auto-flush policies. [#19680](https://github.com/open-webui/open-webui/pull/19680) +- ✨ Various improvements were implemented across the frontend and backend to enhance performance, stability, and security. +- 🌍 Translations for German, French, Portuguese (Brazil), Catalan, Simplified Chinese, and Traditional Chinese were enhanced and expanded. + +### Fixed + +- 🔄 Tool call response token duplication was fixed by removing redundant message history additions in non-native function calling mode, resolving an issue where tool results were included twice in the context and causing 2x token consumption. [#19656](https://github.com/open-webui/open-webui/issues/19656), [Commit](https://github.com/open-webui/open-webui/commit/52ccab8) +- 🛡️ Web search domain filtering was corrected to properly block results when any resolved hostname or IP address matches a blocked domain, preventing blocked sites from appearing in search results due to permissive hostname resolution logic that previously allowed results through if any single resolved address passed the filter. [#19670](https://github.com/open-webui/open-webui/pull/19670), [#19669](https://github.com/open-webui/open-webui/issues/19669) +- 🧠 Custom models based on Ollama or OpenAI now properly inherit the connection type from their base model, ensuring they appear correctly in the "Local" or "External" model selection tabs instead of only appearing under "All". [#19183](https://github.com/open-webui/open-webui/issues/19183), [Commit](https://github.com/open-webui/open-webui/commit/39f7575) +- 🐍 SentenceTransformers embedding initialization was fixed by updating the transformers dependency to version 4.57.3, resolving a regression in v0.6.40 where document ingestion failed with "'NoneType' object has no attribute 'encode'" errors due to a bug in transformers 4.57.2. [#19512](https://github.com/open-webui/open-webui/issues/19512), [#19513](https://github.com/open-webui/open-webui/pull/19513) +- 📈 Active user count accuracy was significantly improved by replacing the socket-based USER_POOL tracking with a database-backed heartbeat mechanism, resolving long-standing issues where Redis deployments displayed inflated user counts due to stale sessions never being cleaned up on disconnect. [#16074](https://github.com/open-webui/open-webui/discussions/16074), [Commit](https://github.com/open-webui/open-webui/commit/70948f8803e417459d5203839f8077fdbfbbb213) +- 👥 Default group assignment now applies consistently across all user registration methods including OAuth/SSO, LDAP, and admin-created users, fixing an issue where the "DEFAULT_GROUP_ID" setting was only being applied to users who signed up via the email/password signup form. [#19685](https://github.com/open-webui/open-webui/pull/19685) +- 🔦 Model list filtering in workspaces was corrected to properly include models shared with user groups, ensuring members can view models they have write access to through group permissions. [#19461](https://github.com/open-webui/open-webui/issues/19461), [Commit](https://github.com/open-webui/open-webui/commit/69722ba973768a5f689f2e2351bf583a8db9bba8) +- 🖼️ User profile image display in preview contexts was fixed by resolving a Pydantic validation error that prevented proper rendering. [Commit](https://github.com/open-webui/open-webui/commit/c7eb7136893b0ddfdc5d55ffc7a05bd84a00f5d6) +- 🔒 Redis TLS connection failures were resolved by updating the python-socketio dependency to version 5.15.0, restoring support for the "rediss://" URL schema. [#19480](https://github.com/open-webui/open-webui/issues/19480), [#19488](https://github.com/open-webui/open-webui/pull/19488) +- 📝 MCP tool server configuration was corrected to properly handle the "Function Name Filter List" as both string and list types, preventing AttributeError when the field is empty and ensuring backward compatibility. [#19486](https://github.com/open-webui/open-webui/issues/19486), [Commit](https://github.com/open-webui/open-webui/commit/c5b73d71843edc024325d4a6e625ec939a747279), [Commit](https://github.com/open-webui/open-webui/commit/477097c2e42985c14892301d0127314629d07df1) +- 📎 Web page attachment failures causing TypeError on metadata checks were resolved by correcting async threadpool parameter passing in vector database operations. [#19493](https://github.com/open-webui/open-webui/issues/19493), [Commit](https://github.com/open-webui/open-webui/commit/4370dee79e19d77062c03fba81780cb3b779fca3) +- 💾 Model allowlist persistence in multi-worker deployments was fixed by implementing Redis-based shared state for the internal models dictionary, ensuring configuration changes are consistently visible across all worker processes. [#19395](https://github.com/open-webui/open-webui/issues/19395), [Commit](https://github.com/open-webui/open-webui/commit/b5e5617d7f7ad3e4eec9f15f4cc7f07cb5afc2fa) +- ⏳ Chat history infinite loading was prevented by enhancing message data structure to properly track parent message relationships, resolving issues where missing parentId fields caused perpetual loading states. [#19225](https://github.com/open-webui/open-webui/issues/19225), [Commit](https://github.com/open-webui/open-webui/commit/ff4b1b9862d15adfa15eac17d2ce066c3d8ae38f) +- 🩹 Database migration robustness was improved by automatically detecting and correcting missing primary key constraints on the user table, ensuring successful schema upgrades for databases with non-standard configurations. [#19487](https://github.com/open-webui/open-webui/discussions/19487), [Commit](https://github.com/open-webui/open-webui/commit/453ea9b9a167c0b03d86c46e6efd086bf10056ce) +- 🏷️ OAuth group assignment now updates correctly on first login when users transition from admin to user role, ensuring group memberships reflect immediately when group management is enabled. [#19475](https://github.com/open-webui/open-webui/issues/19475), [#19476](https://github.com/open-webui/open-webui/pull/19476) +- 💡 Knowledge base file tooltips now properly display the parent collection name when referencing files with the hash symbol, preventing confusion between identically-named files in different collections. [#19491](https://github.com/open-webui/open-webui/issues/19491), [Commit](https://github.com/open-webui/open-webui/commit/3fe5a47b0ff84ac97f8e4ff56a19fa2ec065bf66) +- 🔐 Knowledge base file access inconsistencies were resolved where authorized non-admin users received "Not found" or permission errors for certain files due to race conditions during upload causing mismatched collection_name values, with file access validation now properly checking against knowledge base file associations. [#18689](https://github.com/open-webui/open-webui/issues/18689), [#19523](https://github.com/open-webui/open-webui/pull/19523), [Commit](https://github.com/open-webui/open-webui/commit/e301d1962e45900ababd3eabb7e9a2ad275a5761) +- 📦 Knowledge API batch file addition endpoint was corrected to properly handle async operations, resolving 500 Internal Server Error responses when adding multiple files simultaneously. [#19538](https://github.com/open-webui/open-webui/issues/19538), [Commit](https://github.com/open-webui/open-webui/commit/28659f60d94feb4f6a99bb1a5b54d7f45e5ea10f) +- 🤖 Embedding model auto-update functionality was fixed to properly respect the "RAG_EMBEDDING_MODEL_AUTO_UPDATE" setting by correctly passing the flag to the model path resolver, ensuring models update as expected when the auto-update option is enabled. [#19687](https://github.com/open-webui/open-webui/pull/19687) +- 📉 API response payload sizes were dramatically reduced by removing base64-encoded profile images from most endpoints, eliminating multi-megabyte responses caused by high-resolution avatars and enabling better browser caching. [#19519](https://github.com/open-webui/open-webui/issues/19519), [Commit](https://github.com/open-webui/open-webui/commit/384753c4c17f62a68d38af4bbcf55a21ee08e0f2) +- 📞 Redundant API calls on the admin user overview page were eliminated by consolidating reactive statements, reducing four duplicate requests to a single efficient call and significantly improving page load performance. [#19509](https://github.com/open-webui/open-webui/issues/19509), [Commit](https://github.com/open-webui/open-webui/commit/9f89cc5e9f7e1c6c9e2bc91177e08df7c79f66f9) +- 🧹 Duplicate API calls on the workspace models page were eliminated by removing redundant model list fetching, reducing two identical requests to a single call and improving page responsiveness. [#19517](https://github.com/open-webui/open-webui/issues/19517), [Commit](https://github.com/open-webui/open-webui/commit/d1bbf6be7a4d1d53fa8ad46ca4f62fc4b2e6a8cb) +- 🔘 The model valves button was corrected to prevent unintended form submission by adding explicit button type attribute, ensuring it no longer triggers message sending when the input area contains text. [#19534](https://github.com/open-webui/open-webui/pull/19534) +- 🗑️ Ollama model deletion was fixed by correcting the request payload format and ensuring the model selector properly displays the placeholder option. [Commit](https://github.com/open-webui/open-webui/commit/0f3156651c64bc5af188a65fc2908bdcecf30c74) +- 🎨 Image generation in temporary chats was fixed by correctly handling local chat sessions that are not persisted to the database. [Commit](https://github.com/open-webui/open-webui/commit/a7c7993bbf3a21cb7ba416525b89233cf2ad877f) +- 🕵️‍♂️ Audit logging was fixed by correctly awaiting the async user authentication call, resolving failures where coroutine objects were passed instead of user data. [#19658](https://github.com/open-webui/open-webui/pull/19658), [Commit](https://github.com/open-webui/open-webui/commit/dba86bc) +- 🌙 Dark mode select dropdown styling was corrected to use proper background colors, fixing an issue where dropdown borders and hover states appeared white instead of matching the dark theme. [#19693](https://github.com/open-webui/open-webui/pull/19693), [#19442](https://github.com/open-webui/open-webui/issues/19442) +- 🔍 Milvus vector database query filtering was fixed by correcting string quote handling in filter expressions and using the proper parameter name for queries, resolving false "duplicate content detected" errors that prevented uploading multiple files to knowledge bases. [#19602](https://github.com/open-webui/open-webui/pull/19602), [#18119](https://github.com/open-webui/open-webui/issues/18119), [#16345](https://github.com/open-webui/open-webui/issues/16345), [#17088](https://github.com/open-webui/open-webui/issues/17088), [#18485](https://github.com/open-webui/open-webui/issues/18485) +- 🆙 Milvus multitenancy vector database was updated to use query_iterator() for improved robustness and consistency with the standard Milvus implementation, fixing the same false duplicate detection errors and improving handling of large result sets in multi-tenant deployments. [#19695](https://github.com/open-webui/open-webui/pull/19695) + +### Changed + +- ⚠️ **IMPORTANT for Multi-Instance Deployments** — 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. +- 👮 Channel creation is now restricted to administrators only, with the channel add button hidden for regular users to maintain organizational control over communication channels. [Commit](https://github.com/open-webui/open-webui/commit/421aba7cd7cd708168b1f2565026c74525a67905) +- ➖ The active user count indicator was removed from the bottom-left user menu in the sidebar to streamline the interface. [Commit](https://github.com/open-webui/open-webui/commit/848f3fd4d86ca66656e0ff0335773945af8d7d8d) +- 🗂️ The user table was restructured with API keys migrated to a dedicated table supporting future multi-key functionality, OAuth data storage converted to a JSON structure enabling multiple identity providers per user account, and internal column types optimized from TEXT to JSON for the "info" and "settings" fields, with automatic migration preserving all existing data and associations. [#19573](https://github.com/open-webui/open-webui/pull/19573) +- 🔄 The knowledge base API was restructured to support the new file relationship model. + ## [0.6.40] - 2025-11-25 ### Fixed 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/backend/open_webui/config.py b/backend/open_webui/config.py index a3a9050f78..41e88df5d2 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2590,6 +2590,12 @@ DOCUMENT_INTELLIGENCE_KEY = PersistentConfig( os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""), ) +DOCUMENT_INTELLIGENCE_MODEL = PersistentConfig( + "DOCUMENT_INTELLIGENCE_MODEL", + "rag.document_intelligence_model", + os.getenv("DOCUMENT_INTELLIGENCE_MODEL", "prebuilt-layout"), +) + MISTRAL_OCR_API_BASE_URL = PersistentConfig( "MISTRAL_OCR_API_BASE_URL", "rag.MISTRAL_OCR_API_BASE_URL", diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 127f22e103..21a1aee043 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -273,6 +273,7 @@ from open_webui.config import ( DOCLING_PARAMS, DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY, + DOCUMENT_INTELLIGENCE_MODEL, MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY, RAG_TEXT_SPLITTER, @@ -871,6 +872,7 @@ app.state.config.DOCLING_API_KEY = DOCLING_API_KEY app.state.config.DOCLING_PARAMS = DOCLING_PARAMS app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY +app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY app.state.config.MINERU_API_MODE = MINERU_API_MODE @@ -982,9 +984,7 @@ app.state.YOUTUBE_LOADER_TRANSLATION = None try: app.state.ef = get_ef( - app.state.config.RAG_EMBEDDING_ENGINE, - app.state.config.RAG_EMBEDDING_MODEL, - RAG_EMBEDDING_MODEL_AUTO_UPDATE, + app.state.config.RAG_EMBEDDING_ENGINE, app.state.config.RAG_EMBEDDING_MODEL ) if ( app.state.config.ENABLE_RAG_HYBRID_SEARCH @@ -995,7 +995,6 @@ try: app.state.config.RAG_RERANKING_MODEL, app.state.config.RAG_EXTERNAL_RERANKER_URL, app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - RAG_RERANKING_MODEL_AUTO_UPDATE, ) else: app.state.rf = None @@ -2086,7 +2085,7 @@ except Exception as e: ) -async def register_client(self, request, client_id: str) -> bool: +async def register_client(request, client_id: str) -> bool: server_type, server_id = client_id.split(":", 1) connection = None diff --git a/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py b/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py new file mode 100644 index 0000000000..82249bb278 --- /dev/null +++ b/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py @@ -0,0 +1,169 @@ +"""Add knowledge_file table + +Revision ID: 3e0e00844bb0 +Revises: 90ef40d4714e +Create Date: 2025-12-02 06:54:19.401334 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect +import open_webui.internal.db + +import time +import json +import uuid + +# revision identifiers, used by Alembic. +revision: str = "3e0e00844bb0" +down_revision: Union[str, None] = "90ef40d4714e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "knowledge_file", + sa.Column("id", sa.Text(), primary_key=True), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column( + "knowledge_id", + sa.Text(), + sa.ForeignKey("knowledge.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_knowledge_file_knowledge_id", "knowledge_id"), + sa.Index("ix_knowledge_file_file_id", "file_id"), + sa.Index("ix_knowledge_file_user_id", "user_id"), + # unique constraints + sa.UniqueConstraint( + "knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file" + ), # prevent duplicate entries + ) + + connection = op.get_bind() + + # 2. Read existing group with user_ids JSON column + knowledge_table = sa.Table( + "knowledge", + sa.MetaData(), + sa.Column("id", sa.Text()), + sa.Column("user_id", sa.Text()), + sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG + ) + + results = connection.execute( + sa.select( + knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data + ) + ).fetchall() + + # 3. Insert members into group_member table + kf_table = sa.Table( + "knowledge_file", + sa.MetaData(), + sa.Column("id", sa.Text()), + sa.Column("user_id", sa.Text()), + sa.Column("knowledge_id", sa.Text()), + sa.Column("file_id", sa.Text()), + sa.Column("created_at", sa.BigInteger()), + sa.Column("updated_at", sa.BigInteger()), + ) + + file_table = sa.Table( + "file", + sa.MetaData(), + sa.Column("id", sa.Text()), + ) + + now = int(time.time()) + for knowledge_id, user_id, data in results: + if not data: + continue + + if isinstance(data, str): + try: + data = json.loads(data) + except Exception: + continue # skip invalid JSON + + if not isinstance(data, dict): + continue + + file_ids = data.get("file_ids", []) + + for file_id in file_ids: + file_exists = connection.execute( + sa.select(file_table.c.id).where(file_table.c.id == file_id) + ).fetchone() + + if not file_exists: + continue # skip non-existing files + + row = { + "id": str(uuid.uuid4()), + "user_id": user_id, + "knowledge_id": knowledge_id, + "file_id": file_id, + "created_at": now, + "updated_at": now, + } + connection.execute(kf_table.insert().values(**row)) + + with op.batch_alter_table("knowledge") as batch: + batch.drop_column("data") + + +def downgrade() -> None: + # 1. Add back the old data column + op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True)) + + connection = op.get_bind() + + # 2. Read knowledge_file entries and reconstruct data JSON + knowledge_table = sa.Table( + "knowledge", + sa.MetaData(), + sa.Column("id", sa.Text()), + sa.Column("data", sa.JSON()), + ) + + kf_table = sa.Table( + "knowledge_file", + sa.MetaData(), + sa.Column("id", sa.Text()), + sa.Column("knowledge_id", sa.Text()), + sa.Column("file_id", sa.Text()), + ) + + results = connection.execute(sa.select(knowledge_table.c.id)).fetchall() + + for (knowledge_id,) in results: + file_ids = connection.execute( + sa.select(kf_table.c.file_id).where(kf_table.c.knowledge_id == knowledge_id) + ).fetchall() + + file_ids_list = [fid for (fid,) in file_ids] + + data_json = {"file_ids": file_ids_list} + + connection.execute( + knowledge_table.update() + .where(knowledge_table.c.id == knowledge_id) + .values(data=data_json) + ) + + # 3. Drop the knowledge_file table + op.drop_table("knowledge_file") diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index cfef77e237..2c72401181 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -7,13 +7,21 @@ 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 +from open_webui.models.files import File, FileModel, FileMetadataResponse from open_webui.models.groups import Groups from open_webui.models.users import Users, UserResponse from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, JSON +from sqlalchemy import ( + BigInteger, + Column, + ForeignKey, + String, + Text, + JSON, + UniqueConstraint, +) from open_webui.utils.access_control import has_access @@ -34,9 +42,7 @@ class Knowledge(Base): name = Column(Text) description = Column(Text) - data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) - access_control = Column(JSON, nullable=True) # Controls data access levels. # Defines access control rules for this entry. # - `None`: Public access, available to all users with the "user" role. @@ -67,7 +73,6 @@ class KnowledgeModel(BaseModel): name: str description: str - data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None @@ -76,11 +81,42 @@ class KnowledgeModel(BaseModel): updated_at: int # timestamp in epoch +class KnowledgeFile(Base): + __tablename__ = "knowledge_file" + + id = Column(Text, unique=True, primary_key=True) + + knowledge_id = Column( + Text, ForeignKey("knowledge.id", ondelete="CASCADE"), nullable=False + ) + file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Text, nullable=False) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file" + ), + ) + + +class KnowledgeFileModel(BaseModel): + id: str + knowledge_id: str + file_id: str + user_id: str + + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + #################### # Forms #################### - - class KnowledgeUserModel(KnowledgeModel): user: Optional[UserResponse] = None @@ -96,7 +132,6 @@ class KnowledgeUserResponse(KnowledgeUserModel): class KnowledgeForm(BaseModel): name: str description: str - data: Optional[dict] = None access_control: Optional[dict] = None @@ -182,6 +217,100 @@ class KnowledgeTable: except Exception: return None + def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: + try: + with get_db() as db: + knowledges = ( + db.query(Knowledge) + .join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id) + .filter(KnowledgeFile.file_id == file_id) + .all() + ) + return [ + KnowledgeModel.model_validate(knowledge) for knowledge in knowledges + ] + except Exception: + return [] + + def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: + try: + with get_db() as db: + files = ( + db.query(File) + .join(KnowledgeFile, File.id == KnowledgeFile.file_id) + .filter(KnowledgeFile.knowledge_id == knowledge_id) + .all() + ) + return [FileModel.model_validate(file) for file in files] + except Exception: + return [] + + def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]: + try: + with get_db() as db: + files = self.get_files_by_id(knowledge_id) + return [FileMetadataResponse(**file.model_dump()) for file in files] + except Exception: + return [] + + def add_file_to_knowledge_by_id( + self, knowledge_id: str, file_id: str, user_id: str + ) -> Optional[KnowledgeFileModel]: + with get_db() as db: + knowledge_file = KnowledgeFileModel( + **{ + "id": str(uuid.uuid4()), + "knowledge_id": knowledge_id, + "file_id": file_id, + "user_id": user_id, + "created_at": int(time.time()), + "updated_at": int(time.time()), + } + ) + + try: + result = KnowledgeFile(**knowledge_file.model_dump()) + db.add(result) + db.commit() + db.refresh(result) + if result: + return KnowledgeFileModel.model_validate(result) + else: + return None + except Exception: + return None + + def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool: + try: + with get_db() as db: + db.query(KnowledgeFile).filter_by( + knowledge_id=knowledge_id, file_id=file_id + ).delete() + db.commit() + return True + except Exception: + return False + + def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: + try: + with get_db() as db: + # Delete all knowledge_file entries for this knowledge_id + db.query(KnowledgeFile).filter_by(knowledge_id=id).delete() + db.commit() + + # Update the knowledge entry's updated_at timestamp + db.query(Knowledge).filter_by(id=id).update( + { + "updated_at": int(time.time()), + } + ) + db.commit() + + return self.get_knowledge_by_id(id=id) + except Exception as e: + log.exception(e) + return None + def update_knowledge_by_id( self, id: str, form_data: KnowledgeForm, overwrite: bool = False ) -> Optional[KnowledgeModel]: 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.users import Users, User, UserNameResponse from open_webui.models.channels import Channels, ChannelMember -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import or_, func, select, and_, text from sqlalchemy.sql import exists @@ -108,11 +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/users.py b/backend/open_webui/models/users.py index ba56b74ece..86f9d011e8 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -24,8 +24,10 @@ from sqlalchemy import ( Date, exists, select, + cast, ) from sqlalchemy import or_, case +from sqlalchemy.dialects.postgresql import JSONB import datetime @@ -188,7 +190,7 @@ class UserIdNameResponse(BaseModel): name: str -class UserIdNameStatusResponse(BaseModel): +class UserIdNameStatusResponse(UserStatus): id: str name: str is_active: Optional[bool] = None @@ -296,14 +298,21 @@ class UsersTable: def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: try: - with get_db() as db: - user = ( - db.query(User) - .filter(User.oauth.contains({provider: {"sub": sub}})) - .first() - ) + with get_db() as db: # type: Session + dialect_name = db.bind.dialect.name + + query = db.query(User) + if dialect_name == "sqlite": + query = query.filter(User.oauth.contains({provider: {"sub": sub}})) + elif dialect_name == "postgresql": + query = query.filter( + User.oauth[provider].cast(JSONB)["sub"].astext == sub + ) + + user = query.first() return UserModel.model_validate(user) if user else None - except Exception: + except Exception as e: + # You may want to log the exception here return None def get_users( @@ -443,6 +452,16 @@ class UsersTable: "total": total, } + def get_users_by_group_id(self, group_id: str) -> list[UserModel]: + with get_db() as db: + users = ( + db.query(User) + .join(GroupMember, User.id == GroupMember.user_id) + .filter(GroupMember.group_id == group_id) + .all() + ) + return [UserModel.model_validate(user) for user in users] + def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]: with get_db() as db: users = db.query(User).filter(User.id.in_(user_ids)).all() diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index fcc507e088..1346cd065c 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -322,12 +322,14 @@ class Loader: file_path=file_path, api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"), + api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"), ) else: loader = AzureAIDocumentIntelligenceLoader( file_path=file_path, api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), azure_credential=DefaultAzureCredential(), + api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"), ) elif self.engine == "mineru" and file_ext in [ "pdf" diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index b041a00471..711b1a8b79 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1088,23 +1088,19 @@ async def get_sources_from_items( or knowledge_base.user_id == user.id or has_access(user.id, "read", knowledge_base.access_control) ): - - file_ids = knowledge_base.data.get("file_ids", []) + files = Knowledges.get_files_by_id(knowledge_base.id) documents = [] metadatas = [] - for file_id in file_ids: - file_object = Files.get_file_by_id(file_id) - - if file_object: - documents.append(file_object.data.get("content", "")) - metadatas.append( - { - "file_id": file_id, - "name": file_object.filename, - "source": file_object.filename, - } - ) + for file in files: + documents.append(file.data.get("content", "")) + metadatas.append( + { + "file_id": file.id, + "name": file.filename, + "source": file.filename, + } + ) query_result = { "documents": [documents], diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 98f8e335f2..3dae4672f3 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -200,23 +200,24 @@ class MilvusClient(VectorDBBase): def query(self, collection_name: str, filter: dict, limit: int = -1): connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB) - # Construct the filter string for querying collection_name = collection_name.replace("-", "_") if not self.has_collection(collection_name): log.warning( f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}" ) return None - filter_string = " && ".join( - [ - f'metadata["{key}"] == {json.dumps(value)}' - for key, value in filter.items() - ] - ) + + filter_expressions = [] + for key, value in filter.items(): + if isinstance(value, str): + filter_expressions.append(f'metadata["{key}"] == "{value}"') + else: + filter_expressions.append(f'metadata["{key}"] == {value}') + + filter_string = " && ".join(filter_expressions) collection = Collection(f"{self.collection_prefix}_{collection_name}") collection.load() - all_results = [] try: log.info( @@ -224,24 +225,25 @@ class MilvusClient(VectorDBBase): ) iterator = collection.query_iterator( - filter=filter_string, + expr=filter_string, output_fields=[ "id", "data", "metadata", ], - limit=limit, # Pass the limit directly; -1 means no limit. + limit=limit if limit > 0 else -1, ) + all_results = [] while True: - result = iterator.next() - if not result: + batch = iterator.next() + if not batch: iterator.close() break - all_results += result + all_results.extend(batch) - log.info(f"Total results from query: {len(all_results)}") - return self._result_to_get_result([all_results]) + log.debug(f"Total results from query: {len(all_results)}") + return self._result_to_get_result([all_results] if all_results else [[]]) except Exception as e: log.exception( diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index 5c80d155d3..cd2ceed795 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -157,7 +157,6 @@ class MilvusClient(VectorDBBase): for item in items ] collection.insert(entities) - collection.flush() def search( self, collection_name: str, vectors: List[List[float]], limit: int @@ -263,15 +262,23 @@ class MilvusClient(VectorDBBase): else: expr.append(f"metadata['{key}'] == {value}") - results = collection.query( + iterator = collection.query_iterator( expr=" and ".join(expr), output_fields=["id", "text", "metadata"], - limit=limit, + limit=limit if limit else -1, ) - ids = [res["id"] for res in results] - documents = [res["text"] for res in results] - metadatas = [res["metadata"] for res in results] + all_results = [] + while True: + batch = iterator.next() + if not batch: + iterator.close() + break + all_results.extend(batch) + + ids = [res["id"] for res in all_results] + documents = [res["text"] for res in all_results] + metadatas = [res["metadata"] for res in all_results] return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index 6d2fd1bc5a..1b8df9f8ee 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -33,7 +33,7 @@ def get_filtered_results(results, filter_list): except Exception: pass - if any(is_string_allowed(hostname, filter_list) for hostname in hostnames): + if is_string_allowed(hostnames, filter_list): filtered_results.append(result) continue diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 42302043ed..3d83dcaea6 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -6,6 +6,7 @@ import logging from aiohttp import ClientSession import urllib + from open_webui.models.auths import ( AddUserForm, ApiKey, @@ -64,6 +65,11 @@ from open_webui.utils.auth import ( ) from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions, has_permission +from open_webui.utils.groups import apply_default_group_assignment + +from open_webui.utils.redis import get_redis_client +from open_webui.utils.rate_limit import RateLimiter + from typing import Optional, List @@ -77,6 +83,10 @@ 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 +) + ############################ # GetSessionUser ############################ @@ -408,6 +418,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): 500, detail=ERROR_MESSAGES.CREATE_USER_ERROR ) + apply_default_group_assignment( + request.app.state.config.DEFAULT_GROUP_ID, + user.id, + ) + except HTTPException: raise except Exception as err: @@ -456,7 +471,6 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ): if ENABLE_LDAP_GROUP_CREATION: Groups.create_groups_by_group_names(user.id, user_groups) - try: Groups.sync_groups_by_group_names(user.id, user_groups) log.info( @@ -551,6 +565,12 @@ async def signin(request: Request, response: Response, form_data: SigninForm): admin_email.lower(), lambda pw: verify_password(admin_password, pw) ) else: + if signin_rate_limiter.is_limited(form_data.email.lower()): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED, + ) + password_bytes = form_data.password.encode("utf-8") if len(password_bytes) > 72: # TODO: Implement other hashing algorithms that support longer passwords @@ -707,9 +727,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm): # Disable signup after the first user is created request.app.state.config.ENABLE_SIGNUP = False - default_group_id = getattr(request.app.state.config, "DEFAULT_GROUP_ID", "") - if default_group_id and default_group_id: - Groups.add_users_to_group(default_group_id, [user.id]) + apply_default_group_assignment( + request.app.state.config.DEFAULT_GROUP_ID, + user.id, + ) return { "token": token, @@ -814,7 +835,9 @@ async def signout(request: Request, response: Response): @router.post("/add", response_model=SigninResponse) -async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): +async def add_user( + request: Request, form_data: AddUserForm, user=Depends(get_admin_user) +): if not validate_email_format(form_data.email.lower()): raise HTTPException( status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT @@ -839,6 +862,11 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): ) if user: + apply_default_group_assignment( + request.app.state.config.DEFAULT_GROUP_ID, + user.id, + ) + token = create_token(data={"id": user.id}) return { "token": token, diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 0dff67da3e..58cdcdc661 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -5,7 +5,7 @@ from typing import Optional 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,6 +39,8 @@ from open_webui.models.messages import ( ) +from open_webui.utils.files import get_image_base64_from_file_id + from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS @@ -666,7 +668,16 @@ 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]) @@ -906,6 +917,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 = { @@ -1108,7 +1123,7 @@ async def post_new_message( ############################ -@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse]) +@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageResponse]) async def get_channel_message( id: str, message_id: str, user=Depends(get_verified_user) ): @@ -1142,7 +1157,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 +1167,48 @@ async def get_channel_message( ) +############################ +# GetChannelMessageData +############################ + + +@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict]) +async def get_channel_message_data( + id: str, message_id: str, user=Depends(get_verified_user) +): + 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 ############################ diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 54084941fe..e10722c0c8 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -22,6 +22,7 @@ from fastapi import ( ) 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 @@ -34,12 +35,19 @@ from open_webui.models.files import ( Files, ) 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 + from open_webui.storage.provider import Storage + + from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access + from pydantic import BaseModel log = logging.getLogger(__name__) @@ -53,31 +61,37 @@ router = APIRouter() ############################ +# TODO: Optimize this function to use the knowledge_file table for faster lookups. def has_access_to_file( file_id: Optional[str], access_type: str, user=Depends(get_verified_user) ) -> bool: file = Files.get_file_by_id(file_id) log.debug(f"Checking if user has {access_type} access to file") - if not file: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - has_access = False - knowledge_base_id = file.meta.get("collection_name") if file.meta else None + 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 + ): + return True + + knowledge_base_id = file.meta.get("collection_name") if file.meta else None if knowledge_base_id: knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( user.id, access_type ) for knowledge_base in knowledge_bases: if knowledge_base.id == knowledge_base_id: - has_access = True - break + return True - return has_access + return False ############################ @@ -165,7 +179,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: diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 05d52c5c7b..7d2efcf899 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional import logging -from open_webui.models.users import Users +from open_webui.models.users import Users, UserInfoResponse from open_webui.models.groups import ( Groups, GroupForm, @@ -118,6 +118,24 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user)): ) +############################ +# GetUsersInGroupById +############################ + + +@router.post("/id/{id}/users", response_model=list[UserInfoResponse]) +async def get_users_in_group(id: str, user=Depends(get_admin_user)): + try: + users = Users.get_users_by_group_id(id) + return users + except Exception as e: + log.exception(f"Error adding users to group {id}: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + ############################ # UpdateGroupById ############################ diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 46baa0eaea..3bfc961ac3 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -42,97 +42,38 @@ router = APIRouter() @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") - # Get files for each knowledge base - knowledge_with_files = [] - for knowledge_base in knowledge_bases: - files = [] - if knowledge_base.data: - files = Files.get_file_metadatas_by_ids( - knowledge_base.data.get("file_ids", []) - ) - - # Check if all files exist - if len(files) != len(knowledge_base.data.get("file_ids", [])): - missing_files = list( - set(knowledge_base.data.get("file_ids", [])) - - set([file.id for file in files]) - ) - if missing_files: - data = knowledge_base.data or {} - file_ids = data.get("file_ids", []) - - for missing_file in missing_files: - file_ids.remove(missing_file) - - data["file_ids"] = file_ids - Knowledges.update_knowledge_data_by_id( - id=knowledge_base.id, data=data - ) - - files = Files.get_file_metadatas_by_ids(file_ids) - - knowledge_with_files.append( - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=files, - ) + return [ + KnowledgeUserResponse( + **knowledge_base.model_dump(), + files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), ) - - return knowledge_with_files + for knowledge_base in knowledge_bases + ] @router.get("/list", response_model=list[KnowledgeUserResponse]) async def get_knowledge_list(user=Depends(get_verified_user)): + # Return knowledge bases with write access knowledge_bases = [] - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: knowledge_bases = Knowledges.get_knowledge_bases() else: knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write") - # Get files for each knowledge base - knowledge_with_files = [] - for knowledge_base in knowledge_bases: - files = [] - if knowledge_base.data: - files = Files.get_file_metadatas_by_ids( - knowledge_base.data.get("file_ids", []) - ) - - # Check if all files exist - if len(files) != len(knowledge_base.data.get("file_ids", [])): - missing_files = list( - set(knowledge_base.data.get("file_ids", [])) - - set([file.id for file in files]) - ) - if missing_files: - data = knowledge_base.data or {} - file_ids = data.get("file_ids", []) - - for missing_file in missing_files: - file_ids.remove(missing_file) - - data["file_ids"] = file_ids - Knowledges.update_knowledge_data_by_id( - id=knowledge_base.id, data=data - ) - - files = Files.get_file_metadatas_by_ids(file_ids) - - knowledge_with_files.append( - KnowledgeUserResponse( - **knowledge_base.model_dump(), - files=files, - ) + return [ + KnowledgeUserResponse( + **knowledge_base.model_dump(), + files=Knowledges.get_file_metadatas_by_id(knowledge_base.id), ) - return knowledge_with_files + for knowledge_base in knowledge_bases + ] ############################ @@ -192,26 +133,9 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") - deleted_knowledge_bases = [] - for knowledge_base in knowledge_bases: - # -- Robust error handling for missing or invalid data - if not knowledge_base.data or not isinstance(knowledge_base.data, dict): - log.warning( - f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting." - ) - try: - Knowledges.delete_knowledge_by_id(id=knowledge_base.id) - deleted_knowledge_bases.append(knowledge_base.id) - except Exception as e: - log.error( - f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}" - ) - continue - try: - file_ids = knowledge_base.data.get("file_ids", []) - files = Files.get_files_by_ids(file_ids) + files = Knowledges.get_files_by_id(knowledge_base.id) try: if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): VECTOR_DB_CLIENT.delete_collection( @@ -251,9 +175,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us for failed in failed_files: log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}") - log.info( - f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}" - ) + log.info(f"Reindexing completed.") return True @@ -271,19 +193,15 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): knowledge = Knowledges.get_knowledge_by_id(id=id) if knowledge: - if ( user.role == "admin" or knowledge.user_id == user.id or has_access(user.id, "read", knowledge.access_control) ): - file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] - files = Files.get_file_metadatas_by_ids(file_ids) - return KnowledgeFilesResponse( **knowledge.model_dump(), - files=files, + files=Knowledges.get_file_metadatas_by_id(knowledge.id), ) else: raise HTTPException( @@ -335,12 +253,9 @@ async def update_knowledge_by_id( knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: - file_ids = knowledge.data.get("file_ids", []) if knowledge.data else [] - files = Files.get_file_metadatas_by_ids(file_ids) - return KnowledgeFilesResponse( **knowledge.model_dump(), - files=files, + files=Knowledges.get_file_metadatas_by_id(knowledge.id), ) else: raise HTTPException( @@ -366,7 +281,6 @@ def add_file_to_knowledge_by_id( 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, @@ -395,6 +309,11 @@ 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( @@ -410,32 +329,10 @@ def add_file_to_knowledge_by_id( ) if knowledge: - data = knowledge.data or {} - file_ids = data.get("file_ids", []) - - if form_data.file_id not in file_ids: - file_ids.append(form_data.file_id) - data["file_ids"] = file_ids - - knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) - - if knowledge: - files = Files.get_file_metadatas_by_ids(file_ids) - - return KnowledgeFilesResponse( - **knowledge.model_dump(), - files=files, - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("knowledge"), - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("file_id"), - ) + return KnowledgeFilesResponse( + **knowledge.model_dump(), + files=Knowledges.get_file_metadatas_by_id(knowledge.id), + ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -494,14 +391,9 @@ def update_file_from_knowledge_by_id( ) if knowledge: - data = knowledge.data or {} - file_ids = data.get("file_ids", []) - - files = Files.get_file_metadatas_by_ids(file_ids) - return KnowledgeFilesResponse( **knowledge.model_dump(), - files=files, + files=Knowledges.get_file_metadatas_by_id(knowledge.id), ) else: raise HTTPException( @@ -546,6 +438,10 @@ def remove_file_from_knowledge_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) + Knowledges.remove_file_from_knowledge_by_id( + knowledge_id=id, file_id=form_data.file_id + ) + # Remove content from the vector database try: VECTOR_DB_CLIENT.delete( @@ -575,31 +471,10 @@ def remove_file_from_knowledge_by_id( Files.delete_file_by_id(form_data.file_id) if knowledge: - data = knowledge.data or {} - file_ids = data.get("file_ids", []) - - if form_data.file_id in file_ids: - file_ids.remove(form_data.file_id) - data["file_ids"] = file_ids - - knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) - if knowledge: - files = Files.get_file_metadatas_by_ids(file_ids) - - return KnowledgeFilesResponse( - **knowledge.model_dump(), - files=files, - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("knowledge"), - ) - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("file_id"), - ) + return KnowledgeFilesResponse( + **knowledge.model_dump(), + files=Knowledges.get_file_metadatas_by_id(knowledge.id), + ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -700,8 +575,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): log.debug(e) pass - knowledge = Knowledges.update_knowledge_data_by_id(id=id, data={"file_ids": []}) - + knowledge = Knowledges.reset_knowledge_by_id(id=id) return knowledge @@ -762,25 +636,19 @@ async def add_files_to_knowledge_batch( ) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - # Add successful files to knowledge base - data = knowledge.data or {} - existing_file_ids = data.get("file_ids", []) - # Only add files that were successfully processed successful_file_ids = [r.file_id for r in result.results if r.status == "completed"] for file_id in successful_file_ids: - if file_id not in existing_file_ids: - existing_file_ids.append(file_id) - - data["file_ids"] = existing_file_ids - knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) + Knowledges.add_file_to_knowledge_by_id( + knowledge_id=id, file_id=file_id, user_id=user.id + ) # If there were any errors, include them in the response if result.errors: error_details = [f"{err.file_id}: {err.error}" for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Files.get_file_metadatas_by_ids(existing_file_ids), + files=Knowledges.get_file_metadatas_by_id(knowledge.id), warnings={ "message": "Some files failed to process", "errors": error_details, @@ -789,5 +657,5 @@ async def add_files_to_knowledge_batch( return KnowledgeFilesResponse( **knowledge.model_dump(), - files=Files.get_file_metadatas_by_ids(existing_file_ids), + files=Knowledges.get_file_metadatas_by_id(knowledge.id), ) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 72090e3ba0..b7ed993895 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -123,7 +123,7 @@ log.setLevel(SRC_LOG_LEVELS["RAG"]) def get_ef( engine: str, embedding_model: str, - auto_update: bool = False, + auto_update: bool = RAG_EMBEDDING_MODEL_AUTO_UPDATE, ): ef = None if embedding_model and engine == "": @@ -148,7 +148,7 @@ def get_rf( reranking_model: Optional[str] = None, external_reranker_url: str = "", external_reranker_api_key: str = "", - auto_update: bool = False, + auto_update: bool = RAG_RERANKING_MODEL_AUTO_UPDATE, ): rf = None if reranking_model: @@ -468,6 +468,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)): "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "DOCUMENT_INTELLIGENCE_MODEL": request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings @@ -647,6 +648,7 @@ class ConfigForm(BaseModel): DOCLING_PARAMS: Optional[dict] = None DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None + DOCUMENT_INTELLIGENCE_MODEL: Optional[str] = None MISTRAL_OCR_API_BASE_URL: Optional[str] = None MISTRAL_OCR_API_KEY: Optional[str] = None @@ -842,6 +844,11 @@ async def update_rag_config( if form_data.DOCUMENT_INTELLIGENCE_KEY is not None else request.app.state.config.DOCUMENT_INTELLIGENCE_KEY ) + request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL = ( + form_data.DOCUMENT_INTELLIGENCE_MODEL + if form_data.DOCUMENT_INTELLIGENCE_MODEL is not None + else request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL + ) request.app.state.config.MISTRAL_OCR_API_BASE_URL = ( form_data.MISTRAL_OCR_API_BASE_URL @@ -927,7 +934,6 @@ 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, - True, ) request.app.state.RERANKING_FUNCTION = get_reranking_function( @@ -1132,6 +1138,7 @@ async def update_rag_config( "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + "DOCUMENT_INTELLIGENCE_MODEL": request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings @@ -1544,6 +1551,7 @@ def process_file( PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, MISTRAL_OCR_API_BASE_URL=request.app.state.config.MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY, MINERU_API_MODE=request.app.state.config.MINERU_API_MODE, diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 3f05256c70..23fe517150 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -235,7 +235,7 @@ async def invalidate_token(request, token): jti = decoded.get("jti") exp = decoded.get("exp") - if jti: + if jti and exp: ttl = exp - int( datetime.now(UTC).timestamp() ) # Calculate time-to-live for the token diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index 4f9564b7d4..cd94a41144 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -10,7 +10,11 @@ from fastapi import ( Request, UploadFile, ) +from typing import Optional +from pathlib import Path +from open_webui.storage.provider import Storage +from open_webui.models.files import Files from open_webui.routers.files import upload_file_handler import mimetypes @@ -113,3 +117,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/groups.py b/backend/open_webui/utils/groups.py new file mode 100644 index 0000000000..0f15f27e2c --- /dev/null +++ b/backend/open_webui/utils/groups.py @@ -0,0 +1,24 @@ +import logging +from open_webui.models.groups import Groups + +log = logging.getLogger(__name__) + + +def apply_default_group_assignment( + default_group_id: str, + user_id: str, +) -> None: + """ + Apply default group assignment to a user if default_group_id is provided. + + Args: + default_group_id: ID of the default group to add the user to + user_id: ID of the user to add to the default group + """ + if default_group_id: + try: + Groups.add_users_to_group(default_group_id, [user_id]) + except Exception as e: + log.error( + f"Failed to add user {user_id} to default group {default_group_id}: {e}" + ) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index bc5b741146..b3ee13cec5 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -468,12 +468,6 @@ async def chat_completion_tools_handler( } ) - print( - f"Tool {tool_function_name} result: {tool_result}", - tool_result_files, - tool_result_embeds, - ) - if tool_result: tool = tools[tool_function_name] tool_id = tool.get("tool_id", "") @@ -501,12 +495,6 @@ async def chat_completion_tools_handler( } ) - # Citation is not enabled for this tool - body["messages"] = add_or_update_user_message( - f"\nTool `{tool_name}` Output: {tool_result}", - body["messages"], - ) - if ( tools[tool_function_name] .get("metadata", {}) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index c7ff2a3edd..5e3f3c4834 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -6,7 +6,7 @@ import uuid import logging from datetime import timedelta from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Optional, Sequence, Union import json import aiohttp @@ -43,25 +43,28 @@ def get_allow_block_lists(filter_list): return allow_list, block_list -def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> bool: +def is_string_allowed( + string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None +) -> bool: """ Checks if a string is allowed based on the provided filter list. - :param string: The string to check (e.g., domain or hostname). + :param string: The string or sequence of strings to check (e.g., domain or hostname). :param filter_list: List of allowed/blocked strings. Strings starting with "!" are blocked. - :return: True if the string is allowed, False otherwise. + :return: True if the string or sequence of strings is allowed, False otherwise. """ if not filter_list: return True allow_list, block_list = get_allow_block_lists(filter_list) + strings = [string] if isinstance(string, str) else list(string) # If allow list is non-empty, require domain to match one of them if allow_list: - if not any(string.endswith(allowed) for allowed in allow_list): + if not any(s.endswith(allowed) for s in strings for allowed in allow_list): return False # Block list always removes matches - if any(string.endswith(blocked) for blocked in block_list): + if any(s.endswith(blocked) for s in strings for blocked in block_list): return False return True diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 525ba22e76..fbd1089382 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -191,6 +191,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) ): # Custom model based on a base model owned_by = "openai" + connection_type = None + pipe = None for m in models: @@ -201,6 +203,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) owned_by = m.get("owned_by", "unknown") if "pipe" in m: pipe = m["pipe"] + + connection_type = m.get("connection_type", None) break model = { @@ -209,6 +213,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) "object": "model", "created": custom_model.created_at, "owned_by": owned_by, + "connection_type": connection_type, "preset": True, **({"pipe": pipe} if pipe is not None else {}), } diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 9cd329a861..61c98ca744 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -72,6 +72,7 @@ from open_webui.env import ( from open_webui.utils.misc import parse_duration from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.webhook import post_webhook +from open_webui.utils.groups import apply_default_group_assignment from mcp.shared.auth import ( OAuthClientMetadata as MCPOAuthClientMetadata, @@ -1167,7 +1168,6 @@ class OAuthManager: log.debug( f"Removing user from group {group_model.name} as it is no longer in their oauth groups" ) - Groups.remove_users_from_group(group_model.id, [user.id]) # In case a group is created, but perms are never assigned to the group by hitting "save" @@ -1478,6 +1478,12 @@ class OAuthManager: "user": user.model_dump_json(exclude_none=True), }, ) + + apply_default_group_assignment( + request.app.state.config.DEFAULT_GROUP_ID, + user.id, + ) + else: raise HTTPException( status.HTTP_403_FORBIDDEN, diff --git a/backend/open_webui/utils/rate_limit.py b/backend/open_webui/utils/rate_limit.py new file mode 100644 index 0000000000..b657a937ab --- /dev/null +++ b/backend/open_webui/utils/rate_limit.py @@ -0,0 +1,139 @@ +import time +from typing import Optional, Dict +from open_webui.env import REDIS_KEY_PREFIX + + +class RateLimiter: + """ + General-purpose rate limiter using Redis with a rolling window strategy. + Falls back to in-memory storage if Redis is not available. + """ + + # In-memory fallback storage + _memory_store: Dict[str, Dict[int, int]] = {} + + def __init__( + self, + redis_client, + limit: int, + window: int, + bucket_size: int = 60, + enabled: bool = True, + ): + """ + :param redis_client: Redis client instance or None + :param limit: Max allowed events in the window + :param window: Time window in seconds + :param bucket_size: Bucket resolution + :param enabled: Turn on/off rate limiting globally + """ + self.r = redis_client + self.limit = limit + self.window = window + self.bucket_size = bucket_size + self.num_buckets = window // bucket_size + self.enabled = enabled + + def _bucket_key(self, key: str, bucket_index: int) -> str: + return f"{REDIS_KEY_PREFIX}:ratelimit:{key.lower()}:{bucket_index}" + + def _current_bucket(self) -> int: + return int(time.time()) // self.bucket_size + + def _redis_available(self) -> bool: + return self.r is not None + + def is_limited(self, key: str) -> bool: + """ + Main rate-limit check. + Gracefully handles missing or failing Redis. + """ + if not self.enabled: + return False + + if self._redis_available(): + try: + return self._is_limited_redis(key) + except Exception: + return self._is_limited_memory(key) + else: + return self._is_limited_memory(key) + + def get_count(self, key: str) -> int: + if not self.enabled: + return 0 + + if self._redis_available(): + try: + return self._get_count_redis(key) + except Exception: + return self._get_count_memory(key) + else: + return self._get_count_memory(key) + + def remaining(self, key: str) -> int: + used = self.get_count(key) + return max(0, self.limit - used) + + def _is_limited_redis(self, key: str) -> bool: + now_bucket = self._current_bucket() + bucket_key = self._bucket_key(key, now_bucket) + + attempts = self.r.incr(bucket_key) + if attempts == 1: + self.r.expire(bucket_key, self.window + self.bucket_size) + + # Collect buckets + buckets = [ + self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1) + ] + + counts = self.r.mget(buckets) + total = sum(int(c) for c in counts if c) + + return total > self.limit + + def _get_count_redis(self, key: str) -> int: + now_bucket = self._current_bucket() + buckets = [ + self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1) + ] + counts = self.r.mget(buckets) + return sum(int(c) for c in counts if c) + + def _is_limited_memory(self, key: str) -> bool: + now_bucket = self._current_bucket() + + # Init storage + if key not in self._memory_store: + self._memory_store[key] = {} + + store = self._memory_store[key] + + # Increment bucket + store[now_bucket] = store.get(now_bucket, 0) + 1 + + # Drop expired buckets + min_bucket = now_bucket - self.num_buckets + expired = [b for b in store if b < min_bucket] + for b in expired: + del store[b] + + # Count totals + total = sum(store.values()) + return total > self.limit + + def _get_count_memory(self, key: str) -> int: + now_bucket = self._current_bucket() + if key not in self._memory_store: + return 0 + + store = self._memory_store[key] + min_bucket = now_bucket - self.num_buckets + + # Remove expired + expired = [b for b in store if b < min_bucket] + for b in expired: + del store[b] + + return sum(store.values()) diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index c60a6fa517..cc29ce6683 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -5,7 +5,13 @@ import logging import redis -from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT +from open_webui.env import ( + REDIS_CLUSTER, + REDIS_SENTINEL_HOSTS, + REDIS_SENTINEL_MAX_RETRY_COUNT, + REDIS_SENTINEL_PORT, + REDIS_URL, +) log = logging.getLogger(__name__) @@ -108,6 +114,21 @@ def parse_redis_service_url(redis_url): } +def get_redis_client(async_mode=False): + try: + return get_redis_connection( + redis_url=REDIS_URL, + redis_sentinels=get_sentinels_from_env( + REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT + ), + redis_cluster=REDIS_CLUSTER, + async_mode=async_mode, + ) + except Exception as e: + log.debug(f"Failed to get Redis client: {e}") + return None + + def get_redis_connection( redis_url, redis_sentinels, diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt index 8d63bd4b82..f22ad7f0cf 100644 --- a/backend/requirements-min.txt +++ b/backend/requirements-min.txt @@ -1,9 +1,9 @@ # Minimal requirements for backend to run # WIP: use this as a reference to build a minimal docker image -fastapi==0.118.0 +fastapi==0.123.0 uvicorn[standard]==0.37.0 -pydantic==2.11.9 +pydantic==2.12.5 python-multipart==0.0.20 itsdangerous==2.2.0 @@ -20,14 +20,14 @@ aiohttp==3.12.15 async-timeout aiocache aiofiles -starlette-compress==1.6.0 +starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 sqlalchemy==2.0.38 -alembic==1.14.0 -peewee==3.18.1 -peewee-migrate==1.12.2 +alembic==1.17.2 +peewee==3.18.3 +peewee-migrate==1.14.3 pycrdt==0.12.25 redis @@ -36,9 +36,9 @@ APScheduler==3.10.4 RestrictedPython==8.0 loguru==0.7.3 -asgiref==3.8.1 +asgiref==3.11.0 -mcp==1.21.2 +mcp==1.22.0 openai langchain==0.3.27 @@ -46,6 +46,6 @@ langchain-community==0.3.29 fake-useragent==2.2.0 chromadb==1.1.0 -black==25.9.0 +black==25.11.0 pydub chardet==5.2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 1ddd886a8c..a1a8034959 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ -fastapi==0.118.0 +fastapi==0.123.0 uvicorn[standard]==0.37.0 -pydantic==2.11.9 +pydantic==2.12.5 python-multipart==0.0.20 itsdangerous==2.2.0 @@ -17,14 +17,14 @@ aiohttp==3.12.15 async-timeout aiocache aiofiles -starlette-compress==1.6.0 +starlette-compress==1.6.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1 starsessions[redis]==2.2.1 sqlalchemy==2.0.38 -alembic==1.14.0 -peewee==3.18.1 -peewee-migrate==1.12.2 +alembic==1.17.2 +peewee==3.18.3 +peewee-migrate==1.14.3 pycrdt==0.12.25 redis @@ -33,11 +33,11 @@ APScheduler==3.10.4 RestrictedPython==8.0 loguru==0.7.3 -asgiref==3.8.1 +asgiref==3.11.0 # AI libraries tiktoken -mcp==1.21.2 +mcp==1.22.0 openai anthropic @@ -58,18 +58,18 @@ accelerate pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.1 -ftfy==6.2.3 +ftfy==6.3.1 chardet==5.2.0 pypdf==6.4.0 fpdf2==2.8.2 -pymdown-extensions==10.14.2 +pymdown-extensions==10.17.2 docx2txt==0.8 python-pptx==1.0.2 -unstructured==0.18.18 +unstructured==0.18.21 msoffcrypto-tool==5.4.2 nltk==3.9.1 -Markdown==3.9 -pypandoc==1.15 +Markdown==3.10 +pypandoc==1.16.2 pandas==2.2.3 openpyxl==3.1.5 pyxlsb==1.0.10 @@ -87,12 +87,12 @@ rank-bm25==0.2.2 onnxruntime==1.20.1 faster-whisper==1.1.1 -black==25.9.0 +black==25.11.0 youtube-transcript-api==1.2.2 pytube==15.0.0 pydub -ddgs==9.0.0 +ddgs==9.9.2 azure-ai-documentintelligence==1.0.2 azure-identity==1.25.0 @@ -104,7 +104,7 @@ google-api-python-client google-auth-httplib2 google-auth-oauthlib -googleapis-common-protos==1.70.0 +googleapis-common-protos==1.72.0 google-cloud-storage==2.19.0 ## Databases @@ -113,11 +113,11 @@ psycopg2-binary==2.9.10 pgvector==0.4.1 PyMySQL==1.1.1 -boto3==1.40.5 +boto3==1.41.5 pymilvus==2.6.4 qdrant-client==1.14.3 -playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml +playwright==1.56.0 # Caution: version must match docker-compose.playwright.yaml elasticsearch==9.1.0 pinecone==6.0.2 oracledb==3.2.0 @@ -130,13 +130,13 @@ colbert-ai==0.2.21 ## Tests docker~=7.1.0 pytest~=8.4.1 -pytest-docker~=3.1.1 +pytest-docker~=3.2.5 ## LDAP ldap3==2.9.1 ## Firecrawl -firecrawl-py==4.5.0 +firecrawl-py==4.10.0 ## Trace opentelemetry-api==1.38.0 diff --git a/package-lock.json b/package-lock.json index 899f3f5356..1572d43240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.6.40", + "version": "0.6.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.40", + "version": "0.6.41", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index 97bdda0871..ae4bc3f8ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.40", + "version": "0.6.41", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/pyproject.toml b/pyproject.toml index 709f4ec672..10dd3259e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,9 @@ authors = [ ] license = { file = "LICENSE" } dependencies = [ - "fastapi==0.118.0", + "fastapi==0.123.0", "uvicorn[standard]==0.37.0", - "pydantic==2.11.9", + "pydantic==2.12.5", "python-multipart==0.0.20", "itsdangerous==2.2.0", @@ -25,14 +25,14 @@ dependencies = [ "async-timeout", "aiocache", "aiofiles", - "starlette-compress==1.6.0", + "starlette-compress==1.6.1", "httpx[socks,http2,zstd,cli,brotli]==0.28.1", "starsessions[redis]==2.2.1", "sqlalchemy==2.0.38", - "alembic==1.14.0", - "peewee==3.18.1", - "peewee-migrate==1.12.2", + "alembic==1.17.2", + "peewee==3.18.3", + "peewee-migrate==1.14.3", "pycrdt==0.12.25", "redis", @@ -41,10 +41,10 @@ dependencies = [ "RestrictedPython==8.0", "loguru==0.7.3", - "asgiref==3.8.1", + "asgiref==3.11.0", "tiktoken", - "mcp==1.21.2", + "mcp==1.22.0", "openai", "anthropic", @@ -58,7 +58,7 @@ dependencies = [ "chromadb==1.0.20", "opensearch-py==2.8.0", "PyMySQL==1.1.1", - "boto3==1.40.5", + "boto3==1.41.5", "transformers==4.57.3", "sentence-transformers==5.1.2", @@ -66,18 +66,18 @@ dependencies = [ "pyarrow==20.0.0", "einops==0.8.1", - "ftfy==6.2.3", + "ftfy==6.3.1", "chardet==5.2.0", "pypdf==6.4.0", "fpdf2==2.8.2", - "pymdown-extensions==10.14.2", + "pymdown-extensions==10.17.2", "docx2txt==0.8", "python-pptx==1.0.2", - "unstructured==0.18.18", + "unstructured==0.18.21", "msoffcrypto-tool==5.4.2", "nltk==3.9.1", - "Markdown==3.9", - "pypandoc==1.15", + "Markdown==3.10", + "pypandoc==1.16.2", "pandas==2.2.3", "openpyxl==3.1.5", "pyxlsb==1.0.10", @@ -96,18 +96,18 @@ dependencies = [ "onnxruntime==1.20.1", "faster-whisper==1.1.1", - "black==25.9.0", + "black==25.11.0", "youtube-transcript-api==1.2.2", "pytube==15.0.0", "pydub", - "ddgs==9.0.0", + "ddgs==9.9.2", "google-api-python-client", "google-auth-httplib2", "google-auth-oauthlib", - "googleapis-common-protos==1.70.0", + "googleapis-common-protos==1.72.0", "google-cloud-storage==2.19.0", "azure-identity==1.25.0", @@ -142,8 +142,8 @@ all = [ "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", "pytest~=8.3.2", - "pytest-docker~=3.1.1", - "playwright==1.49.1", + "pytest-docker~=3.2.5", + "playwright==1.56.0", "elasticsearch==9.1.0", "qdrant-client==1.14.3", @@ -153,7 +153,7 @@ all = [ "oracledb==3.2.0", "colbert-ai==0.2.21", - "firecrawl-py==4.5.0", + "firecrawl-py==4.10.0", "azure-search-documents==11.6.0", ] 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/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index 5cb0f60a72..75065910d6 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -35,6 +35,7 @@ type ChunkConfigForm = { type DocumentIntelligenceConfigForm = { key: string; endpoint: string; + model: string; }; type ContentExtractConfigForm = { diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 5a75774fa0..557549098c 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -358,7 +358,7 @@
@@ -644,7 +644,7 @@
+
+
+ {:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mistral_ocr'}
- user + + user +
{user.name}
diff --git a/src/lib/components/admin/Users/UserList/AddUserModal.svelte b/src/lib/components/admin/Users/UserList/AddUserModal.svelte index d3b98ac6e7..3081f997a5 100644 --- a/src/lib/components/admin/Users/UserList/AddUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/AddUserModal.svelte @@ -180,7 +180,7 @@
{ console.log(_gender); diff --git a/src/lib/components/chat/Settings/Audio.svelte b/src/lib/components/chat/Settings/Audio.svelte index ea65f5ec0a..1649152fa5 100644 --- a/src/lib/components/chat/Settings/Audio.svelte +++ b/src/lib/components/chat/Settings/Audio.svelte @@ -353,7 +353,7 @@