LibreChat/client/src/components/Prompts/PromptForm.tsx
2025-09-18 10:00:33 -04:00

529 lines
17 KiB
TypeScript

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) {
// TODO: show toast, cannot be empty.
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;
}
// We're adding to an existing group, so use the addPromptToGroup mutation
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 />;
}
// Show read-only view if user doesn't have edit permission
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;