diff --git a/CHANGELOG.md b/CHANGELOG.md index 955c1f066e..01bdde5f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ 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.8.1] - 2026-02-14 + +### Added + +- ๐Ÿš€ **Channel user active status.** Checking user active status in channels is now faster thanks to optimized database queries. [Commit](https://github.com/open-webui/open-webui/commit/ca6b18ab5cb94153a9dae233f975d36bf6b19b76) +- ๐Ÿ”— **Responses API endpoint with model routing.** The OpenAI API proxy now supports a /responses endpoint that routes requests to the correct backend based on the model field in the request, instead of always using the first configured endpoint. This enables support for backends like vLLM that provide /skills and /v1/responses endpoints. [Commit](https://github.com/open-webui/open-webui/commit/abc9b63093d65f4d74342db85b7d5df1809aa0f0), [Commit](https://github.com/open-webui/open-webui/commit/79ecbfc757f0642740d0e44fab98263d84295490) +- โšก **Model and prompt list optimization.** Improved performance when loading models and prompts by pre-fetching user group IDs once instead of making multiple database queries. [Commit](https://github.com/open-webui/open-webui/commit/20de5a87da0c12e4052b50887a42ddd7228c5ef5) +- ๐Ÿ—„๏ธ **Batch access control queries.** Improved performance when loading models, prompts, and knowledge bases by replacing multiple individual access checks with single batch queries, significantly reducing database load for large deployments. [Commit](https://github.com/open-webui/open-webui/commit/589c4e64c1b7bb7a7a5abc20382b92fb860e28c2) +- ๐Ÿ’จ **Faster user list loading.** User lists now load significantly faster by deferring profile image loading; images are fetched separately in parallel by the browser, improving caching and reducing database load. [Commit](https://github.com/open-webui/open-webui/commit/b7549d2f6ca2843661ec79a5a1e55da9e7553368) +- ๐Ÿ” **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#21373](https://github.com/open-webui/open-webui/pull/21373), [#21371](https://github.com/open-webui/open-webui/issues/21371) +- ๐Ÿ” **SCIM externalId support.** SCIM-enabled deployments can now store and manage externalId for user provisioning, enabling better integration with identity providers like Microsoft Entra ID and Okta. [#21099](https://github.com/open-webui/open-webui/pull/21099), [#21280](https://github.com/open-webui/open-webui/issues/21280), [Commit](https://github.com/open-webui/open-webui/commit/d1d1efe212b16e0052359991d67fd813125077e8) +- ๐ŸŒ **Translation updates.** Portuguese (Brazil) translations were updated. + +### Fixed + +- ๐Ÿ›ก๏ธ **Public sharing security fix.** Fixed a security issue where users with write access could see the Public sharing option regardless of their actual public sharing permission, and direct API calls could bypass frontend sharing restrictions. [#21358](https://github.com/open-webui/open-webui/pull/21358), [#21356](https://github.com/open-webui/open-webui/issues/21356) +- ๐Ÿ”’ **Direct model access control fix.** Model access control changes now persist correctly for direct Ollama and OpenAI models that don't have database entries, and error messages display properly instead of showing "[object Object]". [Commit](https://github.com/open-webui/open-webui/commit/f027a01ab2ff3b6175af3dd13a4478c265c0544a), [#21377](https://github.com/open-webui/open-webui/issues/21377) +- ๐Ÿ’ญ **Reasoning trace rendering performance.** Reasoning traces from models now render properly without being split into many fragments, preventing browser slowdowns during streaming responses. [#21348](https://github.com/open-webui/open-webui/issues/21348), [Commit](https://github.com/open-webui/open-webui/commit/3b61562c82448cf83710d8b6ed29b797991aa83a) +- ๐Ÿ–ฅ๏ธ **ARM device compatibility fix.** Fixed an issue where upgrading to 0.8.0 would fail to start on ARM devices (like Raspberry Pi 4) due to torch 2.10.0 causing SIGILL errors; now pinned to torch<=2.9.1. [#21385](https://github.com/open-webui/open-webui/pull/21385), [#21349](https://github.com/open-webui/open-webui/issues/21349) +- ๐Ÿ—„๏ธ **Skills PostgreSQL compatibility fix.** Fixed a PostgreSQL compatibility issue where creating or listing skills would fail with a TypeError, while SQLite worked correctly. [#21372](https://github.com/open-webui/open-webui/pull/21372), [Commit](https://github.com/open-webui/open-webui/commit/b4c3f54f9648c4232a0fd6557703ffa66fcf4caa), [#21365](https://github.com/open-webui/open-webui/issues/21365) +- ๐Ÿ—„๏ธ **PostgreSQL analytics query fix.** Fixed an issue where retrieving chat IDs by model ID would fail on PostgreSQL due to incompatible DISTINCT ordering, while SQLite worked correctly. [#21347](https://github.com/open-webui/open-webui/issues/21347), [Commit](https://github.com/open-webui/open-webui/commit/7bda6bf767d5d5c4dc1111465096a88e10b5030e) +- ๐Ÿ—ƒ๏ธ **SQLite cascade delete fix.** Deleting chats now properly removes all associated messages in SQLite, matching PostgreSQL behavior and preventing orphaned data. [#21362](https://github.com/open-webui/open-webui/pull/21362) +- โ˜๏ธ **Ollama Cloud model naming fix.** Fixed an issue where using Ollama Cloud models would fail with "Model not found" errors because ":latest" was incorrectly appended to model names. [#21386](https://github.com/open-webui/open-webui/issues/21386) +- ๐Ÿ› ๏ธ **Knowledge selector tooltip z-index.** Fixed an issue where tooltips in the "Select Knowledge" dropdown were hidden behind the menu, making it difficult to read knowledge item names and descriptions. [#21375](https://github.com/open-webui/open-webui/pull/21375) +- ๐ŸŽฏ **Model selector scroll position.** The model selector dropdown now correctly scrolls to and centers the currently selected model when opened, and resets scroll position when reopened. [Commit](https://github.com/open-webui/open-webui/commit/0b05b2fc7ed4c38af158707438ff404d1beb7c91) +- ๐Ÿ› **Sync modal unexpected appearance.** Fixed an issue where the Sync Modal would appear unexpectedly after enabling the "Community Sharing" feature if the user had previously visited the app with the sync parameter. [#21376](https://github.com/open-webui/open-webui/pull/21376) +- ๐ŸŽจ **Knowledge collection layout fix.** Fixed a layout issue in the Knowledge integration menu where long collection names caused indentation artifacts and now properly truncate with ellipsis. [#21374](https://github.com/open-webui/open-webui/pull/21374) +- ๐Ÿ“ **Metadata processing crash fix.** Fixed a latent bug where processing document metadata containing certain keys (content, pages, tables, paragraphs, sections, figures) would cause a RuntimeError due to dictionary mutation during iteration. [#21105](https://github.com/open-webui/open-webui/pull/21105) +- ๐Ÿ”‘ **Password validation regex fix.** Fixed the password validation regex by adding the raw string prefix, ensuring escape sequences like \d and \w are interpreted correctly. [#21400](https://github.com/open-webui/open-webui/pull/21400), [#21399](https://github.com/open-webui/open-webui/issues/21399) + +### Changed + +- โš ๏ธ **Database Migrations:** This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility. + ## [0.8.0] - 2026-02-12 ### Added diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index 60f1fa2ad3..dbae12dcec 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,13 @@ 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.8.1.1] - 2026.02.14 + +### Changed + +- ๅˆๅนถๅฎ˜ๆ–น 0.8.1 ๆ”นๅŠจ +- ็งป้™ค Responses ๆŽฅๅฃ + ## [0.8.0.2] - 2026.02.13 ### Fixed diff --git a/Dockerfile b/Dockerfile index b3a2b3e1c3..9a10236076 100644 --- a/Dockerfile +++ b/Dockerfile @@ -138,7 +138,8 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt RUN pip3 install --no-cache-dir uv && \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ + # fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349 + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ @@ -146,7 +147,7 @@ RUN pip3 install --no-cache-dir uv && \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ python -c "import nltk; nltk.download('punkt_tab')"; \ else \ - pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \ if [ "$USE_SLIM" != "true" ]; then \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index f589073e8a..412a2b6116 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -480,7 +480,7 @@ def parse_section(section): ) PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get( "PASSWORD_VALIDATION_REGEX_PATTERN", - "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", ) @@ -574,6 +574,14 @@ def parse_section(section): == "true" ) SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "") +SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "") + +if ENABLE_SCIM and not SCIM_AUTH_PROVIDER: + log.warning( + "SCIM is enabled but SCIM_AUTH_PROVIDER is not set. " + "Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') " + "to enable externalId storage." + ) #################################### # LICENSE_KEY diff --git a/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py new file mode 100644 index 0000000000..e8bf9a850f --- /dev/null +++ b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py @@ -0,0 +1,26 @@ +"""add scim column to user table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-13 14:19:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("user", "scim") diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index fa6e79a8db..dd5a344b46 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -515,6 +515,62 @@ def has_access( ) return exists is not None + def get_accessible_resource_ids( + self, + user_id: str, + resource_type: str, + resource_ids: list[str], + permission: str = "read", + user_group_ids: Optional[set[str]] = None, + db: Optional[Session] = None, + ) -> set[str]: + """ + Batch check: return the subset of resource_ids that the user can access. + + This replaces calling has_access() in a loop (N+1) with a single query. + """ + if not resource_ids: + return set() + + with get_db_context(db) as db: + conditions = [ + and_( + AccessGrant.principal_type == "user", + AccessGrant.principal_id == "*", + ), + and_( + AccessGrant.principal_type == "user", + AccessGrant.principal_id == user_id, + ), + ] + + if user_group_ids is None: + from open_webui.models.groups import Groups + + user_groups = Groups.get_groups_by_member_id(user_id, db=db) + user_group_ids = {group.id for group in user_groups} + + if user_group_ids: + conditions.append( + and_( + AccessGrant.principal_type == "group", + AccessGrant.principal_id.in_(user_group_ids), + ) + ) + + rows = ( + db.query(AccessGrant.resource_id) + .filter( + AccessGrant.resource_type == resource_type, + AccessGrant.resource_id.in_(resource_ids), + AccessGrant.permission == permission, + or_(*conditions), + ) + .distinct() + .all() + ) + return {row[0] for row in rows} + def get_users_with_access( self, resource_type: str, diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index fe3539f9cd..e6e932be12 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -15,6 +15,7 @@ Text, JSON, Index, + func, ) #################### @@ -279,25 +280,26 @@ def get_chat_ids_by_model_id( db: Optional[Session] = None, ) -> list[str]: """Get distinct chat_ids that used a specific model.""" - from sqlalchemy import distinct with get_db_context(db) as db: - query = db.query(distinct(ChatMessage.chat_id)).filter( - ChatMessage.model_id == model_id - ) + query = db.query( + ChatMessage.chat_id, + func.max(ChatMessage.created_at).label("last_message_at"), + ).filter(ChatMessage.model_id == model_id) if start_date: query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) - # Order by most recent message in each chat + # Group by chat_id and order by most recent message in each chat chat_ids = ( - query.order_by(ChatMessage.created_at.desc()) + query.group_by(ChatMessage.chat_id) + .order_by(func.max(ChatMessage.created_at).desc()) .offset(skip) .limit(limit) .all() ) - return [chat_id for (chat_id,) in chat_ids] + return [chat_id for chat_id, _ in chat_ids] def delete_messages_by_chat_id( self, chat_id: str, db: Optional[Session] = None diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 6040050fc3..7ae9f7a38b 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -8,7 +8,7 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.folders import Folders -from open_webui.models.chat_messages import ChatMessages +from open_webui.models.chat_messages import ChatMessage, ChatMessages from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db from pydantic import BaseModel, ConfigDict @@ -621,6 +621,13 @@ def delete_shared_chat_by_chat_id( ) -> bool: try: with get_db_context(db) as db: + # Use subquery to delete chat_messages for shared chats + shared_chat_id_subquery = ( + db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery() + ) + db.query(ChatMessage).filter( + ChatMessage.chat_id.in_(shared_chat_id_subquery) + ).delete(synchronize_session=False) db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete() db.commit() @@ -1410,6 +1417,7 @@ def delete_all_tags_by_id_and_user_id( def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: + db.query(ChatMessage).filter_by(chat_id=id).delete() db.query(Chat).filter_by(id=id).delete() db.commit() @@ -1422,6 +1430,7 @@ def delete_chat_by_id_and_user_id( ) -> bool: try: with get_db_context(db) as db: + db.query(ChatMessage).filter_by(chat_id=id).delete() db.query(Chat).filter_by(id=id, user_id=user_id).delete() db.commit() @@ -1436,6 +1445,12 @@ def delete_chats_by_user_id( with get_db_context(db) as db: self.delete_shared_chats_by_user_id(user_id, db=db) + chat_id_subquery = ( + db.query(Chat.id).filter_by(user_id=user_id).subquery() + ) + db.query(ChatMessage).filter( + ChatMessage.chat_id.in_(chat_id_subquery) + ).delete(synchronize_session=False) db.query(Chat).filter_by(user_id=user_id).delete() db.commit() @@ -1448,6 +1463,14 @@ def delete_chats_by_user_id_and_folder_id( ) -> bool: try: with get_db_context(db) as db: + chat_id_subquery = ( + db.query(Chat.id) + .filter_by(user_id=user_id, folder_id=folder_id) + .subquery() + ) + db.query(ChatMessage).filter( + ChatMessage.chat_id.in_(chat_id_subquery) + ).delete(synchronize_session=False) db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete() db.commit() @@ -1481,6 +1504,15 @@ def delete_shared_chats_by_user_id( chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user] + # Use subquery to delete chat_messages for shared chats + shared_id_subq = ( + db.query(Chat.id) + .filter(Chat.user_id.in_(shared_chat_ids)) + .subquery() + ) + db.query(ChatMessage).filter( + ChatMessage.chat_id.in_(shared_id_subq) + ).delete(synchronize_session=False) db.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete() db.commit() diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 71e8f97b31..1262830153 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -3,13 +3,13 @@ from typing import Optional from sqlalchemy.orm import Session -from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.internal.db import Base, get_db, get_db_context from open_webui.models.users import Users, UserResponse from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrantModel, AccessGrants from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Boolean, Column, String, Text, or_ +from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_ log = logging.getLogger(__name__) @@ -26,7 +26,7 @@ class Skill(Base): name = Column(Text, unique=True) description = Column(Text, nullable=True) content = Column(Text) - meta = Column(JSONField) + meta = Column(JSON) is_active = Column(Boolean, default=True) updated_at = Column(BigInteger) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index cb784850f8..cf07fe5c17 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -1,7 +1,7 @@ import time from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from open_webui.internal.db import Base, JSONField, get_db, get_db_context from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL @@ -13,7 +13,7 @@ from open_webui.utils.misc import throttle from open_webui.utils.validate import validate_profile_image_url -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import ( BigInteger, JSON, @@ -26,7 +26,7 @@ select, cast, ) -from sqlalchemy import or_, case +from sqlalchemy import or_, case, func from sqlalchemy.dialects.postgresql import JSONB import datetime @@ -69,6 +69,7 @@ class User(Base): settings = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True) + scim = Column(JSON, nullable=True) last_active_at = Column(BigInteger) updated_at = Column(BigInteger) @@ -84,7 +85,7 @@ class UserModel(BaseModel): name: str - profile_image_url: str + profile_image_url: Optional[str] = None profile_banner_image_url: Optional[str] = None bio: Optional[str] = None @@ -101,6 +102,7 @@ class UserModel(BaseModel): settings: Optional[UserSettings] = None oauth: Optional[dict] = None + scim: Optional[dict] = None last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -108,6 +110,12 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True, extra="allow") + @model_validator(mode="after") + def set_profile_image_url(self): + if not self.profile_image_url: + self.profile_image_url = f"/api/v1/users/{self.id}/profile/image" + return self + class UserStatusModel(UserModel): is_active: bool = False @@ -319,8 +327,12 @@ def get_user_by_email( ) -> Optional[UserModel]: try: with get_db_context(db) as db: - user = db.query(User).filter_by(email=email).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .filter(func.lower(User.email) == email.lower()) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -345,6 +357,29 @@ def get_user_by_oauth_sub( # You may want to log the exception here return None + def get_user_by_scim_external_id( + self, provider: str, external_id: str, db: Optional[Session] = None + ) -> Optional[UserModel]: + try: + with get_db_context(db) as db: # type: Session + dialect_name = db.bind.dialect.name + + query = db.query(User) + if dialect_name == "sqlite": + query = query.filter( + User.scim.contains({provider: {"external_id": external_id}}) + ) + elif dialect_name == "postgresql": + query = query.filter( + User.scim[provider].cast(JSONB)["external_id"].astext + == external_id + ) + + user = query.first() + return UserModel.model_validate(user) if user else None + except Exception: + return None + def get_users( self, filter: Optional[dict] = None, @@ -354,7 +389,7 @@ def get_users( ) -> dict: with get_db_context(db) as db: # Join GroupMember so we can order by group_id when requested - query = db.query(User) + query = db.query(User).options(defer(User.profile_image_url)) if filter: query_key = filter.get("query") @@ -489,6 +524,7 @@ def get_users_by_group_id( with get_db_context(db) as db: users = ( db.query(User) + .options(defer(User.profile_image_url)) .join(GroupMember, User.id == GroupMember.user_id) .filter(GroupMember.group_id == group_id) .all() @@ -499,7 +535,12 @@ def get_users_by_user_ids( self, user_ids: list[str], db: Optional[Session] = None ) -> list[UserStatusModel]: with get_db_context(db) as db: - users = db.query(User).filter(User.id.in_(user_ids)).all() + users = ( + db.query(User) + .options(defer(User.profile_image_url)) + .filter(User.id.in_(user_ids)) + .all() + ) return [UserModel.model_validate(user) for user in users] def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: @@ -639,6 +680,38 @@ def update_user_oauth_by_id( except Exception: return None + def update_user_scim_by_id( + self, + id: str, + provider: str, + external_id: str, + db: Optional[Session] = None, + ) -> Optional[UserModel]: + """ + Update or insert a SCIM provider/external_id pair into the user's scim JSON field. + Example resulting structure: + { + "microsoft": { "external_id": "abc" }, + "okta": { "external_id": "def" } + } + """ + try: + with get_db_context(db) as db: + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + scim = user.scim or {} + scim[provider] = {"external_id": external_id} + + db.query(User).filter_by(id=id).update({"scim": scim}) + db.commit() + + return UserModel.model_validate(user) + + except Exception: + return None + def update_user_by_id( self, id: str, updated: dict, db: Optional[Session] = None ) -> Optional[UserModel]: @@ -766,6 +839,14 @@ def get_active_user_count(self, db: Optional[Session] = None) -> int: ) return count + @staticmethod + def is_active(user: UserModel) -> bool: + """Compute active status from an already-loaded UserModel (no DB hit).""" + if user.last_active_at: + three_minutes_ago = int(time.time()) - 180 + return user.last_active_at >= three_minutes_ago + return False + def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: user = db.query(User).filter_by(id=user_id).first() diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index a597390b92..a39d364419 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -4,6 +4,7 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: + # Removes large/redundant fields from metadata dict. metadata = { key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE } @@ -13,16 +14,15 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: def process_metadata( metadata: dict[str, any], ) -> dict[str, any]: + # Removes large fields and converts non-serializable types (datetime, list, dict) to strings. + result = {} for key, value in metadata.items(): - # Remove large fields + # Skip large fields if key in KEYS_TO_EXCLUDE: - del metadata[key] - + continue # Convert non-serializable fields to strings - if ( - isinstance(value, datetime) - or isinstance(value, list) - or isinstance(value, dict) - ): - metadata[key] = str(value) - return metadata + if isinstance(value, (datetime, list, dict)): + result[key] = str(value) + else: + result[key] = value + return result diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 8851a829f8..ab76b1b6ef 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -160,7 +160,7 @@ def create_session_response( "email": user.email, "name": user.name, "role": user.role, - "profile_image_url": user.profile_image_url, + "profile_image_url": f"/api/v1/users/{user.id}/profile/image", "permissions": user_permissions, "credit": credit.credit, } @@ -978,7 +978,7 @@ async def add_user( "email": user.email, "name": user.name, "role": user.role, - "profile_image_url": user.profile_image_url, + "profile_image_url": f"/api/v1/users/{user.id}/profile/image", } else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 1748eaf7ea..e3cdc46a16 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -204,7 +204,7 @@ async def get_channels( UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_user_active(user.id, db=db), + "is_active": Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) @@ -424,7 +424,7 @@ async def get_channel_by_id( UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_user_active(user.id, db=db), + "is_active": Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) @@ -540,9 +540,7 @@ async def get_channel_members_by_id( return { "users": [ - UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) - ) + UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users ], "total": total, @@ -575,9 +573,7 @@ async def get_channel_members_by_id( return { "users": [ - UserModelResponse( - **user.model_dump(), is_active=Users.is_user_active(user.id, db=db) - ) + UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users ], "total": total, diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index eab00aa19b..1fedab4466 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -115,8 +115,10 @@ async def get_knowledge_bases( skip = (page - 1) * limit filter = {} + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -126,6 +128,17 @@ async def get_knowledge_bases( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls + knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] + writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="knowledge", + resource_ids=knowledge_base_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return KnowledgeAccessListResponse( items=[ KnowledgeAccessResponse( @@ -133,13 +146,7 @@ async def get_knowledge_bases( write_access=( user.id == knowledge_base.user_id or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or AccessGrants.has_access( - user_id=user.id, - resource_type="knowledge", - resource_id=knowledge_base.id, - permission="write", - db=db, - ) + or knowledge_base.id in writable_knowledge_base_ids ), ) for knowledge_base in result.items @@ -166,8 +173,10 @@ async def search_knowledge_bases( if view_option: filter["view_option"] = view_option + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -177,6 +186,17 @@ async def search_knowledge_bases( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls + knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] + writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="knowledge", + resource_ids=knowledge_base_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return KnowledgeAccessListResponse( items=[ KnowledgeAccessResponse( @@ -184,13 +204,7 @@ async def search_knowledge_bases( write_access=( user.id == knowledge_base.user_id or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or AccessGrants.has_access( - user_id=user.id, - resource_type="knowledge", - resource_id=knowledge_base.id, - permission="write", - db=db, - ) + or knowledge_base.id in writable_knowledge_base_ids ), ) for knowledge_base in result.items @@ -511,6 +525,7 @@ class KnowledgeAccessGrantsForm(BaseModel): @router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse]) async def update_knowledge_access_by_id( + request: Request, id: str, form_data: KnowledgeAccessGrantsForm, user=Depends(get_verified_user), @@ -539,6 +554,25 @@ async def update_knowledge_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_knowledge", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db) return KnowledgeFilesResponse( diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 7202262bbd..e93d8a729d 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -8,14 +8,16 @@ from open_webui.models.groups import Groups from open_webui.models.models import ( ModelForm, + ModelMeta, ModelModel, + ModelParams, ModelResponse, ModelListResponse, ModelAccessListResponse, ModelAccessResponse, Models, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES @@ -84,14 +86,29 @@ async def get_models( if direction: filter["direction"] = direction + # Pre-fetch user group IDs once - used for both filter and write_access check + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] filter["user_id"] = user.id result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) + + # Batch-fetch writable model IDs in a single query instead of N has_access calls + model_ids = [model.id for model in result.items] + writable_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=model_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return ModelAccessListResponse( items=[ ModelAccessResponse( @@ -99,13 +116,7 @@ async def get_models( write_access=( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model.id, - permission="write", - db=db, - ) + or model.id in writable_model_ids ), ) for model in result.items @@ -506,16 +517,36 @@ class ModelAccessGrantsForm(BaseModel): @router.post("/model/access/update", response_model=Optional[ModelModel]) async def update_model_access_by_id( + request: Request, form_data: ModelAccessGrantsForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): model = Models.get_model_by_id(form_data.id, db=db) + + # Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB + # entry yet. Create a minimal one so access grants can be stored. if not model: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + model = Models.insert_new_model( + ModelForm( + id=form_data.id, + name=form_data.id, + meta=ModelMeta(), + params=ModelParams(), + ), + user.id, + db=db, ) + if not model: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.DEFAULT("Error creating model entry"), + ) if ( model.user_id != user.id @@ -533,6 +564,25 @@ async def update_model_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_models", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants( "model", form_data.id, form_data.access_grants, db=db ) diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 04841e87cc..8d1a66c4af 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -345,6 +345,25 @@ async def update_note_access_by_id( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_notes", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db) return Notes.get_note_by_id(id, db=db) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 1d72d7efdf..1e69b36982 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -420,21 +420,29 @@ async def get_all_models(request: Request, user: UserModel = None): async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model["model"] for model in models.get("models", [])] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = { + model_info.id: model_info + for model_info in Models.get_models_by_ids(model_ids, db=db) + } + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models.get("models", []): model_info = model_infos.get(model["model"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) return filtered_models @@ -655,10 +663,6 @@ async def unload_model( await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS - # Canonicalize model name (if not supplied with version) - if ":" not in model_name: - model_name = f"{model_name}:latest" - if model_name not in models: raise HTTPException( status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name) @@ -1352,6 +1356,9 @@ async def generate_chat_completion( # Check if user has access to the model if not bypass_filter and user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1359,6 +1366,7 @@ async def generate_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1372,9 +1380,6 @@ async def generate_chat_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), @@ -1451,9 +1456,6 @@ async def generate_openai_completion( del payload["metadata"] model_id = form_data.model - if ":" not in model_id: - model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: @@ -1465,6 +1467,9 @@ async def generate_openai_completion( # Check if user has access to the model if user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1472,6 +1477,7 @@ async def generate_openai_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1485,9 +1491,6 @@ async def generate_openai_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), @@ -1540,9 +1543,6 @@ async def generate_openai_chat_completion( del payload["metadata"] model_id = completion_form.model - if ":" not in model_id: - model_id = f"{model_id}:latest" - model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: @@ -1558,6 +1558,9 @@ async def generate_openai_chat_completion( # Check if user has access to the model if user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -1565,6 +1568,7 @@ async def generate_openai_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1578,9 +1582,6 @@ async def generate_openai_chat_completion( detail="Model not found", ) - if ":" not in payload["model"]: - payload["model"] = f"{payload['model']}:latest" - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), @@ -1659,20 +1660,31 @@ async def get_openai_models( if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: # Filter models based on user access control model_ids = [model["id"] for model in models] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = { + model_info.id: model_info + for model_info in Models.get_models_by_ids(model_ids, db=db) + } + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models: model_info = model_infos.get(model["id"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, + if ( + user.id == model_info.user_id + or model_info.id in accessible_model_ids ): filtered_models.append(model) models = filtered_models diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 587509800c..18440f1e8f 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -18,7 +18,7 @@ JSONResponse, PlainTextResponse, ) -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session @@ -457,21 +457,29 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: async def get_filtered_models(models, user, db=None): # Filter models based on user access control model_ids = [model["id"] for model in models.get("data", [])] - model_infos = {m.id: m for m in Models.get_models_by_ids(model_ids, db=db)} - user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} + model_infos = { + model_info.id: model_info + for model_info in Models.get_models_by_ids(model_ids, db=db) + } + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) filtered_models = [] for model in models.get("data", []): model_info = model_infos.get(model["id"]) if model_info: - if user.id == model_info.user_id or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) return filtered_models @@ -598,9 +606,12 @@ async def get_models( if r.status != 200: # Extract response error details if available error_detail = f"HTTP Error: {r.status}" - res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" + try: + res = await r.json() + if "error" in res: + error_detail = f"External Error: {res['error']}" + except Exception: + pass raise Exception(error_detail) response_data = await r.json() @@ -970,6 +981,9 @@ async def generate_chat_completion( # Check if user has access to the model if not bypass_filter and user.role == "user": + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id) + } if not ( user.id == model_info.user_id or AccessGrants.has_access( @@ -977,6 +991,7 @@ async def generate_chat_completion( resource_type="model", resource_id=model_info.id, permission="read", + user_group_ids=user_group_ids, ) ): raise HTTPException( @@ -1262,3 +1277,23 @@ async def embeddings(request: Request, form_data: dict, user): finally: if not streaming: await cleanup_response(r, session) + + +class ResponsesForm(BaseModel): + model_config = ConfigDict(extra="allow") + + model: str + input: Optional[list | str] = None + instructions: Optional[str] = None + stream: Optional[bool] = None + temperature: Optional[float] = None + max_output_tokens: Optional[int] = None + top_p: Optional[float] = None + tools: Optional[list] = None + tool_choice: Optional[str | dict] = None + text: Optional[dict] = None + truncation: Optional[str] = None + metadata: Optional[dict] = None + store: Optional[bool] = None + reasoning: Optional[dict] = None + previous_response_id: Optional[str] = None diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index e8d4660f03..2491578959 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -9,7 +9,7 @@ PromptModel, Prompts, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( PromptHistories, @@ -100,8 +100,11 @@ async def get_prompt_list( if direction: filter["direction"] = direction + # Pre-fetch user group IDs once - used for both filter and write_access check + groups = Groups.get_groups_by_member_id(user.id, db=db) + user_group_ids = {group.id for group in groups} + if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL): - groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: filter["group_ids"] = [group.id for group in groups] @@ -111,6 +114,17 @@ async def get_prompt_list( user.id, filter=filter, skip=skip, limit=limit, db=db ) + # Batch-fetch writable prompt IDs in a single query instead of N has_access calls + prompt_ids = [prompt.id for prompt in result.items] + writable_prompt_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="prompt", + resource_ids=prompt_ids, + permission="write", + user_group_ids=user_group_ids, + db=db, + ) + return PromptAccessListResponse( items=[ PromptAccessResponse( @@ -118,13 +132,7 @@ async def get_prompt_list( write_access=( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="prompt", - resource_id=prompt.id, - permission="write", - db=db, - ) + or prompt.id in writable_prompt_ids ), ) for prompt in result.items @@ -436,6 +444,7 @@ class PromptAccessGrantsForm(BaseModel): @router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel]) async def update_prompt_access_by_id( + request: Request, prompt_id: str, form_data: PromptAccessGrantsForm, user=Depends(get_verified_user), @@ -464,6 +473,25 @@ async def update_prompt_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_prompts", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db) return Prompts.get_prompt_by_id(prompt_id, db=db) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 681be3c7d2..0c16eb99bd 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -25,6 +25,9 @@ ) from open_webui.constants import ERROR_MESSAGES +from open_webui.config import OAUTH_PROVIDERS +from open_webui.env import SCIM_AUTH_PROVIDER + from sqlalchemy.orm import Session from open_webui.internal.db import get_session @@ -300,6 +303,43 @@ def get_scim_auth( ) +def get_external_id(user: UserModel) -> Optional[str]: + """Extract externalId from a user's scim data. + + Checks all stored provider entries and returns the first external_id found. + """ + if not user.scim: + return None + for provider_data in user.scim.values(): + if isinstance(provider_data, dict) and "external_id" in provider_data: + return provider_data["external_id"] + return None + + +def get_scim_provider() -> str: + """Return the configured SCIM auth provider. + + Requires SCIM_AUTH_PROVIDER env var to be set (e.g. 'microsoft', 'oidc'). + """ + if not SCIM_AUTH_PROVIDER: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled", + ) + return SCIM_AUTH_PROVIDER + + +def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]: + """Find a user by SCIM externalId, falling back to OAuth sub match.""" + provider = get_scim_provider() + user = Users.get_user_by_scim_external_id(provider, external_id, db=db) + if user: + return user + + # Fallback: check if externalId matches an existing OAuth sub (account linking) + return Users.get_user_by_oauth_sub(provider, external_id, db=db) + + def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components @@ -321,6 +361,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: return SCIMUser( id=user.id, + externalId=get_external_id(user), userName=user.email, name=SCIMName( formatted=user.name, @@ -494,13 +535,17 @@ async def get_users( # Get users from database if filter: - # Simple filter parsing - supports userName eq "email" - # In production, you'd want a more robust filter parser + # Simple filter parsing - supports userName eq, externalId eq if "userName eq" in filter: email = filter.split('"')[1] user = Users.get_user_by_email(email, db=db) users_list = [user] if user else [] total = 1 if user else 0 + elif "externalId eq" in filter: + external_id = filter.split('"')[1] + user = find_user_by_external_id(external_id, db=db) + users_list = [user] if user else [] + total = 1 if user else 0 else: response = Users.get_users(skip=skip, limit=limit, db=db) users_list = response["users"] @@ -546,17 +591,33 @@ async def create_user( db: Session = Depends(get_session), ): """Create SCIM User""" - # Check if user already exists - existing_user = Users.get_user_by_email(user_data.userName, db=db) + # Check for duplicate by externalId + if user_data.externalId: + existing_user = find_user_by_external_id(user_data.externalId, db=db) + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with externalId {user_data.externalId} already exists", + ) + + # Determine primary email (lowercased per RFC 5321) + email = user_data.userName + for entry in user_data.emails: + if entry.primary: + email = entry.value + break + email = email.lower() + + # Check for duplicate by email + existing_user = Users.get_user_by_email(email, db=db) if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"User with email {user_data.userName} already exists", + detail=f"User with email {email} already exists", ) # Create user user_id = str(uuid.uuid4()) - email = user_data.emails[0].value if user_data.emails else user_data.userName # Parse name if provided name = user_data.displayName @@ -571,7 +632,6 @@ async def create_user( if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value - # Create user new_user = Users.insert_new_user( id=user_id, name=name, @@ -587,6 +647,12 @@ async def create_user( detail="Failed to create user", ) + # Store externalId in the scim field + if user_data.externalId: + provider = get_scim_provider() + Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) + new_user = Users.get_user_by_id(user_id, db=db) + return user_to_scim(new_user, request, db=db) @@ -631,7 +697,6 @@ async def update_user( if user_data.photos and len(user_data.photos) > 0: update_data["profile_image_url"] = user_data.photos[0].value - # Update user updated_user = Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( @@ -639,6 +704,12 @@ async def update_user( detail="Failed to update user", ) + # Update externalId in the scim field + if user_data.externalId: + provider = get_scim_provider() + Users.update_user_scim_by_id(user_id, provider, user_data.externalId, db=db) + updated_user = Users.get_user_by_id(user_id, db=db) + return user_to_scim(updated_user, request, db=db) @@ -676,6 +747,9 @@ async def patch_user( update_data["email"] = value elif path == "name.formatted": update_data["name"] = value + elif path == "externalId": + provider = get_scim_provider() + Users.update_user_scim_by_id(user_id, provider, value, db=db) # Update user if update_data: diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 367768e61b..fb7b01b87f 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -17,7 +17,7 @@ SkillAccessListResponse, Skills, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission @@ -312,6 +312,7 @@ class SkillAccessGrantsForm(BaseModel): @router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) async def update_skill_access_by_id( + request: Request, id: str, form_data: SkillAccessGrantsForm, user=Depends(get_verified_user), @@ -340,6 +341,25 @@ async def update_skill_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_skills", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db) return Skills.get_skill_by_id(id, db=db) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 057eb509a1..6657b34462 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -21,7 +21,7 @@ ToolAccessResponse, Tools, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant from open_webui.utils.plugin import ( load_tool_module_by_id, replace_imports, @@ -526,6 +526,7 @@ class ToolAccessGrantsForm(BaseModel): @router.post("/id/{id}/access/update", response_model=Optional[ToolModel]) async def update_tool_access_by_id( + request: Request, id: str, form_data: ToolAccessGrantsForm, user=Depends(get_verified_user), @@ -554,6 +555,25 @@ async def update_tool_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) + # Strip public sharing if user lacks permission + if ( + user.role != "admin" + and has_public_read_access_grant(form_data.access_grants) + and not has_permission( + user.id, + "sharing.public_tools", + request.app.state.config.USER_PERMISSIONS, + ) + ): + form_data.access_grants = [ + grant + for grant in form_data.access_grants + if not ( + grant.get("principal_type") == "user" + and grant.get("principal_id") == "*" + ) + ] + AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) return Tools.get_tool_by_id(id, db=db) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 0d762ee5b2..b43c56b4e6 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -314,7 +314,13 @@ async def connect(sid, environ, auth): if user: SESSION_POOL[sid] = user.model_dump( - exclude=["date_of_birth", "bio", "gender"] + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] ) await sio.enter_room(sid, f"user:{user.id}") diff --git a/backend/open_webui/test/apps/webui/routers/test_users.py b/backend/open_webui/test/apps/webui/routers/test_users.py index 1a58ab147a..3108729710 100644 --- a/backend/open_webui/test/apps/webui/routers/test_users.py +++ b/backend/open_webui/test/apps/webui/routers/test_users.py @@ -12,7 +12,7 @@ def _assert_user(data, id, **kwargs): comparison_data = { "name": f"user {id}", "email": f"user{id}@openwebui.com", - "profile_image_url": f"/user{id}.png", + "profile_image_url": f"/api/v1/users/{id}/profile/image", "role": "user", **kwargs, } @@ -150,7 +150,7 @@ def test_users(self): role="admin", name="user 2 updated", email="user2-updated@openwebui.com", - profile_image_url="/user2-updated.png", + profile_image_url=f"/api/v1/users/2/profile/image", ) # Delete user by id diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 0175ba5583..cec0375ab7 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -167,6 +167,9 @@ async def search_web( engine = __request__.app.state.config.WEB_SEARCH_ENGINE user = UserModel(**__user__) if __user__ else None + # Use admin-configured result count if configured, falling back to model-provided count of provided, else default to 5 + count = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT or count + results = await asyncio.to_thread(_search_web, __request__, engine, query, user) # Limit results diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index e39787f715..3354889e7e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -411,6 +411,36 @@ def serialize_output(output: list) -> str: if content and not content.endswith("\n"): content += "\n" + # Render the code_interpreter item as a
block + # so the frontend Collapsible renders "Analyzing..."/"Analyzed". + code = item.get("code", "").strip() + lang = item.get("lang", "python") + status = item.get("status", "in_progress") + duration = item.get("duration") + is_last_item = idx == len(output) - 1 + + # Build inner content: code block + display = "" + if code: + display = f"```{lang}\n{code}\n```" + + # Build output attribute as HTML-escaped JSON for CodeBlock.svelte + ci_output = item.get("output") + output_attr = "" + if ci_output: + if isinstance(ci_output, dict): + output_json = json.dumps(ci_output, ensure_ascii=False) + else: + output_json = json.dumps( + {"result": str(ci_output)}, ensure_ascii=False + ) + output_attr = f' output="{html.escape(output_json)}"' + + if status == "completed" or duration is not None or not is_last_item: + content += f'
\nAnalyzed\n{display}\n
\n' + else: + content += f'
\nAnalyzingโ€ฆ\n{display}\n
\n' + return content.strip() @@ -2930,11 +2960,14 @@ async def streaming_chat_response_handler(response, ctx): # Handle as a background task async def response_handler(response, events): - def tag_output_handler(content_type, tags, content, output): + def tag_output_handler(content_type, tags, output): """ Detect special tags (reasoning, solution, code_interpreter) in streaming content and create corresponding OR-aligned output items directly. Operates on output items instead of content_blocks. + + Uses the text from the output items themselves for tag detection, + eliminating state divergence between accumulated content and items. """ end_flag = False @@ -2974,6 +3007,8 @@ def set_last_text(out, text): last_type = output[-1].get("type", "") if output else "" if last_type == "message": + # Use the output item's own text for tag detection + item_text = get_last_text(output) for start_tag, end_tag in tags: start_tag_pattern = rf"{re.escape(start_tag)}" @@ -2982,7 +3017,7 @@ def set_last_text(out, text): rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>" ) - match = re.search(start_tag_pattern, content) + match = re.search(start_tag_pattern, item_text) if match: try: attr_content = match.group(1) if match.group(1) else "" @@ -2991,20 +3026,13 @@ def set_last_text(out, text): attributes = extract_attributes(attr_content) - before_tag = content[: match.start()] - after_tag = content[match.end() :] + before_tag = item_text[: match.start()] + after_tag = item_text[match.end() :] - # Remove the start tag and everything after from last message - current_text = get_last_text(output) - set_last_text( - output, - current_text.replace(match.group(0) + after_tag, ""), - ) - - if before_tag: - set_last_text(output, before_tag) + # Keep only text before the tag in the message + set_last_text(output, before_tag) - if not get_last_text(output).strip(): + if not before_tag.strip(): # Remove empty message item if output and output[-1].get("type") == "message": output.pop() @@ -3069,9 +3097,11 @@ def set_last_text(out, text): else: set_last_text(output, after_tag) - tag_output_handler( - content_type, tags, after_tag, output + _, recursive_end = tag_output_handler( + content_type, tags, output ) + if recursive_end: + end_flag = True break @@ -3090,25 +3120,22 @@ def set_last_text(out, text): start_tag = item.get("start_tag", "") end_tag = item.get("end_tag", "") - if end_tag.startswith("<") and end_tag.endswith(">"): - end_tag_pattern = rf"{re.escape(end_tag)}" + end_tag_pattern = rf"{re.escape(end_tag)}" + + # Get the block content from the item itself + if last_type == "reasoning": + parts = item.get("content", []) + block_content = "" + if parts and parts[-1].get("type") == "output_text": + block_content = parts[-1].get("text", "") + elif last_type == "open_webui:code_interpreter": + block_content = item.get("code", "") else: - end_tag_pattern = rf"{re.escape(end_tag)}" + block_content = get_last_text(output) - if re.search(end_tag_pattern, content): + if re.search(end_tag_pattern, block_content): end_flag = True - # Get the block content - if last_type == "reasoning": - parts = item.get("content", []) - block_content = "" - if parts and parts[-1].get("type") == "output_text": - block_content = parts[-1].get("text", "") - elif last_type == "open_webui:code_interpreter": - block_content = item.get("code", "") - else: - block_content = get_last_text(output) - # Strip start and end tags from content start_tag_pattern = rf"{re.escape(start_tag)}" if start_tag.startswith("<") and start_tag.endswith(">"): @@ -3151,36 +3178,20 @@ def set_last_text(out, text): item["ended_at"] = time.time() # Reset by appending a new message item for leftover - if content_type != "code_interpreter": - output.append( - { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": leftover_content, - } - ], - } - ) - else: - output.append( - { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": leftover_content, - } - ], - } - ) + output.append( + { + "type": "message", + "id": output_id("msg"), + "status": "in_progress", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": leftover_content, + } + ], + } + ) else: # Remove the block if content is empty output.pop() @@ -3199,19 +3210,7 @@ def set_last_text(out, text): } ) - # Clean processed content - start_tag_clean = rf"{re.escape(start_tag)}" - if start_tag.startswith("<") and start_tag.endswith(">"): - start_tag_clean = rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>" - - content = re.sub( - rf"{start_tag_clean}(.|\n)*?{re.escape(end_tag)}", - "", - content, - flags=re.DOTALL, - ) - - return content, output, end_flag + return output, end_flag message = Chats.get_message_by_id_and_message_id( metadata["chat_id"], metadata["message_id"] @@ -3674,58 +3673,132 @@ async def flush_pending_delta_data(threshold: int = 0): ) content = f"{content}{value}" - if ( - not output - or output[-1].get("type") != "message" - ): - output.append( - { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ + + # Check if we're inside a tag-based block + # (reasoning, code_interpreter, or solution). + # If so, append to the existing in-progress + # item instead of creating a new message โ€” + # otherwise tag_output_handler re-detects the + # start tag on every chunk and fragments the + # output. + last_item = output[-1] if output else None + last_item_type = ( + last_item.get("type", "") + if last_item + else "" + ) + inside_tag_block = ( + last_item is not None + and last_item.get("status") == "in_progress" + and last_item.get("attributes", {}).get( + "type" + ) + != "reasoning_content" + and ( + last_item_type == "reasoning" + or last_item_type + == "open_webui:code_interpreter" + or ( + last_item_type == "message" + and last_item.get("_tag_type") + is not None + ) + ) + ) + + if inside_tag_block: + # Append to the existing tag-based item + if ( + last_item_type + == "open_webui:code_interpreter" + ): + last_item["code"] = ( + last_item.get("code", "") + value + ) + elif last_item_type == "reasoning": + parts = last_item.get("content", []) + if ( + parts + and parts[-1].get("type") + == "output_text" + ): + parts[-1]["text"] += value + else: + last_item["content"] = [ { "type": "output_text", - "text": "", + "text": value, } - ], - } - ) - - # Append value to last message item's text - msg_parts = output[-1].get("content", []) - if ( - msg_parts - and msg_parts[-1].get("type") - == "output_text" - ): - msg_parts[-1]["text"] += value + ] + else: + # solution or other _tag_type message + msg_parts = last_item.get("content", []) + if ( + msg_parts + and msg_parts[-1].get("type") + == "output_text" + ): + msg_parts[-1]["text"] += value + else: + last_item["content"] = [ + { + "type": "output_text", + "text": value, + } + ] else: - output[-1]["content"] = [ - {"type": "output_text", "text": value} - ] + if ( + not output + or output[-1].get("type") != "message" + ): + output.append( + { + "type": "message", + "id": output_id("msg"), + "status": "in_progress", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "", + } + ], + } + ) + + # Append value to last message item's text + msg_parts = output[-1].get("content", []) + if ( + msg_parts + and msg_parts[-1].get("type") + == "output_text" + ): + msg_parts[-1]["text"] += value + else: + output[-1]["content"] = [ + { + "type": "output_text", + "text": value, + } + ] if DETECT_REASONING_TAGS: - content, output, _ = tag_output_handler( + output, _ = tag_output_handler( "reasoning", reasoning_tags, - content, output, ) - content, output, _ = tag_output_handler( + output, _ = tag_output_handler( "solution", DEFAULT_SOLUTION_TAGS, - content, output, ) if DETECT_CODE_INTERPRETER: - content, output, end = tag_output_handler( + output, end = tag_output_handler( "code_interpreter", DEFAULT_CODE_INTERPRETER_TAGS, - content, output, ) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index fbed41420c..a3c8277af4 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -229,25 +229,27 @@ def flush_pending(): # else: skip reasoning blocks for normal LLM messages elif item_type == "open_webui:code_interpreter": - if raw: - # Include code interpreter content for LLM re-processing - code = item.get("code", "") - code_output = item.get("output", "") - - if code: - lang = item.get("lang", "python") - pending_content.append(f"```{lang}\n{code}\n```") - - if code_output: - if isinstance(code_output, dict): - stdout = code_output.get("stdout", "") - result = code_output.get("result", "") - output_text = stdout or result - else: - output_text = str(code_output) - if output_text: - pending_content.append(f"Output:\n{output_text}") - # else: skip extension types + # Always include code interpreter content so the LLM knows + # the code was already executed and doesn't retry. + code = item.get("code", "") + code_output = item.get("output", "") + + if code: + pending_content.append( + f"\n{code}\n" + ) + + if code_output: + if isinstance(code_output, dict): + stdout = code_output.get("stdout", "") + result = code_output.get("result", "") + output_text = stdout or result + else: + output_text = str(code_output) + if output_text: + pending_content.append( + f"\n{output_text}\n" + ) elif item_type.startswith("open_webui:"): # Skip other extension types diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index ff3a6e0caf..8bef1591bb 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -377,10 +377,21 @@ def get_filtered_models(models, user, db=None): 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, db=db) } + + # Batch-fetch accessible resource IDs in a single query instead of N has_access calls + accessible_model_ids = AccessGrants.get_accessible_resource_ids( + user_id=user.id, + resource_type="model", + resource_ids=list(model_infos.keys()), + permission="read", + user_group_ids=user_group_ids, + db=db, + ) + + filtered_models = [] for model in models: if model.get("arena"): meta = model.get("info", {}).get("meta", {}) @@ -399,14 +410,7 @@ def get_filtered_models(models, user, db=None): if ( (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model_info.user_id - or AccessGrants.has_access( - user_id=user.id, - resource_type="model", - resource_id=model_info.id, - permission="read", - user_group_ids=user_group_ids, - db=db, - ) + or model_info.id in accessible_model_ids ): filtered_models.append(model) diff --git a/package-lock.json b/package-lock.json index cc10773454..33e9203c49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.0.2", + "version": "0.8.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.0.2", + "version": "0.8.1.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index c1b01d2e66..5a02343073 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.0.2", + "version": "0.8.1.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte index 249b2a2128..2df974c83d 100644 --- a/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu/Knowledge.svelte @@ -186,12 +186,16 @@ }} data-selected={idx === selectedIdx} > -
+
- +
{decodeString(item?.name)}
diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 2ca8872350..07779bfd1e 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -179,6 +179,16 @@ selectedModelIdx = 0; } + // Set the virtual scroll position so the selected item is rendered and centered + const targetScrollTop = Math.max(0, selectedModelIdx * ITEM_HEIGHT - 128 + ITEM_HEIGHT / 2); + listScrollTop = targetScrollTop; + + await tick(); + + if (listContainer) { + listContainer.scrollTop = targetScrollTop; + } + await tick(); const item = document.querySelector(`[data-arrow-selected="true"]`); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); @@ -380,6 +390,7 @@ bind:open={show} onOpenChange={async () => { searchValue = ''; + listScrollTop = 0; window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); resetView(); diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte index 50b4430332..c85308c9e1 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte @@ -836,9 +836,7 @@ bind:show={showAccessControlModal} bind:accessGrants={knowledge.access_grants} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_knowledge || - $user?.role === 'admin' || - knowledge?.write_access} + sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} onChange={async () => { try { await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []); @@ -856,7 +854,7 @@
{#if item.type === 'note'} - + {:else if item.type === 'collection'} - + {:else if item.type === 'file'} - + {/if} @@ -186,6 +198,7 @@
{decodeString(item?.name)} diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 2e01a490bd..c878c36bc8 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -368,14 +368,14 @@ bind:accessGrants accessRoles={preset ? ['read', 'write'] : ['read']} share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} - sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit} + sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'} onChange={async () => { if (edit && model?.id) { try { await updateModelAccessGrants(localStorage.token, model.id, accessGrants); toast.success($i18n.t('Saved')); } catch (error) { - toast.error(`${error}`); + toast.error(error?.detail ?? `${error}`); } } }} @@ -492,11 +492,11 @@ }} >
-
-
+
+