From dcd96c29c55621a7fe6f14aa93c83c09aefa43cc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 11 Aug 2025 22:06:15 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=A8=EF=B8=8F=20refactor:=20Optimize=20?= =?UTF-8?q?Prompt=20Queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Refactor prompt and prompt group schemas; move types to separate file feat: Implement paginated access to prompt groups with filtering and public visibility refactor: Add PromptGroups context provider and integrate it into relevant components refactor: Optimize filter change handling and query invalidation in usePromptGroupsNav hook refactor: Simplify context usage in FilterPrompts and GroupSidePanel components --- api/models/Prompt.js | 120 ++++++++++++++ api/server/routes/prompts.js | 116 ++++++++++---- client/src/Providers/PromptGroupsContext.tsx | 76 +++++++++ client/src/Providers/index.ts | 1 + .../components/Chat/Input/PromptsCommand.tsx | 31 +--- client/src/components/Chat/PromptCard.tsx | 15 -- client/src/components/Chat/Prompts.tsx | 96 ----------- .../Prompts/Groups/FilterPrompts.tsx | 18 +-- .../Prompts/Groups/GroupSidePanel.tsx | 21 +-- client/src/components/Prompts/PromptForm.tsx | 18 ++- .../components/Prompts/PromptsAccordion.tsx | 4 +- client/src/components/Prompts/PromptsView.tsx | 36 +++-- .../src/hooks/Prompts/usePromptGroupsNav.ts | 35 ++-- client/src/routes/Root.tsx | 19 ++- packages/api/src/format/index.ts | 1 + packages/api/src/format/prompts.ts | 150 ++++++++++++++++++ packages/api/src/types/index.ts | 1 + packages/api/src/types/prompts.ts | 24 +++ packages/data-schemas/src/schema/prompt.ts | 12 +- .../data-schemas/src/schema/promptGroup.ts | 19 +-- packages/data-schemas/src/types/index.ts | 2 + packages/data-schemas/src/types/prompts.ts | 27 ++++ 22 files changed, 583 insertions(+), 259 deletions(-) create mode 100644 client/src/Providers/PromptGroupsContext.tsx delete mode 100644 client/src/components/Chat/PromptCard.tsx delete mode 100644 client/src/components/Chat/Prompts.tsx create mode 100644 packages/api/src/format/prompts.ts create mode 100644 packages/api/src/types/prompts.ts create mode 100644 packages/data-schemas/src/types/prompts.ts diff --git a/api/models/Prompt.js b/api/models/Prompt.js index eadd60163..d4737b6a8 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -247,10 +247,130 @@ const deletePromptGroup = async ({ _id, author, role }) => { return { message: 'Prompt group deleted successfully' }; }; +/** + * Get prompt groups by accessible IDs with optional cursor-based pagination. + * @param {Object} params - The parameters for getting accessible prompt groups. + * @param {Array} [params.accessibleIds] - Array of prompt group ObjectIds the user has ACL access to. + * @param {Object} [params.otherParams] - Additional query parameters (including author filter). + * @param {number} [params.limit] - Number of prompt groups to return (max 100). If not provided, returns all prompt groups. + * @param {string} [params.after] - Cursor for pagination - get prompt groups after this cursor. // base64 encoded JSON string with updatedAt and _id. + * @returns {Promise} A promise that resolves to an object containing the prompt groups data and pagination info. + */ +async function getListPromptGroupsByAccess({ + accessibleIds = [], + otherParams = {}, + limit = null, + after = null, +}) { + const isPaginated = limit !== null && limit !== undefined; + const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null; + + // Build base query combining ACL accessible prompt groups with other filters + const baseQuery = { ...otherParams, _id: { $in: accessibleIds } }; + + // Add cursor condition + if (after) { + try { + const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')); + const { updatedAt, _id } = cursor; + + const cursorCondition = { + $or: [ + { updatedAt: { $lt: new Date(updatedAt) } }, + { updatedAt: new Date(updatedAt), _id: { $gt: new ObjectId(_id) } }, + ], + }; + + // Merge cursor condition with base query + if (Object.keys(baseQuery).length > 0) { + baseQuery.$and = [{ ...baseQuery }, cursorCondition]; + // Remove the original conditions from baseQuery to avoid duplication + Object.keys(baseQuery).forEach((key) => { + if (key !== '$and') delete baseQuery[key]; + }); + } else { + Object.assign(baseQuery, cursorCondition); + } + } catch (error) { + logger.warn('Invalid cursor:', error.message); + } + } + + // Build aggregation pipeline + const pipeline = [{ $match: baseQuery }, { $sort: { updatedAt: -1, _id: 1 } }]; + + // Only apply limit if pagination is requested + if (isPaginated) { + pipeline.push({ $limit: normalizedLimit + 1 }); + } + + // Add lookup for production prompt + pipeline.push( + { + $lookup: { + from: 'prompts', + localField: 'productionId', + foreignField: '_id', + as: 'productionPrompt', + }, + }, + { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } }, + { + $project: { + name: 1, + numberOfGenerations: 1, + oneliner: 1, + category: 1, + projectIds: 1, + productionId: 1, + author: 1, + authorName: 1, + createdAt: 1, + updatedAt: 1, + 'productionPrompt.prompt': 1, + }, + }, + ); + + const promptGroups = await PromptGroup.aggregate(pipeline).exec(); + + const hasMore = isPaginated ? promptGroups.length > normalizedLimit : false; + const data = (isPaginated ? promptGroups.slice(0, normalizedLimit) : promptGroups).map( + (group) => { + if (group.author) { + group.author = group.author.toString(); + } + return group; + }, + ); + + // Generate next cursor only if paginated + let nextCursor = null; + if (isPaginated && hasMore && data.length > 0) { + const lastGroup = promptGroups[normalizedLimit - 1]; + nextCursor = Buffer.from( + JSON.stringify({ + updatedAt: lastGroup.updatedAt.toISOString(), + _id: lastGroup._id.toString(), + }), + ).toString('base64'); + } + + return { + object: 'list', + data, + first_id: data.length > 0 ? data[0]._id.toString() : null, + last_id: data.length > 0 ? data[data.length - 1]._id.toString() : null, + has_more: hasMore, + after: nextCursor, + }; +} + module.exports = { getPromptGroups, deletePromptGroup, getAllPromptGroups, + getListPromptGroupsByAccess, /** * Create a prompt and its respective group * @param {TCreatePromptRecord} saveData diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index 6384ad1b1..48d4819d8 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -1,6 +1,13 @@ const express = require('express'); const { logger } = require('@librechat/data-schemas'); -const { generateCheckAccess } = require('@librechat/api'); +const { + generateCheckAccess, + markPublicPromptGroups, + buildPromptGroupFilter, + formatPromptGroupsResponse, + createEmptyPromptGroupsResponse, + filterAccessibleIdsBySharedLogic, +} = require('@librechat/api'); const { Permissions, SystemRoles, @@ -11,12 +18,11 @@ const { PermissionTypes, } = require('librechat-data-provider'); const { + getListPromptGroupsByAccess, makePromptProduction, - getAllPromptGroups, updatePromptGroup, deletePromptGroup, createPromptGroup, - getPromptGroups, getPromptGroup, deletePrompt, getPrompts, @@ -95,23 +101,48 @@ router.get( router.get('/all', async (req, res) => { try { const userId = req.user.id; + const { name, category, ...otherFilters } = req.query; + const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ + name, + category, + ...otherFilters, + }); - // Get promptGroup IDs the user has VIEW access to via ACL - const accessibleIds = await findAccessibleResources({ + let accessibleIds = await findAccessibleResources({ userId, role: req.user.role, resourceType: ResourceType.PROMPTGROUP, requiredPermissions: PermissionBits.VIEW, }); - const groups = await getAllPromptGroups(req, {}); + const publiclyAccessibleIds = await findPubliclyAccessibleResources({ + resourceType: ResourceType.PROMPTGROUP, + requiredPermissions: PermissionBits.VIEW, + }); - // Filter the results to only include accessible groups - const accessibleGroups = groups.filter((group) => - accessibleIds.some((id) => id.toString() === group._id.toString()), - ); + const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ + accessibleIds, + searchShared, + searchSharedOnly, + publicPromptGroupIds: publiclyAccessibleIds, + }); - res.status(200).send(accessibleGroups); + const result = await getListPromptGroupsByAccess({ + accessibleIds: filteredAccessibleIds, + otherParams: filter, + }); + + if (!result) { + return res.status(200).send([]); + } + + const { data: promptGroups = [] } = result; + if (!promptGroups.length) { + return res.status(200).send([]); + } + + const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds); + res.status(200).send(groupsWithPublicFlag); } catch (error) { logger.error(error); res.status(500).send({ error: 'Error getting prompt groups' }); @@ -125,40 +156,66 @@ router.get('/all', async (req, res) => { router.get('/groups', async (req, res) => { try { const userId = req.user.id; - const filter = { ...req.query }; - delete filter.author; // Remove author filter as we'll use ACL + const { pageSize, pageNumber, limit, cursor, name, category, ...otherFilters } = req.query; - // Get promptGroup IDs the user has VIEW access to via ACL - const accessibleIds = await findAccessibleResources({ + const { filter, searchShared, searchSharedOnly } = buildPromptGroupFilter({ + name, + category, + ...otherFilters, + }); + + let actualLimit = limit; + let actualCursor = cursor; + + if (pageSize && !limit) { + actualLimit = parseInt(pageSize, 10); + } + + let accessibleIds = await findAccessibleResources({ userId, role: req.user.role, resourceType: ResourceType.PROMPTGROUP, requiredPermissions: PermissionBits.VIEW, }); - // Get publicly accessible promptGroups const publiclyAccessibleIds = await findPubliclyAccessibleResources({ resourceType: ResourceType.PROMPTGROUP, requiredPermissions: PermissionBits.VIEW, }); - const groups = await getPromptGroups(req, filter); + const filteredAccessibleIds = await filterAccessibleIdsBySharedLogic({ + accessibleIds, + searchShared, + searchSharedOnly, + publicPromptGroupIds: publiclyAccessibleIds, + }); - if (groups.promptGroups && groups.promptGroups.length > 0) { - groups.promptGroups = groups.promptGroups.filter((group) => - accessibleIds.some((id) => id.toString() === group._id.toString()), - ); + const result = await getListPromptGroupsByAccess({ + accessibleIds: filteredAccessibleIds, + otherParams: filter, + limit: actualLimit, + after: actualCursor, + }); - // Mark public groups - groups.promptGroups = groups.promptGroups.map((group) => { - if (publiclyAccessibleIds.some((id) => id.equals(group._id))) { - group.isPublic = true; - } - return group; - }); + if (!result) { + const emptyResponse = createEmptyPromptGroupsResponse({ pageNumber, pageSize, actualLimit }); + return res.status(200).send(emptyResponse); } - res.status(200).send(groups); + const { data: promptGroups = [], has_more = false, after = null } = result; + + const groupsWithPublicFlag = markPublicPromptGroups(promptGroups, publiclyAccessibleIds); + + const response = formatPromptGroupsResponse({ + promptGroups: groupsWithPublicFlag, + pageNumber, + pageSize, + actualLimit, + hasMore: has_more, + after, + }); + + res.status(200).send(response); } catch (error) { logger.error(error); res.status(500).send({ error: 'Error getting prompt groups' }); @@ -188,7 +245,6 @@ const createNewPromptGroup = async (req, res) => { const result = await createPromptGroup(saveData); - // Grant owner permissions to the creator on the new promptGroup if (result.prompt && result.prompt._id && result.prompt.groupId) { try { await grantPermission({ diff --git a/client/src/Providers/PromptGroupsContext.tsx b/client/src/Providers/PromptGroupsContext.tsx new file mode 100644 index 000000000..1dd158d7d --- /dev/null +++ b/client/src/Providers/PromptGroupsContext.tsx @@ -0,0 +1,76 @@ +import React, { createContext, useContext, ReactNode, useMemo } from 'react'; +import type { TPromptGroup } from 'librechat-data-provider'; +import type { PromptOption } from '~/common'; +import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; +import { useGetAllPromptGroups } from '~/data-provider'; +import { usePromptGroupsNav } from '~/hooks'; +import { mapPromptGroups } from '~/utils'; + +type AllPromptGroupsData = + | { + promptsMap: Record; + promptGroups: PromptOption[]; + } + | undefined; + +type PromptGroupsContextType = + | (ReturnType & { + allPromptGroups: { + data: AllPromptGroupsData; + isLoading: boolean; + }; + }) + | null; + +const PromptGroupsContext = createContext(null); + +export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => { + const promptGroupsNav = usePromptGroupsNav(); + const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, { + select: (data) => { + const mappedArray: PromptOption[] = data.map((group) => ({ + id: group._id ?? '', + type: 'prompt', + value: group.command ?? group.name, + label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${ + group.name + }: ${ + (group.oneliner?.length ?? 0) > 0 + ? group.oneliner + : (group.productionPrompt?.prompt ?? '') + }`, + icon: , + })); + + const promptsMap = mapPromptGroups(data); + + return { + promptsMap, + promptGroups: mappedArray, + }; + }, + }); + + const contextValue = useMemo( + () => ({ + ...promptGroupsNav, + allPromptGroups: { + data: allGroupsData, + isLoading: isLoadingAll, + }, + }), + [promptGroupsNav, allGroupsData, isLoadingAll], + ); + + return ( + {children} + ); +}; + +export const usePromptGroupsContext = () => { + const context = useContext(PromptGroupsContext); + if (!context) { + throw new Error('usePromptGroupsContext must be used within a PromptGroupsProvider'); + } + return context; +}; diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 50dabaf5a..581e3cd55 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -24,4 +24,5 @@ export * from './SearchContext'; export * from './BadgeRowContext'; export * from './SidePanelContext'; export * from './ArtifactsContext'; +export * from './PromptGroupsContext'; export { default as BadgeRowProvider } from './BadgeRowContext'; diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 0756be17e..5f384e631 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -5,11 +5,10 @@ import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; import type { PromptOption } from '~/common'; -import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils'; +import { removeCharIfLast, detectVariables } from '~/utils'; import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; -import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; +import { usePromptGroupsContext } from '~/Providers'; import { useLocalize, useHasAccess } from '~/hooks'; -import { useGetAllPromptGroups } from '~/data-provider'; import MentionItem from './MentionItem'; import store from '~/store'; @@ -60,30 +59,8 @@ function PromptsCommand({ permission: Permissions.USE, }); - const { data, isLoading } = useGetAllPromptGroups(undefined, { - enabled: hasAccess, - select: (data) => { - const mappedArray = data.map((group) => ({ - id: group._id, - value: group.command ?? group.name, - label: `${group.command != null && group.command ? `/${group.command} - ` : ''}${ - group.name - }: ${ - (group.oneliner?.length ?? 0) > 0 - ? group.oneliner - : (group.productionPrompt?.prompt ?? '') - }`, - icon: , - })); - - const promptsMap = mapPromptGroups(data); - - return { - promptsMap, - promptGroups: mappedArray, - }; - }, - }); + const { allPromptGroups } = usePromptGroupsContext(); + const { data, isLoading } = allPromptGroups; const [activeIndex, setActiveIndex] = useState(0); const timeoutRef = useRef(null); diff --git a/client/src/components/Chat/PromptCard.tsx b/client/src/components/Chat/PromptCard.tsx deleted file mode 100644 index 4a37fd998..000000000 --- a/client/src/components/Chat/PromptCard.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TPromptGroup } from 'librechat-data-provider'; -import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon'; - -export default function PromptCard({ promptGroup }: { promptGroup?: TPromptGroup }) { - return ( -
-
- -
-

- {(promptGroup?.oneliner ?? '') || promptGroup?.productionPrompt?.prompt} -

-
- ); -} diff --git a/client/src/components/Chat/Prompts.tsx b/client/src/components/Chat/Prompts.tsx deleted file mode 100644 index f6c1ba09b..000000000 --- a/client/src/components/Chat/Prompts.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { usePromptGroupsNav } from '~/hooks'; -import PromptCard from './PromptCard'; -import { Button } from '../ui'; - -export default function Prompts() { - const { prevPage, nextPage, hasNextPage, promptGroups, hasPreviousPage, setPageSize, pageSize } = - usePromptGroupsNav(); - - const renderPromptCards = (start = 0, count) => { - return promptGroups - .slice(start, count + start) - .map((promptGroup) => ); - }; - - const getRows = () => { - switch (pageSize) { - case 4: - return [4]; - case 8: - return [4, 4]; - case 12: - return [4, 4, 4]; - default: - return []; - } - }; - - const rows = getRows(); - - return ( -
-
- - - -
-
-
- {rows.map((rowSize, index) => ( -
- {renderPromptCards(rowSize * index, rowSize)} -
- ))} -
-
- - -
-
-
- ); -} diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 47608eba9..35080693d 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -1,25 +1,21 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { useRecoilState } from 'recoil'; import { ListFilter, User, Share2 } from 'lucide-react'; import { SystemCategories } from 'librechat-data-provider'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Dropdown, AnimatedSearchInput } from '@librechat/client'; import type { Option } from '~/common'; -import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks'; +import { useLocalize, useCategories } from '~/hooks'; +import { usePromptGroupsContext } from '~/Providers'; import { cn } from '~/utils'; import store from '~/store'; -export default function FilterPrompts({ - setName, - className = '', -}: Pick, 'setName'> & { - className?: string; -}) { +export default function FilterPrompts({ className = '' }: { className?: string }) { const localize = useLocalize(); - const [displayName, setDisplayName] = useState(''); - const setCategory = useSetRecoilState(store.promptsCategory); - const categoryFilter = useRecoilValue(store.promptsCategory); + const { setName } = usePromptGroupsContext(); const { categories } = useCategories('h-4 w-4'); + const [displayName, setDisplayName] = useState(''); const [isSearching, setIsSearching] = useState(false); + const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory); const filterOptions = useMemo(() => { const baseOptions: Option[] = [ diff --git a/client/src/components/Prompts/Groups/GroupSidePanel.tsx b/client/src/components/Prompts/Groups/GroupSidePanel.tsx index ae263dca5..9d58ee325 100644 --- a/client/src/components/Prompts/Groups/GroupSidePanel.tsx +++ b/client/src/components/Prompts/Groups/GroupSidePanel.tsx @@ -3,30 +3,31 @@ 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 { usePromptGroupsNav } from '~/hooks'; import { cn } from '~/utils'; export default function GroupSidePanel({ children, isDetailView, className = '', - /* usePromptGroupsNav */ - nextPage, - prevPage, - isFetching, - hasNextPage, - groupsQuery, - promptGroups, - hasPreviousPage, }: { children?: React.ReactNode; isDetailView?: boolean; className?: string; -} & ReturnType) { +}) { 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(); return (
{ const [showSidePanel, setShowSidePanel] = useState(false); const sidePanelWidth = '320px'; - // Fetch group early so it is available for later hooks. const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId); const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts( { groupId: promptId }, { enabled: !!promptId }, ); - // Check permissions for the promptGroup const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( - 'promptGroup', + ResourceType.PROMPTGROUP, group?._id || '', ); @@ -206,7 +210,7 @@ const PromptForm = () => { const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]); - const { groupsQuery } = useOutletContext>(); + const { groupsQuery } = usePromptGroupsContext(); const updateGroupMutation = useUpdatePromptGroup({ onError: () => { diff --git a/client/src/components/Prompts/PromptsAccordion.tsx b/client/src/components/Prompts/PromptsAccordion.tsx index 6bc84005c..91523c5a8 100644 --- a/client/src/components/Prompts/PromptsAccordion.tsx +++ b/client/src/components/Prompts/PromptsAccordion.tsx @@ -1,10 +1,10 @@ import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel'; import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; -import { usePromptGroupsNav } from '~/hooks'; +import { usePromptGroupsContext } from '~/Providers'; export default function PromptsAccordion() { - const groupsNav = usePromptGroupsNav(); + const groupsNav = usePromptGroupsContext(); return (
diff --git a/client/src/components/Prompts/PromptsView.tsx b/client/src/components/Prompts/PromptsView.tsx index 9a5b3f4ea..74805c058 100644 --- a/client/src/components/Prompts/PromptsView.tsx +++ b/client/src/components/Prompts/PromptsView.tsx @@ -3,14 +3,14 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts'; import DashBreadcrumb from '~/routes/Layouts/DashBreadcrumb'; -import { usePromptGroupsNav, useHasAccess } from '~/hooks'; import GroupSidePanel from './Groups/GroupSidePanel'; +import { PromptGroupsProvider } from '~/Providers'; +import { useHasAccess } from '~/hooks'; import { cn } from '~/utils'; export default function PromptsView() { const params = useParams(); const navigate = useNavigate(); - const groupsNav = usePromptGroupsNav(); const isDetailView = useMemo(() => !!(params.promptId || params['*'] === 'new'), [params]); const hasAccess = useHasAccess({ permissionType: PermissionTypes.PROMPTS, @@ -34,23 +34,25 @@ export default function PromptsView() { } return ( -
- -
- -
- + +
+ +
+ +
+ +
+
+
+
- -
-
-
+
); } diff --git a/client/src/hooks/Prompts/usePromptGroupsNav.ts b/client/src/hooks/Prompts/usePromptGroupsNav.ts index 1d932d85d..17ee0712a 100644 --- a/client/src/hooks/Prompts/usePromptGroupsNav.ts +++ b/client/src/hooks/Prompts/usePromptGroupsNav.ts @@ -1,10 +1,10 @@ +import { useMemo, useRef, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { useMemo, useRef, useEffect, useCallback } from 'react'; import { usePromptGroupsInfiniteQuery } from '~/data-provider'; -import debounce from 'lodash/debounce'; -import store from '~/store'; 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(); @@ -14,6 +14,7 @@ export default function usePromptGroupsNav() { const [pageNumber, setPageNumber] = useRecoilState(store.promptsPageNumber); const maxPageNumberReached = useRef(1); + const prevFiltersRef = useRef({ name, category, pageSize }); useEffect(() => { if (pageNumber > 1 && pageNumber > maxPageNumberReached.current) { @@ -29,10 +30,25 @@ export default function usePromptGroupsNav() { }); useEffect(() => { + const filtersChanged = + prevFiltersRef.current.name !== name || + prevFiltersRef.current.category !== category || + prevFiltersRef.current.pageSize !== pageSize; + + if (!filtersChanged) { + return; + } maxPageNumberReached.current = 1; setPageNumber(1); - queryClient.resetQueries([QueryKeys.promptGroups, name, category, pageSize]); - }, [pageSize, name, category, setPageNumber]); + + // 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 || []; @@ -52,10 +68,11 @@ export default function usePromptGroupsNav() { const hasNextPage = !!groupsQuery.hasNextPage || maxPageNumberReached.current > pageNumber; const hasPreviousPage = !!groupsQuery.hasPreviousPage || pageNumber > 1; - const debouncedSetName = useCallback( - debounce((nextValue: string) => { - setName(nextValue); - }, 850), + const debouncedSetName = useMemo( + () => + debounce((nextValue: string) => { + setName(nextValue); + }, 850), [setName], ); diff --git a/client/src/routes/Root.tsx b/client/src/routes/Root.tsx index 8a34eb944..ab8453752 100644 --- a/client/src/routes/Root.tsx +++ b/client/src/routes/Root.tsx @@ -9,6 +9,7 @@ import { useFileMap, } from '~/hooks'; import { + PromptGroupsProvider, AssistantsMapContext, AgentsMapContext, SetConvoProvider, @@ -68,16 +69,18 @@ export default function Root() { - -
-
-