From ec84944ccf01f8d2a8dcd314bcc2f94ce69b4ac8 Mon Sep 17 00:00:00 2001 From: Max Techera Date: Wed, 22 Oct 2025 13:34:54 -0300 Subject: [PATCH] Add pagination and lazy loading to chat drawer (#620) ## Summary - Implements cursor-based pagination for chat loading in the drawer - Adds lazy loading with infinite scroll as user scrolls to bottom - Reduces initial load from all chats to 20 chats per page - Fetches chats exclusively from remote chatflows API ## Changes ### Backend (packages/server) - **Chatflows API** (`/api/v1/chats`): Added pagination support - Accepts `limit` (default: 20, max: 100) and `cursor` query params - Uses cursor-based pagination with `createdDate` field - Validates limit to prevent abuse (1-100 range) ### API Endpoint (apps/web) - **Local API** (`/api/chats`): Proxy to chatflows API with pagination - Accepts and validates `limit` and `cursor` params - Enforces max limit of 100 items per page ### Client (packages-answers/ui) - **ChatDrawer**: Infinite scroll implementation - Replaced `useSWR` with `useSWRInfinite` - Added IntersectionObserver to detect scroll to bottom - Shows loading spinner when fetching more chats - Stops loading when no more chats available - Uses `CHATS_PAGE_SIZE` constant (20) ### Utilities (packages-answers/utils) - **getChats**: Simplified to only fetch from remote API - Removed local Prisma chat fetching - Removed merge/deduplication logic - Directly returns chatflow API results with pagination ## Test plan - [ ] Verify initial load shows first 20 chats - [ ] Scroll to bottom and verify more chats load automatically - [ ] Verify loading spinner appears while fetching - [ ] Verify loading stops when all chats are loaded - [ ] Test with accounts that have < 20, exactly 20, and > 20 chats - [ ] Verify limit validation works (requesting > 100 should cap at 100) --- apps/web/app/api/chats/route.ts | 10 ++- packages-answers/ui/src/ChatDrawer.tsx | 62 +++++++++++++-- packages-answers/utils/src/getChats.ts | 78 ++++++------------- .../server/src/controllers/chats/index.ts | 8 +- packages/server/src/services/chats/index.ts | 33 ++++++-- 5 files changed, 119 insertions(+), 72 deletions(-) diff --git a/apps/web/app/api/chats/route.ts b/apps/web/app/api/chats/route.ts index 3b1f1895718..fcc54c38cb5 100644 --- a/apps/web/app/api/chats/route.ts +++ b/apps/web/app/api/chats/route.ts @@ -5,6 +5,9 @@ import { getChats } from '@utils/getChats' import type { Chat } from 'types' +const DEFAULT_PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 100 + export async function GET(req: Request): Promise> { const session = await getCachedSession() @@ -12,7 +15,12 @@ export async function GET(req: Request): Promise> { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const mergedChats = await getChats(session.user) + const { searchParams } = new URL(req.url) + const requestedLimit = parseInt(searchParams.get('limit') || String(DEFAULT_PAGE_SIZE)) + const limit = Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE) + const cursor = searchParams.get('cursor') || undefined + + const mergedChats = await getChats(session.user, { limit, cursor }) return NextResponse.json(mergedChats) } diff --git a/packages-answers/ui/src/ChatDrawer.tsx b/packages-answers/ui/src/ChatDrawer.tsx index df8bdf2f5b0..cc8972d1169 100644 --- a/packages-answers/ui/src/ChatDrawer.tsx +++ b/packages-answers/ui/src/ChatDrawer.tsx @@ -1,6 +1,6 @@ 'use client' import * as React from 'react' -import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' import NextLink from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { styled } from '@mui/material/styles' @@ -9,6 +9,7 @@ import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemButton from '@mui/material/ListItemButton' import ListItemText from '@mui/material/ListItemText' +import CircularProgress from '@mui/material/CircularProgress' import closedMixin from './theme/closedMixin' import openedMixin from './theme/openedMixin' @@ -17,6 +18,7 @@ import { Chat, Journey } from 'types' import { Box } from '@mui/material' const drawerWidth = 400 +const CHATS_PAGE_SIZE = 20 const DrawerHeader = styled('div')(({ theme }) => ({ display: 'flex', @@ -57,8 +59,28 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP const pathname = usePathname() const [open, setOpen] = React.useState(defaultOpen) const [opened, setOpened] = React.useState<{ [key: string | number]: boolean }>({ chats: true }) + const loadMoreRef = React.useRef(null) - const { data: fetchedChats } = useSWR('/api/chats', fetcher, { fallback: chats }) + const getKey = (pageIndex: number, previousPageData: Chat[] | null) => { + // Reached the end (no data or less than limit means no more pages) + if (previousPageData && previousPageData.length < CHATS_PAGE_SIZE) return null + + // First page + if (pageIndex === 0) return `/api/chats?limit=${CHATS_PAGE_SIZE}` + + // Get cursor from last chat of previous page + // Chatflows API uses 'createdDate', but fallback to 'createdAt' for type compatibility + const lastChat = previousPageData?.[previousPageData.length - 1] + const cursor = lastChat?.createdDate || lastChat?.createdAt + return `/api/chats?limit=${CHATS_PAGE_SIZE}&cursor=${cursor}` + } + + const { data, size, setSize, isValidating } = useSWRInfinite(getKey, fetcher, { + fallbackData: chats ? [chats] : undefined, + revalidateFirstPage: false + }) + + const fetchedChats = React.useMemo(() => data?.flat() || [], [data]) const getDateKey = (chat: Chat) => { const date = new Date(chat.createdAt ?? chat.createdDate) const now = new Date() @@ -70,17 +92,37 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP } const chatsByDate = React.useMemo(() => { - if (!fetchedChats || fetchedChats?.error) return {} + if (!fetchedChats || fetchedChats.length === 0) return {} - const sortedChats = fetchedChats?.sort( - (a, b) => new Date(b.createdAt ?? b.createdDate).getTime() - new Date(a.createdAt ?? a.createdDate).getTime() - ) - return sortedChats?.reduce((accum: { [key: string]: Chat[] }, chat: Chat) => { + return fetchedChats.reduce((accum: { [key: string]: Chat[] }, chat: Chat) => { const dateKey = getDateKey(chat) return { ...accum, [dateKey]: [...(accum[dateKey] || []), chat] } }, {}) }, [fetchedChats]) + // Check if there's more data to load + const hasMore = data && data[data.length - 1]?.length === CHATS_PAGE_SIZE + + // IntersectionObserver for infinite scroll + React.useEffect(() => { + if (!hasMore) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + setSize(size + 1) + } + }, + { threshold: 0.1 } + ) + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current) + } + + return () => observer.disconnect() + }, [size, setSize, isValidating, hasMore]) + return ( <> @@ -119,6 +161,12 @@ export default function ChatDrawer({ journeys, chats, defaultOpen }: ChatDrawerP ))} ))} + {/* Load more trigger */} + {hasMore && ( + + {isValidating && } + + )} ) diff --git a/packages-answers/utils/src/getChats.ts b/packages-answers/utils/src/getChats.ts index 3df5d018383..f5d45b995bd 100644 --- a/packages-answers/utils/src/getChats.ts +++ b/packages-answers/utils/src/getChats.ts @@ -1,4 +1,3 @@ -import { prisma } from '@db/client' import auth0 from '@utils/auth/auth0' interface User { @@ -8,7 +7,13 @@ interface User { chatflowDomain: string } -export async function getChats(user: User) { +interface PaginationOptions { + limit?: number + cursor?: string +} + +export async function getChats(user: User, options: PaginationOptions = {}) { + const { limit = 20, cursor } = options // Get auth token for chatflow API let token try { @@ -19,64 +24,27 @@ export async function getChats(user: User) { token = accessToken } catch (err) { console.error('Auth error:', err) + return [] } - // Fetch local chats - const localChatsPromise = prisma.chat - .findMany({ - where: { - users: { some: { email: user.email } }, - organization: { id: user.organizationId }, - chatflowChatId: { not: null }, - journeyId: null - }, - orderBy: { - createdAt: 'desc' - }, - include: { - prompt: true, - messages: { orderBy: { createdAt: 'desc' }, take: 1 } + // Fetch chatflow chats with pagination + try { + const response = await fetch(`${user.chatflowDomain}/api/v1/chats?limit=${limit}${cursor ? `&cursor=${cursor}` : ''}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` } }) - .then((data: any) => JSON.parse(JSON.stringify(data))) - - // Fetch chatflow chats - const chatflowChatsPromise = token - ? fetch(`${user.chatflowDomain}/api/v1/chats`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - } - }) - .then((res) => (res.ok ? res.json() : [])) - .catch((err: any) => { - console.error('Error fetching chatflow chats:', err.message) - return [] - }) - : Promise.resolve([]) - // Wait for both promises to resolve - const [localChats, chatflowChats] = await Promise.all([localChatsPromise, chatflowChatsPromise]) + if (!response.ok) { + console.error('Error fetching chatflow chats:', response.statusText) + return [] + } - // Merge and deduplicate chats - const mergedChats = [ - // ...localChats - ] - if (chatflowChats.length > 0) { - chatflowChats.forEach((chatflowChat: any) => { - // Only add if not already in local chats - if (!localChats.some((local) => local.chatflowChatId === chatflowChat.id)) { - mergedChats.push({ - ...chatflowChat, - chatflowChatId: chatflowChat.id - }) - } - }) + return await response.json() + } catch (err: any) { + console.error('Error fetching chatflow chats:', err.message) + return [] } - - // Sort merged chats by date - mergedChats.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - - return mergedChats } diff --git a/packages/server/src/controllers/chats/index.ts b/packages/server/src/controllers/chats/index.ts index abc7d6f1fa1..0a5d38d100e 100644 --- a/packages/server/src/controllers/chats/index.ts +++ b/packages/server/src/controllers/chats/index.ts @@ -3,12 +3,18 @@ import chatsService from '../../services/chats' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { StatusCodes } from 'http-status-codes' +const DEFAULT_PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 100 + const getAllChats = async (req: Request, res: Response, next: NextFunction) => { try { if (!req.user) { throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Error: chatsController.getAllChats - Unauthorized') } - const apiResponse = await chatsService.getAllChats(req.user) + const requestedLimit = req.query.limit ? parseInt(req.query.limit as string, 10) : DEFAULT_PAGE_SIZE + const limit = Math.min(Math.max(1, requestedLimit), MAX_PAGE_SIZE) + const cursor = req.query.cursor as string | undefined + const apiResponse = await chatsService.getAllChats(req.user, { limit, cursor }) return res.json(apiResponse) } catch (error) { next(error) diff --git a/packages/server/src/services/chats/index.ts b/packages/server/src/services/chats/index.ts index 9a826c947ae..ae206f6ed9d 100644 --- a/packages/server/src/services/chats/index.ts +++ b/packages/server/src/services/chats/index.ts @@ -4,20 +4,37 @@ import { getRunningExpressApp } from '../../utils/getRunningExpressApp' import { InternalFlowiseError } from '../../errors/internalFlowiseError' import { getErrorMessage } from '../../errors/utils' import { Chat } from '../../database/entities/Chat' -import { Not, IsNull } from 'typeorm' +import { Not, IsNull, LessThan } from 'typeorm' + +interface PaginationOptions { + limit?: number + cursor?: string +} + +const getAllChats = async (user: IUser, options: PaginationOptions = {}) => { + const { limit = 20, cursor } = options + + // Validate cursor date if provided + if (cursor) { + const cursorDate = new Date(cursor) + if (isNaN(cursorDate.getTime())) { + throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, 'Invalid cursor date format') + } + } -const getAllChats = async (user: IUser) => { try { const appServer = getRunningExpressApp() const chats = await appServer.AppDataSource.getRepository(Chat).find({ where: { - owner: { id: user.id }, - organization: { id: user.organizationId }, - chatflowChatId: Not(IsNull()) + ownerId: user.id, + organizationId: user.organizationId, + chatflowChatId: Not(IsNull()), + ...(cursor ? { createdDate: LessThan(new Date(cursor)) } : {}) }, order: { createdDate: 'DESC' - } + }, + take: limit }) return JSON.parse(JSON.stringify(chats)) } catch (error) { @@ -31,8 +48,8 @@ const getChatById = async (chatId: string, user: IUser) => { const chat = await appServer.AppDataSource.getRepository(Chat).findOne({ where: { id: chatId, - owner: { id: user.id }, - organization: { id: user.organizationId } + ownerId: user.id, + organizationId: user.organizationId }, relations: { chatflow: true