diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py
index 5a9844c067..54ca0218d7 100644
--- a/backend/open_webui/config.py
+++ b/backend/open_webui/config.py
@@ -583,14 +583,16 @@ OAUTH_ROLES_CLAIM = PersistentConfig(
os.environ.get("OAUTH_ROLES_CLAIM", "roles"),
)
-SEP = os.environ.get("OAUTH_ROLES_SEPARATOR", ",")
+OAUTH_ROLES_SEPARATOR = os.environ.get("OAUTH_ROLES_SEPARATOR", ",")
OAUTH_ALLOWED_ROLES = PersistentConfig(
"OAUTH_ALLOWED_ROLES",
"oauth.allowed_roles",
[
role.strip()
- for role in os.environ.get("OAUTH_ALLOWED_ROLES", f"user{SEP}admin").split(SEP)
+ for role in os.environ.get(
+ "OAUTH_ALLOWED_ROLES", f"user{OAUTH_ROLES_SEPARATOR}admin"
+ ).split(OAUTH_ROLES_SEPARATOR)
if role
],
)
@@ -600,7 +602,9 @@ OAUTH_ADMIN_ROLES = PersistentConfig(
"oauth.admin_roles",
[
role.strip()
- for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(SEP)
+ for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(
+ OAUTH_ROLES_SEPARATOR
+ )
if role
],
)
@@ -1443,6 +1447,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
== "true"
)
+USER_PERMISSIONS_FEATURES_FOLDERS = (
+ os.environ.get("USER_PERMISSIONS_FEATURES_FOLDERS", "True").lower() == "true"
+)
+
USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
)
@@ -1499,12 +1507,15 @@ DEFAULT_USER_PERMISSIONS = {
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
},
"features": {
+ # General features
"api_keys": USER_PERMISSIONS_FEATURES_API_KEYS,
+ "folders": USER_PERMISSIONS_FEATURES_FOLDERS,
+ "notes": USER_PERMISSIONS_FEATURES_NOTES,
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
+ # Chat features
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
- "notes": USER_PERMISSIONS_FEATURES_NOTES,
},
}
@@ -1514,6 +1525,12 @@ USER_PERMISSIONS = PersistentConfig(
DEFAULT_USER_PERMISSIONS,
)
+ENABLE_FOLDERS = PersistentConfig(
+ "ENABLE_FOLDERS",
+ "folders.enable",
+ os.environ.get("ENABLE_FOLDERS", "True").lower() == "true",
+)
+
ENABLE_CHANNELS = PersistentConfig(
"ENABLE_CHANNELS",
"channels.enable",
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index af8e670a53..727bfe65dd 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -61,6 +61,7 @@ from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.logger import start_logger
from open_webui.socket.main import (
+ MODELS,
app as socket_app,
periodic_usage_pool_cleanup,
get_event_emitter,
@@ -352,6 +353,7 @@ from open_webui.config import (
ENABLE_API_KEYS,
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEYS_ALLOWED_ENDPOINTS,
+ ENABLE_FOLDERS,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_COMMUNITY_SHARING,
@@ -767,6 +769,7 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.BANNERS = WEBUI_BANNERS
+app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
@@ -1215,7 +1218,7 @@ app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE
#
########################################
-app.state.MODELS = {}
+app.state.MODELS = MODELS
# Add the middleware to the app
if ENABLE_COMPRESSION_MIDDLEWARE:
@@ -1575,6 +1578,7 @@ async def chat_completion(
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None),
+ "parent_message_id": form_data.pop("parent_id", None),
"session_id": form_data.pop("session_id", None),
"filter_ids": form_data.pop("filter_ids", []),
"tool_ids": form_data.get("tool_ids", None),
@@ -1631,6 +1635,7 @@ async def chat_completion(
metadata["chat_id"],
metadata["message_id"],
{
+ "parentId": metadata.get("parent_message_id", None),
"model": model_id,
},
)
@@ -1663,6 +1668,7 @@ async def chat_completion(
metadata["chat_id"],
metadata["message_id"],
{
+ "parentId": metadata.get("parent_message_id", None),
"error": {"content": str(e)},
},
)
@@ -1842,6 +1848,7 @@ async def get_app_config(request: Request):
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
+ "enable_folders": app.state.config.ENABLE_FOLDERS,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_notes": app.state.config.ENABLE_NOTES,
"enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
diff --git a/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py
new file mode 100644
index 0000000000..2d72583ebe
--- /dev/null
+++ b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py
@@ -0,0 +1,103 @@
+"""Update messages and channel member table
+
+Revision ID: 2f1211949ecc
+Revises: 37f288994c47
+Create Date: 2025-11-27 03:07:56.200231
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import open_webui.internal.db
+
+
+# revision identifiers, used by Alembic.
+revision: str = "2f1211949ecc"
+down_revision: Union[str, None] = "37f288994c47"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # New columns to be added to channel_member table
+ op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True))
+ op.add_column(
+ "channel_member",
+ sa.Column(
+ "is_active",
+ sa.Boolean(),
+ nullable=False,
+ default=True,
+ server_default=sa.sql.expression.true(),
+ ),
+ )
+
+ op.add_column(
+ "channel_member",
+ sa.Column(
+ "is_channel_muted",
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ server_default=sa.sql.expression.false(),
+ ),
+ )
+ op.add_column(
+ "channel_member",
+ sa.Column(
+ "is_channel_pinned",
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ server_default=sa.sql.expression.false(),
+ ),
+ )
+
+ op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True))
+ op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True))
+
+ op.add_column(
+ "channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False)
+ )
+ op.add_column(
+ "channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True)
+ )
+
+ op.add_column(
+ "channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True)
+ )
+
+ op.add_column(
+ "channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True)
+ )
+
+ # New columns to be added to message table
+ op.add_column(
+ "message",
+ sa.Column(
+ "is_pinned",
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ server_default=sa.sql.expression.false(),
+ ),
+ )
+ op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True))
+ op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("channel_member", "updated_at")
+ op.drop_column("channel_member", "last_read_at")
+
+ op.drop_column("channel_member", "meta")
+ op.drop_column("channel_member", "data")
+
+ op.drop_column("channel_member", "is_channel_pinned")
+ op.drop_column("channel_member", "is_channel_muted")
+
+ op.drop_column("message", "pinned_by")
+ op.drop_column("message", "pinned_at")
+ op.drop_column("message", "is_pinned")
diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py
index 39ff1cc7fb..0d0b881a78 100644
--- a/backend/open_webui/models/auths.py
+++ b/backend/open_webui/models/auths.py
@@ -3,7 +3,7 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
-from open_webui.models.users import UserModel, Users
+from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text
@@ -46,15 +46,7 @@ class ApiKey(BaseModel):
api_key: Optional[str] = None
-class UserResponse(BaseModel):
- id: str
- email: str
- name: str
- role: str
- profile_image_url: str
-
-
-class SigninResponse(Token, UserResponse):
+class SigninResponse(Token, UserProfileImageResponse):
pass
diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py
index 5f4d1436d9..5d452b0216 100644
--- a/backend/open_webui/models/channels.py
+++ b/backend/open_webui/models/channels.py
@@ -7,7 +7,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict
-from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
@@ -48,8 +48,58 @@ class ChannelModel(BaseModel):
meta: Optional[dict] = None
access_control: Optional[dict] = None
- created_at: int # timestamp in epoch
- updated_at: int # timestamp in epoch
+ created_at: int # timestamp in epoch (time_ns)
+ updated_at: int # timestamp in epoch (time_ns)
+
+
+class ChannelMember(Base):
+ __tablename__ = "channel_member"
+
+ id = Column(Text, primary_key=True, unique=True)
+ channel_id = Column(Text, nullable=False)
+ user_id = Column(Text, nullable=False)
+
+ status = Column(Text, nullable=True)
+ is_active = Column(Boolean, nullable=False, default=True)
+
+ is_channel_muted = Column(Boolean, nullable=False, default=False)
+ is_channel_pinned = Column(Boolean, nullable=False, default=False)
+
+ data = Column(JSON, nullable=True)
+ meta = Column(JSON, nullable=True)
+
+ joined_at = Column(BigInteger)
+ left_at = Column(BigInteger, nullable=True)
+
+ last_read_at = Column(BigInteger, nullable=True)
+
+ created_at = Column(BigInteger)
+ updated_at = Column(BigInteger)
+
+
+class ChannelMemberModel(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ channel_id: str
+ user_id: str
+
+ status: Optional[str] = None
+ is_active: bool = True
+
+ is_channel_muted: bool = False
+ is_channel_pinned: bool = False
+
+ data: Optional[dict] = None
+ meta: Optional[dict] = None
+
+ joined_at: Optional[int] = None # timestamp in epoch (time_ns)
+ left_at: Optional[int] = None # timestamp in epoch (time_ns)
+
+ last_read_at: Optional[int] = None # timestamp in epoch (time_ns)
+
+ created_at: Optional[int] = None # timestamp in epoch (time_ns)
+ updated_at: Optional[int] = None # timestamp in epoch (time_ns)
####################
@@ -63,22 +113,24 @@ class ChannelResponse(ChannelModel):
class ChannelForm(BaseModel):
+ type: Optional[str] = None
name: str
description: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
+ user_ids: Optional[list[str]] = None
class ChannelTable:
def insert_new_channel(
- self, type: Optional[str], form_data: ChannelForm, user_id: str
+ self, form_data: ChannelForm, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
- "type": type,
+ "type": form_data.type if form_data.type else None,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
@@ -86,9 +138,34 @@ class ChannelTable:
"updated_at": int(time.time_ns()),
}
)
-
new_channel = Channel(**channel.model_dump())
+ if form_data.type == "dm":
+ # For direct message channels, automatically add the specified users as members
+ user_ids = form_data.user_ids or []
+ if user_id not in user_ids:
+ user_ids.append(user_id) # Ensure the creator is also a member
+
+ for uid in user_ids:
+ channel_member = ChannelMemberModel(
+ **{
+ "id": str(uuid.uuid4()),
+ "channel_id": channel.id,
+ "user_id": uid,
+ "status": "joined",
+ "is_active": True,
+ "is_channel_muted": False,
+ "is_channel_pinned": False,
+ "joined_at": int(time.time_ns()),
+ "left_at": None,
+ "last_read_at": int(time.time_ns()),
+ "created_at": int(time.time_ns()),
+ "updated_at": int(time.time_ns()),
+ }
+ )
+ new_membership = ChannelMember(**channel_member.model_dump())
+ db.add(new_membership)
+
db.add(new_channel)
db.commit()
return channel
@@ -102,12 +179,210 @@ class ChannelTable:
self, user_id: str, permission: str = "read"
) -> list[ChannelModel]:
channels = self.get_channels()
- return [
- channel
- for channel in channels
- if channel.user_id == user_id
- or has_access(user_id, permission, channel.access_control)
- ]
+
+ channel_list = []
+ for channel in channels:
+ if channel.type == "dm":
+ membership = self.get_member_by_channel_and_user_id(channel.id, user_id)
+ if membership and membership.is_active:
+ channel_list.append(channel)
+ else:
+ if channel.user_id == user_id or has_access(
+ user_id, permission, channel.access_control
+ ):
+ channel_list.append(channel)
+
+ return channel_list
+
+ def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
+ with get_db() as db:
+ # Ensure uniqueness in case a list with duplicates is passed
+ unique_user_ids = list(set(user_ids))
+
+ match_count = func.sum(
+ case(
+ (ChannelMember.user_id.in_(unique_user_ids), 1),
+ else_=0,
+ )
+ )
+
+ subquery = (
+ db.query(ChannelMember.channel_id)
+ .group_by(ChannelMember.channel_id)
+ # 1. Channel must have exactly len(user_ids) members
+ .having(func.count(ChannelMember.user_id) == len(unique_user_ids))
+ # 2. All those members must be in unique_user_ids
+ .having(match_count == len(unique_user_ids))
+ .subquery()
+ )
+
+ channel = (
+ db.query(Channel)
+ .filter(
+ Channel.id.in_(subquery),
+ Channel.type == "dm",
+ )
+ .first()
+ )
+
+ return ChannelModel.model_validate(channel) if channel else None
+
+ def join_channel(
+ self, channel_id: str, user_id: str
+ ) -> Optional[ChannelMemberModel]:
+ with get_db() as db:
+ # Check if the membership already exists
+ existing_membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ if existing_membership:
+ return ChannelMemberModel.model_validate(existing_membership)
+
+ # Create new membership
+ channel_member = ChannelMemberModel(
+ **{
+ "id": str(uuid.uuid4()),
+ "channel_id": channel_id,
+ "user_id": user_id,
+ "status": "joined",
+ "is_active": True,
+ "is_channel_muted": False,
+ "is_channel_pinned": False,
+ "joined_at": int(time.time_ns()),
+ "left_at": None,
+ "last_read_at": int(time.time_ns()),
+ "created_at": int(time.time_ns()),
+ "updated_at": int(time.time_ns()),
+ }
+ )
+ new_membership = ChannelMember(**channel_member.model_dump())
+
+ db.add(new_membership)
+ db.commit()
+ return channel_member
+
+ def leave_channel(self, channel_id: str, user_id: str) -> bool:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ if not membership:
+ return False
+
+ membership.status = "left"
+ membership.is_active = False
+ membership.left_at = int(time.time_ns())
+ membership.updated_at = int(time.time_ns())
+
+ db.commit()
+ return True
+
+ def get_member_by_channel_and_user_id(
+ self, channel_id: str, user_id: str
+ ) -> Optional[ChannelMemberModel]:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ return ChannelMemberModel.model_validate(membership) if membership else None
+
+ def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]:
+ with get_db() as db:
+ memberships = (
+ db.query(ChannelMember)
+ .filter(ChannelMember.channel_id == channel_id)
+ .all()
+ )
+ return [
+ ChannelMemberModel.model_validate(membership)
+ for membership in memberships
+ ]
+
+ def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ if not membership:
+ return False
+
+ membership.is_channel_pinned = is_pinned
+ membership.updated_at = int(time.time_ns())
+
+ db.commit()
+ return True
+
+ def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ if not membership:
+ return False
+
+ membership.last_read_at = int(time.time_ns())
+ membership.updated_at = int(time.time_ns())
+
+ db.commit()
+ return True
+
+ def update_member_active_status(
+ self, channel_id: str, user_id: str, is_active: bool
+ ) -> bool:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ if not membership:
+ return False
+
+ membership.is_active = is_active
+ membership.updated_at = int(time.time_ns())
+
+ db.commit()
+ return True
+
+ def is_user_channel_member(self, channel_id: str, user_id: str) -> bool:
+ with get_db() as db:
+ membership = (
+ db.query(ChannelMember)
+ .filter(
+ ChannelMember.channel_id == channel_id,
+ ChannelMember.user_id == user_id,
+ )
+ .first()
+ )
+ return membership is not None
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
with get_db() as db:
diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py
index 6aaf09ca46..1094035fd5 100644
--- a/backend/open_webui/models/messages.py
+++ b/backend/open_webui/models/messages.py
@@ -6,6 +6,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, UserNameResponse
+from open_webui.models.channels import Channels, ChannelMember
from pydantic import BaseModel, ConfigDict
@@ -47,6 +48,11 @@ class Message(Base):
reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
+ # Pins
+ is_pinned = Column(Boolean, nullable=False, default=False)
+ pinned_at = Column(BigInteger, nullable=True)
+ pinned_by = Column(Text, nullable=True)
+
content = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
@@ -65,12 +71,17 @@ class MessageModel(BaseModel):
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
+ # Pins
+ is_pinned: bool = False
+ pinned_by: Optional[str] = None
+ pinned_at: Optional[int] = None # timestamp in epoch (time_ns)
+
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
- created_at: int # timestamp in epoch
- updated_at: int # timestamp in epoch
+ created_at: int # timestamp in epoch (time_ns)
+ updated_at: int # timestamp in epoch (time_ns)
####################
@@ -111,9 +122,11 @@ class MessageTable:
self, form_data: MessageForm, channel_id: str, user_id: str
) -> Optional[MessageModel]:
with get_db() as db:
- id = str(uuid.uuid4())
+ channel_member = Channels.join_channel(channel_id, user_id)
+ id = str(uuid.uuid4())
ts = int(time.time_ns())
+
message = MessageModel(
**{
"id": id,
@@ -121,6 +134,9 @@ class MessageTable:
"channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
+ "is_pinned": False,
+ "pinned_at": None,
+ "pinned_by": None,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
@@ -128,8 +144,8 @@ class MessageTable:
"updated_at": ts,
}
)
-
result = Message(**message.model_dump())
+
db.add(result)
db.commit()
db.refresh(result)
@@ -280,6 +296,16 @@ class MessageTable:
)
return messages
+ def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]:
+ with get_db() as db:
+ message = (
+ db.query(Message)
+ .filter_by(channel_id=channel_id)
+ .order_by(Message.created_at.desc())
+ .first()
+ )
+ return MessageModel.model_validate(message) if message else None
+
def update_message_by_id(
self, id: str, form_data: MessageForm
) -> Optional[MessageModel]:
@@ -299,6 +325,32 @@ class MessageTable:
db.refresh(message)
return MessageModel.model_validate(message) if message else None
+ def update_message_pin_by_id(
+ self, id: str, is_pinned: bool, pinned_by: Optional[str] = None
+ ) -> Optional[MessageModel]:
+ with get_db() as db:
+ message = db.get(Message, id)
+ message.is_pinned = is_pinned
+ message.pinned_at = int(time.time_ns()) if is_pinned else None
+ message.pinned_by = pinned_by if is_pinned else None
+ message.updated_at = int(time.time_ns())
+ db.commit()
+ db.refresh(message)
+ return MessageModel.model_validate(message) if message else None
+
+ def get_unread_message_count(
+ self, channel_id: str, user_id: str, last_read_at: Optional[int] = None
+ ) -> int:
+ with get_db() as db:
+ query = db.query(Message).filter(
+ Message.channel_id == channel_id,
+ Message.parent_id == None, # only count top-level messages
+ Message.created_at > (last_read_at if last_read_at else 0),
+ )
+ if user_id:
+ query = query.filter(Message.user_id != user_id)
+ return query.count()
+
def add_reaction_to_message(
self, id: str, user_id: str, name: str
) -> Optional[MessageReactionModel]:
diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py
index e902a978d1..329b87a91f 100755
--- a/backend/open_webui/models/models.py
+++ b/backend/open_webui/models/models.py
@@ -220,6 +220,34 @@ class ModelsTable:
or has_access(user_id, permission, model.access_control, user_group_ids)
]
+ def _has_write_permission(self, query, filter: dict):
+ if filter.get("group_ids") or filter.get("user_id"):
+ conditions = []
+
+ # --- ANY group_ids match ("write".group_ids) ---
+ if filter.get("group_ids"):
+ group_ids = filter["group_ids"]
+ like_clauses = []
+
+ for gid in group_ids:
+ like_clauses.append(
+ cast(Model.access_control, String).like(
+ f'%"write"%"group_ids"%"{gid}"%'
+ )
+ )
+
+ # ANY → OR
+ conditions.append(or_(*like_clauses))
+
+ # --- user_id match (owner) ---
+ if filter.get("user_id"):
+ conditions.append(Model.user_id == filter["user_id"])
+
+ # Apply OR across the two groups of conditions
+ query = query.filter(or_(*conditions))
+
+ return query
+
def search_models(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
) -> ModelListResponse:
@@ -238,11 +266,10 @@ class ModelsTable:
)
)
- if filter.get("user_id"):
- query = query.filter(Model.user_id == filter.get("user_id"))
+ # Apply access control filtering
+ query = self._has_write_permission(query, filter)
view_option = filter.get("view_option")
-
if view_option == "created":
query = query.filter(Model.user_id == user_id)
elif view_option == "shared":
diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py
index d93f7ddeb3..5809a7124f 100644
--- a/backend/open_webui/models/users.py
+++ b/backend/open_webui/models/users.py
@@ -135,18 +135,18 @@ class UserIdNameListResponse(BaseModel):
total: int
-class UserResponse(BaseModel):
- id: str
- name: str
- email: str
- role: str
- profile_image_url: str
-
-
class UserNameResponse(BaseModel):
id: str
name: str
role: str
+
+
+class UserResponse(UserNameResponse):
+ email: str
+
+
+class UserProfileImageResponse(UserNameResponse):
+ email: str
profile_image_url: str
diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py
index 764196c5f1..24cbd9a03f 100644
--- a/backend/open_webui/routers/auths.py
+++ b/backend/open_webui/routers/auths.py
@@ -16,9 +16,8 @@ from open_webui.models.auths import (
SigninResponse,
SignupForm,
UpdatePasswordForm,
- UserResponse,
)
-from open_webui.models.users import Users, UpdateProfileForm
+from open_webui.models.users import UserProfileImageResponse, Users, UpdateProfileForm
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
@@ -78,7 +77,7 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
############################
-class SessionUserResponse(Token, UserResponse):
+class SessionUserResponse(Token, UserProfileImageResponse):
expires_at: Optional[int] = None
permissions: Optional[dict] = None
@@ -149,7 +148,7 @@ async def get_session_user(
############################
-@router.post("/update/profile", response_model=UserResponse)
+@router.post("/update/profile", response_model=UserProfileImageResponse)
async def update_profile(
form_data: UpdateProfileForm, session_user=Depends(get_verified_user)
):
@@ -901,6 +900,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
+ "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
@@ -922,6 +922,7 @@ class AdminConfig(BaseModel):
JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
+ ENABLE_FOLDERS: bool
ENABLE_CHANNELS: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
@@ -946,6 +947,7 @@ async def update_admin_config(
form_data.API_KEYS_ALLOWED_ENDPOINTS
)
+ request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
@@ -988,6 +990,7 @@ async def update_admin_config(
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
+ "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index e47c98554e..1bf905155e 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -13,6 +13,7 @@ from open_webui.socket.main import (
get_active_status_by_user_id,
)
from open_webui.models.users import (
+ UserIdNameResponse,
UserListResponse,
UserModelResponse,
Users,
@@ -65,9 +66,52 @@ router = APIRouter()
############################
-@router.get("/", response_model=list[ChannelModel])
+class ChannelListItemResponse(ChannelModel):
+ user_ids: Optional[list[str]] = None # 'dm' channels only
+ users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only
+
+ last_message_at: Optional[int] = None # timestamp in epoch (time_ns)
+ unread_count: int = 0
+
+
+@router.get("/", response_model=list[ChannelListItemResponse])
async def get_channels(user=Depends(get_verified_user)):
- return Channels.get_channels_by_user_id(user.id)
+
+ channels = Channels.get_channels_by_user_id(user.id)
+
+ channel_list = []
+ for channel in channels:
+ last_message = Messages.get_last_message_by_channel_id(channel.id)
+ last_message_at = last_message.created_at if last_message else None
+
+ channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id)
+ unread_count = Messages.get_unread_message_count(
+ channel.id, user.id, channel_member.last_read_at if channel_member else None
+ )
+
+ user_ids = None
+ users = None
+ if channel.type == "dm":
+ user_ids = [
+ member.user_id
+ for member in Channels.get_members_by_channel_id(channel.id)
+ ]
+ users = [
+ UserIdNameResponse(**user.model_dump())
+ for user in Users.get_users_by_user_ids(user_ids)
+ ]
+
+ channel_list.append(
+ ChannelListItemResponse(
+ **channel.model_dump(),
+ user_ids=user_ids,
+ users=users,
+ last_message_at=last_message_at,
+ unread_count=unread_count,
+ )
+ )
+
+ return channel_list
@router.get("/list", response_model=list[ChannelModel])
@@ -85,7 +129,15 @@ async def get_all_channels(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[ChannelModel])
async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
try:
- channel = Channels.insert_new_channel(None, form_data, user.id)
+ if form_data.type == "dm":
+ existing_channel = Channels.get_dm_channel_by_user_ids(
+ [user.id, *form_data.user_ids]
+ )
+ if existing_channel:
+ Channels.update_member_active_status(existing_channel.id, user.id, True)
+ return ChannelModel(**existing_channel.model_dump())
+
+ channel = Channels.insert_new_channel(form_data, user.id)
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
@@ -99,7 +151,15 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
############################
-@router.get("/{id}", response_model=Optional[ChannelResponse])
+class ChannelFullResponse(ChannelResponse):
+ user_ids: Optional[list[str]] = None # 'dm' channels only
+ users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only
+
+ last_read_at: Optional[int] = None # timestamp in epoch (time_ns)
+ unread_count: int = 0
+
+
+@router.get("/{id}", response_model=Optional[ChannelFullResponse])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
channel = Channels.get_channel_by_id(id)
if not channel:
@@ -107,33 +167,82 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ user_ids = None
+ users = None
+
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+ user_ids = [
+ member.user_id for member in Channels.get_members_by_channel_id(channel.id)
+ ]
+ users = [
+ UserIdNameResponse(**user.model_dump())
+ for user in Users.get_users_by_user_ids(user_ids)
+ ]
+
+ channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id)
+ unread_count = Messages.get_unread_message_count(
+ channel.id, user.id, channel_member.last_read_at if channel_member else None
)
- write_access = has_access(
- user.id, type="write", access_control=channel.access_control, strict=False
- )
+ return ChannelFullResponse(
+ **{
+ **channel.model_dump(),
+ "user_ids": user_ids,
+ "users": users,
+ "write_access": True,
+ "user_count": len(user_ids),
+ "last_read_at": channel_member.last_read_at if channel_member else None,
+ "unread_count": unread_count,
+ }
+ )
- user_count = len(get_users_with_access("read", channel.access_control))
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="read", access_control=channel.access_control
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
- return ChannelResponse(
- **{
- **channel.model_dump(),
- "write_access": write_access or user.role == "admin",
- "user_count": user_count,
- }
- )
+ write_access = has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ )
+
+ user_count = len(get_users_with_access("read", channel.access_control))
+
+ channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id)
+ unread_count = Messages.get_unread_message_count(
+ channel.id, user.id, channel_member.last_read_at if channel_member else None
+ )
+
+ return ChannelFullResponse(
+ **{
+ **channel.model_dump(),
+ "user_ids": user_ids,
+ "users": users,
+ "write_access": write_access or user.role == "admin",
+ "user_count": user_count,
+ "last_read_at": channel_member.last_read_at if channel_member else None,
+ "unread_count": unread_count,
+ }
+ )
+
+
+############################
+# GetChannelMembersById
+############################
PAGE_ITEM_COUNT = 30
-@router.get("/{id}/users", response_model=UserListResponse)
-async def get_channel_users_by_id(
+@router.get("/{id}/members", response_model=UserListResponse)
+async def get_channel_members_by_id(
id: str,
query: Optional[str] = None,
order_by: Optional[str] = None,
@@ -153,36 +262,90 @@ async def get_channel_users_by_id(
page = max(1, page)
skip = (page - 1) * limit
- filter = {
- "roles": ["!pending"],
- }
-
- if query:
- filter["query"] = query
- if order_by:
- filter["order_by"] = order_by
- if direction:
- filter["direction"] = direction
-
- permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control)
- if permitted_ids:
- filter["user_ids"] = permitted_ids.get("user_ids")
- filter["group_ids"] = permitted_ids.get("group_ids")
-
- result = Users.get_users(filter=filter, skip=skip, limit=limit)
-
- users = result["users"]
- total = result["total"]
-
- return {
- "users": [
- UserModelResponse(
- **user.model_dump(), is_active=get_active_status_by_user_id(user.id)
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
- for user in users
- ],
- "total": total,
- }
+
+ user_ids = [
+ member.user_id for member in Channels.get_members_by_channel_id(channel.id)
+ ]
+ users = Users.get_users_by_user_ids(user_ids)
+
+ total = len(users)
+
+ return {
+ "users": [
+ UserModelResponse(
+ **user.model_dump(), is_active=get_active_status_by_user_id(user.id)
+ )
+ for user in users
+ ],
+ "total": total,
+ }
+
+ else:
+ filter = {
+ "roles": ["!pending"],
+ }
+
+ if query:
+ filter["query"] = query
+ if order_by:
+ filter["order_by"] = order_by
+ if direction:
+ filter["direction"] = direction
+
+ permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control)
+ if permitted_ids:
+ filter["user_ids"] = permitted_ids.get("user_ids")
+ filter["group_ids"] = permitted_ids.get("group_ids")
+
+ result = Users.get_users(filter=filter, skip=skip, limit=limit)
+
+ users = result["users"]
+ total = result["total"]
+
+ return {
+ "users": [
+ UserModelResponse(
+ **user.model_dump(), is_active=get_active_status_by_user_id(user.id)
+ )
+ for user in users
+ ],
+ "total": total,
+ }
+
+
+#################################################
+# UpdateIsActiveMemberByIdAndUserId
+#################################################
+
+
+class UpdateActiveMemberForm(BaseModel):
+ is_active: bool
+
+
+@router.post("/{id}/members/active", response_model=bool)
+async def update_is_active_member_by_id_and_user_id(
+ id: str,
+ form_data: UpdateActiveMemberForm,
+ user=Depends(get_verified_user),
+):
+ channel = Channels.get_channel_by_id(id)
+ if not channel:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
+
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+ )
+
+ Channels.update_member_active_status(channel.id, user.id, form_data.is_active)
+ return True
############################
@@ -252,12 +415,22 @@ async def get_channel_messages(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="read", access_control=channel.access_control
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+
+ channel_member = Channels.join_channel(
+ id, user.id
+ ) # Ensure user is a member of the channel
message_list = Messages.get_messages_by_channel_id(id, skip, limit)
users = {}
@@ -297,7 +470,9 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
users = get_users_with_access("read", channel.access_control)
for user in users:
- if user.id not in active_user_ids:
+ if (user.id not in active_user_ids) and Channels.is_user_channel_member(
+ channel.id, user.id
+ ):
if user.settings:
webhook_url = user.settings.ui.get("notifications", {}).get(
"webhook_url", None
@@ -501,16 +676,30 @@ async def new_message_handler(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="write", access_control=channel.access_control, strict=False
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
try:
message = Messages.insert_new_message(form_data, channel.id, user.id)
if message:
+ if channel.type == "dm":
+ members = Channels.get_members_by_channel_id(channel.id)
+ for member in members:
+ if not member.is_active:
+ Channels.update_member_active_status(
+ channel.id, member.user_id, True
+ )
+
message = Messages.get_message_by_id(message.id)
event_data = {
"channel_id": channel.id,
@@ -609,12 +798,18 @@ async def get_channel_message(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="read", access_control=channel.access_control
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
message = Messages.get_message_by_id(message_id)
if not message:
@@ -658,12 +853,18 @@ async def get_channel_thread_messages(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="read", access_control=channel.access_control
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="read", access_control=channel.access_control
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit)
users = {}
@@ -717,14 +918,22 @@ async def update_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
- if (
- user.role != "admin"
- and message.user_id != user.id
- and not has_access(user.id, type="read", access_control=channel.access_control)
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if (
+ user.role != "admin"
+ and message.user_id != user.id
+ and not has_access(
+ user.id, type="read", access_control=channel.access_control
+ )
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
try:
message = Messages.update_message_by_id(message_id, form_data)
@@ -773,12 +982,18 @@ async def add_reaction_to_message(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="write", access_control=channel.access_control, strict=False
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
message = Messages.get_message_by_id(message_id)
if not message:
@@ -836,12 +1051,18 @@ async def remove_reaction_by_id_and_user_id_and_name(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
- if user.role != "admin" and not has_access(
- user.id, type="write", access_control=channel.access_control, strict=False
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if user.role != "admin" and not has_access(
+ user.id, type="write", access_control=channel.access_control, strict=False
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
message = Messages.get_message_by_id(message_id)
if not message:
@@ -913,16 +1134,25 @@ async def delete_message_by_id(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
- if (
- user.role != "admin"
- and message.user_id != user.id
- and not has_access(
- user.id, type="write", access_control=channel.access_control, strict=False
- )
- ):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
- )
+ if channel.type == "dm":
+ if not Channels.is_user_channel_member(channel.id, user.id):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
+ else:
+ if (
+ user.role != "admin"
+ and message.user_id != user.id
+ and not has_access(
+ user.id,
+ type="write",
+ access_control=channel.access_control,
+ strict=False,
+ )
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+ )
try:
Messages.delete_message_by_id(message_id)
diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py
index 03212bdb7c..fe2bf367bf 100644
--- a/backend/open_webui/routers/folders.py
+++ b/backend/open_webui/routers/folders.py
@@ -46,7 +46,23 @@ router = APIRouter()
@router.get("/", response_model=list[FolderNameIdResponse])
-async def get_folders(user=Depends(get_verified_user)):
+async def get_folders(request: Request, user=Depends(get_verified_user)):
+ if request.app.state.config.ENABLE_FOLDERS is False:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+ )
+
+ if user.role != "admin" and not has_permission(
+ user.id,
+ "features.folders",
+ request.app.state.config.USER_PERMISSIONS,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+ )
+
folders = Folders.get_folders_by_user_id(user.id)
# Verify folder data integrity
diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py
index 2b531b462b..b68db3a15e 100755
--- a/backend/open_webui/routers/groups.py
+++ b/backend/open_webui/routers/groups.py
@@ -106,6 +106,32 @@ async def get_group_by_id(id: str, user=Depends(get_admin_user)):
)
+############################
+# ExportGroupById
+############################
+
+
+class GroupExportResponse(GroupResponse):
+ user_ids: list[str] = []
+ pass
+
+
+@router.get("/id/{id}/export", response_model=Optional[GroupExportResponse])
+async def export_group_by_id(id: str, user=Depends(get_admin_user)):
+ group = Groups.get_group_by_id(id)
+ if group:
+ return GroupExportResponse(
+ **group.model_dump(),
+ member_count=Groups.get_group_member_count_by_id(group.id),
+ user_ids=Groups.get_group_user_ids_by_id(group.id),
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail=ERROR_MESSAGES.NOT_FOUND,
+ )
+
+
############################
# UpdateGroupById
############################
diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py
index ad47fc1686..654f11588a 100644
--- a/backend/open_webui/routers/knowledge.py
+++ b/backend/open_webui/routers/knowledge.py
@@ -708,7 +708,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
-def add_files_to_knowledge_batch(
+async def add_files_to_knowledge_batch(
request: Request,
id: str,
form_data: list[KnowledgeFileIdForm],
@@ -748,7 +748,7 @@ def add_files_to_knowledge_batch(
# Process files
try:
- result = process_files_batch(
+ result = await process_files_batch(
request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user,
diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py
index 93d8cb8bf7..df5a7377dc 100644
--- a/backend/open_webui/routers/models.py
+++ b/backend/open_webui/routers/models.py
@@ -5,6 +5,7 @@ import json
import asyncio
import logging
+from open_webui.models.groups import Groups
from open_webui.models.models import (
ModelForm,
ModelModel,
@@ -78,6 +79,10 @@ async def get_models(
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
+ groups = Groups.get_groups_by_member_id(user.id)
+ if groups:
+ filter["group_ids"] = [group.id for group in groups]
+
filter["user_id"] = user.id
return Models.search_models(user.id, filter=filter, skip=skip, limit=limit)
diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py
index 6080337250..72090e3ba0 100644
--- a/backend/open_webui/routers/retrieval.py
+++ b/backend/open_webui/routers/retrieval.py
@@ -1249,7 +1249,7 @@ def save_docs_to_vector_db(
return ", ".join(docs_info)
- log.info(
+ log.debug(
f"save_docs_to_vector_db: document {_get_docs_info(docs)} {collection_name}"
)
@@ -1689,7 +1689,7 @@ async def process_text(
log.debug(f"text_content: {text_content}")
result = await run_in_threadpool(
- save_docs_to_vector_db, request, docs, collection_name, user
+ save_docs_to_vector_db, request, docs, collection_name, user=user
)
if result:
return {
@@ -1721,7 +1721,12 @@ async def process_web(
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
await run_in_threadpool(
- save_docs_to_vector_db, request, docs, collection_name, True, user
+ save_docs_to_vector_db,
+ request,
+ docs,
+ collection_name,
+ overwrite=True,
+ user=user,
)
else:
collection_name = None
@@ -2464,7 +2469,12 @@ async def process_files_batch(
if all_docs:
try:
await run_in_threadpool(
- save_docs_to_vector_db, request, all_docs, collection_name, True, user
+ save_docs_to_vector_db,
+ request,
+ all_docs,
+ collection_name,
+ add=True,
+ user=user,
)
# Update all files with collection name
diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py
index 0b44e4319a..9b30ba8f20 100644
--- a/backend/open_webui/routers/users.py
+++ b/backend/open_webui/routers/users.py
@@ -6,7 +6,7 @@ import io
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response, StreamingResponse, FileResponse
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from open_webui.models.auths import Auths
@@ -219,11 +219,13 @@ class ChatPermissions(BaseModel):
class FeaturesPermissions(BaseModel):
api_keys: bool = False
+ folders: bool = True
+ notes: bool = True
direct_tool_servers: bool = False
+
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
- notes: bool = True
class UserPermissions(BaseModel):
@@ -359,13 +361,14 @@ async def update_user_info_by_session_user(
############################
-class UserResponse(BaseModel):
+class UserActiveResponse(BaseModel):
name: str
- profile_image_url: str
+ profile_image_url: Optional[str] = None
active: Optional[bool] = None
+ model_config = ConfigDict(extra="allow")
-@router.get("/{user_id}", response_model=UserResponse)
+@router.get("/{user_id}", response_model=UserActiveResponse)
async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
# Check if user_id is a shared chat
# If it is, get the user_id from the chat
@@ -383,10 +386,10 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
user = Users.get_user_by_id(user_id)
if user:
- return UserResponse(
+ return UserActiveResponse(
**{
+ "id": user.id,
"name": user.name,
- "profile_image_url": user.profile_image_url,
"active": get_active_status_by_user_id(user_id),
}
)
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index f79d1dd958..04b67dd786 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -118,6 +118,14 @@ if WEBSOCKET_MANAGER == "redis":
redis_sentinels = get_sentinels_from_env(
WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
)
+
+ MODELS = RedisDict(
+ f"{REDIS_KEY_PREFIX}:models",
+ redis_url=WEBSOCKET_REDIS_URL,
+ redis_sentinels=redis_sentinels,
+ redis_cluster=WEBSOCKET_REDIS_CLUSTER,
+ )
+
SESSION_POOL = RedisDict(
f"{REDIS_KEY_PREFIX}:session_pool",
redis_url=WEBSOCKET_REDIS_URL,
@@ -148,6 +156,8 @@ if WEBSOCKET_MANAGER == "redis":
renew_func = clean_up_lock.renew_lock
release_func = clean_up_lock.release_lock
else:
+ MODELS = {}
+
SESSION_POOL = {}
USER_POOL = {}
USAGE_POOL = {}
@@ -398,6 +408,11 @@ async def channel_events(sid, data):
event_data = data["data"]
event_type = event_data["type"]
+ user = SESSION_POOL.get(sid)
+
+ if not user:
+ return
+
if event_type == "typing":
await sio.emit(
"events:channel",
@@ -405,10 +420,12 @@ async def channel_events(sid, data):
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
"data": event_data,
- "user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
+ "user": UserNameResponse(**user).model_dump(),
},
room=room,
)
+ elif event_type == "last_read_at":
+ Channels.update_member_last_read_at(data["channel_id"], user["id"])
@sio.on("ydoc:document:join")
diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py
index 168d2fd88e..5739a8027a 100644
--- a/backend/open_webui/socket/utils.py
+++ b/backend/open_webui/socket/utils.py
@@ -86,6 +86,15 @@ class RedisDict:
def items(self):
return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
+ def set(self, mapping: dict):
+ pipe = self.redis.pipeline()
+
+ pipe.delete(self.name)
+ if mapping:
+ pipe.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()})
+
+ pipe.execute()
+
def get(self, key, default=None):
try:
return self[key]
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index efa187a382..cc2de8e1c7 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -1409,11 +1409,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
headers=headers if headers else None,
)
- function_name_filter_list = (
- mcp_server_connection.get("config", {})
- .get("function_name_filter_list", "")
- .split(",")
- )
+ function_name_filter_list = mcp_server_connection.get(
+ "config", {}
+ ).get("function_name_filter_list", "")
+
+ if isinstance(function_name_filter_list, str):
+ function_name_filter_list = function_name_filter_list.split(",")
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py
index 8b53ce5193..525ba22e76 100644
--- a/backend/open_webui/utils/models.py
+++ b/backend/open_webui/utils/models.py
@@ -6,6 +6,7 @@ import sys
from aiocache import cached
from fastapi import Request
+from open_webui.socket.utils import RedisDict
from open_webui.routers import openai, ollama
from open_webui.functions import get_function_models
@@ -323,7 +324,12 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
log.debug(f"get_all_models() returned {len(models)} models")
- request.app.state.MODELS = {model["id"]: model for model in models}
+ models_dict = {model["id"]: model for model in models}
+ if isinstance(request.app.state.MODELS, RedisDict):
+ request.app.state.MODELS.set(models_dict)
+ else:
+ request.app.state.MODELS = models_dict
+
return models
diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py
index dc53be7ac5..ac1a4818c1 100644
--- a/backend/open_webui/utils/oauth.py
+++ b/backend/open_webui/utils/oauth.py
@@ -44,6 +44,7 @@ from open_webui.config import (
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_BLOCKED_GROUPS,
OAUTH_GROUPS_SEPARATOR,
+ OAUTH_ROLES_SEPARATOR,
OAUTH_ROLES_CLAIM,
OAUTH_SUB_CLAIM,
OAUTH_GROUPS_CLAIM,
@@ -1059,16 +1060,22 @@ class OAuthManager:
for nested_claim in nested_claims:
claim_data = claim_data.get(nested_claim, {})
- # Try flat claim structure as alternative
- if not claim_data:
- claim_data = user_data.get(oauth_claim, {})
+ # Try flat claim structure as alternative
+ if not claim_data:
+ claim_data = user_data.get(oauth_claim, {})
- oauth_roles = []
+ oauth_roles = []
- if isinstance(claim_data, list):
- oauth_roles = claim_data
- if isinstance(claim_data, str) or isinstance(claim_data, int):
- oauth_roles = [str(claim_data)]
+ if isinstance(claim_data, list):
+ oauth_roles = claim_data
+ elif isinstance(claim_data, str):
+ # Split by the configured separator if present
+ if OAUTH_ROLES_SEPARATOR and OAUTH_ROLES_SEPARATOR in claim_data:
+ oauth_roles = claim_data.split(OAUTH_ROLES_SEPARATOR)
+ else:
+ oauth_roles = [claim_data]
+ elif isinstance(claim_data, int):
+ oauth_roles = [str(claim_data)]
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
@@ -1529,7 +1536,9 @@ class OAuthManager:
)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
-
+ # Update the user object in memory as well,
+ # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
+ user.role = determined_role
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py
index 268624135d..2baff503ee 100644
--- a/backend/open_webui/utils/tools.py
+++ b/backend/open_webui/utils/tools.py
@@ -150,11 +150,12 @@ async def get_tools(
)
specs = tool_server_data.get("specs", [])
- function_name_filter_list = (
- tool_server_connection.get("config", {})
- .get("function_name_filter_list", "")
- .split(",")
- )
+ function_name_filter_list = tool_server_connection.get(
+ "config", {}
+ ).get("function_name_filter_list", "")
+
+ if isinstance(function_name_filter_list, str):
+ function_name_filter_list = function_name_filter_list.split(",")
for spec in specs:
function_name = spec["name"]
diff --git a/backend/requirements-min.txt b/backend/requirements-min.txt
index c09f1af820..8d63bd4b82 100644
--- a/backend/requirements-min.txt
+++ b/backend/requirements-min.txt
@@ -7,7 +7,7 @@ pydantic==2.11.9
python-multipart==0.0.20
itsdangerous==2.2.0
-python-socketio==5.14.0
+python-socketio==5.15.0
python-jose==3.5.0
cryptography
bcrypt==5.0.0
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 658e249090..ba8cbbfc07 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -4,7 +4,7 @@ pydantic==2.11.9
python-multipart==0.0.20
itsdangerous==2.2.0
-python-socketio==5.14.0
+python-socketio==5.15.0
python-jose==3.5.0
cryptography
bcrypt==5.0.0
@@ -52,15 +52,15 @@ chromadb==1.1.0
weaviate-client==4.17.0
opensearch-py==2.8.0
-transformers
-sentence-transformers==5.1.1
+transformers==4.57.3
+sentence-transformers==5.1.2
accelerate
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1
ftfy==6.2.3
chardet==5.2.0
-pypdf==6.0.0
+pypdf==6.4.0
fpdf2==2.8.2
pymdown-extensions==10.14.2
docx2txt==0.8
@@ -115,7 +115,7 @@ pgvector==0.4.1
PyMySQL==1.1.1
boto3==1.40.5
-pymilvus==2.6.2
+pymilvus==2.6.4
qdrant-client==1.14.3
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==9.1.0
diff --git a/pyproject.toml b/pyproject.toml
index f0568a4237..709f4ec672 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,7 +12,7 @@ dependencies = [
"python-multipart==0.0.20",
"itsdangerous==2.2.0",
- "python-socketio==5.14.0",
+ "python-socketio==5.15.0",
"python-jose==3.5.0",
"cryptography",
"bcrypt==5.0.0",
@@ -60,15 +60,15 @@ dependencies = [
"PyMySQL==1.1.1",
"boto3==1.40.5",
- "transformers",
- "sentence-transformers==5.1.1",
+ "transformers==4.57.3",
+ "sentence-transformers==5.1.2",
"accelerate",
"pyarrow==20.0.0",
"einops==0.8.1",
"ftfy==6.2.3",
"chardet==5.2.0",
- "pypdf==6.0.0",
+ "pypdf==6.4.0",
"fpdf2==2.8.2",
"pymdown-extensions==10.14.2",
"docx2txt==0.8",
@@ -148,7 +148,7 @@ all = [
"qdrant-client==1.14.3",
"weaviate-client==4.17.0",
- "pymilvus==2.6.2",
+ "pymilvus==2.6.4",
"pinecone==6.0.2",
"oracledb==3.2.0",
"colbert-ai==0.2.21",
diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts
index 2872bd89f8..5b510491fe 100644
--- a/src/lib/apis/channels/index.ts
+++ b/src/lib/apis/channels/index.ts
@@ -1,10 +1,12 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
type ChannelForm = {
+ type?: string;
name: string;
data?: object;
meta?: object;
access_control?: object;
+ user_ids?: string[];
};
export const createNewChannel = async (token: string = '', channel: ChannelForm) => {
@@ -101,7 +103,7 @@ export const getChannelById = async (token: string = '', channel_id: string) =>
return res;
};
-export const getChannelUsersById = async (
+export const getChannelMembersById = async (
token: string,
channel_id: string,
query?: string,
@@ -129,7 +131,7 @@ export const getChannelUsersById = async (
}
res = await fetch(
- `${WEBUI_API_BASE_URL}/channels/${channel_id}/users?${searchParams.toString()}`,
+ `${WEBUI_API_BASE_URL}/channels/${channel_id}/members?${searchParams.toString()}`,
{
method: 'GET',
headers: {
@@ -155,6 +157,42 @@ export const getChannelUsersById = async (
return res;
};
+export const updateChannelMemberActiveStatusById = async (
+ token: string = '',
+ channel_id: string,
+ is_active: boolean
+) => {
+ let error = null;
+
+ const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/members/active`, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({ is_active })
+ })
+ .then(async (res) => {
+ if (!res.ok) throw await res.json();
+ return res.json();
+ })
+ .then((json) => {
+ return json;
+ })
+ .catch((err) => {
+ error = err.detail;
+ console.error(err);
+ return null;
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ return res;
+};
+
export const updateChannelById = async (
token: string = '',
channel_id: string,
diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte
index 2b639b3e64..79fe4c97fc 100644
--- a/src/lib/components/AddToolServerModal.svelte
+++ b/src/lib/components/AddToolServerModal.svelte
@@ -47,7 +47,7 @@
let key = '';
let headers = '';
- let functionNameFilterList = [];
+ let functionNameFilterList = '';
let accessControl = {};
let id = '';
@@ -338,7 +338,7 @@
oauthClientInfo = null;
enable = true;
- functionNameFilterList = [];
+ functionNameFilterList = '';
accessControl = null;
};
@@ -362,7 +362,7 @@
oauthClientInfo = connection.info?.oauth_client_info ?? null;
enable = connection.config?.enable ?? true;
- functionNameFilterList = connection.config?.function_name_filter_list ?? [];
+ functionNameFilterList = connection.config?.function_name_filter_list ?? '';
accessControl = connection.config?.access_control ?? null;
}
};
diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte
index a7b62857c0..d46f37a89f 100644
--- a/src/lib/components/admin/Settings/General.svelte
+++ b/src/lib/components/admin/Settings/General.svelte
@@ -676,6 +676,14 @@