diff --git a/client/src/components/Prompts/DeleteVersion.tsx b/client/src/components/Prompts/DeleteVersion.tsx index 541549145f..8a7f6109b8 100644 --- a/client/src/components/Prompts/DeleteVersion.tsx +++ b/client/src/components/Prompts/DeleteVersion.tsx @@ -1,8 +1,10 @@ +import React, { useCallback } from 'react'; import { Trash2 } from 'lucide-react'; +import { useDeletePrompt } from '~/data-provider'; import { Button, OGDialog, OGDialogTrigger, Label, OGDialogTemplate } from '@librechat/client'; import { useLocalize } from '~/hooks'; -const DeleteVersion = ({ +const DeleteConfirmDialog = ({ name, disabled, selectHandler, @@ -58,4 +60,42 @@ const DeleteVersion = ({ ); }; -export default DeleteVersion; +interface DeletePromptProps { + promptId?: string; + groupId: string; + promptName: string; + disabled: boolean; +} + +const DeletePrompt = React.memo( + ({ promptId, groupId, promptName, disabled }: DeletePromptProps) => { + const deletePromptMutation = useDeletePrompt(); + + const handleDelete = useCallback(() => { + if (!promptId) { + console.warn('No prompt ID provided for deletion'); + return; + } + deletePromptMutation.mutate({ + _id: promptId, + groupId, + }); + }, [promptId, groupId, deletePromptMutation]); + + if (!promptId) { + return null; + } + + return ( + + ); + }, +); + +DeletePrompt.displayName = 'DeletePrompt'; + +export default DeletePrompt; diff --git a/client/src/components/Prompts/Groups/CategorySelector.tsx b/client/src/components/Prompts/Groups/CategorySelector.tsx index 3ceb7242b7..e8661b8b60 100644 --- a/client/src/components/Prompts/Groups/CategorySelector.tsx +++ b/client/src/components/Prompts/Groups/CategorySelector.tsx @@ -1,10 +1,13 @@ -import React, { useMemo } from 'react'; -import { Dropdown } from '@librechat/client'; +import React, { useMemo, useState } from 'react'; +import * as Ariakit from '@ariakit/react'; import { useTranslation } from 'react-i18next'; -import { useFormContext, Controller } from 'react-hook-form'; +import { DropdownPopup } from '@librechat/client'; import { LocalStorageKeys } from 'librechat-data-provider'; +import { useFormContext, Controller } from 'react-hook-form'; +import type { MenuItemProps } from '@librechat/client'; import type { ReactNode } from 'react'; import { useCategories } from '~/hooks'; +import { cn } from '~/utils'; interface CategorySelectorProps { currentCategory?: string; @@ -20,10 +23,11 @@ const CategorySelector: React.FC = ({ const { t } = useTranslation(); const formContext = useFormContext(); const { categories, emptyCategory } = useCategories(); + const [isOpen, setIsOpen] = useState(false); - const control = formContext.control; - const watch = formContext.watch; - const setValue = formContext.setValue; + const control = formContext?.control; + const watch = formContext?.watch; + const setValue = formContext?.setValue; const watchedCategory = watch ? watch('category') : currentCategory; @@ -46,53 +50,71 @@ const CategorySelector: React.FC = ({ return categoryOption; }, [categoryOption, t]); + const menuItems: MenuItemProps[] = useMemo(() => { + if (!categories) return []; + + return categories.map((category) => ({ + id: category.value, + label: category.label, + icon: 'icon' in category ? category.icon : undefined, + onClick: () => { + const value = category.value || ''; + if (formContext && setValue) { + setValue('category', value, { shouldDirty: false }); + } + localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); + onValueChange?.(value); + setIsOpen(false); + }, + })); + }, [categories, formContext, setValue, onValueChange]); + + const trigger = ( + setIsOpen(!isOpen)} + aria-label="Prompt's category selector" + aria-labelledby="category-selector-label" + > +
+ {'icon' in displayCategory && displayCategory.icon != null && ( + {displayCategory.icon as ReactNode} + )} + {displayCategory.value ? displayCategory.label : t('com_ui_category')} +
+ +
+ ); + return formContext ? ( ( - { - setValue('category', value, { shouldDirty: false }); - localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); - onValueChange?.(value); - }} - aria-labelledby="category-selector-label" - ariaLabel="Prompt's category selector" - className={className} - options={categories || []} - renderValue={() => ( -
- {'icon' in displayCategory && displayCategory.icon != null && ( - {displayCategory.icon as ReactNode} - )} - {displayCategory.label} -
- )} + )} /> ) : ( - { - localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value); - onValueChange?.(value); - }} - aria-labelledby="category-selector-label" - ariaLabel="Prompt's category selector" - className={className} - options={categories || []} - renderValue={() => ( -
- {'icon' in displayCategory && displayCategory.icon != null && ( - {displayCategory.icon as ReactNode} - )} - {displayCategory.label} -
- )} + ); }; diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx index 32277d5b6d..1be920a05a 100644 --- a/client/src/components/Prompts/PromptForm.tsx +++ b/client/src/components/Prompts/PromptForm.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import React from 'react'; import debounce from 'lodash/debounce'; import { useRecoilValue } from 'recoil'; import { Menu, Rocket } from 'lucide-react'; @@ -6,14 +7,13 @@ import { useForm, FormProvider } from 'react-hook-form'; import { useParams, useOutletContext } from 'react-router-dom'; import { Button, Skeleton, useToastContext } from '@librechat/client'; import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; -import type { TCreatePrompt } from 'librechat-data-provider'; +import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider'; import { - useCreatePrompt, useGetPrompts, + useCreatePrompt, useGetPromptGroup, useUpdatePromptGroup, useMakePromptProduction, - useDeletePrompt, } from '~/data-provider'; import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks'; import CategorySelector from './Groups/CategorySelector'; @@ -22,7 +22,7 @@ import PromptVariables from './PromptVariables'; import { cn, findPromptGroup } from '~/utils'; import PromptVersions from './PromptVersions'; import { PromptsEditorMode } from '~/common'; -import DeleteConfirm from './DeleteVersion'; +import DeleteVersion from './DeleteVersion'; import PromptDetails from './PromptDetails'; import PromptEditor from './PromptEditor'; import SkeletonForm from './SkeletonForm'; @@ -32,16 +32,136 @@ import PromptName from './PromptName'; import Command from './Command'; import store from '~/store'; +interface RightPanelProps { + group: TPromptGroup; + prompts: TPrompt[]; + selectedPrompt: any; + selectionIndex: number; + selectedPromptId?: string; + isLoadingPrompts: boolean; + setSelectionIndex: React.Dispatch>; +} + +const RightPanel = React.memo( + ({ + group, + prompts, + selectedPrompt, + selectedPromptId, + isLoadingPrompts, + selectionIndex, + setSelectionIndex, + }: RightPanelProps) => { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const editorMode = useRecoilValue(store.promptsEditorMode); + const hasShareAccess = useHasAccess({ + permissionType: PermissionTypes.PROMPTS, + permission: Permissions.SHARED_GLOBAL, + }); + + const updateGroupMutation = useUpdatePromptGroup({ + onError: () => { + showToast({ + status: 'error', + message: localize('com_ui_prompt_update_error'), + }); + }, + }); + + const makeProductionMutation = useMakePromptProduction(); + + const groupId = group?._id || ''; + const groupName = group?.name || ''; + const groupCategory = group?.category || ''; + const isLoadingGroup = !group; + + return ( +
+
+ + updateGroupMutation.mutate({ + id: groupId, + payload: { name: groupName, category: value }, + }) + } + /> +
+ {hasShareAccess && } + {editorMode === PromptsEditorMode.ADVANCED && ( + + )} + +
+
+ {editorMode === PromptsEditorMode.ADVANCED && + (isLoadingPrompts + ? Array.from({ length: 6 }).map((_, index: number) => ( +
+ +
+ )) + : prompts.length > 0 && ( + + ))} +
+ ); + }, +); + +RightPanel.displayName = 'RightPanel'; + const PromptForm = () => { const params = useParams(); const localize = useLocalize(); const { user } = useAuthContext(); - const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); const { showToast } = useToastContext(); + const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); const promptId = params.promptId || ''; - const [selectionIndex, setSelectionIndex] = useState(0); const editorMode = useRecoilValue(store.promptsEditorMode); + const [selectionIndex, setSelectionIndex] = useState(0); + const prevIsEditingRef = useRef(false); const [isEditing, setIsEditing] = useState(false); const [initialLoad, setInitialLoad] = useState(true); @@ -72,11 +192,9 @@ const PromptForm = () => { [prompts, selectionIndex], ); + const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]); + const { groupsQuery } = useOutletContext>(); - const hasShareAccess = useHasAccess({ - permissionType: PermissionTypes.PROMPTS, - permission: Permissions.SHARED_GLOBAL, - }); const updateGroupMutation = useUpdatePromptGroup({ onError: () => { @@ -88,7 +206,6 @@ const PromptForm = () => { }); const makeProductionMutation = useMakePromptProduction(); - const deletePromptMutation = useDeletePrompt(); const createPromptMutation = useCreatePrompt({ onMutate: (variables) => { @@ -177,24 +294,40 @@ const PromptForm = () => { return () => window.removeEventListener('resize', handleResize); }, []); - const debouncedUpdateOneliner = useCallback( - debounce((oneliner: string) => { - if (!group || !group._id) { - return console.warn('Group not found'); - } - updateGroupMutation.mutate({ id: group._id, payload: { oneliner } }); - }, 950), - [updateGroupMutation, group], + const debouncedUpdateOneliner = useMemo( + () => + debounce((groupId: string, oneliner: string, mutate: any) => { + mutate({ id: groupId, payload: { oneliner } }); + }, 950), + [], ); - const debouncedUpdateCommand = useCallback( - debounce((command: string) => { + const debouncedUpdateCommand = useMemo( + () => + debounce((groupId: string, command: string, mutate: any) => { + mutate({ id: groupId, payload: { command } }); + }, 950), + [], + ); + + const handleUpdateOneliner = useCallback( + (oneliner: string) => { if (!group || !group._id) { return console.warn('Group not found'); } - updateGroupMutation.mutate({ id: group._id, payload: { command } }); - }, 950), - [updateGroupMutation, group], + debouncedUpdateOneliner(group._id, oneliner, updateGroupMutation.mutate); + }, + [group, updateGroupMutation.mutate, debouncedUpdateOneliner], + ); + + const handleUpdateCommand = useCallback( + (command: string) => { + if (!group || !group._id) { + return console.warn('Group not found'); + } + debouncedUpdateCommand(group._id, command, updateGroupMutation.mutate); + }, + [group, updateGroupMutation.mutate, debouncedUpdateCommand], ); if (initialLoad) { @@ -217,89 +350,7 @@ const PromptForm = () => { return null; } - const groupId = group._id; - const groupName = group.name; - const groupCategory = group.category; - - const RightPanel = () => ( -
-
- - updateGroupMutation.mutate({ - id: groupId, - payload: { name: groupName, category: value }, - }) - } - /> -
- {hasShareAccess && } - {editorMode === PromptsEditorMode.ADVANCED && ( - - )} - { - if (!selectedPrompt || !selectedPrompt._id) { - console.warn('No prompt is selected or prompt _id is missing'); - return; - } - deletePromptMutation.mutate({ - _id: selectedPrompt._id, - groupId, - }); - }} - /> -
-
- {editorMode === PromptsEditorMode.ADVANCED && - (isLoadingPrompts - ? Array.from({ length: 6 }).map((_, index: number) => ( -
- -
- )) - : prompts.length > 0 && ( - - ))} -
- ); return ( @@ -339,7 +390,17 @@ const PromptForm = () => {
- {editorMode === PromptsEditorMode.SIMPLE && } + {editorMode === PromptsEditorMode.SIMPLE && ( + + )}
)} @@ -352,11 +413,11 @@ const PromptForm = () => { )} @@ -364,7 +425,15 @@ const PromptForm = () => { {editorMode === PromptsEditorMode.ADVANCED && (
- +
)} @@ -395,7 +464,15 @@ const PromptForm = () => { >
- +
diff --git a/client/src/components/SidePanel/Memories/MemoryViewer.tsx b/client/src/components/SidePanel/Memories/MemoryViewer.tsx index 89459d66e3..473b1e06b4 100644 --- a/client/src/components/SidePanel/Memories/MemoryViewer.tsx +++ b/client/src/components/SidePanel/Memories/MemoryViewer.tsx @@ -4,18 +4,18 @@ import { Plus } from 'lucide-react'; import { matchSorter } from 'match-sorter'; import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider'; import { - Spinner, - EditIcon, - TrashIcon, Table, Input, Label, Button, Switch, + Spinner, TableRow, OGDialog, + EditIcon, TableHead, TableBody, + TrashIcon, TableCell, TableHeader, TooltipAnchor, @@ -25,10 +25,10 @@ import { } from '@librechat/client'; import type { TUserMemory } from 'librechat-data-provider'; import { - useGetUserQuery, - useMemoriesQuery, - useDeleteMemoryMutation, useUpdateMemoryPreferencesMutation, + useDeleteMemoryMutation, + useMemoriesQuery, + useGetUserQuery, } from '~/data-provider'; import { useLocalize, useAuthContext, useHasAccess } from '~/hooks'; import MemoryCreateDialog from './MemoryCreateDialog'; @@ -36,18 +36,114 @@ import MemoryEditDialog from './MemoryEditDialog'; import AdminSettings from './AdminSettings'; import { cn } from '~/utils'; +const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + return ( + } + > + + setOpen(!open)} + className="h-8 w-8 p-0" + > + + + } + /> + + + ); +}; + +const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const [open, setOpen] = useState(false); + const { mutate: deleteMemory } = useDeleteMemoryMutation(); + const [deletingKey, setDeletingKey] = useState(null); + + const confirmDelete = async () => { + setDeletingKey(memory.key); + deleteMemory(memory.key, { + onSuccess: () => { + showToast({ + message: localize('com_ui_deleted'), + status: 'success', + }); + setOpen(false); + }, + onError: () => + showToast({ + message: localize('com_ui_error'), + status: 'error', + }), + onSettled: () => setDeletingKey(null), + }); + }; + + return ( + + + setOpen(!open)} + className="h-8 w-8 p-0" + > + {deletingKey === memory.key ? ( + + ) : ( + + )} + + } + /> + + + {localize('com_ui_delete_confirm')} "{memory.key}"? + + } + selection={{ + selectHandler: confirmDelete, + selectClasses: + 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white', + selectText: localize('com_ui_delete'), + }} + /> + + ); +}; + +const pageSize = 10; export default function MemoryViewer() { const localize = useLocalize(); const { user } = useAuthContext(); const { data: userData } = useGetUserQuery(); const { data: memData, isLoading } = useMemoriesQuery(); - const { mutate: deleteMemory } = useDeleteMemoryMutation(); const { showToast } = useToastContext(); const [pageIndex, setPageIndex] = useState(0); const [searchQuery, setSearchQuery] = useState(''); - const pageSize = 10; const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [deletingKey, setDeletingKey] = useState(null); const [referenceSavedMemories, setReferenceSavedMemories] = useState(true); const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({ @@ -119,108 +215,6 @@ export default function MemoryViewer() { return 'stroke-green-500'; }; - const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => { - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - - // Only show edit button if user has UPDATE permission - if (!hasUpdateAccess) { - return null; - } - - return ( - } - > - - setOpen(!open)} - className="h-8 w-8 p-0" - > - - - } - /> - - - ); - }; - - const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => { - const [open, setOpen] = useState(false); - - if (!hasUpdateAccess) { - return null; - } - - const confirmDelete = async () => { - setDeletingKey(memory.key); - deleteMemory(memory.key, { - onSuccess: () => { - showToast({ - message: localize('com_ui_deleted'), - status: 'success', - }); - setOpen(false); - }, - onError: () => - showToast({ - message: localize('com_ui_error'), - status: 'error', - }), - onSettled: () => setDeletingKey(null), - }); - }; - - return ( - - - setOpen(!open)} - className="h-8 w-8 p-0" - > - {deletingKey === memory.key ? ( - - ) : ( - - )} - - } - /> - - - {localize('com_ui_delete_confirm')} "{memory.key}"? - - } - selection={{ - selectHandler: confirmDelete, - selectClasses: - 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - ); - }; - if (isLoading) { return (