diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index c20c1db247..1d959f0bc6 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -1722,17 +1722,21 @@ class ChatTable: folder_id: str, skip: int = 0, limit: int = 60, + sort_by: str = 'updated_at', + sort_dir: str = 'desc', db: AsyncSession | None = None, ) -> list[dict]: """Get chats in a folder across ALL users. Returns dicts with user_id.""" async with get_async_db_context(db) as session: + sort_column = Chat.title if sort_by == 'title' else Chat.updated_at + order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc() stmt = ( select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at) .filter_by(folder_id=folder_id) .filter(or_(Chat.pinned == False, Chat.pinned == None)) .filter_by(archived=False) .where(Chat.meta['internal'].as_boolean().is_not(True)) - .order_by(Chat.updated_at.desc(), Chat.id) + .order_by(order_clause, Chat.id) ) if skip: @@ -1754,6 +1758,22 @@ class ChatTable: for chat in all_chats ] + async def count_all_chats_by_folder_id( + self, + folder_id: str, + db: AsyncSession | None = None, + ) -> int: + async with get_async_db_context(db) as session: + stmt = ( + select(func.count(Chat.id)) + .filter_by(folder_id=folder_id) + .filter(or_(Chat.pinned == False, Chat.pinned == None)) + .filter_by(archived=False) + .where(Chat.meta['internal'].as_boolean().is_not(True)) + ) + result = await session.execute(stmt) + return result.scalar_one() + async def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None ) -> list[ChatModel]: diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index a4ea4b8961..e3ebbfd96f 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -6,7 +6,7 @@ import uuid from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse from open_webui.config import UPLOAD_DIR from open_webui.constants import ERROR_MESSAGES @@ -482,6 +482,9 @@ async def update_folder_access_by_id( async def get_shared_folder_chats( request: Request, id: str, + page: int | None = Query(None, ge=1), + sort_by: str = Query('updated_at'), + sort_dir: str = Query('desc'), user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): @@ -505,7 +508,17 @@ async def get_shared_folder_chats( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - chats = await Chats.get_all_chats_by_folder_id(id, db=db) + limit = 10 + skip = (page - 1) * limit if page is not None else 0 + chats = await Chats.get_all_chats_by_folder_id( + id, + skip=skip, + limit=limit if page is not None else 60, + sort_by=sort_by, + sort_dir=sort_dir, + db=db, + ) + total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats) # Resolve owner names for display (avatar URLs are constructed client-side) owner_cache: dict[str, str] = {} @@ -516,10 +529,13 @@ async def get_shared_folder_chats( owner_cache[uid] = u.name if u else 'Unknown' chat['owner_name'] = owner_cache[uid] - return { + response = { 'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats], 'folder_permission': 'write' if has_write else 'read', } + if page is not None: + response.update({'total': total, 'has_more': skip + limit < total}) + return response ############################ diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index b79e588947..d82f591289 100644 --- a/src/lib/apis/folders/index.ts +++ b/src/lib/apis/folders/index.ts @@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => { return res; }; -export const getSharedFolderChats = async (token: string, folderId: string) => { +export const getSharedFolderChats = async ( + token: string, + folderId: string, + params: { + page?: number | null; + sortBy?: 'title' | 'updated_at'; + sortDir?: 'asc' | 'desc'; + } = {} +) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - authorization: `Bearer ${token}` + const searchParams = new URLSearchParams(); + if (params.page !== undefined && params.page !== null) { + searchParams.append('page', `${params.page}`); + } + if (params.sortBy) { + searchParams.append('sort_by', params.sortBy); + } + if (params.sortDir) { + searchParams.append('sort_dir', params.sortDir); + } + const query = searchParams.toString(); + + const res = await fetch( + `${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`, + { + 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(); diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index e77e296129..08bde0bc9f 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1177,7 +1177,7 @@ } const pageSubscribe = page.subscribe(async (p) => { - if (p.url.pathname === '/') { + if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) { await tick(); initNewChat(); } diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte index 64a9ed215d..77bd5ae4ab 100644 --- a/src/lib/components/chat/Placeholder/ChatList.svelte +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -1,27 +1,35 @@ {#if chatList} @@ -153,7 +170,6 @@ class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850" draggable="false" href={`/c/${chat.id}`} - on:click={() => (show = false)} >
{chat?.title} @@ -177,19 +193,48 @@ {/each} - {#if !allChatsLoaded && loadHandler} - { - if (!chatListLoading) { - loadHandler(); - } - }} - > -
- -
Loading...
-
-
+ {#if totalPages > 1} +
+ + + {#each pages as item, index (item === 'ellipsis' ? `ellipsis-${index}` : item)} + {#if item === 'ellipsis'} + + ... + + {:else} + + {/if} + {/each} + + +
{/if}
{/if} diff --git a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte index d7cbc6b400..c943f57ee0 100644 --- a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte +++ b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte @@ -11,18 +11,21 @@ import ChatList from './ChatList.svelte'; import FolderKnowledge from './FolderKnowledge.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; - import { getChatListByFolderId } from '$lib/apis/chats'; import { getSharedFolderChats } from '$lib/apis/folders'; export let folder: any = null; let selectedTab = 'chats'; + const CHATS_PAGE_SIZE = 10; let page = 1; + let totalChats = 0; + let orderBy: 'title' | 'updated_at' = 'updated_at'; + let direction: 'asc' | 'desc' = 'desc'; + let currentFolderId: string | null = null; let chats: any[] | null = null; let chatListLoading = false; - let allChatsLoaded = false; $: showOwnerInfo = Boolean( folder?.shared || @@ -30,42 +33,68 @@ (folder?.access_grants?.length ?? 0) > 0 ); - const loadChats = async () => { - // getSharedFolderChats returns all users' chats in one shot; no pagination - allChatsLoaded = true; + const setSortKey = (key: 'title' | 'updated_at') => { + if (orderBy === key) { + direction = direction === 'asc' ? 'desc' : 'asc'; + } else { + orderBy = key; + direction = key === 'title' ? 'asc' : 'desc'; + } + page = 1; + setChatList(); + }; + + const setPage = (nextPage: number) => { + if (nextPage === page || chatListLoading) { + return; + } + + page = nextPage; + setChatList(); }; const setChatList = async () => { + const folderId = folder?.id; chats = null; - page = 1; - allChatsLoaded = false; - chatListLoading = false; - if (folder && folder.id) { + if (folderId) { // Always use the shared folder endpoint so owners also see // chats created by users who have write access to this folder. - const res = await getSharedFolderChats(localStorage.token, folder.id).catch((error) => { + chatListLoading = true; + const res = await getSharedFolderChats(localStorage.token, folderId, { + page, + sortBy: orderBy, + sortDir: direction + }).catch((error) => { console.error(error); return null; }); + chatListLoading = false; + if (res && res.chats) { chats = res.chats; - allChatsLoaded = true; + totalChats = res.total ?? res.chats.length; } else { - // Fallback to regular API (e.g. if user has no shared access) - const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch( - () => [] - ); - chats = fallback || []; + chats = []; + totalChats = 0; } } else { chats = []; + totalChats = 0; } }; - $: if (folder) { + $: if (folder?.id && folder.id !== currentFolderId) { + currentFolderId = folder.id; + page = 1; setChatList(); } + + $: if (!folder?.id && currentFolderId !== null) { + currentFolderId = null; + chats = []; + totalChats = 0; + }
@@ -105,9 +134,14 @@ {:else}
diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte index c13058200c..f971a93a9c 100644 --- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte +++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte @@ -44,7 +44,6 @@ import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import FolderModal from './Folders/FolderModal.svelte'; import Emoji from '$lib/components/common/Emoji.svelte'; - import Spinner from '$lib/components/common/Spinner.svelte'; export let folderRegistry = {}; export let open = false; @@ -385,24 +384,46 @@ }, 500); }; + const SIDEBAR_CHATS_PAGE_SIZE = 10; + /** @type {any[] | null} */ let chats = null; - export const setFolderItems = async () => { + let chatsPage = 1; + let hasMoreChats = false; + let chatsLoading = false; + + export const setFolderItems = async (append = false) => { await tick(); - if (open) { + if (open && !chatsLoading) { // Always use getSharedFolderChats so owners also see chats // created by users who have write access to this folder. + const nextPage = append ? chatsPage + 1 : 1; + chatsLoading = true; try { - const res = await getSharedFolderChats(localStorage.token, folderId); - chats = res?.chats ?? []; + const res = await getSharedFolderChats(localStorage.token, folderId, { + page: nextPage + }); + const nextChats = res?.chats ?? []; + chats = append ? [...(chats ?? []), ...nextChats] : nextChats; + chatsPage = nextPage; + hasMoreChats = res?.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE; } catch (error) { // Fallback to regular API - chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => { - toast.error(`${error}`); - return []; - }); + const fallback = await getChatListByFolderId(localStorage.token, folderId, nextPage).catch( + (error) => { + toast.error(`${error}`); + return []; + } + ); + chats = append ? [...(chats ?? []), ...(fallback ?? [])] : (fallback ?? []); + chatsPage = nextPage; + hasMoreChats = (fallback?.length ?? 0) === SIDEBAR_CHATS_PAGE_SIZE; + } finally { + chatsLoading = false; } } else { chats = null; + chatsPage = 1; + hasMoreChats = false; } }; @@ -564,7 +585,7 @@ await selectedFolder.set({ ...folders[folderId], ...folder }); } - await goto('/'); + await goto(`/folders/${folderId}`); if ($mobile) { showSidebar.set(!$showSidebar); @@ -678,7 +699,7 @@
- {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0} + {#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0 || hasMoreChats}
@@ -731,12 +752,41 @@ }} /> {/each} + + {#if hasMoreChats} + + {/if}
{/if} - {#if chats === null} -
- + {#if chats === null && chatsLoading} +
+ + +
{/if}
diff --git a/src/lib/components/layout/Sidebar/SharedFolderItem.svelte b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte index ba74ffbba4..98a92f9042 100644 --- a/src/lib/components/layout/Sidebar/SharedFolderItem.svelte +++ b/src/lib/components/layout/Sidebar/SharedFolderItem.svelte @@ -1,6 +1,8 @@ + +{#if ready} + +{:else} +
+ +
+{/if}