From 848f3fd4d86ca66656e0ff0335773945af8d7d8d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 03:40:16 -0500 Subject: [PATCH 01/23] refac: hide active user count in sidebar user menu --- src/lib/components/layout/Sidebar.svelte | 2 ++ src/lib/components/layout/Sidebar/UserMenu.svelte | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index c49135090e..48e707302a 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -707,6 +707,7 @@ {#if $user !== undefined && $user !== null} { if (e.detail === 'archived-chat') { showArchivedChats.set(true); @@ -1256,6 +1257,7 @@ {#if $user !== undefined && $user !== null} { if (e.detail === 'archived-chat') { showArchivedChats.set(true); diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index c3775ebf22..6ad91050e4 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -29,6 +29,8 @@ export let help = false; export let className = 'max-w-[240px]'; + export let showActiveUsers = true; + const dispatch = createEventDispatcher(); let usage = null; @@ -219,7 +221,7 @@
{$i18n.t('Sign Out')}
- {#if usage} + {#if showActiveUsers && usage} {#if usage?.user_ids?.length > 0}
From d232e433e8deea0ca402a30c60f85e592bf8b100 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:22:54 -0500 Subject: [PATCH 02/23] refac: profile preview --- .../Messages/Message/ProfilePreview.svelte | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index 8ed95e63e8..a7daef9147 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -11,11 +11,21 @@ export let align = 'center'; export let side = 'right'; export let sideOffset = 8; + + let openPreview = false; - - - + + + From 4b6773885cd7527c5a56b963781dac5e95105eec Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:24:25 -0500 Subject: [PATCH 03/23] enh: dm active user indicator --- backend/open_webui/models/users.py | 6 +++++ backend/open_webui/routers/channels.py | 10 ++++++-- .../layout/Sidebar/ChannelItem.svelte | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 2fa634097c..e7beeee1bf 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -125,6 +125,12 @@ class UserIdNameResponse(BaseModel): name: str +class UserIdNameStatusResponse(BaseModel): + id: str + name: str + is_active: bool + + class UserInfoListResponse(BaseModel): users: list[UserInfoResponse] total: int diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 1bf905155e..a3228f5c80 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -14,6 +14,7 @@ from open_webui.socket.main import ( ) from open_webui.models.users import ( UserIdNameResponse, + UserIdNameStatusResponse, UserListResponse, UserModelResponse, Users, @@ -68,7 +69,7 @@ router = APIRouter() class ChannelListItemResponse(ChannelModel): user_ids: Optional[list[str]] = None # 'dm' channels only - users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + users: Optional[list[UserIdNameStatusResponse]] = None # 'dm' channels only last_message_at: Optional[int] = None # timestamp in epoch (time_ns) unread_count: int = 0 @@ -97,7 +98,12 @@ async def get_channels(user=Depends(get_verified_user)): for member in Channels.get_members_by_channel_id(channel.id) ] users = [ - UserIdNameResponse(**user.model_dump()) + UserIdNameStatusResponse( + **{ + **user.model_dump(), + "is_active": get_active_status_by_user_id(user.id), + } + ) for user in Users.get_users_by_user_ids(user_ids) ] diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 11030727eb..cf148658b8 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -5,6 +5,7 @@ import { page } from '$app/stores'; import { channels, mobile, showSidebar, user } from '$lib/stores'; + import { getUserActiveStatusById } from '$lib/apis/users'; import { updateChannelById, updateChannelMemberActiveStatusById } from '$lib/apis/channels'; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -83,8 +84,9 @@
{#if channel?.type === 'dm'} {#if channel?.users} -
- {#each channel.users.filter((u) => u.id !== $user?.id).slice(0, 2) as u, index} + {@const channelMembers = channel.users.filter((u) => u.id !== $user?.id)} +
+ {#each channelMembers.slice(0, 2) as u, index} {u.name} {/each} + + {#if channelMembers.length === 1} +
+ + {#if channelMembers[0]?.is_active} + + {/if} + + +
+ {/if}
{:else} From b99c9b277a732c530dc0fb7c4e64c1f9207e9002 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:29:50 -0500 Subject: [PATCH 04/23] refac: styling --- src/lib/components/chat/SettingsModal.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index d782f0a9cc..6e7825ba3f 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -569,7 +569,7 @@ }); - +
{$i18n.t('Settings')}
@@ -588,7 +588,7 @@
-
+
{#if selectedTab === 'general'} Date: Fri, 28 Nov 2025 06:29:41 -0500 Subject: [PATCH 05/23] refac: user table db migration --- .../b10670c03dd5_update_user_table.py | 264 ++++++++++++++++++ backend/open_webui/models/users.py | 114 +++++--- 2 files changed, 345 insertions(+), 33 deletions(-) create mode 100644 backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py new file mode 100644 index 0000000000..ce7d3d9870 --- /dev/null +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -0,0 +1,264 @@ +"""Update user table + +Revision ID: b10670c03dd5 +Revises: 2f1211949ecc +Create Date: 2025-11-28 04:55:31.737538 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +import open_webui.internal.db +import json +import time + +# revision identifiers, used by Alembic. +revision: str = "b10670c03dd5" +down_revision: Union[str, None] = "2f1211949ecc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _drop_sqlite_indexes_for_column(table_name, column_name, conn): + """ + SQLite requires manual removal of any indexes referencing a column + before ALTER TABLE ... DROP COLUMN can succeed. + """ + indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall() + + for idx in indexes: + index_name = idx[1] # index name + # Get indexed columns + idx_info = conn.execute( + sa.text(f"PRAGMA index_info('{index_name}')") + ).fetchall() + + indexed_cols = [row[2] for row in idx_info] # col names + if column_name in indexed_cols: + conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}")) + + +def _convert_column_to_json(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + # SQLite cannot ALTER COLUMN → must recreate column + if dialect == "sqlite": + # 1. Add temporary column + op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True)) + + # 2. Load old data + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for row in rows: + uid, raw = row + if raw is None: + parsed = None + else: + try: + parsed = json.loads(raw) + except Exception: + parsed = None # fallback safe behavior + + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'), + {"val": json.dumps(parsed) if parsed else None, "id": uid}, + ) + + # 3. Drop old TEXT column + op.drop_column(table, column) + + # 4. Rename new JSON column → original name + op.alter_column(table, f"{column}_json", new_column_name=column) + + else: + # PostgreSQL supports direct CAST + op.alter_column( + table, + column, + type_=sa.JSON(), + postgresql_using=f"{column}::json", + ) + + +def _convert_column_to_text(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + if dialect == "sqlite": + op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True)) + + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for uid, raw in rows: + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'), + {"val": json.dumps(raw) if raw else None, "id": uid}, + ) + + op.drop_column(table, column) + op.alter_column(table, f"{column}_text", new_column_name=column) + + else: + op.alter_column( + table, + column, + type_=sa.Text(), + postgresql_using=f"to_json({column})::text", + ) + + +def upgrade() -> None: + + op.add_column( + "user", + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + default=False, + server_default=sa.sql.expression.false(), + ), + ) + + op.add_column( + "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) + ) + + op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + + op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) + op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) + op.add_column( + "user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True) + ) + + op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True)) + + # Convert info (TEXT/JSONField) → JSON + _convert_column_to_json("user", "info") + # Convert settings (TEXT/JSONField) → JSON + _convert_column_to_json("user", "settings") + + op.create_table( + "api_key", + sa.Column("id", sa.Text(), primary_key=True, unique=True), + sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")), + sa.Column("key", sa.Text(), unique=True, nullable=False), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("expires_at", sa.BigInteger(), nullable=True), + sa.Column("last_used_at", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL') + ).fetchall() + + for uid, oauth_sub in users: + if oauth_sub: + # Example formats supported: + # provider@sub + # plain sub (stored as {"oidc": {"sub": sub}}) + if "@" in oauth_sub: + provider, sub = oauth_sub.split("@", 1) + else: + provider, sub = "oidc", oauth_sub + + oauth_json = json.dumps({provider: {"sub": sub}}) + conn.execute( + sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'), + {"oauth": oauth_json, "id": uid}, + ) + + users_with_keys = conn.execute( + sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL') + ).fetchall() + now = int(time.time()) + + for uid, api_key in users_with_keys: + if api_key: + conn.execute( + sa.text( + """ + INSERT INTO api_key (id, user_id, key, created_at, updated_at) + VALUES (:id, :user_id, :key, :created_at, :updated_at) + """ + ), + { + "id": f"key_{uid}", + "user_id": uid, + "key": api_key, + "created_at": now, + "updated_at": now, + }, + ) + + if conn.dialect.name == "sqlite": + _drop_sqlite_indexes_for_column("user", "api_key", conn) + _drop_sqlite_indexes_for_column("user", "oauth_sub", conn) + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("api_key") + batch_op.drop_column("oauth_sub") + + +def downgrade() -> None: + # --- 1. Restore old oauth_sub column --- + op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True)) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL') + ).fetchall() + + for uid, oauth in users: + try: + data = json.loads(oauth) + provider = list(data.keys())[0] + sub = data[provider].get("sub") + oauth_sub = f"{provider}@{sub}" + except Exception: + oauth_sub = None + + conn.execute( + sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'), + {"oauth_sub": oauth_sub, "id": uid}, + ) + + op.drop_column("user", "oauth") + + # --- 2. Restore api_key field --- + op.add_column("user", sa.Column("api_key", sa.String(), nullable=True)) + + # Restore values from api_key + keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall() + for uid, key in keys: + conn.execute( + sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'), + {"key": key, "id": uid}, + ) + + # Drop new table + op.drop_table("api_key") + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("is_active") + + batch_op.drop_column("profile_banner_image_url") + batch_op.drop_column("timezone") + + batch_op.drop_column("status_emoji") + batch_op.drop_column("status_message") + batch_op.drop_column("status_expires_at") + + # Convert info (JSON) → TEXT + _convert_column_to_text("user", "info") + # Convert settings (JSON) → TEXT + _convert_column_to_text("user", "settings") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index e7beeee1bf..b68b195cd2 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -11,7 +11,17 @@ from open_webui.utils.misc import throttle from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select +from sqlalchemy import ( + BigInteger, + JSON, + Column, + String, + Boolean, + Text, + Date, + exists, + select, +) from sqlalchemy import or_, case import datetime @@ -21,59 +31,71 @@ import datetime #################### -class User(Base): - __tablename__ = "user" - - id = Column(String, primary_key=True, unique=True) - name = Column(String) - - email = Column(String) - username = Column(String(50), nullable=True) - - role = Column(String) - profile_image_url = Column(Text) - - bio = Column(Text, nullable=True) - gender = Column(Text, nullable=True) - date_of_birth = Column(Date, nullable=True) - - info = Column(JSONField, nullable=True) - settings = Column(JSONField, nullable=True) - - api_key = Column(String, nullable=True, unique=True) - oauth_sub = Column(Text, unique=True) - - last_active_at = Column(BigInteger) - - updated_at = Column(BigInteger) - created_at = Column(BigInteger) - - class UserSettings(BaseModel): ui: Optional[dict] = {} model_config = ConfigDict(extra="allow") pass +class User(Base): + __tablename__ = "user" + + id = Column(String, primary_key=True, unique=True) + email = Column(String) + username = Column(String(50), nullable=True) + role = Column(String) + + name = Column(String) + is_active = Column(Boolean, nullable=False, default=False) + + profile_image_url = Column(Text) + profile_banner_image_url = Column(Text, nullable=True) + + bio = Column(Text, nullable=True) + gender = Column(Text, nullable=True) + date_of_birth = Column(Date, nullable=True) + timezone = Column(String, nullable=True) + + status_emoji = Column(String, nullable=True) + status_message = Column(Text, nullable=True) + status_expires_at = Column(BigInteger, nullable=True) + + info = Column(JSON, nullable=True) + settings = Column(JSON, nullable=True) + + oauth = Column(JSON, nullable=True) + + last_active_at = Column(BigInteger) + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + class UserModel(BaseModel): id: str - name: str email: str username: Optional[str] = None - role: str = "pending" + + name: str + is_active: bool = False + profile_image_url: str + profile_banner_image_url: Optional[str] = None bio: Optional[str] = None gender: Optional[str] = None date_of_birth: Optional[datetime.date] = None + timezone: Optional[str] = None + + status_emoji: Optional[str] = None + status_message: Optional[str] = None + status_expires_at: Optional[int] = None info: Optional[dict] = None settings: Optional[UserSettings] = None - api_key: Optional[str] = None - oauth_sub: Optional[str] = None + oauth: Optional[dict] = None last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -82,6 +104,32 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class ApiKey(Base): + __tablename__ = "api_key" + + id = Column(Text, primary_key=True, unique=True) + user_id = Column(Text, nullable=False) + key = Column(Text, unique=True, nullable=False) + data = Column(JSON, nullable=True) + expires_at = Column(BigInteger, nullable=True) + last_used_at = Column(BigInteger, nullable=True) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class ApiKeyModel(BaseModel): + id: str + user_id: str + key: str + data: Optional[dict] = None + expires_at: Optional[int] = None + last_used_at: Optional[int] = None + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + #################### # Forms #################### From 0a4358c3d181c48ec2400d4d80c8450108600dc9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:39:36 -0500 Subject: [PATCH 06/23] refac: oauth_sub -> oauth migration --- backend/open_webui/models/auths.py | 4 +-- backend/open_webui/models/users.py | 41 +++++++++++++++++++++++------- backend/open_webui/utils/oauth.py | 15 ++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 0d0b881a78..8b03580e6c 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -88,7 +88,7 @@ class AuthsTable: name: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: log.info("insert_new_auth") @@ -102,7 +102,7 @@ class AuthsTable: db.add(result) user = Users.insert_new_user( - id, name, email, profile_image_url, role, oauth_sub + id, name, email, profile_image_url, role, oauth=oauth ) db.commit() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index b68b195cd2..6a2f3bc9b0 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -225,7 +225,7 @@ class UsersTable: email: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: user = UserModel( @@ -238,7 +238,7 @@ class UsersTable: "last_active_at": int(time.time()), "created_at": int(time.time()), "updated_at": int(time.time()), - "oauth_sub": oauth_sub, + "oauth": oauth, } ) result = User(**user.model_dump()) @@ -274,11 +274,15 @@ class UsersTable: except Exception: return None - def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]: + def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(oauth_sub=sub).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .filter(User.oauth.contains({provider: {"sub": sub}})) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -493,16 +497,35 @@ class UsersTable: except Exception: return None - def update_user_oauth_sub_by_id( - self, id: str, oauth_sub: str + def update_user_oauth_by_id( + self, id: str, provider: str, sub: str ) -> Optional[UserModel]: + """ + Update or insert an OAuth provider/sub pair into the user's oauth JSON field. + Example resulting structure: + { + "google": { "sub": "123" }, + "github": { "sub": "abc" } + } + """ try: with get_db() as db: - db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub}) + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + # Load existing oauth JSON or create empty + oauth = user.oauth or {} + + # Update or insert provider entry + oauth[provider] = {"sub": sub} + + # Persist updated JSON + db.query(User).filter_by(id=id).update({"oauth": oauth}) db.commit() - user = db.query(User).filter_by(id=id).first() return UserModel.model_validate(user) + except Exception: return None diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index f8a924e8d0..6bd955e90c 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1329,7 +1329,10 @@ class OAuthManager: log.warning(f"OAuth callback failed, sub is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - provider_sub = f"{provider}@{sub}" + oauth_data = {} + oauth_data[provider] = { + "sub": sub, + } # Email extraction email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM @@ -1376,12 +1379,12 @@ class OAuthManager: log.warning(f"Error fetching GitHub email: {e}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) elif ENABLE_OAUTH_EMAIL_FALLBACK: - email = f"{provider_sub}.local" + email = f"{provider}@{sub}.local" else: log.warning(f"OAuth callback failed, email is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - email = email.lower() + email = email.lower() # If allowed domains are configured, check if the email domain is in the list if ( "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS @@ -1394,7 +1397,7 @@ class OAuthManager: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists - user = Users.get_user_by_oauth_sub(provider_sub) + user = Users.get_user_by_oauth_sub(provider, sub) if not user: # If the user does not exist, check if merging is enabled if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: @@ -1402,7 +1405,7 @@ class OAuthManager: user = Users.get_user_by_email(email) if user: # Update the user with the new oauth sub - Users.update_user_oauth_sub_by_id(user.id, provider_sub) + Users.update_user_oauth_by_id(user.id, provider, sub) if user: determined_role = self.get_user_role(user, user_data) @@ -1461,7 +1464,7 @@ class OAuthManager: name=name, profile_image_url=picture_url, role=self.get_user_role(None, user_data), - oauth_sub=provider_sub, + oauth=oauth_data, ) if auth_manager_config.WEBHOOK_URL: From 742832a850c5590d03df738b83fa27b4ddda3aab Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:41:41 -0500 Subject: [PATCH 07/23] refac --- backend/open_webui/models/users.py | 2 +- src/lib/components/admin/Users/UserList/EditUserModal.svelte | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 6a2f3bc9b0..7e207eeee1 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -231,8 +231,8 @@ class UsersTable: user = UserModel( **{ "id": id, - "name": name, "email": email, + "name": name, "role": role, "profile_image_url": profile_image_url, "last_active_at": int(time.time()), diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 9adbac0e4f..1803eaecbd 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -180,12 +180,12 @@
- {#if _user?.oauth_sub} + {#if _user?.oauth}
{$i18n.t('OAuth ID')}
- {_user.oauth_sub ?? ''} + {JSON.stringify(_user.oauth ?? '')}
{/if} From dcf50c47584498bad94defd38b13b84b11a40aa1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:49:10 -0500 Subject: [PATCH 08/23] refac: api_key table migration --- backend/open_webui/models/users.py | 53 ++++++++++++++++++++++------- backend/open_webui/routers/auths.py | 3 +- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 7e207eeee1..1ae33f6537 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -261,8 +261,13 @@ class UsersTable: def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(api_key=api_key).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .join(ApiKey, User.id == ApiKey.user_id) + .filter(ApiKey.key == api_key) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -579,23 +584,45 @@ class UsersTable: except Exception: return False - def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: - try: - with get_db() as db: - result = db.query(User).filter_by(id=id).update({"api_key": api_key}) - db.commit() - return True if result == 1 else False - except Exception: - return False - def get_user_api_key_by_id(self, id: str) -> Optional[str]: try: with get_db() as db: - user = db.query(User).filter_by(id=id).first() - return user.api_key + api_key = db.query(ApiKey).filter_by(user_id=id).first() + return api_key.key if api_key else None except Exception: return None + def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + + now = int(time.time()) + new_api_key = ApiKey( + id=f"key_{id}", + user_id=id, + key=api_key, + created_at=now, + updated_at=now, + ) + db.add(new_api_key) + db.commit() + + return True + + except Exception: + return False + + def delete_user_api_key_by_id(self, id: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + return True + except Exception: + return False + def get_valid_user_ids(self, user_ids: list[str]) -> list[str]: with get_db() as db: users = db.query(User).filter(User.id.in_(user_ids)).all() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 24cbd9a03f..1b79d84cfd 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1133,8 +1133,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)): # delete api key @router.delete("/api_key", response_model=bool) async def delete_api_key(user=Depends(get_current_user)): - success = Users.update_user_api_key_by_id(user.id, None) - return success + return Users.delete_user_api_key_by_id(user.id) # get api key From 8ef482a52aa7587d11c42311cbd0809cddb3172f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 06:59:59 -0500 Subject: [PATCH 09/23] refac: user oauth display --- .../components/admin/Users/UserList/EditUserModal.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 1803eaecbd..f73551219a 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -184,8 +184,13 @@
{$i18n.t('OAuth ID')}
-
- {JSON.stringify(_user.oauth ?? '')} +
+ {#each Object.keys(_user.oauth) as key} +
+ {key} + {_user.oauth[key]?.sub} +
+ {/each}
{/if} From c2634d45ad496c077fa6699fd8822993f2031ad7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:27:55 -0500 Subject: [PATCH 10/23] refac --- .../b10670c03dd5_update_user_table.py | 17 ++----------- backend/open_webui/models/users.py | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py index ce7d3d9870..f35a382645 100644 --- a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -113,24 +113,12 @@ def _convert_column_to_text(table: str, column: str): def upgrade() -> None: - - op.add_column( - "user", - sa.Column( - "is_active", - sa.Boolean(), - nullable=False, - default=False, - server_default=sa.sql.expression.false(), - ), - ) - op.add_column( "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) ) - op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True)) op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) op.add_column( @@ -249,11 +237,10 @@ def downgrade() -> None: op.drop_table("api_key") with op.batch_alter_table("user") as batch_op: - batch_op.drop_column("is_active") - batch_op.drop_column("profile_banner_image_url") batch_op.drop_column("timezone") + batch_op.drop_column("presence_state") batch_op.drop_column("status_emoji") batch_op.drop_column("status_message") batch_op.drop_column("status_expires_at") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 1ae33f6537..c09cc93934 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -46,7 +46,6 @@ class User(Base): role = Column(String) name = Column(String) - is_active = Column(Boolean, nullable=False, default=False) profile_image_url = Column(Text) profile_banner_image_url = Column(Text, nullable=True) @@ -56,6 +55,7 @@ class User(Base): date_of_birth = Column(Date, nullable=True) timezone = Column(String, nullable=True) + presence_state = Column(String, nullable=True) status_emoji = Column(String, nullable=True) status_message = Column(Text, nullable=True) status_expires_at = Column(BigInteger, nullable=True) @@ -78,7 +78,6 @@ class UserModel(BaseModel): role: str = "pending" name: str - is_active: bool = False profile_image_url: str profile_banner_image_url: Optional[str] = None @@ -88,6 +87,7 @@ class UserModel(BaseModel): date_of_birth: Optional[datetime.date] = None timezone: Optional[str] = None + presence_state: Optional[str] = None status_emoji: Optional[str] = None status_message: Optional[str] = None status_expires_at: Optional[int] = None @@ -176,7 +176,7 @@ class UserIdNameResponse(BaseModel): class UserIdNameStatusResponse(BaseModel): id: str name: str - is_active: bool + is_active: bool = False class UserInfoListResponse(BaseModel): @@ -636,5 +636,23 @@ class UsersTable: else: return None + def get_active_user_count(self) -> int: + with get_db() as db: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + count = ( + db.query(User).filter(User.last_active_at >= three_minutes_ago).count() + ) + return count + + def is_user_active(self, user_id: str) -> bool: + with get_db() as db: + user = db.query(User).filter_by(id=user_id).first() + if user and user.last_active_at: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + return user.last_active_at >= three_minutes_ago + return False + Users = UsersTable() From 70948f8803e417459d5203839f8077fdbfbbb213 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:39:02 -0500 Subject: [PATCH 11/23] enh/refac: deprecate USER_POOL --- backend/open_webui/main.py | 6 +- backend/open_webui/models/users.py | 2 +- backend/open_webui/routers/channels.py | 10 +--- backend/open_webui/routers/users.py | 29 +--------- backend/open_webui/socket/main.py | 56 ++++++------------- backend/open_webui/utils/auth.py | 7 +-- backend/open_webui/utils/middleware.py | 5 +- backend/open_webui/utils/telemetry/metrics.py | 3 +- .../components/layout/Sidebar/UserMenu.svelte | 4 +- src/routes/+layout.svelte | 16 ++++++ 10 files changed, 50 insertions(+), 88 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 727bfe65dd..127f22e103 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -66,7 +66,6 @@ from open_webui.socket.main import ( periodic_usage_pool_cleanup, get_event_emitter, get_models_in_use, - get_active_user_ids, ) from open_webui.routers import ( audio, @@ -2021,7 +2020,10 @@ async def get_current_usage(user=Depends(get_verified_user)): This is an experimental endpoint and subject to change. """ try: - return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()} + return { + "model_ids": get_models_in_use(), + "user_count": Users.get_active_user_count(), + } except Exception as e: log.error(f"Error getting usage statistics: {e}") raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index c09cc93934..ede5f5e761 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -489,7 +489,7 @@ class UsersTable: return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]: + def update_last_active_by_id(self, id: str) -> Optional[UserModel]: try: with get_db() as db: db.query(User).filter_by(id=id).update( diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index a3228f5c80..394c9f0009 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -10,7 +10,6 @@ from pydantic import BaseModel from open_webui.socket.main import ( sio, get_user_ids_from_room, - get_active_status_by_user_id, ) from open_webui.models.users import ( UserIdNameResponse, @@ -99,10 +98,7 @@ async def get_channels(user=Depends(get_verified_user)): ] users = [ UserIdNameStatusResponse( - **{ - **user.model_dump(), - "is_active": get_active_status_by_user_id(user.id), - } + **{**user.model_dump(), "is_active": Users.is_user_active(user.id)} ) for user in Users.get_users_by_user_ids(user_ids) ] @@ -284,7 +280,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], @@ -316,7 +312,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 9b30ba8f20..7c4b801f4d 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -26,12 +26,6 @@ from open_webui.models.users import ( UserUpdateForm, ) - -from open_webui.socket.main import ( - get_active_status_by_user_id, - get_active_user_ids, - get_user_active_status, -) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR @@ -51,23 +45,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() -############################ -# GetActiveUsers -############################ - - -@router.get("/active") -async def get_active_users( - user=Depends(get_verified_user), -): - """ - Get a list of active users. - """ - return { - "user_ids": get_active_user_ids(), - } - - ############################ # GetUsers ############################ @@ -364,7 +341,7 @@ async def update_user_info_by_session_user( class UserActiveResponse(BaseModel): name: str profile_image_url: Optional[str] = None - active: Optional[bool] = None + is_active: bool model_config = ConfigDict(extra="allow") @@ -390,7 +367,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): **{ "id": user.id, "name": user.name, - "active": get_active_status_by_user_id(user_id), + "is_active": Users.is_user_active(user_id), } ) else: @@ -457,7 +434,7 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u @router.get("/{user_id}/active", response_model=dict) async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): return { - "active": get_user_active_status(user_id), + "active": Users.is_user_active(user_id), } diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 04b67dd786..84705648d9 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -132,12 +132,6 @@ if WEBSOCKET_MANAGER == "redis": redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) - USER_POOL = RedisDict( - f"{REDIS_KEY_PREFIX}:user_pool", - redis_url=WEBSOCKET_REDIS_URL, - redis_sentinels=redis_sentinels, - redis_cluster=WEBSOCKET_REDIS_CLUSTER, - ) USAGE_POOL = RedisDict( f"{REDIS_KEY_PREFIX}:usage_pool", redis_url=WEBSOCKET_REDIS_URL, @@ -159,7 +153,6 @@ else: MODELS = {} SESSION_POOL = {} - USER_POOL = {} USAGE_POOL = {} aquire_func = release_func = renew_func = lambda: True @@ -235,16 +228,6 @@ def get_models_in_use(): return models_in_use -def get_active_user_ids(): - """Get the list of active user IDs.""" - return list(USER_POOL.keys()) - - -def get_user_active_status(user_id): - """Check if a user is currently active.""" - return user_id in USER_POOL - - def get_user_id_from_session_pool(sid): user = SESSION_POOL.get(sid) if user: @@ -270,12 +253,6 @@ def get_user_ids_from_room(room): return active_user_ids -def get_active_status_by_user_id(user_id): - if user_id in USER_POOL: - return True - return False - - @sio.on("usage") async def usage(sid, data): if sid in SESSION_POOL: @@ -303,11 +280,6 @@ async def connect(sid, environ, auth): SESSION_POOL[sid] = user.model_dump( exclude=["date_of_birth", "bio", "gender"] ) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] - await sio.enter_room(sid, f"user:{user.id}") @@ -326,11 +298,15 @@ async def user_join(sid, data): if not user: return - SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"]) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] + SESSION_POOL[sid] = user.model_dump( + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] + ) await sio.enter_room(sid, f"user:{user.id}") # Join all the channels @@ -341,6 +317,13 @@ async def user_join(sid, data): return {"id": user.id, "name": user.name} +@sio.on("heartbeat") +async def heartbeat(sid, data): + user = SESSION_POOL.get(sid) + if user: + Users.update_last_active_by_id(user["id"]) + + @sio.on("join-channels") async def join_channel(sid, data): auth = data["auth"] if "auth" in data else None @@ -669,13 +652,6 @@ async def disconnect(sid): if sid in SESSION_POOL: user = SESSION_POOL[sid] del SESSION_POOL[sid] - - user_id = user["id"] - USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid] - - if len(USER_POOL[user_id]) == 0: - del USER_POOL[user_id] - await YDOC_MANAGER.remove_user_from_all_documents(sid) else: pass diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index f3069a093f..3f05256c70 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -344,9 +344,7 @@ async def get_current_user( # Refresh the user's last active timestamp asynchronously # to prevent blocking the request if background_tasks: - background_tasks.add_task( - Users.update_user_last_active_by_id, user.id - ) + background_tasks.add_task(Users.update_last_active_by_id, user.id) return user else: raise HTTPException( @@ -397,8 +395,7 @@ def get_current_user_by_api_key(request, api_key: str): current_span.set_attribute("client.user.role", user.role) current_span.set_attribute("client.auth.type", "api_key") - Users.update_user_last_active_by_id(user.id) - + Users.update_last_active_by_id(user.id) return user diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index cc2de8e1c7..dc45daca0e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -32,7 +32,6 @@ from open_webui.models.users import Users from open_webui.socket.main import ( get_event_call, get_event_emitter, - get_active_status_by_user_id, ) from open_webui.routers.tasks import ( generate_queries, @@ -1915,7 +1914,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( @@ -3210,7 +3209,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 85bd418844..d935ddaafa 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -45,7 +45,6 @@ from open_webui.env import ( OTEL_METRICS_OTLP_SPAN_EXPORTER, OTEL_METRICS_EXPORTER_OTLP_INSECURE, ) -from open_webui.socket.main import get_active_user_ids from open_webui.models.users import Users _EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds @@ -135,7 +134,7 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: ) -> Sequence[metrics.Observation]: return [ metrics.Observation( - value=len(get_active_user_ids()), + value=Users.get_active_user_count(), ) ] diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte index 6ad91050e4..5da759ee88 100644 --- a/src/lib/components/layout/Sidebar/UserMenu.svelte +++ b/src/lib/components/layout/Sidebar/UserMenu.svelte @@ -222,7 +222,7 @@ {#if showActiveUsers && usage} - {#if usage?.user_ids?.length > 0} + {#if usage?.user_count}
- {usage?.user_ids?.length} + {usage?.user_count}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a153423909..ca6777bbee 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -90,6 +90,8 @@ let showRefresh = false; + let heartbeatInterval = null; + const BREAKPOINT = 768; const setupSocket = async (enableWebsocket) => { @@ -126,6 +128,14 @@ } } + // Send heartbeat every 30 seconds + heartbeatInterval = setInterval(() => { + if (_socket.connected) { + console.log('Sending heartbeat'); + _socket.emit('heartbeat', {}); + } + }, 30000); + if (deploymentId !== null) { WEBUI_DEPLOYMENT_ID.set(deploymentId); } @@ -154,6 +164,12 @@ _socket.on('disconnect', (reason, details) => { console.log(`Socket ${_socket.id} disconnected due to ${reason}`); + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (details) { console.log('Additional details:', details); } From 33b59adf27f515d804f54f49733ba88504063204 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 07:42:45 -0500 Subject: [PATCH 12/23] refac --- src/lib/components/channel/Messages/Message/UserStatus.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte index 7bca57774c..6ec9caa052 100644 --- a/src/lib/components/channel/Messages/Message/UserStatus.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte @@ -23,7 +23,7 @@
- {#if user?.active} + {#if user?.is_active}
Date: Fri, 28 Nov 2025 08:01:42 -0500 Subject: [PATCH 13/23] refac: pin icons --- src/lib/components/icons/Pin.svelte | 21 +++++++++++++++++++++ src/lib/components/icons/PinSlash.svelte | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/icons/Pin.svelte diff --git a/src/lib/components/icons/Pin.svelte b/src/lib/components/icons/Pin.svelte new file mode 100644 index 0000000000..d62c9049db --- /dev/null +++ b/src/lib/components/icons/Pin.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/icons/PinSlash.svelte b/src/lib/components/icons/PinSlash.svelte index ab119b0292..6a90da3e87 100644 --- a/src/lib/components/icons/PinSlash.svelte +++ b/src/lib/components/icons/PinSlash.svelte @@ -4,6 +4,7 @@ Date: Fri, 28 Nov 2025 08:45:31 -0500 Subject: [PATCH 14/23] refac: admin user list active indicator --- src/lib/components/admin/Users/UserList.svelte | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index a76234643d..f0c9c8462e 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/src/lib/components/admin/Users/UserList.svelte @@ -355,14 +355,25 @@ -
+
user
{user.name}
+ + {#if user?.last_active_at && Date.now() / 1000 - user.last_active_at < 180} +
+ + + + +
+ {/if}
{user.email} From 451907cc9264f1a7913428072a142bd189aa1e57 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 09:32:37 -0500 Subject: [PATCH 15/23] refac --- src/lib/components/channel/MessageInput.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 02323a5f20..e36819fda7 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -865,7 +865,7 @@
- {#key $settings?.richTextInput} + {#key $settings?.richTextInput && $settings?.showFormattingToolbar} Date: Fri, 28 Nov 2025 09:58:44 -0500 Subject: [PATCH 16/23] feat/enh: pinned messages in channels --- backend/open_webui/models/messages.py | 21 ++- backend/open_webui/routers/channels.py | 120 ++++++++++++++ src/lib/apis/channels/index.ts | 78 +++++++++ src/lib/components/channel/Messages.svelte | 28 +++- .../channel/Messages/Message.svelte | 150 +++++++++++------- src/lib/components/channel/Navbar.svelte | 21 ++- .../channel/PinnedMessagesModal.svelte | 150 ++++++++++++++++++ 7 files changed, 510 insertions(+), 58 deletions(-) create mode 100644 src/lib/components/channel/PinnedMessagesModal.svelte diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 7901f3af66..6ed49ba597 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -111,6 +111,10 @@ class MessageReplyToResponse(MessageUserResponse): reply_to_message: Optional[MessageUserResponse] = None +class MessageWithReactionsResponse(MessageUserResponse): + reactions: list[Reactions] + + class MessageResponse(MessageReplyToResponse): latest_reply_at: Optional[int] reply_count: int @@ -306,6 +310,20 @@ class MessageTable: ) return MessageModel.model_validate(message) if message else None + def get_pinned_messages_by_channel_id( + self, channel_id: str, skip: int = 0, limit: int = 50 + ) -> list[MessageModel]: + with get_db() as db: + all_messages = ( + db.query(Message) + .filter_by(channel_id=channel_id, is_pinned=True) + .order_by(Message.pinned_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [MessageModel.model_validate(message) for message in all_messages] + def update_message_by_id( self, id: str, form_data: MessageForm ) -> Optional[MessageModel]: @@ -325,7 +343,7 @@ class MessageTable: db.refresh(message) return MessageModel.model_validate(message) if message else None - def update_message_pin_by_id( + def update_is_pinned_by_id( self, id: str, is_pinned: bool, pinned_by: Optional[str] = None ) -> Optional[MessageModel]: with get_db() as db: @@ -333,7 +351,6 @@ class MessageTable: 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 diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 394c9f0009..f6e3ebe47a 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -31,6 +31,7 @@ from open_webui.models.messages import ( Messages, MessageModel, MessageResponse, + MessageWithReactionsResponse, MessageForm, ) @@ -463,6 +464,62 @@ async def get_channel_messages( return messages +############################ +# GetPinnedChannelMessages +############################ + +PAGE_ITEM_COUNT_PINNED = 20 + + +@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) +async def get_pinned_channel_messages( + id: str, page: int = 1, user=Depends(get_verified_user) +): + channel = Channels.get_channel_by_id(id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if channel.type == "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() + ) + + page = max(1, page) + skip = (page - 1) * PAGE_ITEM_COUNT_PINNED + limit = PAGE_ITEM_COUNT_PINNED + + message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit) + users = {} + + messages = [] + for message in message_list: + if message.user_id not in users: + user = Users.get_user_by_id(message.user_id) + users[message.user_id] = user + + messages.append( + MessageWithReactionsResponse( + **{ + **message.model_dump(), + "reactions": Messages.get_reactions_by_message_id(message.id), + "user": UserNameResponse(**users[message.user_id].model_dump()), + } + ) + ) + + return messages + + ############################ # PostNewMessage ############################ @@ -834,6 +891,69 @@ async def get_channel_message( ) +############################ +# PinChannelMessage +############################ + + +class PinMessageForm(BaseModel): + is_pinned: bool + + +@router.post( + "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] +) +async def pin_channel_message( + id: str, message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user) +): + channel = Channels.get_channel_by_id(id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if channel.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + message = Messages.get_message_by_id(message_id) + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if message.channel_id != id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id) + message = Messages.get_message_by_id(message_id) + return MessageUserResponse( + **{ + **message.model_dump(), + "user": UserNameResponse( + **Users.get_user_by_id(message.user_id).model_dump() + ), + } + ) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # GetChannelThreadMessages ############################ diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 5b510491fe..7a954a7507 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -299,6 +299,44 @@ export const getChannelMessages = async ( return res; }; +export const getChannelPinnedMessages = async ( + token: string = '', + channel_id: string, + page: number = 1 +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/pinned?page=${page}`, + { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getChannelThreadMessages = async ( token: string = '', channel_id: string, @@ -379,6 +417,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag return res; }; +export const pinMessage = async ( + token: string = '', + channel_id: string, + message_id: string, + is_pinned: boolean +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/pin`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ is_pinned }) + } + ) + .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 updateMessage = async ( token: string = '', channel_id: string, diff --git a/src/lib/components/channel/Messages.svelte b/src/lib/components/channel/Messages.svelte index 9127fd8c38..b8e6dbb9ea 100644 --- a/src/lib/components/channel/Messages.svelte +++ b/src/lib/components/channel/Messages.svelte @@ -16,7 +16,13 @@ import Message from './Messages/Message.svelte'; import Loader from '../common/Loader.svelte'; import Spinner from '../common/Spinner.svelte'; - import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels'; + import { + addReaction, + deleteMessage, + pinMessage, + removeReaction, + updateMessage + } from '$lib/apis/channels'; import { WEBUI_API_BASE_URL } from '$lib/constants'; const i18n = getContext('i18n'); @@ -155,6 +161,26 @@ onReply={(message) => { onReply(message); }} + onPin={async (message) => { + messages = messages.map((m) => { + if (m.id === message.id) { + m.is_pinned = !m.is_pinned; + m.pinned_by = !m.is_pinned ? null : $user?.id; + m.pinned_at = !m.is_pinned ? null : Date.now() * 1000000; + } + return m; + }); + + const updatedMessage = await pinMessage( + localStorage.token, + message.channel_id, + message.id, + message.is_pinned + ).catch((error) => { + toast.error(`${error}`); + return null; + }); + }} onThread={(id) => { onThread(id); }} diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 5379a7be00..5ed17336c9 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -36,6 +36,10 @@ import Emoji from '$lib/components/common/Emoji.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte'; + import PinSlash from '$lib/components/icons/PinSlash.svelte'; + import Pin from '$lib/components/icons/Pin.svelte'; + + export let className = ''; export let message; export let showUserProfile = true; @@ -47,6 +51,7 @@ export let onDelete: Function = () => {}; export let onEdit: Function = () => {}; export let onReply: Function = () => {}; + export let onPin: Function = () => {}; export let onThread: Function = () => {}; export let onReaction: Function = () => {}; @@ -69,13 +74,17 @@ {#if message}
{#if !edit && !disabled} @@ -85,37 +94,56 @@
- (showButtons = false)} - onSubmit={(name) => { - showButtons = false; - onReaction(name); - }} - > - - - - - - - + + + {/if} + + {#if onReply} + + + + {/if} + + + - {#if !thread} + {#if !thread && onThread} - + {#if onEdit} + + + + {/if} - - - + {#if onDelete} + + + + {/if} {/if}
{/if} + {#if message?.is_pinned} +
+
+ + {$i18n.t('Pinned')} +
+
+ {/if} + {#if message?.reply_to_message?.user}
{/if} +
-
+
{#if showUserProfile} {#if message?.meta?.model_id} -
+
{#if showUserProfile}
diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index 13e4b5c415..b8d6c81807 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -18,16 +18,20 @@ import UserAlt from '../icons/UserAlt.svelte'; import ChannelInfoModal from './ChannelInfoModal.svelte'; import Users from '../icons/Users.svelte'; + import Pin from '../icons/Pin.svelte'; + import PinnedMessagesModal from './PinnedMessagesModal.svelte'; const i18n = getContext('i18n'); + let showChannelPinnedMessagesModal = false; let showChannelInfoModal = false; export let channel; + -