From 0638b9f56ce1ba8a496d0e84da2e7fa178b01a3f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 1 Apr 2026 04:00:18 -0500 Subject: [PATCH] refac --- .../b7c8d9e0f1a2_add_last_read_at_to_chat.py | 26 +++++++++++++++++++ backend/open_webui/models/chats.py | 25 ++++++++++++++++-- backend/open_webui/routers/chats.py | 2 +- backend/open_webui/socket/main.py | 16 ++++++++++++ src/lib/components/chat/Chat.svelte | 15 +++++++++++ src/lib/components/layout/Sidebar.svelte | 4 +++ .../components/layout/Sidebar/ChatItem.svelte | 23 +++++++++++++++- .../layout/Sidebar/RecursiveFolder.svelte | 2 ++ 8 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py diff --git a/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py new file mode 100644 index 0000000000..ba921763b4 --- /dev/null +++ b/backend/open_webui/migrations/versions/b7c8d9e0f1a2_add_last_read_at_to_chat.py @@ -0,0 +1,26 @@ +"""add last_read_at to chat + +Revision ID: b7c8d9e0f1a2 +Revises: d4e5f6a7b8c9 +Create Date: 2026-04-01 04:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b7c8d9e0f1a2' +down_revision = 'd4e5f6a7b8c9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True)) + # Set existing chats to be marked as read + op.execute('UPDATE chat SET last_read_at = updated_at') + + +def downgrade(): + op.drop_column('chat', 'last_read_at') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 66d99b05cb..93da2d5736 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -57,6 +57,8 @@ class Chat(Base): tasks = Column(JSON, nullable=True) summary = Column(Text, nullable=True) + last_read_at = Column(BigInteger, nullable=True) + __table_args__ = ( # Performance indexes for common queries # WHERE folder_id = ... @@ -93,6 +95,8 @@ class ChatModel(BaseModel): tasks: Optional[list] = None summary: Optional[str] = None + last_read_at: Optional[int] = None + class ChatFile(Base): __tablename__ = 'chat_file' @@ -176,6 +180,7 @@ class ChatTitleIdResponse(BaseModel): title: str updated_at: int created_at: int + last_read_at: Optional[int] = None class SharedChatResponse(BaseModel): @@ -397,6 +402,20 @@ class ChatTable: except Exception: return None + def update_chat_last_read_at_by_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + try: + with get_db_context(db) as db: + chat = db.get(Chat, id) + if chat and chat.user_id == user_id: + chat.last_read_at = int(time.time()) + db.commit() + return True + return False + except Exception: + return False + def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]: chat = self.get_chat_by_id(id) if chat is None: @@ -834,7 +853,7 @@ class ChatTable: query = query.filter_by(archived=False) query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities( - Chat.id, Chat.title, Chat.updated_at, Chat.created_at + Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at ) if skip: @@ -852,6 +871,7 @@ class ChatTable: 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats @@ -995,7 +1015,7 @@ class ChatTable: db.query(Chat) .filter_by(user_id=user_id, pinned=True, archived=False) .order_by(Chat.updated_at.desc()) - .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) + .with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at) ) return [ ChatTitleIdResponse.model_validate( @@ -1004,6 +1024,7 @@ class ChatTable: 'title': chat[1], 'updated_at': chat[2], 'created_at': chat[3], + 'last_read_at': chat[4], } ) for chat in all_chats diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index eacc084b42..51ef790ac9 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -644,7 +644,7 @@ async def get_chat_list_by_folder_id( skip = (page - 1) * limit return [ - {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at} + {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at} for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) ] diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 33c9ffea05..e8815f7108 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -491,6 +491,22 @@ async def channel_events(sid, data): Channels.update_member_last_read_at(data['channel_id'], user['id']) +@sio.on('events:chat') +async def chat_events(sid, data): + user = SESSION_POOL.get(sid) + if not user: + return + + event_data = data.get('data', {}) + event_type = event_data.get('type') + + if event_type == 'last_read_at': + await asyncio.to_thread( + Chats.update_chat_last_read_at_by_id, + data['chat_id'], user['id'] + ) + + def normalize_document_id(document_id: str) -> str: """Canonicalize document IDs to prevent auth bypass via prefix variants. diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 19dae7cd3e..8500e695f8 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -200,6 +200,11 @@ window.setTimeout(() => scrollToBottom(), 0); await tick(); + + // Mark chat read when initially loading it + if (chatIdProp && !$temporaryChatEnabled) { + updateLastReadAt(chatIdProp); + } // Process any queued requests if the chat is idle const lastMessage = history.currentId ? history.messages[history.currentId] : null; @@ -403,6 +408,13 @@ saveChatHandler(_chatId, history); }; + const updateLastReadAt = (id) => { + $socket?.emit('events:chat', { + chat_id: id, + data: { type: 'last_read_at' } + }); + }; + const terminalEventHandler = (type: string, data: any) => { if (type === 'terminal:display_file') { if (!data?.path) return; @@ -768,6 +780,9 @@ return () => { try { + if (chatIdProp && !$temporaryChatEnabled) { + updateLastReadAt(chatIdProp); + } pageSubscribe(); showControlsSubscribe(); selectedFolderSubscribe(); diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index e47046641b..e28229989e 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -1290,6 +1290,8 @@ id={chat.id} title={chat.title} createdAt={chat.created_at} + updatedAt={chat.updated_at} + lastReadAt={chat.last_read_at} {shiftKey} selected={selectedChatId === chat.id} on:select={() => { @@ -1351,6 +1353,8 @@ id={chat.id} title={chat.title} createdAt={chat.created_at} + updatedAt={chat.updated_at} + lastReadAt={chat.last_read_at} {shiftKey} selected={selectedChatId === chat.id} on:select={() => { diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index ac21229b6a..26b76ce60a 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -50,6 +50,8 @@ export let id; export let title; export let createdAt: number | null = null; + export let updatedAt: number | null = null; + export let lastReadAt: number | null = null; export let selected = false; export let shiftKey = false; @@ -79,6 +81,11 @@ let mouseOver = false; + $: unread = + id !== $chatId && + !$activeChatIds.has(id) && + (lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt)); + const loadChat = async () => { if (!chat) { draggable = false; @@ -435,6 +442,10 @@ if ($mobile) { showSidebar.set(false); } + + // Optimistically mark as read in UI when clicked + unread = false; + lastReadAt = Date.now() / 1000; }} on:dblclick={async (e) => { e.preventDefault(); @@ -460,7 +471,17 @@ {/if}
-
+ {#if unread} +
+
+
+ {/if} +
{title}
diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index 3557a4fd5d..2403c8e9ae 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -682,6 +682,8 @@ id={chat.id} title={chat.title} createdAt={chat.created_at} + updatedAt={chat.updated_at} + lastReadAt={chat.last_read_at} {shiftKey} on:change={(e) => { dispatch('change', e.detail);