| 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'; |
| import { useParams } from 'react-router-dom'; |
| import { useForm, FormProvider } from 'react-hook-form'; |
| import { Button, Skeleton, useToastContext } from '@librechat/client'; |
| import { |
| Permissions, |
| ResourceType, |
| PermissionBits, |
| PermissionTypes, |
| } from 'librechat-data-provider'; |
| import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider'; |
| import { |
| useGetPrompts, |
| useGetPromptGroup, |
| useAddPromptToGroup, |
| useUpdatePromptGroup, |
| useMakePromptProduction, |
| } from '~/data-provider'; |
| import { useResourcePermissions, useHasAccess, useLocalize } from '~/hooks'; |
| import CategorySelector from './Groups/CategorySelector'; |
| import { usePromptGroupsContext } from '~/Providers'; |
| import NoPromptGroup from './Groups/NoPromptGroup'; |
| import PromptVariables from './PromptVariables'; |
| import { cn, findPromptGroup } from '~/utils'; |
| import PromptVersions from './PromptVersions'; |
| import { PromptsEditorMode } from '~/common'; |
| import DeleteVersion from './DeleteVersion'; |
| import PromptDetails from './PromptDetails'; |
| import PromptEditor from './PromptEditor'; |
| import SkeletonForm from './SkeletonForm'; |
| import Description from './Description'; |
| import SharePrompt from './SharePrompt'; |
| 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; |
| canEdit: boolean; |
| setSelectionIndex: React.Dispatch<React.SetStateAction<number>>; |
| } |
|
|
| const RightPanel = React.memo( |
| ({ |
| group, |
| prompts, |
| selectedPrompt, |
| selectedPromptId, |
| isLoadingPrompts, |
| canEdit, |
| 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 ( |
| <div |
| className="h-full w-full overflow-y-auto bg-surface-primary px-4" |
| style={{ maxHeight: 'calc(100vh - 100px)' }} |
| > |
| <div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0"> |
| <CategorySelector |
| currentCategory={groupCategory} |
| onValueChange={ |
| canEdit |
| ? (value) => |
| updateGroupMutation.mutate({ |
| id: groupId, |
| payload: { name: groupName, category: value }, |
| }) |
| : undefined |
| } |
| /> |
| <div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0"> |
| {hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />} |
| {editorMode === PromptsEditorMode.ADVANCED && canEdit && ( |
| <Button |
| variant="submit" |
| size="sm" |
| aria-label="Make prompt production" |
| className="h-10 w-10 border border-transparent p-0.5 transition-all" |
| onClick={() => { |
| if (!selectedPrompt) { |
| console.warn('No prompt is selected'); |
| return; |
| } |
| const { _id: promptVersionId = '', prompt } = selectedPrompt; |
| makeProductionMutation.mutate({ |
| id: promptVersionId, |
| groupId, |
| productionPrompt: { prompt }, |
| }); |
| }} |
| disabled={ |
| isLoadingGroup || |
| !selectedPrompt || |
| selectedPrompt._id === group?.productionId || |
| makeProductionMutation.isLoading || |
| !canEdit |
| } |
| > |
| <Rocket className="size-5 cursor-pointer text-white" /> |
| </Button> |
| )} |
| <DeleteVersion |
| promptId={selectedPromptId} |
| groupId={groupId} |
| promptName={groupName} |
| disabled={isLoadingGroup} |
| /> |
| </div> |
| </div> |
| {editorMode === PromptsEditorMode.ADVANCED && |
| (isLoadingPrompts |
| ? Array.from({ length: 6 }).map((_, index: number) => ( |
| <div key={index} className="my-2"> |
| <Skeleton className="h-[72px] w-full" /> |
| </div> |
| )) |
| : prompts.length > 0 && ( |
| <PromptVersions |
| group={group} |
| prompts={prompts} |
| selectionIndex={selectionIndex} |
| setSelectionIndex={setSelectionIndex} |
| /> |
| ))} |
| </div> |
| ); |
| }, |
| ); |
|
|
| RightPanel.displayName = 'RightPanel'; |
|
|
| const PromptForm = () => { |
| const params = useParams(); |
| const localize = useLocalize(); |
| const { showToast } = useToastContext(); |
| const { hasAccess } = usePromptGroupsContext(); |
| const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd); |
| const promptId = params.promptId || ''; |
|
|
| const editorMode = useRecoilValue(store.promptsEditorMode); |
| const [selectionIndex, setSelectionIndex] = useState<number>(0); |
|
|
| const prevIsEditingRef = useRef(false); |
| const [isEditing, setIsEditing] = useState(false); |
| const [initialLoad, setInitialLoad] = useState(true); |
| const [showSidePanel, setShowSidePanel] = useState(false); |
| const sidePanelWidth = '320px'; |
|
|
| const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId, { |
| enabled: hasAccess && !!promptId, |
| }); |
| const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts( |
| { groupId: promptId }, |
| { enabled: hasAccess && !!promptId }, |
| ); |
|
|
| const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( |
| ResourceType.PROMPTGROUP, |
| group?._id || '', |
| ); |
|
|
| const canEdit = hasPermission(PermissionBits.EDIT); |
| const canView = hasPermission(PermissionBits.VIEW); |
|
|
| const methods = useForm({ |
| defaultValues: { |
| prompt: '', |
| promptName: group ? group.name : '', |
| category: group ? group.category : '', |
| }, |
| }); |
| const { handleSubmit, setValue, reset, watch } = methods; |
| const promptText = watch('prompt'); |
|
|
| const selectedPrompt = useMemo( |
| () => (prompts.length > 0 ? prompts[selectionIndex] : undefined), |
| [prompts, selectionIndex], |
| ); |
|
|
| const selectedPromptId = useMemo(() => selectedPrompt?._id, [selectedPrompt?._id]); |
|
|
| const { groupsQuery } = usePromptGroupsContext(); |
|
|
| const updateGroupMutation = useUpdatePromptGroup({ |
| onError: () => { |
| showToast({ |
| status: 'error', |
| message: localize('com_ui_prompt_update_error'), |
| }); |
| }, |
| }); |
|
|
| const makeProductionMutation = useMakePromptProduction(); |
| const addPromptToGroupMutation = useAddPromptToGroup({ |
| onMutate: (variables) => { |
| reset( |
| { |
| prompt: variables.prompt.prompt, |
| category: group?.category || '', |
| }, |
| { keepDirtyValues: true }, |
| ); |
| }, |
| onSuccess(data) { |
| if (alwaysMakeProd && data.prompt._id != null && data.prompt._id && data.prompt.groupId) { |
| makeProductionMutation.mutate({ |
| id: data.prompt._id, |
| groupId: data.prompt.groupId, |
| productionPrompt: { prompt: data.prompt.prompt }, |
| }); |
| } |
|
|
| reset({ |
| prompt: data.prompt.prompt, |
| promptName: group?.name || '', |
| category: group?.category || '', |
| }); |
| }, |
| }); |
|
|
| const onSave = useCallback( |
| (value: string) => { |
| if (!canEdit) { |
| return; |
| } |
| if (!value) { |
| |
| return; |
| } |
| if (!selectedPrompt) { |
| return; |
| } |
|
|
| const groupId = selectedPrompt.groupId || group?._id; |
| if (!groupId) { |
| console.error('No groupId available'); |
| return; |
| } |
|
|
| const tempPrompt: TCreatePrompt = { |
| prompt: { |
| type: selectedPrompt.type ?? 'text', |
| groupId: groupId, |
| prompt: value, |
| }, |
| }; |
|
|
| if (value === selectedPrompt.prompt) { |
| return; |
| } |
|
|
| |
| addPromptToGroupMutation.mutate({ ...tempPrompt, groupId }); |
| }, |
| [selectedPrompt, group, addPromptToGroupMutation, canEdit], |
| ); |
|
|
| const handleLoadingComplete = useCallback(() => { |
| if (isLoadingGroup || isLoadingPrompts) { |
| return; |
| } |
| setInitialLoad(false); |
| }, [isLoadingGroup, isLoadingPrompts]); |
|
|
| useEffect(() => { |
| if (prevIsEditingRef.current && !isEditing && canEdit) { |
| handleSubmit((data) => onSave(data.prompt))(); |
| } |
| prevIsEditingRef.current = isEditing; |
| }, [isEditing, onSave, handleSubmit, canEdit]); |
|
|
| useEffect(() => { |
| handleLoadingComplete(); |
| }, [params.promptId, editorMode, group?.productionId, prompts, handleLoadingComplete]); |
|
|
| useEffect(() => { |
| setValue('prompt', selectedPrompt ? selectedPrompt.prompt : '', { shouldDirty: false }); |
| setValue('category', group ? group.category : '', { shouldDirty: false }); |
| }, [selectedPrompt, group, setValue]); |
|
|
| useEffect(() => { |
| const handleResize = () => { |
| if (window.matchMedia('(min-width: 1022px)').matches) { |
| setShowSidePanel(false); |
| } |
| }; |
|
|
| window.addEventListener('resize', handleResize); |
| return () => window.removeEventListener('resize', handleResize); |
| }, []); |
|
|
| const debouncedUpdateOneliner = useMemo( |
| () => |
| debounce((groupId: string, oneliner: string, mutate: any) => { |
| mutate({ id: groupId, payload: { oneliner } }); |
| }, 950), |
| [], |
| ); |
|
|
| 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'); |
| } |
| 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) { |
| return <SkeletonForm />; |
| } |
|
|
| |
| if (!canEdit && !permissionsLoading && groupsQuery.data) { |
| const fetchedPrompt = findPromptGroup( |
| groupsQuery.data, |
| (group) => group._id === params.promptId, |
| ); |
| if (!fetchedPrompt && !canView) { |
| return <NoPromptGroup />; |
| } |
|
|
| if (fetchedPrompt || group) { |
| return <PromptDetails group={fetchedPrompt || group} />; |
| } |
| } |
|
|
| if (!group || group._id == null) { |
| return null; |
| } |
|
|
| const groupName = group.name; |
|
|
| return ( |
| <FormProvider {...methods}> |
| <form className="mt-4 flex w-full" onSubmit={handleSubmit((data) => onSave(data.prompt))}> |
| <div className="relative w-full"> |
| <div |
| className="h-full w-full" |
| style={{ |
| transform: `translateX(${showSidePanel ? `-${sidePanelWidth}` : '0'})`, |
| transition: 'transform 0.3s ease-in-out', |
| }} |
| > |
| <div className="flex h-full"> |
| <div className="flex-1 overflow-hidden px-4"> |
| <div className="mb-4 flex items-center gap-2 text-text-primary"> |
| {isLoadingGroup ? ( |
| <Skeleton className="mb-1 flex h-10 w-32 font-bold sm:text-xl md:mb-0 md:h-12 md:text-2xl" /> |
| ) : ( |
| <> |
| <PromptName |
| name={groupName} |
| onSave={(value) => { |
| if (!canEdit || !group._id) { |
| return; |
| } |
| updateGroupMutation.mutate({ |
| id: group._id, |
| payload: { name: value }, |
| }); |
| }} |
| /> |
| <div className="flex-1" /> |
| <Button |
| type="button" |
| variant="ghost" |
| className="h-10 w-10 border border-border-light p-0 lg:hidden" |
| onClick={() => setShowSidePanel(true)} |
| aria-label={localize('com_endpoint_open_menu')} |
| > |
| <Menu className="size-5" /> |
| </Button> |
| <div className="hidden lg:block"> |
| {editorMode === PromptsEditorMode.SIMPLE && ( |
| <RightPanel |
| group={group} |
| prompts={prompts} |
| selectedPrompt={selectedPrompt} |
| selectionIndex={selectionIndex} |
| selectedPromptId={selectedPromptId} |
| isLoadingPrompts={isLoadingPrompts} |
| canEdit={canEdit} |
| setSelectionIndex={setSelectionIndex} |
| /> |
| )} |
| </div> |
| </> |
| )} |
| </div> |
| {isLoadingPrompts ? ( |
| <Skeleton className="h-96" aria-live="polite" /> |
| ) : ( |
| <div className="mb-2 flex h-full flex-col gap-4"> |
| <PromptEditor |
| name="prompt" |
| isEditing={isEditing} |
| setIsEditing={(value) => canEdit && setIsEditing(value)} |
| /> |
| <PromptVariables promptText={promptText} /> |
| <Description |
| initialValue={group.oneliner ?? ''} |
| onValueChange={canEdit ? handleUpdateOneliner : undefined} |
| disabled={!canEdit} |
| /> |
| <Command |
| initialValue={group.command ?? ''} |
| onValueChange={canEdit ? handleUpdateCommand : undefined} |
| disabled={!canEdit} |
| /> |
| </div> |
| )} |
| </div> |
| |
| {editorMode === PromptsEditorMode.ADVANCED && ( |
| <div className="hidden w-1/4 border-l border-border-light lg:block"> |
| <RightPanel |
| group={group} |
| prompts={prompts} |
| selectionIndex={selectionIndex} |
| selectedPrompt={selectedPrompt} |
| selectedPromptId={selectedPromptId} |
| isLoadingPrompts={isLoadingPrompts} |
| canEdit={canEdit} |
| setSelectionIndex={setSelectionIndex} |
| /> |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <button |
| type="button" |
| className={cn( |
| 'absolute inset-0 z-40 cursor-default', |
| showSidePanel ? 'opacity-100' : 'pointer-events-none opacity-0', |
| )} |
| style={{ transition: 'opacity 0.3s ease-in-out' }} |
| onClick={() => setShowSidePanel(false)} |
| aria-hidden={!showSidePanel} |
| tabIndex={showSidePanel ? 0 : -1} |
| aria-label={localize('com_ui_close_menu')} |
| /> |
| <div |
| className="absolute inset-y-0 right-0 z-50 lg:hidden" |
| style={{ |
| width: sidePanelWidth, |
| transform: `translateX(${showSidePanel ? '0' : '100%'})`, |
| transition: 'transform 0.3s ease-in-out', |
| }} |
| role="dialog" |
| aria-modal="true" |
| aria-label="Mobile navigation panel" |
| > |
| <div className="h-full"> |
| <div className="h-full overflow-auto"> |
| <RightPanel |
| group={group} |
| prompts={prompts} |
| selectionIndex={selectionIndex} |
| selectedPrompt={selectedPrompt} |
| selectedPromptId={selectedPromptId} |
| isLoadingPrompts={isLoadingPrompts} |
| canEdit={canEdit} |
| setSelectionIndex={setSelectionIndex} |
| /> |
| </div> |
| </div> |
| </div> |
| </div> |
| </form> |
| </FormProvider> |
| ); |
| }; |
|
|
| export default PromptForm; |
|
|