-
- {isLoading && isChatRoute && (
-
- )}
- {isLoading &&
- !isChatRoute &&
- Array.from({ length: 10 }).map((_, index: number) => (
-
- ))}
- {!isLoading && groups.length === 0 && isChatRoute && (
-
- {localize('com_ui_nothing_found')}
-
- )}
- {!isLoading && groups.length === 0 && !isChatRoute && (
-
- {localize('com_ui_nothing_found')}
-
- )}
- {groups.map((group) => {
- if (isChatRoute) {
- return (
-
+
+ {hasCreateAccess && (
+
+
+
+ )}
+
+
+ {isLoading && isChatRoute && (
+
+ )}
+ {isLoading &&
+ !isChatRoute &&
+ Array.from({ length: 10 }).map((_, index: number) => (
+
- );
- }
- return (
-
- );
- })}
+ ))}
+ {!isLoading && groups.length === 0 && isChatRoute && (
+
+ {localize('com_ui_nothing_found')}
+
+ )}
+ {!isLoading && groups.length === 0 && !isChatRoute && (
+
+ {localize('com_ui_nothing_found')}
+
+ )}
+ {!isLoading && groups.length > 0 && enableRanking ? (
+
+ ) : (
+ !isLoading && groups.map((group) => renderGroupItem(group))
+ )}
+
-
+
);
}
diff --git a/client/src/components/Prompts/Groups/RankingComponent.tsx b/client/src/components/Prompts/Groups/RankingComponent.tsx
new file mode 100644
index 0000000000..abb2f99287
--- /dev/null
+++ b/client/src/components/Prompts/Groups/RankingComponent.tsx
@@ -0,0 +1,303 @@
+import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react';
+import { DndProvider, useDrag, useDrop, useDragLayer } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { GripVertical } from 'lucide-react';
+import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider';
+import type { TPromptGroup } from 'librechat-data-provider';
+import { cn } from '~/utils';
+
+const ITEM_TYPE = 'PROMPT_GROUP';
+
+interface DraggablePromptItemProps {
+ group: TPromptGroup;
+ index: number;
+ moveItem: (dragIndex: number, hoverIndex: number) => void;
+ isDragging: boolean;
+ children: ReactNode;
+}
+
+interface DragItem {
+ index: number;
+ id: string;
+ type: string;
+}
+
+function DraggablePromptItem({
+ group,
+ index,
+ moveItem,
+ isDragging: isAnyDragging,
+ children,
+}: DraggablePromptItemProps) {
+ const ref = useRef
(null);
+ const [isOver, setIsOver] = useState(false);
+
+ const [{ isDragging }, drag, preview] = useDrag({
+ type: ITEM_TYPE,
+ item: () => ({ type: ITEM_TYPE, index, id: group._id }),
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ });
+
+ const [{ isOverCurrent }, drop] = useDrop({
+ accept: ITEM_TYPE,
+ hover: (item, monitor) => {
+ if (!ref.current) return;
+
+ const dragIndex = item.index;
+ const hoverIndex = index;
+ if (dragIndex === hoverIndex) return;
+
+ const hoverBoundingRect = ref.current.getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ if (!clientOffset) return;
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY * 0.8) return;
+ if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY * 1.2) return;
+
+ moveItem(dragIndex, hoverIndex);
+ item.index = hoverIndex;
+ },
+ collect: (monitor) => ({
+ isOverCurrent: monitor.isOver({ shallow: true }),
+ }),
+ });
+
+ useEffect(() => {
+ setIsOver(isOverCurrent);
+ }, [isOverCurrent]);
+
+ drag(drop(ref));
+
+ useEffect(() => {
+ preview(new Image(), { captureDraggingState: false });
+ }, [preview]);
+
+ return (
+
+ );
+}
+
+function CustomDragLayer() {
+ const { item, itemType, currentOffset, isDragging } = useDragLayer((monitor) => ({
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset(),
+ isDragging: monitor.isDragging(),
+ }));
+
+ if (!isDragging || !currentOffset || itemType !== ITEM_TYPE) {
+ return null;
+ }
+
+ const renderPreview = () => {
+ if (item && typeof item.id === 'string') {
+ return (
+
+ {`Moving: ${item.id}`}
+
+ );
+ }
+ return Dragging...
;
+ };
+
+ return (
+
+
+ {renderPreview()}
+
+
+ );
+}
+
+interface RankablePromptListProps {
+ groups: TPromptGroup[];
+ renderItem: (group: TPromptGroup) => ReactNode;
+ onRankingChange?: (rankings: string[]) => void;
+}
+
+function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) {
+ const { data: preferences } = useGetUserPromptPreferences();
+ const updateRankings = useUpdatePromptRankings();
+
+ const [sortedGroups, setSortedGroups] = useState([]);
+ const [isDragging, setIsDragging] = useState(false);
+ const saveTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ if (!groups?.length) {
+ setSortedGroups([]);
+ return;
+ }
+ const rankings = preferences?.rankings || [];
+ const favorites = preferences?.favorites || [];
+ const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order]));
+
+ const sorted = [...groups].sort((a, b) => {
+ const aId = a._id ?? '';
+ const bId = b._id ?? '';
+ const aIsFavorite = favorites.includes(aId);
+ const bIsFavorite = favorites.includes(bId);
+
+ if (aIsFavorite && !bIsFavorite) return -1;
+ if (!aIsFavorite && bIsFavorite) return 1;
+
+ const aRank = rankingMap.get(aId);
+ const bRank = rankingMap.get(bId);
+ if (aRank !== undefined && bRank !== undefined) {
+ return aRank - bRank;
+ }
+ if (aRank !== undefined) return -1;
+ if (bRank !== undefined) return 1;
+
+ return a.name.localeCompare(b.name);
+ });
+
+ setSortedGroups(sorted);
+ }, [groups, preferences]);
+
+ const moveItem = useCallback(
+ (dragIndex: number, hoverIndex: number) => {
+ if (dragIndex === hoverIndex) return;
+ setSortedGroups((prevGroups) => {
+ const newGroups = [...prevGroups];
+ const draggedItem = newGroups[dragIndex];
+ newGroups.splice(dragIndex, 1);
+ newGroups.splice(hoverIndex, 0, draggedItem);
+ return newGroups;
+ });
+
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+
+ saveTimeoutRef.current = setTimeout(() => {
+ setSortedGroups((currentGroups) => {
+ const newRankings = currentGroups
+ .map((group, index) =>
+ typeof group._id === 'string' ? { promptGroupId: group._id, order: index } : null,
+ )
+ .filter(
+ (ranking): ranking is { promptGroupId: string; order: number } => ranking !== null,
+ );
+
+ if (newRankings.length > 0) {
+ updateRankings
+ .mutateAsync({ rankings: newRankings })
+ .then(() => {
+ onRankingChange?.(newRankings.map((r) => r.promptGroupId));
+ })
+ .catch((error) => {
+ console.error('Failed to update rankings:', error);
+ });
+ }
+ return currentGroups;
+ });
+ }, 500);
+ },
+ [updateRankings, onRankingChange],
+ );
+
+ useEffect(() => {
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const handleDragStart = () => setIsDragging(true);
+ const handleDragEnd = () => setIsDragging(false);
+
+ document.addEventListener('dragstart', handleDragStart);
+ document.addEventListener('dragend', handleDragEnd);
+
+ return () => {
+ document.removeEventListener('dragstart', handleDragStart);
+ document.removeEventListener('dragend', handleDragEnd);
+ };
+ }, []);
+
+ return (
+
+ {sortedGroups.map((group, index) => (
+
+
+ {renderItem(group)}
+
+
+ ))}
+
+ );
+}
+
+function RankingProvider({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+export { RankablePromptList, RankingProvider };
diff --git a/client/src/components/svg/StarIcon.tsx b/client/src/components/svg/StarIcon.tsx
new file mode 100644
index 0000000000..44f9aab258
--- /dev/null
+++ b/client/src/components/svg/StarIcon.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+
+interface StarIconProps {
+ className?: string;
+ size?: string | number;
+ filled?: boolean;
+}
+
+export default function StarIcon({ className = '', size = '1em', filled = false }: StarIconProps) {
+ return filled ? (
+
+ ) : (
+
+ );
+}
diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts
index 10068a1d47..d4eec4206c 100644
--- a/client/src/components/svg/index.ts
+++ b/client/src/components/svg/index.ts
@@ -60,4 +60,5 @@ export { default as CircleHelpIcon } from './CircleHelpIcon';
export { default as BedrockIcon } from './BedrockIcon';
export { default as ThumbUpIcon } from './ThumbUpIcon';
export { default as ThumbDownIcon } from './ThumbDownIcon';
+export { default as StarIcon } from './StarIcon';
export { default as XAIcon } from './XAIcon';
diff --git a/client/src/data-provider/prompts.ts b/client/src/data-provider/prompts.ts
index 3f6e4bb89a..61e9749548 100644
--- a/client/src/data-provider/prompts.ts
+++ b/client/src/data-provider/prompts.ts
@@ -1,5 +1,5 @@
import { useRecoilValue } from 'recoil';
-import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
@@ -327,3 +327,131 @@ export const useMakePromptProduction = (options?: t.MakePromptProductionOptions)
},
});
};
+
+/* Prompt Favorites and Rankings */
+export const useTogglePromptFavorite = (
+ options?: t.UpdatePromptGroupOptions,
+): UseMutationResult => {
+ const { onMutate, onError, onSuccess } = options || {};
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (variables: { groupId: string }) =>
+ dataService.togglePromptFavorite(variables.groupId),
+ onMutate: async (variables: { groupId: string }) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({ queryKey: [QueryKeys.promptGroups] });
+ await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] });
+
+ // Snapshot the previous values
+ const previousPreferences = queryClient.getQueryData([
+ QueryKeys.userPromptPreferences,
+ ]);
+
+ // Optimistically update the favorites
+ if (previousPreferences) {
+ const isFavorite = previousPreferences.favorites.includes(variables.groupId);
+ const newFavorites = isFavorite
+ ? previousPreferences.favorites.filter((id) => id !== variables.groupId)
+ : [...previousPreferences.favorites, variables.groupId];
+
+ queryClient.setQueryData(
+ [QueryKeys.userPromptPreferences],
+ {
+ ...previousPreferences,
+ favorites: newFavorites,
+ },
+ );
+ }
+
+ if (onMutate) {
+ return onMutate(variables);
+ }
+
+ return { previousPreferences };
+ },
+ onError: (err, variables, context) => {
+ // Revert optimistic update on error
+ if (context?.previousPreferences) {
+ queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences);
+ }
+ if (onError) {
+ onError(err, variables, context);
+ }
+ },
+ onSuccess: (response, variables, context) => {
+ // Invalidate and refetch related queries
+ queryClient.invalidateQueries({ queryKey: [QueryKeys.userPromptPreferences] });
+ queryClient.invalidateQueries({ queryKey: [QueryKeys.promptGroups] });
+
+ if (onSuccess) {
+ onSuccess(response, variables, context);
+ }
+ },
+ });
+};
+
+export const useUpdatePromptRankings = (
+ options?: t.UpdatePromptGroupOptions,
+): UseMutationResult => {
+ const { onMutate, onError, onSuccess } = options || {};
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (variables: t.TPromptRankingRequest) => dataService.updatePromptRankings(variables),
+ onMutate: async (variables: t.TPromptRankingRequest) => {
+ // Cancel any outgoing refetches
+ await queryClient.cancelQueries({ queryKey: [QueryKeys.userPromptPreferences] });
+
+ // Snapshot the previous values
+ const previousPreferences = queryClient.getQueryData([
+ QueryKeys.userPromptPreferences,
+ ]);
+
+ // Optimistically update the rankings
+ if (previousPreferences) {
+ queryClient.setQueryData(
+ [QueryKeys.userPromptPreferences],
+ {
+ ...previousPreferences,
+ rankings: variables.rankings,
+ },
+ );
+ }
+
+ if (onMutate) {
+ return onMutate(variables);
+ }
+
+ return { previousPreferences };
+ },
+ onError: (err, variables, context) => {
+ // Revert optimistic update on error
+ if (context?.previousPreferences) {
+ queryClient.setQueryData([QueryKeys.userPromptPreferences], context.previousPreferences);
+ }
+ if (onError) {
+ onError(err, variables, context);
+ }
+ },
+ onSuccess: (response, variables, context) => {
+ // Don't automatically invalidate queries to prevent infinite loops
+ // The optimistic update in onMutate handles the UI update
+ // Manual invalidation can be done by components when needed
+
+ if (onSuccess) {
+ onSuccess(response, variables, context);
+ }
+ },
+ });
+};
+
+export const useGetUserPromptPreferences = () => {
+ return useQuery({
+ queryKey: [QueryKeys.userPromptPreferences],
+ queryFn: () => dataService.getUserPromptPreferences(),
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ refetchOnWindowFocus: false, // Prevent refetch on window focus
+ refetchOnMount: false, // Prevent refetch on component mount
+ });
+};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index b4c1bd5791..bb05c6ab70 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -968,5 +968,7 @@
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_user_message": "You",
- "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
-}
\ No newline at end of file
+ "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
+ "com_ui_add_to_favorites": "Add to favorites",
+ "com_ui_remove_from_favorites": "Remove from favorites"
+}
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts
index 63a00acef1..dc010d4bfd 100644
--- a/packages/data-provider/src/api-endpoints.ts
+++ b/packages/data-provider/src/api-endpoints.ts
@@ -250,6 +250,13 @@ export const getCategories = () => '/api/categories';
export const getAllPromptGroups = () => `${prompts()}/all`;
+/* Prompt Favorites and Rankings */
+export const togglePromptFavorite = (groupId: string) => `${prompts()}/favorites/${groupId}`;
+
+export const updatePromptRankings = () => `${prompts()}/rankings`;
+
+export const getUserPromptPreferences = () => `${prompts()}/preferences`;
+
/* Roles */
export const roles = () => '/api/roles';
export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`;
diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts
index 036d7928c7..94957a8af8 100644
--- a/packages/data-provider/src/data-service.ts
+++ b/packages/data-provider/src/data-service.ts
@@ -701,6 +701,21 @@ export function getRandomPrompts(
return request.get(endpoints.getRandomPrompts(variables.limit, variables.skip));
}
+/* Prompt Favorites and Rankings */
+export function togglePromptFavorite(groupId: string): Promise {
+ return request.post(endpoints.togglePromptFavorite(groupId));
+}
+
+export function updatePromptRankings(
+ variables: t.TPromptRankingRequest,
+): Promise {
+ return request.put(endpoints.updatePromptRankings(), variables);
+}
+
+export function getUserPromptPreferences(): Promise {
+ return request.get(endpoints.getUserPromptPreferences());
+}
+
/* Roles */
export function getRole(roleName: string): Promise {
return request.get(endpoints.getRole(roleName));
diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts
index f9e51dc094..f3c18d6eb5 100644
--- a/packages/data-provider/src/keys.ts
+++ b/packages/data-provider/src/keys.ts
@@ -39,6 +39,7 @@ export enum QueryKeys {
promptGroups = 'promptGroups',
allPromptGroups = 'allPromptGroups',
promptGroup = 'promptGroup',
+ userPromptPreferences = 'userPromptPreferences',
categories = 'categories',
randomPrompts = 'randomPrompts',
roles = 'roles',
diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts
index 706d2a2e8b..121d6ac44f 100644
--- a/packages/data-provider/src/types.ts
+++ b/packages/data-provider/src/types.ts
@@ -537,6 +537,39 @@ export type TGetRandomPromptsRequest = {
skip: number;
};
+/** Prompt favorites and ranking types */
+export type TPromptFavoriteRequest = {
+ promptGroupId: string;
+};
+
+export type TPromptFavoriteResponse = {
+ promptGroupId: string;
+ isFavorite: boolean;
+};
+
+export type TPromptRankingRequest = {
+ rankings: Array<{
+ promptGroupId: string;
+ order: number;
+ }>;
+};
+
+export type TPromptRankingResponse = {
+ message: string;
+ rankings: Array<{
+ promptGroupId: string;
+ order: number;
+ }>;
+};
+
+export type TGetUserPromptPreferencesResponse = {
+ favorites: string[];
+ rankings: Array<{
+ promptGroupId: string;
+ order: number;
+ }>;
+};
+
export type TCustomConfigSpeechResponse = { [key: string]: string };
export type TUserTermsResponse = {
@@ -557,7 +590,7 @@ export type TUpdateFeedbackResponse = {
messageId: string;
conversationId: string;
feedback?: TMinimalFeedback;
-}
+};
export type TBalanceResponse = {
tokenCredits: number;
diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts
index 6107488ee8..62e4ded4a1 100644
--- a/packages/data-schemas/src/schema/user.ts
+++ b/packages/data-schemas/src/schema/user.ts
@@ -129,6 +129,28 @@ const userSchema = new Schema(
type: Boolean,
default: false,
},
+ promptFavorites: {
+ type: [Schema.Types.ObjectId],
+ ref: 'PromptGroup',
+ default: [],
+ },
+ promptRanking: {
+ type: [
+ {
+ promptGroupId: {
+ type: Schema.Types.ObjectId,
+ ref: 'PromptGroup',
+ required: true,
+ },
+ order: {
+ type: Number,
+ required: true,
+ },
+ },
+ ],
+ default: [],
+ _id: false,
+ },
},
{ timestamps: true },
);
diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts
index 206d051819..e1b7ecf384 100644
--- a/packages/data-schemas/src/types/user.ts
+++ b/packages/data-schemas/src/types/user.ts
@@ -30,6 +30,11 @@ export interface IUser extends Document {
}>;
expiresAt?: Date;
termsAccepted?: boolean;
+ promptFavorites?: Types.ObjectId[];
+ promptRanking?: Array<{
+ promptGroupId: Types.ObjectId;
+ order: number;
+ }>;
createdAt?: Date;
updatedAt?: Date;
}