From 99a7823e0131d26ebe81cbdf907c25241e35a214 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 01:17:43 -0500 Subject: [PATCH 01/28] refac: db --- .../38d63c18f30f_add_oauth_session_table.py | 11 +++--- backend/open_webui/models/messages.py | 2 +- backend/open_webui/models/models.py | 2 +- backend/open_webui/models/notes.py | 2 +- backend/open_webui/models/oauth_sessions.py | 2 +- backend/open_webui/models/tools.py | 2 +- backend/open_webui/models/users.py | 2 +- src/lib/components/chat/Settings/About.svelte | 36 ++----------------- 8 files changed, 16 insertions(+), 43 deletions(-) diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py index 8ead6db6d4..b1930e6389 100644 --- a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -23,15 +23,18 @@ def upgrade() -> None: # Create oauth_session table op.create_table( "oauth_session", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True), + sa.Column( + "user_id", + sa.Text(), + sa.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("provider", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False), sa.Column("expires_at", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), ) # Create indexes for better performance diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 1094035fd5..7901f3af66 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -40,7 +40,7 @@ class MessageReactionModel(BaseModel): class Message(Base): __tablename__ = "message" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) channel_id = Column(Text, nullable=True) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 329b87a91f..8ddcf59d39 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -53,7 +53,7 @@ class ModelMeta(BaseModel): class Model(Base): __tablename__ = "model" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) """ The model's id as used in the API. If set to an existing model, it will override the model. """ diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index f1b11f071e..af75fab598 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -23,7 +23,7 @@ from sqlalchemy.sql import exists class Note(Base): __tablename__ = "note" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) title = Column(Text) diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index b0e465dbe7..d07faad35e 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -25,7 +25,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class OAuthSession(Base): __tablename__ = "oauth_session" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text, nullable=False) provider = Column(Text, nullable=False) token = Column( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 48f84b3ac4..7f6c7fd3f5 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -24,7 +24,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Tool(Base): __tablename__ = "tool" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) name = Column(Text) content = Column(Text) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 5809a7124f..2fa634097c 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -24,7 +24,7 @@ import datetime class User(Base): __tablename__ = "user" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) name = Column(String) email = Column(String) diff --git a/src/lib/components/chat/Settings/About.svelte b/src/lib/components/chat/Settings/About.svelte index 215b53b511..7863e99eb7 100644 --- a/src/lib/components/chat/Settings/About.svelte +++ b/src/lib/components/chat/Settings/About.svelte @@ -157,40 +157,10 @@ class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} Open WebUI (Timothy Jaeryang Baek)Open WebUI Inc. All rights reserved. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below. - -5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license. - -6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 32c888c2807f4edc6b756bf1f734f8d520e8b5d3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 01:40:52 -0500 Subject: [PATCH 02/28] refac --- src/lib/components/channel/Channel.svelte | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index 3caf18bcab..7e4f1f6b7a 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -5,7 +5,14 @@ import { onDestroy, onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; - import { chatId, channelId as _channelId, showSidebar, socket, user } from '$lib/stores'; + import { + chatId, + channels, + channelId as _channelId, + showSidebar, + socket, + user + } from '$lib/stores'; import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; import Messages from './Messages.svelte'; @@ -53,6 +60,18 @@ type: 'last_read_at' } }); + + channels.set( + $channels.map((channel) => { + if (channel.id === channelId) { + return { + ...channel, + unread_count: 0 + }; + } + return channel; + }) + ); }; const initHandler = async () => { From 15dc6077795efa72f4b8043f737bbe3f349c71b1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 02:34:25 -0500 Subject: [PATCH 03/28] refac: rm print --- backend/open_webui/utils/misc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 5591fcdb3f..c7ff2a3edd 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -54,7 +54,6 @@ def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> b return True allow_list, block_list = get_allow_block_lists(filter_list) - print(string, allow_list, block_list) # If allow list is non-empty, require domain to match one of them if allow_list: From 6ee50770cd0afc7237ef17774be7433403324b82 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 02:44:36 -0500 Subject: [PATCH 04/28] refac --- .../chat/Messages/Markdown/Source.svelte | 20 ++++++++++--------- .../chat/Messages/Markdown/SourceToken.svelte | 3 ++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown/Source.svelte b/src/lib/components/chat/Messages/Markdown/Source.svelte index d48525e070..2185599898 100644 --- a/src/lib/components/chat/Messages/Markdown/Source.svelte +++ b/src/lib/components/chat/Messages/Markdown/Source.svelte @@ -1,4 +1,6 @@ {#if title !== 'N/A'} @@ -41,7 +43,7 @@ }} > - {getDisplayTitle(formattedTitle(decodeURIComponent(title)))} + {getDisplayTitle(formattedTitle(decodeString(title)))} {/if} diff --git a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte index fdc422c62a..bd02408e36 100644 --- a/src/lib/components/chat/Messages/Markdown/SourceToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/SourceToken.svelte @@ -1,5 +1,6 @@ - - - + + + From 4b6773885cd7527c5a56b963781dac5e95105eec Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 28 Nov 2025 04:24:25 -0500 Subject: [PATCH 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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; + -