Compare commits
5 Commits
chore/tigh
...
feat/promp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1af9d21f0 | ||
|
|
3d261a969d | ||
|
|
84f62eb70c | ||
|
|
3e698338aa | ||
|
|
0e26df0390 |
@@ -16,8 +16,10 @@ const {
|
||||
// updatePromptLabels,
|
||||
makePromptProduction,
|
||||
} = require('~/models/Prompt');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { getUserById, updateUser } = require('~/models');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -181,6 +183,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;
|
||||
@@ -251,4 +275,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;
|
||||
|
||||
34
client/src/components/Prompts/CreatePrompt.tsx
Normal file
34
client/src/components/Prompts/CreatePrompt.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
const CreatePromptButton: React.FC<{ isChatRoute: boolean }> = ({ isChatRoute }) => {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasCreateAccess && (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{localize('com_ui_create_prompt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePromptButton;
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Cog } from 'lucide-react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import {
|
||||
Label,
|
||||
Switch,
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
} from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
@@ -22,20 +31,30 @@ export default function AutoSendPrompt({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex select-none items-center justify-end gap-2 text-right text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div> {localize('com_nav_auto_send_prompts')} </div>
|
||||
<Switch
|
||||
aria-label="toggle-auto-send-prompts"
|
||||
id="autoSendPrompts"
|
||||
checked={autoSendPrompts}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
data-testid="autoSendPrompts"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<OGDialog>
|
||||
<OGDialogTrigger className="flex items-center justify-center">
|
||||
<Button size="sm" variant="outline" className="bg-transparent hover:bg-surface-hover">
|
||||
<Cog className="h-4 w-4 text-text-primary" />
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-96">
|
||||
<OGDialogTitle className="text-lg font-semibold">
|
||||
{localize('com_ui_prompts_settings')}
|
||||
</OGDialogTitle>
|
||||
|
||||
<div className={cn('flex justify-between text-text-secondary', className)}>
|
||||
<Label>{localize('com_nav_auto_send_prompts')}</Label>
|
||||
<Switch
|
||||
aria-label="toggle-auto-send-prompts"
|
||||
id="autoSendPrompts"
|
||||
checked={autoSendPrompts}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
data-testid="autoSendPrompts"
|
||||
/>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
client/src/components/Prompts/Groups/FavoriteButton.tsx
Normal file
74
client/src/components/Prompts/Groups/FavoriteButton.tsx
Normal 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);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListFilter, User, Share2 } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { SystemCategories } from 'librechat-data-provider';
|
||||
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
|
||||
@@ -19,7 +19,6 @@ export default function FilterPrompts({
|
||||
const setCategory = useSetRecoilState(store.promptsCategory);
|
||||
const categoryFilter = useRecoilValue(store.promptsCategory);
|
||||
const { categories } = useCategories('h-4 w-4');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const filterOptions = useMemo(() => {
|
||||
const baseOptions: Option[] = [
|
||||
@@ -41,7 +40,7 @@ export default function FilterPrompts({
|
||||
{ divider: true, value: null },
|
||||
];
|
||||
|
||||
const categoryOptions = categories
|
||||
const categoryOptions = categories?.length
|
||||
? [...categories]
|
||||
: [
|
||||
{
|
||||
@@ -55,22 +54,18 @@ export default function FilterPrompts({
|
||||
|
||||
const onSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === SystemCategories.ALL) {
|
||||
setCategory('');
|
||||
} else {
|
||||
setCategory(value);
|
||||
}
|
||||
setCategory(value === SystemCategories.ALL ? '' : value);
|
||||
},
|
||||
[setCategory],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setIsSearching(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [displayName]);
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDisplayName(e.target.value);
|
||||
setName(e.target.value);
|
||||
},
|
||||
[setName],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full gap-2 text-text-primary', className)}>
|
||||
@@ -78,7 +73,7 @@ export default function FilterPrompts({
|
||||
value={categoryFilter || SystemCategories.ALL}
|
||||
onChange={onSelect}
|
||||
options={filterOptions}
|
||||
className="bg-transparent"
|
||||
className="rounded-lg bg-transparent"
|
||||
icon={<ListFilter className="h-4 w-4" />}
|
||||
label="Filter: "
|
||||
ariaLabel={localize('com_ui_filter_prompts')}
|
||||
@@ -86,11 +81,7 @@ export default function FilterPrompts({
|
||||
/>
|
||||
<AnimatedSearchInput
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setName(e.target.value);
|
||||
}}
|
||||
isSearching={isSearching}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={localize('com_ui_filter_prompts_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import PanelNavigation from '~/components/Prompts/Groups/PanelNavigation';
|
||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||
import { useMediaQuery, usePromptGroupsNav } from '~/hooks';
|
||||
@@ -18,14 +16,14 @@ export default function GroupSidePanel({
|
||||
groupsQuery,
|
||||
promptGroups,
|
||||
hasPreviousPage,
|
||||
isChatRoute,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
isDetailView?: boolean;
|
||||
className?: string;
|
||||
isChatRoute: boolean;
|
||||
} & ReturnType<typeof usePromptGroupsNav>) {
|
||||
const location = useLocation();
|
||||
const isSmallerScreen = useMediaQuery('(max-width: 1024px)');
|
||||
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,81 +1,107 @@
|
||||
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,
|
||||
SortedPromptList,
|
||||
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';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { Button, Skeleton } from '~/components/ui';
|
||||
import { Skeleton } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface ListProps {
|
||||
groups?: TPromptGroup[];
|
||||
isChatRoute: boolean;
|
||||
isLoading: boolean;
|
||||
enableRanking?: boolean;
|
||||
}
|
||||
|
||||
export default function List({
|
||||
groups = [],
|
||||
isChatRoute,
|
||||
isLoading,
|
||||
}: {
|
||||
groups?: TPromptGroup[];
|
||||
isChatRoute: boolean;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
enableRanking = true,
|
||||
}: ListProps) {
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
|
||||
const { instanceProjectId } = startupConfig;
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
|
||||
const renderGroupItem = useMemo(
|
||||
() => (group: TPromptGroup) => {
|
||||
const Component = isChatRoute ? ChatGroupItem : DashGroupItem;
|
||||
return <Component key={group._id} group={group} instanceProjectId={instanceProjectId} />;
|
||||
},
|
||||
[isChatRoute, instanceProjectId],
|
||||
);
|
||||
|
||||
const emptyMessage = localize('com_ui_nothing_found');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<RankingProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
{isChatRoute ? (
|
||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||
) : (
|
||||
Array.from({ length: 10 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RankingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<RankingProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
{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">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RankingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldUseRanking = !isChatRoute && enableRanking;
|
||||
|
||||
const renderContent = () => {
|
||||
if (isChatRoute) {
|
||||
return <SortedPromptList groups={groups} renderItem={renderGroupItem} />;
|
||||
}
|
||||
if (shouldUseRanking) {
|
||||
return <RankablePromptList groups={groups} renderItem={renderGroupItem} />;
|
||||
}
|
||||
return groups.map((group) => renderGroupItem(group));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{hasCreateAccess && (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{localize('com_ui_create_prompt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
{isLoading && isChatRoute && (
|
||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||
)}
|
||||
{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" />
|
||||
))}
|
||||
{!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">
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && groups.length === 0 && !isChatRoute && (
|
||||
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
|
||||
{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} />
|
||||
);
|
||||
})}
|
||||
<RankingProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-grow overflow-y-auto">
|
||||
<div className="overflow-y-auto overflow-x-hidden">{renderContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RankingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo } from 'react';
|
||||
import { Button, ThemeSelector } from '~/components/ui';
|
||||
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
|
||||
import { Button } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
function PanelNavigation({
|
||||
prevPage,
|
||||
@@ -19,14 +21,16 @@ function PanelNavigation({
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">{!isChatRoute && <ThemeSelector returnThemeOnly={true} />}</div>
|
||||
<div
|
||||
className="flex items-center justify-between gap-2"
|
||||
role="navigation"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => prevPage()} disabled={!hasPreviousPage}>
|
||||
<div className={cn('my-1 flex justify-between', !isChatRoute && 'mx-2')}>
|
||||
<AutoSendPrompt className="text-xs dark:text-white" />
|
||||
<div className="mb-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => prevPage()}
|
||||
disabled={!hasPreviousPage}
|
||||
className="bg-transparent hover:bg-surface-hover"
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -34,6 +38,7 @@ function PanelNavigation({
|
||||
size="sm"
|
||||
onClick={() => nextPage()}
|
||||
disabled={!hasNextPage || isFetching}
|
||||
className="bg-transparent hover:bg-surface-hover"
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
|
||||
294
client/src/components/Prompts/Groups/RankingComponent.tsx
Normal file
294
client/src/components/Prompts/Groups/RankingComponent.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { useDrag, useDrop, useDragLayer } from 'react-dnd';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider';
|
||||
import CategoryIcon from './CategoryIcon';
|
||||
import { Label } from '~/components';
|
||||
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;
|
||||
group: TPromptGroup;
|
||||
}
|
||||
|
||||
const sortGroups = (groups: TPromptGroup[], rankings: any[], favorites: string[]) => {
|
||||
const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order]));
|
||||
|
||||
return [...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);
|
||||
});
|
||||
};
|
||||
|
||||
function DraggablePromptItem({
|
||||
group,
|
||||
index,
|
||||
moveItem,
|
||||
isDragging: isAnyDragging,
|
||||
children,
|
||||
}: DraggablePromptItemProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: ITEM_TYPE,
|
||||
item: { type: ITEM_TYPE, index, id: group._id, group },
|
||||
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
|
||||
});
|
||||
|
||||
const [{ isOver }, drop] = useDrop<DragItem, void, { isOver: boolean }>({
|
||||
accept: ITEM_TYPE,
|
||||
hover: (item, monitor) => {
|
||||
if (!ref.current || item.index === index) return;
|
||||
|
||||
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||
const hoverMiddleY = hoverBoundingRect.height / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
if (item.index < index && hoverClientY < hoverMiddleY * 0.8) return;
|
||||
if (item.index > index && hoverClientY > hoverMiddleY * 1.2) return;
|
||||
|
||||
moveItem(item.index, index);
|
||||
item.index = index;
|
||||
},
|
||||
collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }) }),
|
||||
});
|
||||
|
||||
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={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2 top-1/2 z-10 -translate-y-1/2 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 { itemType, item, currentOffset, isDragging } = useDragLayer((monitor) => ({
|
||||
itemType: monitor.getItemType(),
|
||||
item: monitor.getItem() as DragItem,
|
||||
currentOffset: monitor.getSourceClientOffset(),
|
||||
isDragging: monitor.isDragging(),
|
||||
}));
|
||||
|
||||
if (!isDragging || !currentOffset || itemType !== ITEM_TYPE || !item?.group) return null;
|
||||
|
||||
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)`,
|
||||
}}
|
||||
>
|
||||
<div className="mx-2 my-2 flex h-[60px] w-[430px] min-w-[300px] cursor-pointer rounded-lg border border-border-light bg-surface-primary p-3 opacity-90 shadow-lg">
|
||||
<div className="flex items-center gap-2 truncate pr-2">
|
||||
<CategoryIcon
|
||||
category={item.group.category ?? ''}
|
||||
className="icon-lg"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
|
||||
{item.group.name}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortedPromptList({
|
||||
groups,
|
||||
renderItem,
|
||||
}: {
|
||||
groups: TPromptGroup[];
|
||||
renderItem: (group: TPromptGroup) => ReactNode;
|
||||
}) {
|
||||
const { data: preferences } = useGetUserPromptPreferences();
|
||||
const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groups?.length) {
|
||||
setSortedGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rankings = preferences?.rankings || [];
|
||||
const favorites = preferences?.favorites || [];
|
||||
setSortedGroups(sortGroups(groups, rankings, favorites));
|
||||
}, [groups, preferences]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sortedGroups.map((group) => (
|
||||
<div key={group._id} className="transition-all duration-300 ease-in-out">
|
||||
{renderItem(group)}
|
||||
</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 || [];
|
||||
setSortedGroups(sortGroups(groups, rankings, favorites));
|
||||
}, [groups, preferences]);
|
||||
|
||||
const moveItem = useCallback(
|
||||
(dragIndex: number, hoverIndex: number) => {
|
||||
if (dragIndex === hoverIndex) return;
|
||||
|
||||
setSortedGroups((prevGroups) => {
|
||||
const newGroups = [...prevGroups];
|
||||
const [draggedItem] = 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) => (group._id ? { 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(console.error);
|
||||
}
|
||||
return currentGroups;
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
[updateRankings, onRankingChange],
|
||||
);
|
||||
|
||||
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);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<CustomDragLayer />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { RankablePromptList, SortedPromptList, RankingProvider };
|
||||
@@ -1,16 +1,23 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import PromptSidePanel from '~/components/Prompts/Groups/GroupSidePanel';
|
||||
import AutoSendPrompt from '~/components/Prompts/Groups/AutoSendPrompt';
|
||||
import FilterPrompts from '~/components/Prompts/Groups/FilterPrompts';
|
||||
import ManagePrompts from '~/components/Prompts/ManagePrompts';
|
||||
import CreatePrompt from '~/components/Prompts/CreatePrompt';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
|
||||
export default function PromptsAccordion() {
|
||||
const location = useLocation();
|
||||
const groupsNav = usePromptGroupsNav();
|
||||
const isChatRoute = useMemo(() => location.pathname?.startsWith('/c/'), [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
|
||||
<div className="mt-2 flex h-full w-full flex-col">
|
||||
<PromptSidePanel isChatRoute={isChatRoute} className="lg:w-full xl:w-full" {...groupsNav}>
|
||||
<FilterPrompts setName={groupsNav.setName} className="items-center justify-center" />
|
||||
<div className="flex w-full flex-row items-center justify-end">
|
||||
<AutoSendPrompt className="text-xs dark:text-white" />
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2">
|
||||
<ManagePrompts className="select-none" />
|
||||
<CreatePrompt isChatRoute={isChatRoute} />
|
||||
</div>
|
||||
</PromptSidePanel>
|
||||
</div>
|
||||
|
||||
37
client/src/components/svg/StarIcon.tsx
Normal file
37
client/src/components/svg/StarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ 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';
|
||||
export { default as PersonalizationIcon } from './PersonalizationIcon';
|
||||
export { default as MCPIcon } from './MCPIcon';
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -918,6 +918,7 @@
|
||||
"com_ui_prompts_allow_create": "Allow creating Prompts",
|
||||
"com_ui_prompts_allow_share_global": "Allow sharing Prompts to all users",
|
||||
"com_ui_prompts_allow_use": "Allow using Prompts",
|
||||
"com_ui_prompts_settings": "Prompt Settings",
|
||||
"com_ui_provider": "Provider",
|
||||
"com_ui_quality": "Quality",
|
||||
"com_ui_read_aloud": "Read aloud",
|
||||
@@ -1080,5 +1081,9 @@
|
||||
"com_ui_x_selected": "{{0}} selected",
|
||||
"com_ui_yes": "Yes",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "You"
|
||||
}
|
||||
"com_user_message": "You",
|
||||
"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"
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SystemRoles } from 'librechat-data-provider';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { ArrowLeft, MessageSquareQuote } from 'lucide-react';
|
||||
import {
|
||||
ThemeSelector,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
// DropdownMenuItem,
|
||||
// DropdownMenuContent,
|
||||
// DropdownMenuTrigger,
|
||||
} from '~/components/ui';
|
||||
} from '~/components';
|
||||
import { useLocalize, useCustomLink, useAuthContext } from '~/hooks';
|
||||
import AdvancedSwitch from '~/components/Prompts/AdvancedSwitch';
|
||||
// import { RightPanel } from '../../components/Prompts/RightPanel';
|
||||
@@ -106,6 +107,7 @@ export default function DashBreadcrumb() {
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isPromptsPath && <AdvancedSwitch />}
|
||||
<ThemeSelector returnThemeOnly={true} />
|
||||
{user?.role === SystemRoles.ADMIN && <AdminSettings />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,6 +248,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()}`;
|
||||
|
||||
@@ -698,6 +698,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));
|
||||
|
||||
@@ -39,6 +39,7 @@ export enum QueryKeys {
|
||||
promptGroups = 'promptGroups',
|
||||
allPromptGroups = 'allPromptGroups',
|
||||
promptGroup = 'promptGroup',
|
||||
userPromptPreferences = 'userPromptPreferences',
|
||||
categories = 'categories',
|
||||
randomPrompts = 'randomPrompts',
|
||||
roles = 'roles',
|
||||
|
||||
@@ -601,6 +601,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 = {
|
||||
|
||||
@@ -129,6 +129,27 @@ 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,
|
||||
personalization: {
|
||||
type: {
|
||||
memories: {
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface IUser extends Document {
|
||||
}>;
|
||||
expiresAt?: Date;
|
||||
termsAccepted?: boolean;
|
||||
promptFavorites?: Types.ObjectId[];
|
||||
promptRanking?: Array<{
|
||||
promptGroupId: Types.ObjectId;
|
||||
order: number;
|
||||
}>;
|
||||
personalization?: {
|
||||
memories?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user