diff --git a/api/models/Prompt.js b/api/models/Prompt.js index d4737b6a8..d96780a03 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -269,7 +269,7 @@ async function getListPromptGroupsByAccess({ const baseQuery = { ...otherParams, _id: { $in: accessibleIds } }; // Add cursor condition - if (after) { + if (after && typeof after === 'string' && after !== 'undefined' && after !== 'null') { try { const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')); const { updatedAt, _id } = cursor; diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 46bdf697e..300072b4d 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -156,7 +156,7 @@ router.get('/all', async (req, res) => { router.get('/groups', async (req, res) => { try { const userId = req.user.id; - const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query; + const { pageSize, limit, cursor, name, category, ...otherFilters } = req.query; const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ name, @@ -171,6 +171,13 @@ router.get('/groups', async (req, res) => { actualLimit = parseInt(pageSize, 10); } + if ( + actualCursor && + (actualCursor === 'undefined' || actualCursor === 'null' || actualCursor.length === 0) + ) { + actualCursor = null; + } + let accessibleIds = await findAccessibleResources({ userId, role: req.user.role, @@ -190,6 +197,7 @@ router.get('/groups', async (req, res) => { publicPromptGroupIds: publiclyAccessibleIds, }); + // Cursor-based pagination only const result = await getListPromptGroupsByAccess({ accessibleIds: filteredAccessibleIds, otherParams: filter, @@ -198,19 +206,21 @@ router.get('/groups', async (req, res) => { }); if (!result) { - const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit }); + const emptyResponse = createEmptyPromptGroupsResponse({ + pageNumber: '1', + pageSize: actualLimit, + actualLimit, + }); return res.status(200).send(emptyResponse); } const { data: promptGroups = [], has_more = false, after = null } = result; - const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds); const response = formatPromptGroupsResponse({ promptGroups: groupsWithPublicFlag, - pageNumber, - pageSize, - actualLimit, + pageNumber: '1', // Always 1 for cursor-based pagination + pageSize: actualLimit.toString(), hasMore: has_more, after, }); diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js index 29b806f62..e23676b8d 100644 --- a/api/server/routes/prompts.test.js +++ b/api/server/routes/prompts.test.js @@ -33,22 +33,11 @@ let promptRoutes; let Prompt, PromptGroup, AclEntry, AccessRole, User; let testUsers, testRoles; let grantPermission; +let currentTestUser; // Track current user for middleware // Helper function to set user in middleware function setTestUser(app, user) { - app.use((req, res, next) => { - req.user = { - ...(user.toObject ? user.toObject() : user), - id: user.id || user._id.toString(), - _id: user._id, - name: user.name, - role: user.role, - }; - if (user.role === SystemRoles.ADMIN) { - console.log('Setting admin user with role:', req.user.role); - } - next(); - }); + currentTestUser = user; } beforeAll(async () => { @@ -75,14 +64,35 @@ beforeAll(async () => { app = express(); app.use(express.json()); - // Mock authentication middleware - default to owner - setTestUser(app, testUsers.owner); + // Add user middleware before routes + app.use((req, res, next) => { + if (currentTestUser) { + req.user = { + ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser), + id: currentTestUser._id.toString(), + _id: currentTestUser._id, + name: currentTestUser.name, + role: currentTestUser.role, + }; + } + next(); + }); - // Import routes after mocks are set up + // Set default user + currentTestUser = testUsers.owner; + + // Import routes after middleware is set up promptRoutes = require('./prompts'); app.use('/api/prompts', promptRoutes); }); +afterEach(() => { + // Always reset to owner user after each test for isolation + if (currentTestUser !== testUsers.owner) { + currentTestUser = testUsers.owner; + } +}); + afterAll(async () => { await mongoose.disconnect(); await mongoServer.stop(); @@ -116,36 +126,26 @@ async function setupTestData() { // Create test users testUsers = { owner: await User.create({ - id: new ObjectId().toString(), - _id: new ObjectId(), name: 'Prompt Owner', email: 'owner@example.com', role: SystemRoles.USER, }), viewer: await User.create({ - id: new ObjectId().toString(), - _id: new ObjectId(), name: 'Prompt Viewer', email: 'viewer@example.com', role: SystemRoles.USER, }), editor: await User.create({ - id: new ObjectId().toString(), - _id: new ObjectId(), name: 'Prompt Editor', email: 'editor@example.com', role: SystemRoles.USER, }), noAccess: await User.create({ - id: new ObjectId().toString(), - _id: new ObjectId(), name: 'No Access', email: 'noaccess@example.com', role: SystemRoles.USER, }), admin: await User.create({ - id: new ObjectId().toString(), - _id: new ObjectId(), name: 'Admin', email: 'admin@example.com', role: SystemRoles.ADMIN, @@ -181,8 +181,7 @@ describe('Prompt Routes - ACL Permissions', () => { it('should have routes loaded', async () => { // This should at least not crash const response = await request(app).get('/api/prompts/test-404'); - console.log('Test 404 response status:', response.status); - console.log('Test 404 response body:', response.body); + // We expect a 401 or 404, not 500 expect(response.status).not.toBe(500); }); @@ -207,12 +206,6 @@ describe('Prompt Routes - ACL Permissions', () => { const response = await request(app).post('/api/prompts').send(promptData); - if (response.status !== 200) { - console.log('POST /api/prompts error status:', response.status); - console.log('POST /api/prompts error body:', response.body); - console.log('Console errors:', consoleErrorSpy.mock.calls); - } - expect(response.status).toBe(200); expect(response.body.prompt).toBeDefined(); expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt); @@ -318,29 +311,8 @@ describe('Prompt Routes - ACL Permissions', () => { }); it('should allow admin access without explicit permissions', async () => { - // First, reset the app to remove previous middleware - app = express(); - app.use(express.json()); - - // Set admin user BEFORE adding routes - app.use((req, res, next) => { - req.user = { - ...testUsers.admin.toObject(), - id: testUsers.admin._id.toString(), - _id: testUsers.admin._id, - name: testUsers.admin.name, - role: testUsers.admin.role, - }; - next(); - }); - - // Now add the routes - const promptRoutes = require('./prompts'); - app.use('/api/prompts', promptRoutes); - - console.log('Admin user:', testUsers.admin); - console.log('Admin role:', testUsers.admin.role); - console.log('SystemRoles.ADMIN:', SystemRoles.ADMIN); + // Set admin user + setTestUser(app, testUsers.admin); const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200); @@ -432,21 +404,8 @@ describe('Prompt Routes - ACL Permissions', () => { grantedBy: testUsers.editor._id, }); - // Recreate app with viewer user - app = express(); - app.use(express.json()); - app.use((req, res, next) => { - req.user = { - ...testUsers.viewer.toObject(), - id: testUsers.viewer._id.toString(), - _id: testUsers.viewer._id, - name: testUsers.viewer.name, - role: testUsers.viewer.role, - }; - next(); - }); - const promptRoutes = require('./prompts'); - app.use('/api/prompts', promptRoutes); + // Set viewer user + setTestUser(app, testUsers.viewer); await request(app) .delete(`/api/prompts/${authorPrompt._id}`) @@ -499,21 +458,8 @@ describe('Prompt Routes - ACL Permissions', () => { grantedBy: testUsers.owner._id, }); - // Recreate app to ensure fresh middleware - app = express(); - app.use(express.json()); - app.use((req, res, next) => { - req.user = { - ...testUsers.owner.toObject(), - id: testUsers.owner._id.toString(), - _id: testUsers.owner._id, - name: testUsers.owner.name, - role: testUsers.owner.role, - }; - next(); - }); - const promptRoutes = require('./prompts'); - app.use('/api/prompts', promptRoutes); + // Ensure owner user + setTestUser(app, testUsers.owner); const response = await request(app) .patch(`/api/prompts/${testPrompt._id}/tags/production`) @@ -537,21 +483,8 @@ describe('Prompt Routes - ACL Permissions', () => { grantedBy: testUsers.owner._id, }); - // Recreate app with viewer user - app = express(); - app.use(express.json()); - app.use((req, res, next) => { - req.user = { - ...testUsers.viewer.toObject(), - id: testUsers.viewer._id.toString(), - _id: testUsers.viewer._id, - name: testUsers.viewer.name, - role: testUsers.viewer.role, - }; - next(); - }); - const promptRoutes = require('./prompts'); - app.use('/api/prompts', promptRoutes); + // Set viewer user + setTestUser(app, testUsers.viewer); await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403); @@ -610,4 +543,305 @@ describe('Prompt Routes - ACL Permissions', () => { expect(response.body._id).toBe(publicPrompt._id.toString()); }); }); + + describe('Pagination', () => { + beforeEach(async () => { + // Create multiple prompt groups for pagination testing + const groups = []; + for (let i = 0; i < 15; i++) { + const group = await PromptGroup.create({ + name: `Test Group ${i + 1}`, + category: 'pagination-test', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - i * 1000), // Stagger updatedAt for consistent ordering + }); + groups.push(group); + + // Grant owner permissions on each group + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + }); + + afterEach(async () => { + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should correctly indicate hasMore when there are more pages', async () => { + const response = await request(app) + .get('/api/prompts/groups') + .query({ limit: '10' }) + .expect(200); + + expect(response.body.promptGroups).toHaveLength(10); + expect(response.body.has_more).toBe(true); + expect(response.body.after).toBeTruthy(); + // Since has_more is true, pages should be a high number (9999 in our fix) + expect(parseInt(response.body.pages)).toBeGreaterThan(1); + }); + + it('should correctly indicate no more pages on the last page', async () => { + // First get the cursor for page 2 + const firstPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '10' }) + .expect(200); + + expect(firstPage.body.has_more).toBe(true); + expect(firstPage.body.after).toBeTruthy(); + + // Now fetch the second page using the cursor + const response = await request(app) + .get('/api/prompts/groups') + .query({ limit: '10', cursor: firstPage.body.after }) + .expect(200); + + expect(response.body.promptGroups).toHaveLength(5); // 15 total, 10 on page 1, 5 on page 2 + expect(response.body.has_more).toBe(false); + }); + + it('should support cursor-based pagination', async () => { + // First page + const firstPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5' }) + .expect(200); + + expect(firstPage.body.promptGroups).toHaveLength(5); + expect(firstPage.body.has_more).toBe(true); + expect(firstPage.body.after).toBeTruthy(); + + // Second page using cursor + const secondPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5', cursor: firstPage.body.after }) + .expect(200); + + expect(secondPage.body.promptGroups).toHaveLength(5); + expect(secondPage.body.has_more).toBe(true); + expect(secondPage.body.after).toBeTruthy(); + + // Verify different groups + const firstPageIds = firstPage.body.promptGroups.map((g) => g._id); + const secondPageIds = secondPage.body.promptGroups.map((g) => g._id); + expect(firstPageIds).not.toEqual(secondPageIds); + }); + + it('should paginate correctly with category filtering', async () => { + // Create groups with different categories + await PromptGroup.deleteMany({}); // Clear existing groups + await AclEntry.deleteMany({}); + + // Create 8 groups with category 'test-cat-1' + for (let i = 0; i < 8; i++) { + const group = await PromptGroup.create({ + name: `Category 1 Group ${i + 1}`, + category: 'test-cat-1', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - i * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Create 7 groups with category 'test-cat-2' + for (let i = 0; i < 7; i++) { + const group = await PromptGroup.create({ + name: `Category 2 Group ${i + 1}`, + category: 'test-cat-2', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - (i + 8) * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Test pagination with category filter + const firstPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5', category: 'test-cat-1' }) + .expect(200); + + expect(firstPage.body.promptGroups).toHaveLength(5); + expect(firstPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true); + expect(firstPage.body.has_more).toBe(true); + expect(firstPage.body.after).toBeTruthy(); + + const secondPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5', cursor: firstPage.body.after, category: 'test-cat-1' }) + .expect(200); + + expect(secondPage.body.promptGroups).toHaveLength(3); // 8 total, 5 on page 1, 3 on page 2 + expect(secondPage.body.promptGroups.every((g) => g.category === 'test-cat-1')).toBe(true); + expect(secondPage.body.has_more).toBe(false); + }); + + it('should paginate correctly with name/keyword filtering', async () => { + // Create groups with specific names + await PromptGroup.deleteMany({}); // Clear existing groups + await AclEntry.deleteMany({}); + + // Create 12 groups with 'Search' in the name + for (let i = 0; i < 12; i++) { + const group = await PromptGroup.create({ + name: `Search Test Group ${i + 1}`, + category: 'search-test', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - i * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Create 5 groups without 'Search' in the name + for (let i = 0; i < 5; i++) { + const group = await PromptGroup.create({ + name: `Other Group ${i + 1}`, + category: 'other-test', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - (i + 12) * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Test pagination with name filter + const firstPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '10', name: 'Search' }) + .expect(200); + + expect(firstPage.body.promptGroups).toHaveLength(10); + expect(firstPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true); + expect(firstPage.body.has_more).toBe(true); + expect(firstPage.body.after).toBeTruthy(); + + const secondPage = await request(app) + .get('/api/prompts/groups') + .query({ limit: '10', cursor: firstPage.body.after, name: 'Search' }) + .expect(200); + + expect(secondPage.body.promptGroups).toHaveLength(2); // 12 total, 10 on page 1, 2 on page 2 + expect(secondPage.body.promptGroups.every((g) => g.name.includes('Search'))).toBe(true); + expect(secondPage.body.has_more).toBe(false); + }); + + it('should paginate correctly with combined filters', async () => { + // Create groups with various combinations + await PromptGroup.deleteMany({}); // Clear existing groups + await AclEntry.deleteMany({}); + + // Create 6 groups matching both category and name filters + for (let i = 0; i < 6; i++) { + const group = await PromptGroup.create({ + name: `API Test Group ${i + 1}`, + category: 'api-category', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - i * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Create groups that only match one filter + for (let i = 0; i < 4; i++) { + const group = await PromptGroup.create({ + name: `API Other Group ${i + 1}`, + category: 'other-category', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + updatedAt: new Date(Date.now() - (i + 6) * 1000), + }); + + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + resourceType: ResourceType.PROMPTGROUP, + resourceId: group._id, + accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER, + grantedBy: testUsers.owner._id, + }); + } + + // Test pagination with both filters + const response = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5', name: 'API', category: 'api-category' }) + .expect(200); + + expect(response.body.promptGroups).toHaveLength(5); + expect( + response.body.promptGroups.every( + (g) => g.name.includes('API') && g.category === 'api-category', + ), + ).toBe(true); + expect(response.body.has_more).toBe(true); + expect(response.body.after).toBeTruthy(); + + // Page 2 + const page2 = await request(app) + .get('/api/prompts/groups') + .query({ limit: '5', cursor: response.body.after, name: 'API', category: 'api-category' }) + .expect(200); + + expect(page2.body.promptGroups).toHaveLength(1); // 6 total, 5 on page 1, 1 on page 2 + expect(page2.body.has_more).toBe(false); + }); + }); }); diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 35080693d..5ed3d51fa 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -11,9 +11,9 @@ import store from '~/store'; export default function FilterPrompts({ className = '' }: { className?: string }) { const localize = useLocalize(); - const { setName } = usePromptGroupsContext(); + const { name, setName } = usePromptGroupsContext(); const { categories } = useCategories('h-4 w-4'); - const [displayName, setDisplayName] = useState(''); + const [displayName, setDisplayName] = useState(name || ''); const [isSearching, setIsSearching] = useState(false); const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory); @@ -60,13 +60,26 @@ export default function FilterPrompts({ className = '' }: { className?: string } [setCategory], ); + // Sync displayName with name prop when it changes externally useEffect(() => { + setDisplayName(name || ''); + }, [name]); + + useEffect(() => { + if (displayName === '') { + // Clear immediately when empty + setName(''); + setIsSearching(false); + return; + } + setIsSearching(true); const timeout = setTimeout(() => { setIsSearching(false); + setName(displayName); // Debounced setName call }, 500); return () => clearTimeout(timeout); - }, [displayName]); + }, [displayName, setName]); return (
@@ -84,7 +97,6 @@ export default function FilterPrompts({ className = '' }: { className?: string } value={displayName} onChange={(e) => { setDisplayName(e.target.value); - setName(e.target.value); }} isSearching={isSearching} placeholder={localize('com_ui_filter_prompts_name')} diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index 9d58ee325..fe9426fda 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -1,10 +1,10 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useMediaQuery } from '@librechat/client'; -import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation'; import ManagePrompts from '~/components/Prompts/ManagePrompts'; import { usePromptGroupsContext } from '~/Providers'; import List from '~/components/Prompts/Groups/List'; +import PanelNavigation from './PanelNavigation'; import { cn } from '~/utils'; export default function GroupSidePanel({ @@ -19,38 +19,33 @@ export default function GroupSidePanel({ const location = useLocation(); const isSmallerScreen = useMediaQuery('(max-width: 1024px)'); const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]); - const { - nextPage, - prevPage, - isFetching, - hasNextPage, - groupsQuery, - promptGroups, - hasPreviousPage, - } = usePromptGroupsContext(); + + const { promptGroups, groupsQuery, nextPage, prevPage, hasNextPage, hasPreviousPage } = + usePromptGroupsContext(); return (
{children} -
+
-
- {isChatRoute && } +
+ isLoading={groupsQuery.isFetching} + isChatRoute={isChatRoute} + > + {isChatRoute && } +
); diff --git a/client/src/components/Prompts/Groups/PanelNavigation.tsx b/client/src/components/Prompts/Groups/PanelNavigation.tsx index 851f404dd..421d053aa 100644 --- a/client/src/components/Prompts/Groups/PanelNavigation.tsx +++ b/client/src/components/Prompts/Groups/PanelNavigation.tsx @@ -3,42 +3,51 @@ import { Button, ThemeSelector } from '@librechat/client'; import { useLocalize } from '~/hooks'; function PanelNavigation({ - prevPage, - nextPage, - hasPreviousPage, + onPrevious, + onNext, hasNextPage, - isFetching, + hasPreviousPage, + isLoading, isChatRoute, + children, }: { - prevPage: () => void; - nextPage: () => void; + onPrevious: () => void; + onNext: () => void; hasNextPage: boolean; hasPreviousPage: boolean; - isFetching: boolean; + isLoading?: boolean; isChatRoute: boolean; + children?: React.ReactNode; }) { const localize = useLocalize(); + return ( - <> -
{!isChatRoute && }
-
-
- +
); } diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx index 91523c5a8..1fc910b45 100644 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ b/client/src/components/Prompts/PromptsAccordion.tsx @@ -8,7 +8,7 @@ export default function PromptsAccordion() { return (
- +
diff --git a/client/src/components/Prompts/PromptsView.tsx b/client/src/components/Prompts/PromptsView.tsx index 74805c058..acea8da54 100644 --- a/client/src/components/Prompts/PromptsView.tsx +++ b/client/src/components/Prompts/PromptsView.tsx @@ -39,7 +39,7 @@ export default function PromptsView() {
-
+
diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index 6cc29ea05..447775067 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -400,22 +400,27 @@ export const usePromptGroupsInfiniteQuery = ( params?: t.TPromptGroupsWithFilterRequest, config?: UseInfiniteQueryOptions, ) => { - const { name, pageSize, category, ...rest } = params || {}; + const { name, pageSize, category } = params || {}; return useInfiniteQuery( [QueryKeys.promptGroups, name, category, pageSize], - ({ pageParam = '1' }) => - dataService.getPromptGroups({ - ...rest, + ({ pageParam }) => { + const queryParams: t.TPromptGroupsWithFilterRequest = { name, category: category || '', - pageNumber: pageParam?.toString(), - pageSize: (pageSize || 10).toString(), - }), + limit: (pageSize || 10).toString(), + }; + + // Only add cursor if it's a valid string + if (pageParam && typeof pageParam === 'string') { + queryParams.cursor = pageParam; + } + + return dataService.getPromptGroups(queryParams); + }, { getNextPageParam: (lastPage) => { - const currentPageNumber = Number(lastPage.pageNumber); - const totalPages = Number(lastPage.pages); - return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined; + // Use cursor-based pagination - ensure we return a valid cursor or undefined + return lastPage.has_more && lastPage.after ? lastPage.after : undefined; }, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/client/src/hooks/Prompts/usePromptGroupsNav.ts b/client/src/hooks/Prompts/usePromptGroupsNav.ts index 17ee0712a..a0d51fdb4 100644 --- a/client/src/hooks/Prompts/usePromptGroupsNav.ts +++ b/client/src/hooks/Prompts/usePromptGroupsNav.ts @@ -1,92 +1,108 @@ -import { useMemo, useRef, useEffect } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { useRecoilState } from 'recoil'; import { usePromptGroupsInfiniteQuery } from '~/data-provider'; -import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys } from 'librechat-data-provider'; -import debounce from 'lodash/debounce'; import store from '~/store'; export default function usePromptGroupsNav() { - const queryClient = useQueryClient(); - const category = useRecoilValue(store.promptsCategory); + const [pageSize] = useRecoilState(store.promptsPageSize); + const [category] = useRecoilState(store.promptsCategory); const [name, setName] = useRecoilState(store.promptsName); - const [pageSize, setPageSize] = useRecoilState(store.promptsPageSize); - const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber); - const maxPageNumberReached = useRef(1); - const prevFiltersRef = useRef({ name, category, pageSize }); + // Track current page index and cursor history + const [currentPageIndex, setCurrentPageIndex] = useState(0); + const cursorHistoryRef = useRef>([null]); // Start with null for first page - useEffect(() => { - if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) { - maxPageNumberReached.current = pageNumber; - } - }, [pageNumber]); + const prevFiltersRef = useRef({ name, category }); const groupsQuery = usePromptGroupsInfiniteQuery({ name, pageSize, category, - pageNumber: pageNumber + '', }); + // Get the current page data + const currentPageData = useMemo(() => { + if (!groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) { + return null; + } + // Ensure we don't go out of bounds + const pageIndex = Math.min(currentPageIndex, groupsQuery.data.pages.length - 1); + return groupsQuery.data.pages[pageIndex]; + }, [groupsQuery.data?.pages, currentPageIndex]); + + // Get prompt groups for current page + const promptGroups = useMemo(() => { + return currentPageData?.promptGroups || []; + }, [currentPageData]); + + // Calculate pagination state + const hasNextPage = useMemo(() => { + if (!currentPageData) return false; + + // If we're not on the last loaded page, we have a next page + if (currentPageIndex < (groupsQuery.data?.pages?.length || 0) - 1) { + return true; + } + + // If we're on the last loaded page, check if there are more from backend + return currentPageData.has_more || false; + }, [currentPageData, currentPageIndex, groupsQuery.data?.pages?.length]); + + const hasPreviousPage = currentPageIndex > 0; + const currentPage = currentPageIndex + 1; + const totalPages = hasNextPage ? currentPage + 1 : currentPage; + + // Navigate to next page + const nextPage = useCallback(async () => { + if (!hasNextPage) return; + + const nextPageIndex = currentPageIndex + 1; + + // Check if we need to load more data + if (nextPageIndex >= (groupsQuery.data?.pages?.length || 0)) { + // We need to fetch the next page + const result = await groupsQuery.fetchNextPage(); + if (result.isSuccess && result.data?.pages) { + // Update cursor history with the cursor for the next page + const lastPage = result.data.pages[result.data.pages.length - 2]; // Get the page before the newly fetched one + if (lastPage?.after && !cursorHistoryRef.current.includes(lastPage.after)) { + cursorHistoryRef.current.push(lastPage.after); + } + } + } + + setCurrentPageIndex(nextPageIndex); + }, [currentPageIndex, hasNextPage, groupsQuery]); + + // Navigate to previous page + const prevPage = useCallback(() => { + if (!hasPreviousPage) return; + setCurrentPageIndex(currentPageIndex - 1); + }, [currentPageIndex, hasPreviousPage]); + + // Reset when filters change useEffect(() => { const filtersChanged = - prevFiltersRef.current.name !== name || - prevFiltersRef.current.category !== category || - prevFiltersRef.current.pageSize !== pageSize; + prevFiltersRef.current.name !== name || prevFiltersRef.current.category !== category; - if (!filtersChanged) { - return; + if (filtersChanged) { + setCurrentPageIndex(0); + cursorHistoryRef.current = [null]; + prevFiltersRef.current = { name, category }; } - maxPageNumberReached.current = 1; - setPageNumber(1); - - // Only reset queries if we're not already on page 1 - // This prevents double queries when filters change - if (pageNumber !== 1) { - queryClient.invalidateQueries([QueryKeys.promptGroups, name, category, pageSize]); - } - - prevFiltersRef.current = { name, category, pageSize }; - }, [pageSize, name, category, setPageNumber, pageNumber, queryClient]); - - const promptGroups = useMemo(() => { - return groupsQuery.data?.pages[pageNumber - 1 + '']?.promptGroups || []; - }, [groupsQuery.data, pageNumber]); - - const nextPage = () => { - setPageNumber((prev) => prev + 1); - groupsQuery.hasNextPage && groupsQuery.fetchNextPage(); - }; - - const prevPage = () => { - setPageNumber((prev) => prev - 1); - groupsQuery.hasPreviousPage && groupsQuery.fetchPreviousPage(); - }; - - const isFetching = groupsQuery.isFetchingNextPage; - const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber; - const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1; - - const debouncedSetName = useMemo( - () => - debounce((nextValue: string) => { - setName(nextValue); - }, 850), - [setName], - ); + }, [name, category]); return { - name, - setName: debouncedSetName, + promptGroups, + groupsQuery, + currentPage, + totalPages, + hasNextPage, + hasPreviousPage, nextPage, prevPage, - isFetching, - pageSize, - setPageSize, - hasNextPage, - groupsQuery, - promptGroups, - hasPreviousPage, + isFetching: groupsQuery.isFetching, + name, + setName, }; } diff --git a/packages/api/src/prompts/format.ts b/packages/api/src/prompts/format.ts index 7c436b0ac..ad6f4ec23 100644 --- a/packages/api/src/prompts/format.ts +++ b/packages/api/src/prompts/format.ts @@ -21,9 +21,12 @@ export function formatPromptGroupsResponse({ hasMore?: boolean; after?: string | null; }): PromptGroupsListResponse { - const effectivePageSize = parseInt(pageSize || '') || parseInt(String(actualLimit || '')) || 10; - const totalPages = - promptGroups.length > 0 ? Math.ceil(promptGroups.length / effectivePageSize).toString() : '0'; + const currentPage = parseInt(pageNumber || '1'); + + // Calculate total pages based on whether there are more results + // If hasMore is true, we know there's at least one more page + // We use a high number (9999) to indicate "many pages" since we don't know the exact count + const totalPages = hasMore ? '9999' : currentPage.toString(); return { promptGroups, diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 88e90aeae..be3f2a62a 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -252,8 +252,19 @@ export const getPromptGroup = (_id: string) => `${prompts()}/groups/${_id}`; export const getPromptGroupsWithFilters = (filter: object) => { let url = `${prompts()}/groups`; - if (Object.keys(filter).length > 0) { - const queryParams = new URLSearchParams(filter as Record).toString(); + // Filter out undefined/null values + const cleanedFilter = Object.entries(filter).reduce( + (acc, [key, value]) => { + if (value !== undefined && value !== null && value !== '') { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); + + if (Object.keys(cleanedFilter).length > 0) { + const queryParams = new URLSearchParams(cleanedFilter).toString(); url += `?${queryParams}`; } return url; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index bc69f6ca8..c01db59c1 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -536,8 +536,10 @@ export type TPromptsWithFilterRequest = { export type TPromptGroupsWithFilterRequest = { category: string; - pageNumber: string; - pageSize: string | number; + pageNumber?: string; // Made optional for cursor-based pagination + pageSize?: string | number; + limit?: string | number; // For cursor-based pagination + cursor?: string; // For cursor-based pagination before?: string | null; after?: string | null; order?: 'asc' | 'desc'; @@ -550,6 +552,8 @@ export type PromptGroupListResponse = { pageNumber: string; pageSize: string | number; pages: string | number; + has_more: boolean; // Added for cursor-based pagination + after: string | null; // Added for cursor-based pagination }; export type PromptGroupListData = InfiniteData;