feat: Add user prompt preferences and favorites functionality

This commit is contained in:
Marco Beretta 2025-06-01 11:55:45 +02:00
parent f2f4bf87ca
commit 0e26df0390
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
16 changed files with 788 additions and 54 deletions

View file

@ -15,6 +15,7 @@ const {
makePromptProduction,
} = require('~/models/Prompt');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
const { getUserById, updateUser } = require('~/models');
const { logger } = require('~/config');
const router = express.Router();
@ -173,6 +174,28 @@ router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) =
}
});
/**
* Route to get user's prompt preferences (favorites and rankings)
* GET /preferences
*/
router.get('/preferences', async (req, res) => {
try {
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json({
favorites: user.promptFavorites || [],
rankings: user.promptRanking || [],
});
} catch (error) {
logger.error('Error getting user preferences', error);
res.status(500).json({ message: 'Error getting user preferences' });
}
});
router.get('/:promptId', async (req, res) => {
const { promptId } = req.params;
const author = req.user.id;
@ -243,4 +266,79 @@ const deletePromptGroupController = async (req, res) => {
router.delete('/:promptId', checkPromptCreate, deletePromptController);
router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController);
/**
* Route to toggle favorite status for a prompt group
* POST /favorites/:groupId
*/
router.post('/favorites/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const favorites = user.promptFavorites || [];
const isFavorite = favorites.some((id) => id.toString() === groupId.toString());
let updatedFavorites;
if (isFavorite) {
updatedFavorites = favorites.filter((id) => id.toString() !== groupId.toString());
} else {
updatedFavorites = [...favorites, groupId];
}
await updateUser(req.user.id, { promptFavorites: updatedFavorites });
const response = {
promptGroupId: groupId,
isFavorite: !isFavorite,
};
res.json(response);
} catch (error) {
logger.error('Error toggling favorite status', error);
res.status(500).json({ message: 'Error updating favorite status' });
}
});
/**
* Route to update prompt group rankings
* PUT /rankings
*/
router.put('/rankings', async (req, res) => {
try {
const { rankings } = req.body;
if (!Array.isArray(rankings)) {
return res.status(400).json({ message: 'Rankings must be an array' });
}
const user = await getUserById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
const promptRanking = rankings
.filter(({ promptGroupId, order }) => promptGroupId && !isNaN(parseInt(order, 10)))
.map(({ promptGroupId, order }) => ({
promptGroupId,
order: parseInt(order, 10),
}));
const updatedUser = await updateUser(req.user.id, { promptRanking });
res.json({
message: 'Rankings updated successfully',
rankings: updatedUser?.promptRanking || [],
});
} catch (error) {
logger.error('Error updating rankings', error);
res.status(500).json({ message: 'Error updating rankings' });
}
});
module.exports = router;

View file

@ -10,6 +10,7 @@ import {
} from '~/components/ui';
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import FavoriteButton from '~/components/Prompts/Groups/FavoriteButton';
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard';
import { detectVariables } from '~/utils';
@ -64,6 +65,7 @@ function ChatGroupItem({
{groupIsGlobal === true && (
<EarthIcon className="icon-md text-green-400" aria-label="Global prompt group" />
)}
<FavoriteButton groupId={group._id ?? ''} size="16" />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button

View file

@ -2,12 +2,12 @@ import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'rea
import { EarthIcon, Pen } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
import { Input, Label, Button, OGDialog, OGDialogTrigger, TrashIcon } from '~/components';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import { Input, Label, Button, OGDialog, OGDialogTrigger } from '~/components/ui';
import FavoriteButton from '~/components/Prompts/Groups/FavoriteButton';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useAuthContext } from '~/hooks';
import { TrashIcon } from '~/components/svg';
import { cn } from '~/utils/';
interface DashGroupItemProps {
@ -49,7 +49,6 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
const { isLoading } = updateGroup;
const handleSaveRename = useCallback(() => {
console.log(group._id ?? '', { name: nameInputValue });
updateGroup.mutate({ id: group._id ?? '', payload: { name: nameInputValue } });
}, [group._id, nameInputValue, updateGroup]);
@ -99,6 +98,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
aria-label={localize('com_ui_global_group')}
/>
)}
<FavoriteButton groupId={group._id ?? ''} size="16" />
{(isOwner || user?.role === SystemRoles.ADMIN) && (
<>
<OGDialog>

View file

@ -0,0 +1,74 @@
import React, { memo, useCallback } from 'react';
import { useTogglePromptFavorite, useGetUserPromptPreferences } from '~/data-provider';
import { Button, StarIcon } from '~/components';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface FavoriteButtonProps {
groupId: string;
size?: string | number;
onToggle?: (isFavorite: boolean) => void;
}
function FavoriteButton({ groupId, size = '1em', onToggle }: FavoriteButtonProps) {
const localize = useLocalize();
const { data: preferences } = useGetUserPromptPreferences();
const toggleFavorite = useTogglePromptFavorite();
const isFavorite = preferences?.favorites?.includes(groupId) ?? false;
const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite.mutate(
{ groupId },
{
onSuccess: () => {
onToggle?.(!isFavorite);
},
},
);
},
[groupId, isFavorite, onToggle, toggleFavorite],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleToggle(e as unknown as React.MouseEvent);
}
},
[handleToggle],
);
return (
<Button
variant="ghost"
onClick={handleToggle}
onKeyDown={handleKeyDown}
disabled={toggleFavorite.isLoading}
aria-label={
isFavorite ? localize('com_ui_remove_from_favorites') : localize('com_ui_add_to_favorites')
}
title={
isFavorite ? localize('com_ui_remove_from_favorites') : localize('com_ui_add_to_favorites')
}
className="h-8 w-8 p-0 hover:bg-surface-hover"
>
<StarIcon
size={size}
filled={isFavorite}
className={cn(
'transition-colors duration-200',
isFavorite
? 'text-yellow-500 hover:text-yellow-600'
: 'text-gray-400 hover:text-yellow-500',
)}
/>
</Button>
);
}
export default memo(FavoriteButton);

View file

@ -2,6 +2,7 @@ import { Plus } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
import { RankablePromptList, RankingProvider } from '~/components/Prompts/Groups/RankingComponent';
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
import { useGetStartupConfig } from '~/data-provider';
@ -12,10 +13,12 @@ export default function List({
groups = [],
isChatRoute,
isLoading,
enableRanking = true,
}: {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
enableRanking?: boolean;
}) {
const navigate = useNavigate();
const localize = useLocalize();
@ -26,7 +29,15 @@ export default function List({
permission: Permissions.CREATE,
});
const renderGroupItem = (group: TPromptGroup) => {
if (isChatRoute) {
return <ChatGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />;
}
return <DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />;
};
return (
<RankingProvider>
<div className="flex h-full flex-col">
{hasCreateAccess && (
<div className="flex w-full justify-end">
@ -48,7 +59,10 @@ export default function List({
{isLoading &&
!isChatRoute &&
Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton key={index} className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4" />
<Skeleton
key={index}
className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4"
/>
))}
{!isLoading && groups.length === 0 && isChatRoute && (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
@ -60,22 +74,14 @@ export default function List({
{localize('com_ui_nothing_found')}
</div>
)}
{groups.map((group) => {
if (isChatRoute) {
return (
<ChatGroupItem
key={group._id}
group={group}
instanceProjectId={instanceProjectId}
/>
);
}
return (
<DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />
);
})}
{!isLoading && groups.length > 0 && enableRanking ? (
<RankablePromptList groups={groups} renderItem={renderGroupItem} />
) : (
!isLoading && groups.map((group) => renderGroupItem(group))
)}
</div>
</div>
</div>
</RankingProvider>
);
}

View file

@ -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<HTMLDivElement>(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<DragItem, void, { isOverCurrent: boolean }>({
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 (
<div
ref={ref}
className={cn(
'group relative transition-all duration-300 ease-in-out',
isDragging && 'opacity-0',
isAnyDragging && !isDragging && 'transition-transform',
isOver && !isDragging && 'scale-[1.02]',
)}
style={{
transform: isDragging ? 'scale(1.05)' : 'scale(1)',
cursor: isDragging ? 'grabbing' : 'grab',
}}
>
<div
className={cn(
'absolute left-2 top-1/2 z-10 -translate-y-1/2 transition-all duration-200',
'opacity-0 group-hover:opacity-100',
isDragging && 'opacity-100',
)}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</div>
<div className="pl-8">{children}</div>
</div>
);
}
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 (
<div
style={{
backgroundColor: 'rgba(230, 245, 255, 0.9)',
padding: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
display: 'inline-block',
}}
>
{`Moving: ${item.id}`}
</div>
);
}
return <div>Dragging...</div>;
};
return (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
zIndex: 100,
left: 0,
top: 0,
width: '100%',
height: '100%',
}}
>
<div
style={{
transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
}}
>
{renderPreview()}
</div>
</div>
);
}
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<TPromptGroup[]>([]);
const [isDragging, setIsDragging] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div className={cn('space-y-2 transition-all duration-300', isDragging && 'space-y-3')}>
{sortedGroups.map((group, index) => (
<div
key={group._id || index}
className="transition-all duration-300 ease-in-out"
style={{
transform: `translateY(${isDragging ? '2px' : '0'})`,
}}
>
<DraggablePromptItem
group={group}
index={index}
moveItem={moveItem}
isDragging={isDragging}
>
{renderItem(group)}
</DraggablePromptItem>
</div>
))}
</div>
);
}
function RankingProvider({ children }: { children: ReactNode }) {
return (
<DndProvider backend={HTML5Backend}>
<CustomDragLayer />
{children}
</DndProvider>
);
}
export { RankablePromptList, RankingProvider };

View file

@ -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 ? (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26" />
</svg>
);
}

View file

@ -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';

View file

@ -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<t.TPromptFavoriteResponse, unknown, { groupId: string }, unknown> => {
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<t.TGetUserPromptPreferencesResponse>([
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<t.TGetUserPromptPreferencesResponse>(
[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<t.TPromptRankingResponse, unknown, t.TPromptRankingRequest, unknown> => {
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<t.TGetUserPromptPreferencesResponse>([
QueryKeys.userPromptPreferences,
]);
// Optimistically update the rankings
if (previousPreferences) {
queryClient.setQueryData<t.TGetUserPromptPreferencesResponse>(
[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
});
};

View file

@ -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."
"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"
}

View file

@ -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()}`;

View file

@ -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<t.TPromptFavoriteResponse> {
return request.post(endpoints.togglePromptFavorite(groupId));
}
export function updatePromptRankings(
variables: t.TPromptRankingRequest,
): Promise<t.TPromptRankingResponse> {
return request.put(endpoints.updatePromptRankings(), variables);
}
export function getUserPromptPreferences(): Promise<t.TGetUserPromptPreferencesResponse> {
return request.get(endpoints.getUserPromptPreferences());
}
/* Roles */
export function getRole(roleName: string): Promise<r.TRole> {
return request.get(endpoints.getRole(roleName));

View file

@ -39,6 +39,7 @@ export enum QueryKeys {
promptGroups = 'promptGroups',
allPromptGroups = 'allPromptGroups',
promptGroup = 'promptGroup',
userPromptPreferences = 'userPromptPreferences',
categories = 'categories',
randomPrompts = 'randomPrompts',
roles = 'roles',

View file

@ -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;

View file

@ -129,6 +129,28 @@ const userSchema = new Schema<IUser>(
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 },
);

View file

@ -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;
}