refac
This commit is contained in:
@@ -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')
|
||||||
@@ -57,6 +57,8 @@ class Chat(Base):
|
|||||||
tasks = Column(JSON, nullable=True)
|
tasks = Column(JSON, nullable=True)
|
||||||
summary = Column(Text, nullable=True)
|
summary = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
last_read_at = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Performance indexes for common queries
|
# Performance indexes for common queries
|
||||||
# WHERE folder_id = ...
|
# WHERE folder_id = ...
|
||||||
@@ -93,6 +95,8 @@ class ChatModel(BaseModel):
|
|||||||
tasks: Optional[list] = None
|
tasks: Optional[list] = None
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
|
|
||||||
|
last_read_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ChatFile(Base):
|
class ChatFile(Base):
|
||||||
__tablename__ = 'chat_file'
|
__tablename__ = 'chat_file'
|
||||||
@@ -176,6 +180,7 @@ class ChatTitleIdResponse(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
updated_at: int
|
updated_at: int
|
||||||
created_at: int
|
created_at: int
|
||||||
|
last_read_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class SharedChatResponse(BaseModel):
|
class SharedChatResponse(BaseModel):
|
||||||
@@ -397,6 +402,20 @@ class ChatTable:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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]:
|
def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]:
|
||||||
chat = self.get_chat_by_id(id)
|
chat = self.get_chat_by_id(id)
|
||||||
if chat is None:
|
if chat is None:
|
||||||
@@ -834,7 +853,7 @@ class ChatTable:
|
|||||||
query = query.filter_by(archived=False)
|
query = query.filter_by(archived=False)
|
||||||
|
|
||||||
query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities(
|
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:
|
if skip:
|
||||||
@@ -852,6 +871,7 @@ class ChatTable:
|
|||||||
'title': chat[1],
|
'title': chat[1],
|
||||||
'updated_at': chat[2],
|
'updated_at': chat[2],
|
||||||
'created_at': chat[3],
|
'created_at': chat[3],
|
||||||
|
'last_read_at': chat[4],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for chat in all_chats
|
for chat in all_chats
|
||||||
@@ -995,7 +1015,7 @@ class ChatTable:
|
|||||||
db.query(Chat)
|
db.query(Chat)
|
||||||
.filter_by(user_id=user_id, pinned=True, archived=False)
|
.filter_by(user_id=user_id, pinned=True, archived=False)
|
||||||
.order_by(Chat.updated_at.desc())
|
.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 [
|
return [
|
||||||
ChatTitleIdResponse.model_validate(
|
ChatTitleIdResponse.model_validate(
|
||||||
@@ -1004,6 +1024,7 @@ class ChatTable:
|
|||||||
'title': chat[1],
|
'title': chat[1],
|
||||||
'updated_at': chat[2],
|
'updated_at': chat[2],
|
||||||
'created_at': chat[3],
|
'created_at': chat[3],
|
||||||
|
'last_read_at': chat[4],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for chat in all_chats
|
for chat in all_chats
|
||||||
|
|||||||
@@ -644,7 +644,7 @@ async def get_chat_list_by_folder_id(
|
|||||||
skip = (page - 1) * limit
|
skip = (page - 1) * limit
|
||||||
|
|
||||||
return [
|
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)
|
for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -491,6 +491,22 @@ async def channel_events(sid, data):
|
|||||||
Channels.update_member_last_read_at(data['channel_id'], user['id'])
|
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:
|
def normalize_document_id(document_id: str) -> str:
|
||||||
"""Canonicalize document IDs to prevent auth bypass via prefix variants.
|
"""Canonicalize document IDs to prevent auth bypass via prefix variants.
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,11 @@
|
|||||||
window.setTimeout(() => scrollToBottom(), 0);
|
window.setTimeout(() => scrollToBottom(), 0);
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
|
// Mark chat read when initially loading it
|
||||||
|
if (chatIdProp && !$temporaryChatEnabled) {
|
||||||
|
updateLastReadAt(chatIdProp);
|
||||||
|
}
|
||||||
|
|
||||||
// Process any queued requests if the chat is idle
|
// Process any queued requests if the chat is idle
|
||||||
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
|
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
|
||||||
@@ -403,6 +408,13 @@
|
|||||||
saveChatHandler(_chatId, history);
|
saveChatHandler(_chatId, history);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateLastReadAt = (id) => {
|
||||||
|
$socket?.emit('events:chat', {
|
||||||
|
chat_id: id,
|
||||||
|
data: { type: 'last_read_at' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const terminalEventHandler = (type: string, data: any) => {
|
const terminalEventHandler = (type: string, data: any) => {
|
||||||
if (type === 'terminal:display_file') {
|
if (type === 'terminal:display_file') {
|
||||||
if (!data?.path) return;
|
if (!data?.path) return;
|
||||||
@@ -768,6 +780,9 @@
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
|
if (chatIdProp && !$temporaryChatEnabled) {
|
||||||
|
updateLastReadAt(chatIdProp);
|
||||||
|
}
|
||||||
pageSubscribe();
|
pageSubscribe();
|
||||||
showControlsSubscribe();
|
showControlsSubscribe();
|
||||||
selectedFolderSubscribe();
|
selectedFolderSubscribe();
|
||||||
|
|||||||
@@ -1290,6 +1290,8 @@
|
|||||||
id={chat.id}
|
id={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
createdAt={chat.created_at}
|
createdAt={chat.created_at}
|
||||||
|
updatedAt={chat.updated_at}
|
||||||
|
lastReadAt={chat.last_read_at}
|
||||||
{shiftKey}
|
{shiftKey}
|
||||||
selected={selectedChatId === chat.id}
|
selected={selectedChatId === chat.id}
|
||||||
on:select={() => {
|
on:select={() => {
|
||||||
@@ -1351,6 +1353,8 @@
|
|||||||
id={chat.id}
|
id={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
createdAt={chat.created_at}
|
createdAt={chat.created_at}
|
||||||
|
updatedAt={chat.updated_at}
|
||||||
|
lastReadAt={chat.last_read_at}
|
||||||
{shiftKey}
|
{shiftKey}
|
||||||
selected={selectedChatId === chat.id}
|
selected={selectedChatId === chat.id}
|
||||||
on:select={() => {
|
on:select={() => {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
export let id;
|
export let id;
|
||||||
export let title;
|
export let title;
|
||||||
export let createdAt: number | null = null;
|
export let createdAt: number | null = null;
|
||||||
|
export let updatedAt: number | null = null;
|
||||||
|
export let lastReadAt: number | null = null;
|
||||||
|
|
||||||
export let selected = false;
|
export let selected = false;
|
||||||
export let shiftKey = false;
|
export let shiftKey = false;
|
||||||
@@ -79,6 +81,11 @@
|
|||||||
|
|
||||||
let mouseOver = false;
|
let mouseOver = false;
|
||||||
|
|
||||||
|
$: unread =
|
||||||
|
id !== $chatId &&
|
||||||
|
!$activeChatIds.has(id) &&
|
||||||
|
(lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt));
|
||||||
|
|
||||||
const loadChat = async () => {
|
const loadChat = async () => {
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
draggable = false;
|
draggable = false;
|
||||||
@@ -435,6 +442,10 @@
|
|||||||
if ($mobile) {
|
if ($mobile) {
|
||||||
showSidebar.set(false);
|
showSidebar.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistically mark as read in UI when clicked
|
||||||
|
unread = false;
|
||||||
|
lastReadAt = Date.now() / 1000;
|
||||||
}}
|
}}
|
||||||
on:dblclick={async (e) => {
|
on:dblclick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -460,7 +471,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex self-center flex-1 w-full min-w-0">
|
<div class="flex self-center flex-1 w-full min-w-0">
|
||||||
<div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
|
{#if unread}
|
||||||
|
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
|
||||||
|
<div class="size-1.5 bg-sky-500 rounded-full" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
dir="auto"
|
||||||
|
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
|
||||||
|
? 'font-medium text-gray-900 dark:text-gray-100'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -682,6 +682,8 @@
|
|||||||
id={chat.id}
|
id={chat.id}
|
||||||
title={chat.title}
|
title={chat.title}
|
||||||
createdAt={chat.created_at}
|
createdAt={chat.created_at}
|
||||||
|
updatedAt={chat.updated_at}
|
||||||
|
lastReadAt={chat.last_read_at}
|
||||||
{shiftKey}
|
{shiftKey}
|
||||||
on:change={(e) => {
|
on:change={(e) => {
|
||||||
dispatch('change', e.detail);
|
dispatch('change', e.detail);
|
||||||
|
|||||||
Reference in New Issue
Block a user