Compare commits

..
34 Commits
Author SHA1 Message Date
Tim Baek 883f1dda0f Merge pull request #21346 from open-webui/dev
0.8.1
2026-02-13 18:04:11 -06:00
Classic298 12bad452fa chore: Changelog updates (#21382)
* Add v0.8.1 release section

* changelog: knowledge menu layout fix

* changelog: knowledge tooltip z-index fix

* changelog: sync modal community sharing fix

* changelog: postgresql distinct ordering fix

* changelog: security fix public sharing bypass

* changelog: add issue ref to postgresql fix

* changelog: fix postgresql skills json compatibility

* changelog: apply new format style to 0.8.1 entries

* changelog: web search result count fix

* changelog: metadata, document, crash fix

* changelog: add channel user active status performance entry

* changelog: add model and prompt list optimization entry

* changelog: batch access control queries, channel status, model list optimization

* changelog: user list, performance, deferred loading

* Update CHANGELOG.md for 0.8.1

* Add emoji variation to Added section

* Remove empty Changed section and finalize 0.8.1 changelog

* changelog: arm, torch compatibility fix

* changelog: update database migration warning format

* changelog: ollama cloud, model naming fix

* Add SCIM externalId entry and database migration warning to 0.8.1

* Fix: move web search to Added, restore 0.8.0 headers

* Fix: SCIM above translations, 0.8.0 restored

* Remove 0.8.1 migration warning, keep 0.8.0 original

* changelog: direct model access control fix

* changelog: add commit link to direct model access control fix

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* changelog: sqlite, cascade delete, database fix

* changelog: responses, api, model-routing

* Add PR and issue links to SCIM externalId changelog entry

* Add commit link to Responses API entry, remove PR link from translation entry

* changelog: reasoning traces, performance, browser

* changelog: password, validation, regex

* Update CHANGELOG.md
2026-02-13 17:46:52 -06:00
Timothy Jaeryang Baek 5de60dc922 refac 2026-02-13 17:44:52 -06:00
Taylor Wilsdon a30b106ea3 fix issues/21399 (#21400) 2026-02-13 17:42:34 -06:00
Timothy Jaeryang Baek d33ad462aa refac 2026-02-13 17:38:57 -06:00
Timothy Jaeryang Baek 3b61562c82 refac 2026-02-13 17:26:54 -06:00
Timothy Jaeryang Baek e5d88be4f3 doc: changelog 2026-02-13 15:08:20 -06:00
Timothy Jaeryang Baek b36f8d9314 chore: format 2026-02-13 15:00:47 -06:00
Timothy Jaeryang Baek 626d236d13 chore: format 2026-02-13 15:00:39 -06:00
Timothy Jaeryang Baek 79ecbfc757 refac 2026-02-13 14:59:20 -06:00
Timothy Jaeryang Baek a9b8677cc0 refac 2026-02-13 14:59:05 -06:00
Classic298 0f3f68b0c4 enh (#21362) 2026-02-13 14:56:53 -06:00
Timothy Jaeryang BaekandJuan Calderon-Perez abc9b63093 refac
Co-Authored-By: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
2026-02-13 14:55:13 -06:00
Timothy Jaeryang Baek 64fa26bd28 refac 2026-02-13 14:48:10 -06:00
Timothy Jaeryang Baek 2487c84f1f refac: styling 2026-02-13 14:47:00 -06:00
Timothy Jaeryang Baek 163211a367 refac: styling 2026-02-13 14:43:06 -06:00
Timothy Jaeryang Baek f027a01ab2 fix: direct model access control 2026-02-13 14:39:01 -06:00
Classic298 370a677a38 fix: pin torch to prevent startup errors on ARM devices (#21385)
* fix: rpi

* Update requirements-min.txt

* Update requirements.txt

* Update pyproject.toml
2026-02-13 14:28:23 -06:00
Timothy Jaeryang Baek d1d1efe212 refac: scim 2026-02-13 14:27:11 -06:00
Timothy Jaeryang Baek b7549d2f6c refac: defer profile 2026-02-13 14:08:07 -06:00
Timothy Jaeryang Baek 589c4e64c1 refac 2026-02-13 13:56:29 -06:00
Timothy Jaeryang Baek 20de5a87da refac 2026-02-13 13:43:43 -06:00
Timothy Jaeryang Baek ca6b18ab5c refac: is_user_active 2026-02-13 13:40:59 -06:00
Classic298 97a3b1528d Update utils.py (#21105) 2026-02-13 13:37:12 -06:00
Classic298 d01b1d4880 enh: apply admin default to builtin web search (#21373) 2026-02-13 13:32:48 -06:00
Timothy Jaeryang Baek df6e38039f refac 2026-02-13 13:29:22 -06:00
Timothy Jaeryang Baek b4c3f54f96 fix: skills postgres issue 2026-02-13 11:24:08 -06:00
Classic298 73776d54b8 fix: enforce public sharing permission checks across all resource types (#21358)
The sharePublic prop in editor components (Knowledge, Tools, Skills,
Prompts, Models) incorrectly included an "|| edit" / "|| write_access"
condition, allowing users with write access to see and use the "Public"
sharing option regardless of their actual public sharing permission.
Additionally, all backend access/update endpoints only verified write
authorization but did not check the corresponding sharing.public_*
permission, allowing direct API calls to bypass frontend restrictions
entirely.
Frontend: removed the edit/write_access bypass from sharePublic in all
five editor components so visibility is gated solely by the user's
sharing.public_* permission or admin role.
Backend: added has_public_read_access_grant checks to the access/update
endpoints in knowledge.py, tools.py, prompts.py, skills.py, models.py,
and notes.py. Public grants are silently stripped when the user lacks
the corresponding permission.
Fixes #21356
2026-02-13 11:22:32 -06:00
Timothy Jaeryang BaekandEntropyYue 7bda6bf767 fix: PostgreSQL cannot use get_chat_ids_by_model_id
Co-Authored-By: EntropyYue <164553692+EntropyYue@users.noreply.github.com>
2026-02-13 11:20:26 -06:00
G30 ddcec9842f fix: ensure sync modal only triggers if community sharing is enabled (#21376) 2026-02-13 11:18:58 -06:00
G30 4d5b7b3014 fix: resolve knowledge tooltip z-index issue in model edit page (#21375) 2026-02-13 11:18:35 -06:00
G30 9886ebb97f fix: resolve knowledge collection indentation/truncation issue by correcting flex layout (#21374) 2026-02-13 11:18:14 -06:00
Timothy Jaeryang Baek 0b05b2fc7e refac 2026-02-13 00:44:01 -06:00
joaoback 49e7eade15 Merge pull request #21345 from joaoback/patch-25
i18n: Update translation.json (pt-BR)
2026-02-12 19:42:52 -06:00
39 changed files with 1262 additions and 496 deletions
+34
View File
@@ -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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.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 ## [0.8.0] - 2026-02-12
### Added ### Added
+3 -2
View File
@@ -137,7 +137,8 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install --no-cache-dir uv && \ RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \ if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use # 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 && \ 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['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
@@ -145,7 +146,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 os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \ python -c "import nltk; nltk.download('punkt_tab')"; \
else \ 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 && \ uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \ if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
+9 -1
View File
@@ -478,7 +478,7 @@ ENABLE_PASSWORD_VALIDATION = (
) )
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get( PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"PASSWORD_VALIDATION_REGEX_PATTERN", "PASSWORD_VALIDATION_REGEX_PATTERN",
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
) )
@@ -572,6 +572,14 @@ ENABLE_SCIM = (
== "true" == "true"
) )
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "") 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 # LICENSE_KEY
@@ -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")
@@ -515,6 +515,62 @@ class AccessGrantsTable:
) )
return exists is not None 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( def get_users_with_access(
self, self,
resource_type: str, resource_type: str,
+9 -7
View File
@@ -15,6 +15,7 @@ from sqlalchemy import (
Text, Text,
JSON, JSON,
Index, Index,
func,
) )
#################### ####################
@@ -279,25 +280,26 @@ class ChatMessageTable:
db: Optional[Session] = None, db: Optional[Session] = None,
) -> list[str]: ) -> list[str]:
"""Get distinct chat_ids that used a specific model.""" """Get distinct chat_ids that used a specific model."""
from sqlalchemy import distinct
with get_db_context(db) as db: with get_db_context(db) as db:
query = db.query(distinct(ChatMessage.chat_id)).filter( query = db.query(
ChatMessage.model_id == model_id ChatMessage.chat_id,
) func.max(ChatMessage.created_at).label("last_message_at"),
).filter(ChatMessage.model_id == model_id)
if start_date: if start_date:
query = query.filter(ChatMessage.created_at >= start_date) query = query.filter(ChatMessage.created_at >= start_date)
if end_date: if end_date:
query = query.filter(ChatMessage.created_at <= 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 = ( 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) .offset(skip)
.limit(limit) .limit(limit)
.all() .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( def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None self, chat_id: str, db: Optional[Session] = None
+33 -1
View File
@@ -8,7 +8,7 @@ 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, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders from open_webui.models.folders import Folders
from open_webui.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 open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -621,6 +621,13 @@ class ChatTable:
) -> bool: ) -> bool:
try: try:
with get_db_context(db) as db: 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.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete()
db.commit() db.commit()
@@ -1410,6 +1417,7 @@ class ChatTable:
def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool: def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db_context(db) as db: 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.query(Chat).filter_by(id=id).delete()
db.commit() db.commit()
@@ -1422,6 +1430,7 @@ class ChatTable:
) -> bool: ) -> bool:
try: try:
with get_db_context(db) as db: 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.query(Chat).filter_by(id=id, user_id=user_id).delete()
db.commit() db.commit()
@@ -1436,6 +1445,12 @@ class ChatTable:
with get_db_context(db) as db: with get_db_context(db) as db:
self.delete_shared_chats_by_user_id(user_id, db=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.query(Chat).filter_by(user_id=user_id).delete()
db.commit() db.commit()
@@ -1448,6 +1463,14 @@ class ChatTable:
) -> bool: ) -> bool:
try: try:
with get_db_context(db) as db: 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.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
db.commit() db.commit()
@@ -1481,6 +1504,15 @@ class ChatTable:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() 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] 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.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete()
db.commit() db.commit()
+3 -3
View File
@@ -3,13 +3,13 @@ import time
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session 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.users import Users, UserResponse
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field 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__) log = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ class Skill(Base):
name = Column(Text, unique=True) name = Column(Text, unique=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
content = Column(Text) content = Column(Text)
meta = Column(JSONField) meta = Column(JSON)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
+89 -8
View File
@@ -1,7 +1,7 @@
import time import time
from typing import Optional 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.internal.db import Base, JSONField, get_db, get_db_context
@@ -15,7 +15,7 @@ from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url 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 ( from sqlalchemy import (
BigInteger, BigInteger,
JSON, JSON,
@@ -28,7 +28,7 @@ from sqlalchemy import (
select, select,
cast, cast,
) )
from sqlalchemy import or_, case from sqlalchemy import or_, case, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
import datetime import datetime
@@ -71,6 +71,7 @@ class User(Base):
settings = Column(JSON, nullable=True) settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True) oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
last_active_at = Column(BigInteger) last_active_at = Column(BigInteger)
updated_at = Column(BigInteger) updated_at = Column(BigInteger)
@@ -86,7 +87,7 @@ class UserModel(BaseModel):
name: str name: str
profile_image_url: str profile_image_url: Optional[str] = None
profile_banner_image_url: Optional[str] = None profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None bio: Optional[str] = None
@@ -103,6 +104,7 @@ class UserModel(BaseModel):
settings: Optional[UserSettings] = None settings: Optional[UserSettings] = None
oauth: Optional[dict] = None oauth: Optional[dict] = None
scim: Optional[dict] = None
last_active_at: int # timestamp in epoch last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch updated_at: int # timestamp in epoch
@@ -110,6 +112,12 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@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): class UserStatusModel(UserModel):
is_active: bool = False is_active: bool = False
@@ -315,8 +323,12 @@ class UsersTable:
) -> Optional[UserModel]: ) -> Optional[UserModel]:
try: try:
with get_db_context(db) as db: with get_db_context(db) as db:
user = db.query(User).filter_by(email=email).first() user = (
return UserModel.model_validate(user) db.query(User)
.filter(func.lower(User.email) == email.lower())
.first()
)
return UserModel.model_validate(user) if user else None
except Exception: except Exception:
return None return None
@@ -341,6 +353,29 @@ class UsersTable:
# You may want to log the exception here # You may want to log the exception here
return None 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( def get_users(
self, self,
filter: Optional[dict] = None, filter: Optional[dict] = None,
@@ -350,7 +385,7 @@ class UsersTable:
) -> dict: ) -> dict:
with get_db_context(db) as db: with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested # 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: if filter:
query_key = filter.get("query") query_key = filter.get("query")
@@ -485,6 +520,7 @@ class UsersTable:
with get_db_context(db) as db: with get_db_context(db) as db:
users = ( users = (
db.query(User) db.query(User)
.options(defer(User.profile_image_url))
.join(GroupMember, User.id == GroupMember.user_id) .join(GroupMember, User.id == GroupMember.user_id)
.filter(GroupMember.group_id == group_id) .filter(GroupMember.group_id == group_id)
.all() .all()
@@ -495,7 +531,12 @@ class UsersTable:
self, user_ids: list[str], db: Optional[Session] = None self, user_ids: list[str], db: Optional[Session] = None
) -> list[UserStatusModel]: ) -> list[UserStatusModel]:
with get_db_context(db) as db: 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] return [UserModel.model_validate(user) for user in users]
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
@@ -635,6 +676,38 @@ class UsersTable:
except Exception: except Exception:
return None 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( def update_user_by_id(
self, id: str, updated: dict, db: Optional[Session] = None self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]: ) -> Optional[UserModel]:
@@ -762,6 +835,14 @@ class UsersTable:
) )
return count 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: def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db: with get_db_context(db) as db:
user = db.query(User).filter_by(id=user_id).first() user = db.query(User).filter_by(id=user_id).first()
+10 -10
View File
@@ -4,6 +4,7 @@ KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figu
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
# Removes large/redundant fields from metadata dict.
metadata = { metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE 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( def process_metadata(
metadata: dict[str, any], metadata: dict[str, any],
) -> 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(): for key, value in metadata.items():
# Remove large fields # Skip large fields
if key in KEYS_TO_EXCLUDE: if key in KEYS_TO_EXCLUDE:
del metadata[key] continue
# Convert non-serializable fields to strings # Convert non-serializable fields to strings
if ( if isinstance(value, (datetime, list, dict)):
isinstance(value, datetime) result[key] = str(value)
or isinstance(value, list) else:
or isinstance(value, dict) result[key] = value
): return result
metadata[key] = str(value)
return metadata
+2 -2
View File
@@ -145,7 +145,7 @@ def create_session_response(
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"profile_image_url": user.profile_image_url, "profile_image_url": f"/api/v1/users/{user.id}/profile/image",
"permissions": user_permissions, "permissions": user_permissions,
} }
@@ -926,7 +926,7 @@ async def add_user(
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"profile_image_url": user.profile_image_url, "profile_image_url": f"/api/v1/users/{user.id}/profile/image",
} }
else: else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
+4 -8
View File
@@ -204,7 +204,7 @@ async def get_channels(
UserIdNameStatusResponse( UserIdNameStatusResponse(
**{ **{
**user.model_dump(), **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) for user in Users.get_users_by_user_ids(user_ids, db=db)
@@ -424,7 +424,7 @@ async def get_channel_by_id(
UserIdNameStatusResponse( UserIdNameStatusResponse(
**{ **{
**user.model_dump(), **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) 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 { return {
"users": [ "users": [
UserModelResponse( UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
)
for user in users for user in users
], ],
"total": total, "total": total,
@@ -575,9 +573,7 @@ async def get_channel_members_by_id(
return { return {
"users": [ "users": [
UserModelResponse( UserModelResponse(**user.model_dump(), is_active=Users.is_active(user))
**user.model_dump(), is_active=Users.is_user_active(user.id, db=db)
)
for user in users for user in users
], ],
"total": total, "total": total,
+50 -16
View File
@@ -115,8 +115,10 @@ async def get_knowledge_bases(
skip = (page - 1) * limit skip = (page - 1) * limit
filter = {} 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: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in 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 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( return KnowledgeAccessListResponse(
items=[ items=[
KnowledgeAccessResponse( KnowledgeAccessResponse(
@@ -133,13 +146,7 @@ async def get_knowledge_bases(
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or AccessGrants.has_access( or knowledge_base.id in writable_knowledge_base_ids
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="write",
db=db,
)
), ),
) )
for knowledge_base in result.items for knowledge_base in result.items
@@ -166,8 +173,10 @@ async def search_knowledge_bases(
if view_option: if view_option:
filter["view_option"] = 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: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in 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 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( return KnowledgeAccessListResponse(
items=[ items=[
KnowledgeAccessResponse( KnowledgeAccessResponse(
@@ -184,13 +204,7 @@ async def search_knowledge_bases(
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or AccessGrants.has_access( or knowledge_base.id in writable_knowledge_base_ids
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="write",
db=db,
)
), ),
) )
for knowledge_base in result.items for knowledge_base in result.items
@@ -511,6 +525,7 @@ class KnowledgeAccessGrantsForm(BaseModel):
@router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse]) @router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_access_by_id( async def update_knowledge_access_by_id(
request: Request,
id: str, id: str,
form_data: KnowledgeAccessGrantsForm, form_data: KnowledgeAccessGrantsForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@@ -539,6 +554,25 @@ async def update_knowledge_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 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) AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
+62 -12
View File
@@ -8,14 +8,16 @@ import logging
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.models import ( from open_webui.models.models import (
ModelForm, ModelForm,
ModelMeta,
ModelModel, ModelModel,
ModelParams,
ModelResponse, ModelResponse,
ModelListResponse, ModelListResponse,
ModelAccessListResponse, ModelAccessListResponse,
ModelAccessResponse, ModelAccessResponse,
Models, 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 pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@@ -84,14 +86,29 @@ async def get_models(
if direction: if direction:
filter["direction"] = 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: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) 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( return ModelAccessListResponse(
items=[ items=[
ModelAccessResponse( ModelAccessResponse(
@@ -99,13 +116,7 @@ async def get_models(
write_access=( write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id or user.id == model.user_id
or AccessGrants.has_access( or model.id in writable_model_ids
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
), ),
) )
for model in result.items for model in result.items
@@ -506,16 +517,36 @@ class ModelAccessGrantsForm(BaseModel):
@router.post("/model/access/update", response_model=Optional[ModelModel]) @router.post("/model/access/update", response_model=Optional[ModelModel])
async def update_model_access_by_id( async def update_model_access_by_id(
request: Request,
form_data: ModelAccessGrantsForm, form_data: ModelAccessGrantsForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session), db: Session = Depends(get_session),
): ):
model = Models.get_model_by_id(form_data.id, db=db) 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: if not model:
raise HTTPException( if user.role != "admin":
status_code=status.HTTP_404_NOT_FOUND, raise HTTPException(
detail=ERROR_MESSAGES.NOT_FOUND, 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 ( if (
model.user_id != user.id model.user_id != user.id
@@ -533,6 +564,25 @@ async def update_model_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 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( AccessGrants.set_access_grants(
"model", form_data.id, form_data.access_grants, db=db "model", form_data.id, form_data.access_grants, db=db
) )
+19
View File
@@ -345,6 +345,25 @@ async def update_note_access_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() 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) AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
return Notes.get_note_by_id(id, db=db) return Notes.get_note_by_id(id, db=db)
+50 -38
View File
@@ -418,21 +418,29 @@ async def get_all_models(request: Request, user: UserModel = None):
async def get_filtered_models(models, user, db=None): async def get_filtered_models(models, user, db=None):
# Filter models based on user access control # Filter models based on user access control
model_ids = [model["model"] for model in models.get("models", [])] 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)} model_infos = {
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} 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 = [] filtered_models = []
for model in models.get("models", []): for model in models.get("models", []):
model_info = model_infos.get(model["model"]) model_info = model_infos.get(model["model"])
if model_info: if model_info:
if user.id == model_info.user_id or AccessGrants.has_access( if user.id == model_info.user_id or model_info.id in accessible_model_ids:
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
):
filtered_models.append(model) filtered_models.append(model)
return filtered_models return filtered_models
@@ -653,10 +661,6 @@ async def unload_model(
await get_all_models(request, user=user) await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS 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: if model_name not in models:
raise HTTPException( raise HTTPException(
status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name) status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)
@@ -1333,6 +1337,9 @@ async def generate_chat_completion(
# Check if user has access to the model # Check if user has access to the model
if not bypass_filter and user.role == "user": 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 ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or AccessGrants.has_access( or AccessGrants.has_access(
@@ -1340,6 +1347,7 @@ async def generate_chat_completion(
resource_type="model", resource_type="model",
resource_id=model_info.id, resource_id=model_info.id,
permission="read", permission="read",
user_group_ids=user_group_ids,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1353,9 +1361,6 @@ async def generate_chat_completion(
detail="Model not found", 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) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), str(url_idx),
@@ -1432,9 +1437,6 @@ async def generate_openai_completion(
del payload["metadata"] del payload["metadata"]
model_id = form_data.model 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) model_info = Models.get_model_by_id(model_id)
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
@@ -1446,6 +1448,9 @@ async def generate_openai_completion(
# Check if user has access to the model # Check if user has access to the model
if user.role == "user": if user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or AccessGrants.has_access( or AccessGrants.has_access(
@@ -1453,6 +1458,7 @@ async def generate_openai_completion(
resource_type="model", resource_type="model",
resource_id=model_info.id, resource_id=model_info.id,
permission="read", permission="read",
user_group_ids=user_group_ids,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1466,9 +1472,6 @@ async def generate_openai_completion(
detail="Model not found", 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) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), str(url_idx),
@@ -1518,9 +1521,6 @@ async def generate_openai_chat_completion(
del payload["metadata"] del payload["metadata"]
model_id = completion_form.model 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) model_info = Models.get_model_by_id(model_id)
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
@@ -1536,6 +1536,9 @@ async def generate_openai_chat_completion(
# Check if user has access to the model # Check if user has access to the model
if user.role == "user": if user.role == "user":
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id)
}
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or AccessGrants.has_access( or AccessGrants.has_access(
@@ -1543,6 +1546,7 @@ async def generate_openai_chat_completion(
resource_type="model", resource_type="model",
resource_id=model_info.id, resource_id=model_info.id,
permission="read", permission="read",
user_group_ids=user_group_ids,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1556,9 +1560,6 @@ async def generate_openai_chat_completion(
detail="Model not found", 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) url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx), str(url_idx),
@@ -1637,20 +1638,31 @@ async def get_openai_models(
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
# Filter models based on user access control # Filter models based on user access control
model_ids = [model["id"] for model in models] 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)} model_infos = {
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} 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 = [] filtered_models = []
for model in models: for model in models:
model_info = model_infos.get(model["id"]) model_info = model_infos.get(model["id"])
if model_info: if model_info:
if user.id == model_info.user_id or AccessGrants.has_access( if (
user_id=user.id, user.id == model_info.user_id
resource_type="model", or model_info.id in accessible_model_ids
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
): ):
filtered_models.append(model) filtered_models.append(model)
models = filtered_models models = filtered_models
+169 -14
View File
@@ -18,7 +18,7 @@ from fastapi.responses import (
JSONResponse, JSONResponse,
PlainTextResponse, PlainTextResponse,
) )
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -455,21 +455,29 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
async def get_filtered_models(models, user, db=None): async def get_filtered_models(models, user, db=None):
# Filter models based on user access control # Filter models based on user access control
model_ids = [model["id"] for model in models.get("data", [])] 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)} model_infos = {
user_group_ids = {g.id for g in Groups.get_groups_by_member_id(user.id, db=db)} 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 = [] filtered_models = []
for model in models.get("data", []): for model in models.get("data", []):
model_info = model_infos.get(model["id"]) model_info = model_infos.get(model["id"])
if model_info: if model_info:
if user.id == model_info.user_id or AccessGrants.has_access( if user.id == model_info.user_id or model_info.id in accessible_model_ids:
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
):
filtered_models.append(model) filtered_models.append(model)
return filtered_models return filtered_models
@@ -596,9 +604,12 @@ async def get_models(
if r.status != 200: if r.status != 200:
# Extract response error details if available # Extract response error details if available
error_detail = f"HTTP Error: {r.status}" error_detail = f"HTTP Error: {r.status}"
res = await r.json() try:
if "error" in res: res = await r.json()
error_detail = f"External Error: {res['error']}" if "error" in res:
error_detail = f"External Error: {res['error']}"
except Exception:
pass
raise Exception(error_detail) raise Exception(error_detail)
response_data = await r.json() response_data = await r.json()
@@ -960,6 +971,9 @@ async def generate_chat_completion(
# Check if user has access to the model # Check if user has access to the model
if not bypass_filter and user.role == "user": 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 ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or AccessGrants.has_access( or AccessGrants.has_access(
@@ -967,6 +981,7 @@ async def generate_chat_completion(
resource_type="model", resource_type="model",
resource_id=model_info.id, resource_id=model_info.id,
permission="read", permission="read",
user_group_ids=user_group_ids,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1208,6 +1223,129 @@ async def embeddings(request: Request, form_data: dict, user):
await cleanup_response(r, session) 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
@router.post("/responses")
async def responses(
request: Request,
form_data: ResponsesForm,
user=Depends(get_verified_user),
):
"""
Forward requests to the OpenAI Responses API endpoint.
Routes to the correct upstream backend based on the model field.
"""
payload = form_data.model_dump(exclude_none=True)
body = json.dumps(payload)
idx = 0
model_id = form_data.model
if model_id:
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
session = None
streaming = False
try:
headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key
headers["api-version"] = api_version
model = payload.get("model", "")
request_url = (
f"{url}/openai/deployments/{model}/responses?api-version={api_version}"
)
else:
request_url = f"{url}/responses"
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method="POST",
url=request_url,
data=body,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
# Check if response is SSE
if "text/event-stream" in r.headers.get("Content-Type", ""):
streaming = True
return StreamingResponse(
stream_wrapper(r, session),
status_code=r.status,
headers=dict(r.headers),
)
else:
try:
response_data = await r.json()
except Exception:
response_data = await r.text()
if r.status >= 400:
if isinstance(response_data, (dict, list)):
return JSONResponse(status_code=r.status, content=response_data)
else:
return PlainTextResponse(
status_code=r.status, content=response_data
)
return response_data
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=r.status if r else 500,
detail="Open WebUI: Server Connection Error",
)
finally:
if not streaming:
await cleanup_response(r, session)
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
async def proxy(path: str, request: Request, user=Depends(get_verified_user)): async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
""" """
@@ -1216,7 +1354,24 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
body = await request.body() body = await request.body()
# Parse JSON body to resolve model-based routing
payload = None
if body:
try:
payload = json.loads(body)
except (json.JSONDecodeError, ValueError):
payload = None
idx = 0 idx = 0
model_id = payload.get("model") if isinstance(payload, dict) else None
if model_id:
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
await get_all_models(request, user=user)
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx] url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get( api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
+37 -9
View File
@@ -9,7 +9,7 @@ from open_webui.models.prompts import (
PromptModel, PromptModel,
Prompts, 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.groups import Groups
from open_webui.models.prompt_history import ( from open_webui.models.prompt_history import (
PromptHistories, PromptHistories,
@@ -100,8 +100,11 @@ async def get_prompt_list(
if direction: if direction:
filter["direction"] = 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): if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in 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 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( return PromptAccessListResponse(
items=[ items=[
PromptAccessResponse( PromptAccessResponse(
@@ -118,13 +132,7 @@ async def get_prompt_list(
write_access=( write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id or user.id == prompt.user_id
or AccessGrants.has_access( or prompt.id in writable_prompt_ids
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
), ),
) )
for prompt in result.items for prompt in result.items
@@ -436,6 +444,7 @@ class PromptAccessGrantsForm(BaseModel):
@router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel]) @router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel])
async def update_prompt_access_by_id( async def update_prompt_access_by_id(
request: Request,
prompt_id: str, prompt_id: str,
form_data: PromptAccessGrantsForm, form_data: PromptAccessGrantsForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@@ -464,6 +473,25 @@ async def update_prompt_access_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, 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) AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
return Prompts.get_prompt_by_id(prompt_id, db=db) return Prompts.get_prompt_by_id(prompt_id, db=db)
+82 -8
View File
@@ -25,6 +25,9 @@ from open_webui.utils.auth import (
) )
from open_webui.constants import ERROR_MESSAGES 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 sqlalchemy.orm import Session
from open_webui.internal.db import get_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: def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
"""Convert internal User model to SCIM User""" """Convert internal User model to SCIM User"""
# Parse display name into name components # Parse display name into name components
@@ -321,6 +361,7 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
return SCIMUser( return SCIMUser(
id=user.id, id=user.id,
externalId=get_external_id(user),
userName=user.email, userName=user.email,
name=SCIMName( name=SCIMName(
formatted=user.name, formatted=user.name,
@@ -494,13 +535,17 @@ async def get_users(
# Get users from database # Get users from database
if filter: if filter:
# Simple filter parsing - supports userName eq "email" # Simple filter parsing - supports userName eq, externalId eq
# In production, you'd want a more robust filter parser
if "userName eq" in filter: if "userName eq" in filter:
email = filter.split('"')[1] email = filter.split('"')[1]
user = Users.get_user_by_email(email, db=db) user = Users.get_user_by_email(email, db=db)
users_list = [user] if user else [] users_list = [user] if user else []
total = 1 if user else 0 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: else:
response = Users.get_users(skip=skip, limit=limit, db=db) response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"] users_list = response["users"]
@@ -546,17 +591,33 @@ async def create_user(
db: Session = Depends(get_session), db: Session = Depends(get_session),
): ):
"""Create SCIM User""" """Create SCIM User"""
# Check if user already exists # Check for duplicate by externalId
existing_user = Users.get_user_by_email(user_data.userName, db=db) 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: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, 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 # Create user
user_id = str(uuid.uuid4()) user_id = str(uuid.uuid4())
email = user_data.emails[0].value if user_data.emails else user_data.userName
# Parse name if provided # Parse name if provided
name = user_data.displayName name = user_data.displayName
@@ -571,7 +632,6 @@ async def create_user(
if user_data.photos and len(user_data.photos) > 0: if user_data.photos and len(user_data.photos) > 0:
profile_image = user_data.photos[0].value profile_image = user_data.photos[0].value
# Create user
new_user = Users.insert_new_user( new_user = Users.insert_new_user(
id=user_id, id=user_id,
name=name, name=name,
@@ -587,6 +647,12 @@ async def create_user(
detail="Failed to 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) 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: if user_data.photos and len(user_data.photos) > 0:
update_data["profile_image_url"] = user_data.photos[0].value 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) updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user: if not updated_user:
raise HTTPException( raise HTTPException(
@@ -639,6 +704,12 @@ async def update_user(
detail="Failed to 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) return user_to_scim(updated_user, request, db=db)
@@ -676,6 +747,9 @@ async def patch_user(
update_data["email"] = value update_data["email"] = value
elif path == "name.formatted": elif path == "name.formatted":
update_data["name"] = value 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 # Update user
if update_data: if update_data:
+21 -1
View File
@@ -17,7 +17,7 @@ from open_webui.models.skills import (
SkillAccessListResponse, SkillAccessListResponse,
Skills, 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.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
@@ -312,6 +312,7 @@ class SkillAccessGrantsForm(BaseModel):
@router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) @router.post("/id/{id}/access/update", response_model=Optional[SkillModel])
async def update_skill_access_by_id( async def update_skill_access_by_id(
request: Request,
id: str, id: str,
form_data: SkillAccessGrantsForm, form_data: SkillAccessGrantsForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@@ -340,6 +341,25 @@ async def update_skill_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED, 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) AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
return Skills.get_skill_by_id(id, db=db) return Skills.get_skill_by_id(id, db=db)
+21 -1
View File
@@ -21,7 +21,7 @@ from open_webui.models.tools import (
ToolAccessResponse, ToolAccessResponse,
Tools, 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 ( from open_webui.utils.plugin import (
load_tool_module_by_id, load_tool_module_by_id,
replace_imports, replace_imports,
@@ -526,6 +526,7 @@ class ToolAccessGrantsForm(BaseModel):
@router.post("/id/{id}/access/update", response_model=Optional[ToolModel]) @router.post("/id/{id}/access/update", response_model=Optional[ToolModel])
async def update_tool_access_by_id( async def update_tool_access_by_id(
request: Request,
id: str, id: str,
form_data: ToolAccessGrantsForm, form_data: ToolAccessGrantsForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
@@ -554,6 +555,25 @@ async def update_tool_access_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED, 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) AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
return Tools.get_tool_by_id(id, db=db) return Tools.get_tool_by_id(id, db=db)
+7 -1
View File
@@ -314,7 +314,13 @@ async def connect(sid, environ, auth):
if user: if user:
SESSION_POOL[sid] = user.model_dump( 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}") await sio.enter_room(sid, f"user:{user.id}")
@@ -12,7 +12,7 @@ def _assert_user(data, id, **kwargs):
comparison_data = { comparison_data = {
"name": f"user {id}", "name": f"user {id}",
"email": f"user{id}@openwebui.com", "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", "role": "user",
**kwargs, **kwargs,
} }
@@ -150,7 +150,7 @@ class TestUsers(AbstractPostgresTest):
role="admin", role="admin",
name="user 2 updated", name="user 2 updated",
email="user2-updated@openwebui.com", 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 # Delete user by id
+3
View File
@@ -167,6 +167,9 @@ async def search_web(
engine = __request__.app.state.config.WEB_SEARCH_ENGINE engine = __request__.app.state.config.WEB_SEARCH_ENGINE
user = UserModel(**__user__) if __user__ else None 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) results = await asyncio.to_thread(_search_web, __request__, engine, query, user)
# Limit results # Limit results
+180 -107
View File
@@ -411,6 +411,36 @@ def serialize_output(output: list) -> str:
if content and not content.endswith("\n"): if content and not content.endswith("\n"):
content += "\n" content += "\n"
# Render the code_interpreter item as a <details> 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'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
else:
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
return content.strip() return content.strip()
@@ -2930,11 +2960,14 @@ async def streaming_chat_response_handler(response, ctx):
# Handle as a background task # Handle as a background task
async def response_handler(response, events): 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 Detect special tags (reasoning, solution, code_interpreter) in streaming
content and create corresponding OR-aligned output items directly. content and create corresponding OR-aligned output items directly.
Operates on output items instead of content_blocks. 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 end_flag = False
@@ -2974,6 +3007,8 @@ async def streaming_chat_response_handler(response, ctx):
last_type = output[-1].get("type", "") if output else "" last_type = output[-1].get("type", "") if output else ""
if last_type == "message": 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: for start_tag, end_tag in tags:
start_tag_pattern = rf"{re.escape(start_tag)}" start_tag_pattern = rf"{re.escape(start_tag)}"
@@ -2982,7 +3017,7 @@ async def streaming_chat_response_handler(response, ctx):
rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>" 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: if match:
try: try:
attr_content = match.group(1) if match.group(1) else "" attr_content = match.group(1) if match.group(1) else ""
@@ -2991,20 +3026,13 @@ async def streaming_chat_response_handler(response, ctx):
attributes = extract_attributes(attr_content) attributes = extract_attributes(attr_content)
before_tag = content[: match.start()] before_tag = item_text[: match.start()]
after_tag = content[match.end() :] after_tag = item_text[match.end() :]
# Remove the start tag and everything after from last message # Keep only text before the tag in the message
current_text = get_last_text(output) set_last_text(output, before_tag)
set_last_text(
output,
current_text.replace(match.group(0) + after_tag, ""),
)
if before_tag: if not before_tag.strip():
set_last_text(output, before_tag)
if not get_last_text(output).strip():
# Remove empty message item # Remove empty message item
if output and output[-1].get("type") == "message": if output and output[-1].get("type") == "message":
output.pop() output.pop()
@@ -3069,9 +3097,11 @@ async def streaming_chat_response_handler(response, ctx):
else: else:
set_last_text(output, after_tag) set_last_text(output, after_tag)
tag_output_handler( _, recursive_end = tag_output_handler(
content_type, tags, after_tag, output content_type, tags, output
) )
if recursive_end:
end_flag = True
break break
@@ -3090,25 +3120,22 @@ async def streaming_chat_response_handler(response, ctx):
start_tag = item.get("start_tag", "") start_tag = item.get("start_tag", "")
end_tag = item.get("end_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: 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 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 # Strip start and end tags from content
start_tag_pattern = rf"{re.escape(start_tag)}" start_tag_pattern = rf"{re.escape(start_tag)}"
if start_tag.startswith("<") and start_tag.endswith(">"): if start_tag.startswith("<") and start_tag.endswith(">"):
@@ -3151,36 +3178,20 @@ async def streaming_chat_response_handler(response, ctx):
item["ended_at"] = time.time() item["ended_at"] = time.time()
# Reset by appending a new message item for leftover # Reset by appending a new message item for leftover
if content_type != "code_interpreter": output.append(
output.append( {
{ "type": "message",
"type": "message", "id": output_id("msg"),
"id": output_id("msg"), "status": "in_progress",
"status": "in_progress", "role": "assistant",
"role": "assistant", "content": [
"content": [ {
{ "type": "output_text",
"type": "output_text", "text": leftover_content,
"text": leftover_content, }
} ],
], }
} )
)
else:
output.append(
{
"type": "message",
"id": output_id("msg"),
"status": "in_progress",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": leftover_content,
}
],
}
)
else: else:
# Remove the block if content is empty # Remove the block if content is empty
output.pop() output.pop()
@@ -3199,19 +3210,7 @@ async def streaming_chat_response_handler(response, ctx):
} }
) )
# Clean processed content return output, end_flag
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
message = Chats.get_message_by_id_and_message_id( message = Chats.get_message_by_id_and_message_id(
metadata["chat_id"], metadata["message_id"] metadata["chat_id"], metadata["message_id"]
@@ -3674,58 +3673,132 @@ async def streaming_chat_response_handler(response, ctx):
) )
content = f"{content}{value}" content = f"{content}{value}"
if (
not output # Check if we're inside a tag-based block
or output[-1].get("type") != "message" # (reasoning, code_interpreter, or solution).
): # If so, append to the existing in-progress
output.append( # item instead of creating a new message —
{ # otherwise tag_output_handler re-detects the
"type": "message", # start tag on every chunk and fragments the
"id": output_id("msg"), # output.
"status": "in_progress", last_item = output[-1] if output else None
"role": "assistant", last_item_type = (
"content": [ 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", "type": "output_text",
"text": "", "text": value,
} }
], ]
} else:
) # solution or other _tag_type message
msg_parts = last_item.get("content", [])
# Append value to last message item's text if (
msg_parts = output[-1].get("content", []) msg_parts
if ( and msg_parts[-1].get("type")
msg_parts == "output_text"
and msg_parts[-1].get("type") ):
== "output_text" msg_parts[-1]["text"] += value
): else:
msg_parts[-1]["text"] += value last_item["content"] = [
{
"type": "output_text",
"text": value,
}
]
else: else:
output[-1]["content"] = [ if (
{"type": "output_text", "text": value} 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: if DETECT_REASONING_TAGS:
content, output, _ = tag_output_handler( output, _ = tag_output_handler(
"reasoning", "reasoning",
reasoning_tags, reasoning_tags,
content,
output, output,
) )
content, output, _ = tag_output_handler( output, _ = tag_output_handler(
"solution", "solution",
DEFAULT_SOLUTION_TAGS, DEFAULT_SOLUTION_TAGS,
content,
output, output,
) )
if DETECT_CODE_INTERPRETER: if DETECT_CODE_INTERPRETER:
content, output, end = tag_output_handler( output, end = tag_output_handler(
"code_interpreter", "code_interpreter",
DEFAULT_CODE_INTERPRETER_TAGS, DEFAULT_CODE_INTERPRETER_TAGS,
content,
output, output,
) )
+19 -17
View File
@@ -230,25 +230,27 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
# else: skip reasoning blocks for normal LLM messages # else: skip reasoning blocks for normal LLM messages
elif item_type == "open_webui:code_interpreter": elif item_type == "open_webui:code_interpreter":
if raw: # Always include code interpreter content so the LLM knows
# Include code interpreter content for LLM re-processing # the code was already executed and doesn't retry.
code = item.get("code", "") code = item.get("code", "")
code_output = item.get("output", "") code_output = item.get("output", "")
if code: if code:
lang = item.get("lang", "python") pending_content.append(
pending_content.append(f"```{lang}\n{code}\n```") f"<code_interpreter>\n{code}\n</code_interpreter>"
)
if code_output: if code_output:
if isinstance(code_output, dict): if isinstance(code_output, dict):
stdout = code_output.get("stdout", "") stdout = code_output.get("stdout", "")
result = code_output.get("result", "") result = code_output.get("result", "")
output_text = stdout or result output_text = stdout or result
else: else:
output_text = str(code_output) output_text = str(code_output)
if output_text: if output_text:
pending_content.append(f"Output:\n{output_text}") pending_content.append(
# else: skip extension types f"<code_interpreter_output>\n{output_text}\n</code_interpreter_output>"
)
elif item_type.startswith("open_webui:"): elif item_type.startswith("open_webui:"):
# Skip other extension types # Skip other extension types
+13 -9
View File
@@ -377,10 +377,21 @@ def get_filtered_models(models, user, db=None):
for model_info in Models.get_models_by_ids(model_ids) for model_info in Models.get_models_by_ids(model_ids)
} }
filtered_models = []
user_group_ids = { user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db) 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: for model in models:
if model.get("arena"): if model.get("arena"):
meta = model.get("info", {}).get("meta", {}) meta = model.get("info", {}).get("meta", {})
@@ -399,14 +410,7 @@ def get_filtered_models(models, user, db=None):
if ( if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.user_id or user.id == model_info.user_id
or AccessGrants.has_access( or model_info.id in accessible_model_ids
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
): ):
filtered_models.append(model) filtered_models.append(model)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.8.0", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.8.0", "version": "0.8.1",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.5.0", "@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.8.0", "version": "0.8.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@@ -186,12 +186,16 @@
}} }}
data-selected={idx === selectedIdx} data-selected={idx === selectedIdx}
> >
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0"> <div class="w-full text-left text-black dark:text-gray-100 flex items-center gap-1">
<Tooltip content={$i18n.t('Collection')} placement="top"> <Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-4" /> <Database className="size-4" />
</Tooltip> </Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start"> <Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
className="flex flex-1 min-w-0"
>
<div class="line-clamp-1 flex-1 text-sm"> <div class="line-clamp-1 flex-1 text-sm">
{decodeString(item?.name)} {decodeString(item?.name)}
</div> </div>
@@ -178,6 +178,16 @@
selectedModelIdx = 0; 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(); await tick();
const item = document.querySelector(`[data-arrow-selected="true"]`); const item = document.querySelector(`[data-arrow-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
@@ -379,6 +389,7 @@
bind:open={show} bind:open={show}
onOpenChange={async () => { onOpenChange={async () => {
searchValue = ''; searchValue = '';
listScrollTop = 0;
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
resetView(); resetView();
@@ -836,9 +836,7 @@
bind:show={showAccessControlModal} bind:show={showAccessControlModal}
bind:accessGrants={knowledge.access_grants} bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
$user?.role === 'admin' ||
knowledge?.write_access}
onChange={async () => { onChange={async () => {
try { try {
await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []); await updateKnowledgeAccessGrants(localStorage.token, id, knowledge.access_grants ?? []);
@@ -856,7 +854,7 @@
<div class="w-full flex justify-between items-center"> <div class="w-full flex justify-between items-center">
<input <input
type="text" type="text"
class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1" class="text-left w-full text-lg bg-transparent outline-hidden flex-1"
bind:value={knowledge.name} bind:value={knowledge.name}
placeholder={$i18n.t('Knowledge Name')} placeholder={$i18n.t('Knowledge Name')}
disabled={!knowledge?.write_access} disabled={!knowledge?.write_access}
@@ -170,15 +170,27 @@
> >
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0"> <div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
{#if item.type === 'note'} {#if item.type === 'note'}
<Tooltip content={$i18n.t('Note')} placement="top"> <Tooltip
content={$i18n.t('Note')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<PageEdit className="size-4" /> <PageEdit className="size-4" />
</Tooltip> </Tooltip>
{:else if item.type === 'collection'} {:else if item.type === 'collection'}
<Tooltip content={$i18n.t('Collection')} placement="top"> <Tooltip
content={$i18n.t('Collection')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<Database className="size-4" /> <Database className="size-4" />
</Tooltip> </Tooltip>
{:else if item.type === 'file'} {:else if item.type === 'file'}
<Tooltip content={$i18n.t('File')} placement="top"> <Tooltip
content={$i18n.t('File')}
placement="top"
tippyOptions={{ zIndex: 100000 }}
>
<DocumentPage className="size-4" /> <DocumentPage className="size-4" />
</Tooltip> </Tooltip>
{/if} {/if}
@@ -186,6 +198,7 @@
<Tooltip <Tooltip
content={item.description || decodeString(item?.name)} content={item.description || decodeString(item?.name)}
placement="top-start" placement="top-start"
tippyOptions={{ zIndex: 100000 }}
> >
<div class="line-clamp-1 flex-1 text-sm text-left"> <div class="line-clamp-1 flex-1 text-sm text-left">
{decodeString(item?.name)} {decodeString(item?.name)}
@@ -342,14 +342,14 @@
bind:accessGrants bind:accessGrants
accessRoles={preset ? ['read', 'write'] : ['read']} accessRoles={preset ? ['read', 'write'] : ['read']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} 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 () => { onChange={async () => {
if (edit && model?.id) { if (edit && model?.id) {
try { try {
await updateModelAccessGrants(localStorage.token, model.id, accessGrants); await updateModelAccessGrants(localStorage.token, model.id, accessGrants);
toast.success($i18n.t('Saved')); toast.success($i18n.t('Saved'));
} catch (error) { } catch (error) {
toast.error(`${error}`); toast.error(error?.detail ?? `${error}`);
} }
} }
}} }}
@@ -466,11 +466,11 @@
}} }}
> >
<div class="w-full px-1"> <div class="w-full px-1">
<div class="flex flex-col md:flex-row gap-4 w-full"> <div class="flex flex-row gap-4 md:gap-6 w-full">
<div class="self-center md:self-start flex justify-center my-2 shrink-0"> <div class="self-start flex justify-center my-2 shrink-0">
<div class="self-center"> <div class="self-center">
<button <button
class="rounded-xl flex shrink-0 items-center {info.meta.profile_image_url !== class="rounded-2xl flex shrink-0 items-center {info.meta.profile_image_url !==
`${WEBUI_BASE_URL}/static/favicon.png` `${WEBUI_BASE_URL}/static/favicon.png`
? 'bg-transparent' ? 'bg-transparent'
: 'bg-white'} shadow-xl group relative" : 'bg-white'} shadow-xl group relative"
@@ -483,13 +483,13 @@
<img <img
src={info.meta.profile_image_url} src={info.meta.profile_image_url}
alt="model profile" alt="model profile"
class="rounded-xl size-60 object-cover shrink-0" class="rounded-xl size-20 md:size-48 object-cover shrink-0"
/> />
{:else} {:else}
<img <img
src="{WEBUI_BASE_URL}/static/favicon.png" src="{WEBUI_BASE_URL}/static/favicon.png"
alt="model profile" alt="model profile"
class=" rounded-xl size-60 object-cover shrink-0" class=" rounded-xl size-20 md:size-48 object-cover shrink-0"
/> />
{/if} {/if}
@@ -538,7 +538,7 @@
<div class=" flex flex-col w-full"> <div class=" flex flex-col w-full">
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<input <input
class="text-4xl font-medium w-full bg-transparent outline-hidden" class="text-3xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')} placeholder={$i18n.t('Model Name')}
bind:value={name} bind:value={name}
required required
@@ -282,7 +282,7 @@
bind:accessGrants bind:accessGrants
accessRoles={['read', 'write']} accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'} share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit} sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
onChange={async () => { onChange={async () => {
if (edit && prompt?.id) { if (edit && prompt?.id) {
try { try {
@@ -375,7 +375,7 @@
<div class="flex items-start justify-between gap-4 shrink-0"> <div class="flex items-start justify-between gap-4 shrink-0">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<input <input
class="text-2xl font-medium w-full bg-transparent outline-hidden" class="text-2xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Prompt Name')} placeholder={$i18n.t('Prompt Name')}
bind:value={name} bind:value={name}
on:input={debouncedSaveMetadata} on:input={debouncedSaveMetadata}
@@ -534,7 +534,7 @@
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex items-center"> <div class="flex items-center">
<input <input
class="text-2xl font-medium w-full bg-transparent outline-hidden" class="text-2xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Name')} placeholder={$i18n.t('Name')}
bind:value={name} bind:value={name}
required required
@@ -113,7 +113,7 @@
bind:accessGrants bind:accessGrants
accessRoles={['read', 'write']} accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'} share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin' || edit} sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin'}
onChange={async () => { onChange={async () => {
if (edit && skill?.id) { if (edit && skill?.id) {
try { try {
@@ -149,7 +149,7 @@
<div class="flex-1"> <div class="flex-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start"> <Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<input <input
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary" class="w-full text-2xl bg-transparent outline-hidden"
type="text" type="text"
placeholder={$i18n.t('Skill Name')} placeholder={$i18n.t('Skill Name')}
bind:value={name} bind:value={name}
@@ -192,7 +192,7 @@ class Tools:
bind:accessGrants bind:accessGrants
accessRoles={['read', 'write']} accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'} share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin' || edit} sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
onChange={async () => { onChange={async () => {
if (edit && id) { if (edit && id) {
try { try {
@@ -238,7 +238,7 @@ class Tools:
<div class="flex-1"> <div class="flex-1">
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start"> <Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
<input <input
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary" class="w-full text-2xl bg-transparent outline-hidden"
type="text" type="text"
placeholder={$i18n.t('Tool Name')} placeholder={$i18n.t('Tool Name')}
bind:value={name} bind:value={name}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -849,7 +849,11 @@
} }
// Auto-show SyncStatsModal when opened with ?sync=true (from community) // Auto-show SyncStatsModal when opened with ?sync=true (from community)
if ((window.opener ?? false) && $page.url.searchParams.get('sync') === 'true') { if (
(window.opener ?? false) &&
$page.url.searchParams.get('sync') === 'true' &&
($config?.features?.enable_community_sharing ?? false)
) {
showSyncStatsModal = true; showSyncStatsModal = true;
} }