🗨️ feat: Prompt Slash Commands (#3219)

* chore: Update prompt description placeholder text

* fix: promptsPathPattern to not include new

* feat: command input and styling change for prompt views

* fix: intended validation

* feat: prompts slash command

* chore: localizations and fix add command during creation

* refactor(PromptsCommand): better label

* feat: update `allPrompGroups` cache on all promptGroups mutations

* refactor: ensure assistants builder is first within sidepanel

* refactor: allow defining emailVerified via create-user script
This commit is contained in:
Danny Avila 2024-06-27 17:34:48 -04:00 committed by GitHub
parent b8f2bee3fc
commit 83619de158
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 764 additions and 80 deletions

View file

@ -23,6 +23,7 @@ import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusRings } from '~/utils';
import TextareaHeader from './TextareaHeader';
import PromptsCommand from './PromptsCommand';
import AttachFile from './Files/AttachFile';
import AudioRecorder from './AudioRecorder';
import { mainTextareaId } from '~/common';
@ -48,7 +49,12 @@ const ChatForm = ({ index = 0 }) => {
);
const { requiresKey } = useRequiresKey();
const handleKeyUp = useHandleKeyUp({ textAreaRef, setShowPlusPopover, setShowMentionPopover });
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
@ -83,7 +89,7 @@ const ChatForm = ({ index = 0 }) => {
});
const assistantMap = useAssistantsMapContext();
const { submitMessage } = useSubmitMessage({ clearDraft });
const { submitMessage, submitPrompt } = useSubmitMessage({ clearDraft });
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint;
@ -136,6 +142,7 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef={textAreaRef}
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="bg-token-main-surface-primary relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border dark:border-gray-600 dark:text-white [&:has(textarea:focus)]:border-gray-300 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileRow

View file

@ -0,0 +1,231 @@
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
import type { TPromptGroup } from 'librechat-data-provider';
import type { PromptOption } from '~/common';
import { removeCharIfLast, mapPromptGroups, detectVariables } from '~/utils';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useGetAllPromptGroups } from '~/data-provider';
import { useLocalize, useCombobox } from '~/hooks';
import { Spinner } from '~/components/svg';
import MentionItem from './MentionItem';
import store from '~/store';
const commandChar = '/';
const PopoverContainer = memo(
({
index,
children,
isVariableDialogOpen,
variableGroup,
setVariableDialogOpen,
}: {
index: number;
children: React.ReactNode;
isVariableDialogOpen: boolean;
variableGroup: TPromptGroup | null;
setVariableDialogOpen: (isOpen: boolean) => void;
}) => {
const showPromptsPopover = useRecoilValue(store.showPromptsPopoverFamily(index));
return (
<>
{showPromptsPopover ? children : null}
<VariableDialog
open={isVariableDialogOpen}
onClose={() => setVariableDialogOpen(false)}
group={variableGroup}
/>
</>
);
},
);
function PromptsCommand({
index,
textAreaRef,
submitPrompt,
}: {
index: number;
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
submitPrompt: (textPrompt: string) => void;
}) {
const localize = useLocalize();
const { data, isLoading } = useGetAllPromptGroups(undefined, {
select: (data) => {
const mappedArray = data.map((group) => ({
id: group._id,
value: group.command ?? group.name,
label: `${group.command ? `/${group.command} - ` : ''}${group.name}: ${
group.oneliner?.length ? group.oneliner : group.productionPrompt?.prompt ?? ''
}`,
icon: <CategoryIcon category={group.category ?? ''} />,
}));
const promptsMap = mapPromptGroups(data);
return {
promptsMap,
promptGroups: mappedArray,
};
},
});
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
const [variableGroup, setVariableGroup] = useState<TPromptGroup | null>(null);
const setShowPromptsPopover = useSetRecoilState(store.showPromptsPopoverFamily(index));
const prompts = useMemo(() => data?.promptGroups ?? [], [data]);
const promptsMap = useMemo(() => data?.promptsMap ?? {}, [data]);
const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({
value: '',
options: prompts,
});
const handleSelect = useCallback(
(mention?: PromptOption, e?: React.KeyboardEvent<HTMLInputElement>) => {
if (!mention) {
return;
}
setSearchValue('');
setOpen(false);
setShowPromptsPopover(false);
if (textAreaRef.current) {
removeCharIfLast(textAreaRef.current, commandChar);
}
const isValidPrompt = mention && promptsMap && promptsMap[mention.id];
if (!isValidPrompt) {
return;
}
const group = promptsMap[mention.id];
const hasVariables = detectVariables(group?.productionPrompt?.prompt ?? '');
if (group && hasVariables) {
if (e && e.key === 'Tab') {
e.preventDefault();
}
setVariableGroup(group);
setVariableDialogOpen(true);
return;
} else if (group) {
submitPrompt(group.productionPrompt?.prompt ?? '');
}
},
[setSearchValue, setOpen, setShowPromptsPopover, textAreaRef, promptsMap, submitPrompt],
);
useEffect(() => {
if (!open) {
setActiveIndex(0);
} else {
setVariableGroup(null);
}
}, [open]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
const currentActiveItem = document.getElementById(`prompt-item-${activeIndex}`);
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}, [activeIndex]);
return (
<PopoverContainer
index={index}
isVariableDialogOpen={isVariableDialogOpen}
variableGroup={variableGroup}
setVariableDialogOpen={setVariableDialogOpen}
>
<div className="absolute bottom-16 z-10 w-full space-y-2">
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}
className="mb-1 w-full border-0 bg-surface-tertiary-alt p-2 text-sm focus:outline-none dark:text-gray-200"
autoComplete="off"
value={searchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOpen(false);
setShowPromptsPopover(false);
textAreaRef.current?.focus();
}
if (e.key === 'ArrowDown') {
setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (e.key === 'Enter') {
e.preventDefault();
}
handleSelect(matches[activeIndex] as PromptOption | undefined, e);
} else if (e.key === 'Backspace' && searchValue === '') {
setOpen(false);
setShowPromptsPopover(false);
textAreaRef.current?.focus();
}
}}
onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
setShowPromptsPopover(false);
}, 150);
}}
/>
<div className="max-h-40 overflow-y-auto">
{(() => {
if (isLoading && open) {
return (
<div className="flex h-32 items-center justify-center text-text-primary">
<Spinner />
</div>
);
}
if (!isLoading && open) {
return (matches as PromptOption[]).map((mention, index) => (
<MentionItem
index={index}
key={`${mention.value}-${index}`}
onClick={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = null;
handleSelect(mention);
}}
name={mention.label ?? ''}
icon={mention.icon}
description={mention.description}
isActive={index === activeIndex}
/>
));
}
return null;
})()}
</div>
</div>
</div>
</PopoverContainer>
);
}
export default memo(PromptsCommand);

View file

@ -0,0 +1,66 @@
import { SquareSlash } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useState, useEffect } from 'react';
import { useLocalize } from '~/hooks';
const Command = ({
initialValue,
onValueChange,
disabled,
tabIndex,
}: {
initialValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
tabIndex?: number;
}) => {
const localize = useLocalize();
const [command, setCommand] = useState(initialValue || '');
const [charCount, setCharCount] = useState(initialValue?.length || 0);
useEffect(() => {
setCommand(initialValue || '');
setCharCount(initialValue?.length || 0);
}, [initialValue]);
useEffect(() => {
setCharCount(command.length);
}, [command]);
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
let newValue = e.target.value.toLowerCase();
newValue = newValue.replace(/\s/g, '-').replace(/[^a-z0-9-]/g, '');
if (newValue.length <= Constants.COMMANDS_MAX_LENGTH) {
setCommand(newValue);
onValueChange?.(newValue);
}
};
if (disabled && !command) {
return null;
}
return (
<div className="rounded-lg border border-border-medium">
<h3 className="flex h-10 items-center gap-2 pl-4 text-sm text-text-secondary">
<SquareSlash className="icon-sm" />
<input
type="text"
tabIndex={tabIndex}
disabled={disabled}
placeholder={localize('com_ui_command_placeholder')}
value={command}
onChange={handleInputChange}
className="w-full rounded-lg border-none bg-surface-tertiary p-1 text-text-primary placeholder:text-text-secondary-alt focus:bg-surface-tertiary focus:outline-none focus:ring-0 md:w-96"
/>
{!disabled && (
<span className="mr-1 w-10 text-xs text-text-tertiary md:text-sm">{`${charCount}/${Constants.COMMANDS_MAX_LENGTH}`}</span>
)}
</h3>
</div>
);
};
export default Command;

View file

@ -6,8 +6,9 @@ import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description';
import { useCreatePrompt } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
type CreateFormValues = {
@ -16,6 +17,7 @@ type CreateFormValues = {
type: 'text' | 'chat';
category: string;
oneliner?: string;
command?: string;
};
const defaultPrompt: CreateFormValues = {
@ -24,6 +26,7 @@ const defaultPrompt: CreateFormValues = {
type: 'text',
category: '',
oneliner: undefined,
command: undefined,
};
const CreatePromptForm = ({
@ -73,14 +76,17 @@ const CreatePromptForm = ({
const promptText = watch('prompt');
const onSubmit = (data: CreateFormValues) => {
const { name, category, oneliner, ...rest } = data;
const { name, category, oneliner, command, ...rest } = data;
const groupData = { name, category } as Pick<
CreateFormValues,
'name' | 'category' | 'oneliner'
'name' | 'category' | 'oneliner' | 'command'
>;
if ((oneliner?.length || 0) > 0) {
groupData.oneliner = oneliner;
}
if ((command?.length || 0) > 0) {
groupData.command = command;
}
createPromptMutation.mutate({
prompt: rest,
group: groupData,
@ -121,15 +127,15 @@ const CreatePromptForm = ({
</div>
)}
/>
<CategorySelector tabIndex={4} />
<CategorySelector tabIndex={5} />
</div>
</div>
<div className="w-full md:mt-[1.075rem]">
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 pr-1 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_prompt_text')}*
</h2>
<div className="mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<div className="min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<Controller
name="prompt"
control={control}
@ -159,9 +165,10 @@ const CreatePromptForm = ({
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={3}
/>
<Command onValueChange={(value) => methods.setValue('command', value)} tabIndex={4} />
<div className="mt-4 flex justify-end">
<Button
tabIndex={5}
tabIndex={6}
type="submit"
variant="default"
disabled={!isDirty || isSubmitting || !isValid}

View file

@ -7,7 +7,7 @@ import VariableForm from './VariableForm';
interface VariableDialogProps extends Omit<DialogPrimitive.DialogProps, 'onOpenChange'> {
onClose: () => void;
group: TPromptGroup;
group: TPromptGroup | null;
}
const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group }) => {
@ -18,9 +18,13 @@ const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group })
};
const hasVariables = useMemo(
() => detectVariables(group.productionPrompt?.prompt ?? ''),
[group.productionPrompt?.prompt],
() => detectVariables(group?.productionPrompt?.prompt ?? ''),
[group?.productionPrompt?.prompt],
);
if (!group) {
return null;
}
if (!hasVariables) {
return null;
}

View file

@ -3,6 +3,7 @@ import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import Description from './Description';
import { useLocalize } from '~/hooks';
import Command from './Command';
const PromptDetails = ({ group }: { group: TPromptGroup }) => {
const localize = useLocalize();
@ -27,17 +28,18 @@ const PromptDetails = ({ group }: { group: TPromptGroup }) => {
</div>
</div>
<div className="flex h-full w-full flex-col md:flex-row">
<div className="flex-1 overflow-y-auto border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-4">
<div className="flex flex-1 flex-col gap-4 overflow-y-auto border-gray-300 p-0 dark:border-gray-600 md:max-h-[calc(100vh-150px)] md:p-4">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-gray-300 py-2 pl-4 text-base font-semibold dark:border-gray-600 dark:text-gray-200">
{localize('com_ui_prompt_text')}
</h2>
<div className="group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<div className="group relative min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 dark:border-gray-600">
<span className="block break-words px-2 py-1 dark:text-gray-200">{promptText}</span>
</div>
</div>
<PromptVariables promptText={promptText} />
<Description initialValue={group.oneliner} disabled={true} />
<Command initialValue={group.command} disabled={true} />
</div>
</div>
</div>

View file

@ -50,7 +50,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
</h2>
<div
className={cn(
'group relative mb-4 min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 hover:opacity-90 dark:border-gray-600',
'group relative min-h-32 rounded-b-lg border border-gray-300 p-4 transition-all duration-150 hover:opacity-90 dark:border-gray-600',
{ 'cursor-pointer hover:bg-gray-100/50 dark:hover:bg-gray-100/10': !isEditing },
)}
onClick={() => !isEditing && setIsEditing(true)}

View file

@ -14,12 +14,13 @@ import {
useUpdatePromptGroup,
useMakePromptProduction,
} from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess } from '~/hooks';
import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import CategorySelector from './Groups/CategorySelector';
import AlwaysMakeProd from './Groups/AlwaysMakeProd';
import NoPromptGroup from './Groups/NoPromptGroup';
import { Button, Skeleton } from '~/components/ui';
import PromptVariables from './PromptVariables';
import { useToastContext } from '~/Providers';
import PromptVersions from './PromptVersions';
import DeleteConfirm from './DeleteVersion';
import PromptDetails from './PromptDetails';
@ -29,6 +30,7 @@ import SkeletonForm from './SkeletonForm';
import Description from './Description';
import SharePrompt from './SharePrompt';
import PromptName from './PromptName';
import Command from './Command';
import store from '~/store';
const { PromptsEditorMode, promptsEditorMode } = store;
@ -36,7 +38,10 @@ const { PromptsEditorMode, promptsEditorMode } = store;
const PromptForm = () => {
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const editorMode = useRecoilValue(promptsEditorMode);
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(params.promptId || '');
@ -101,7 +106,14 @@ const PromptForm = () => {
setSelectionIndex(0);
},
});
const updateGroupMutation = useUpdatePromptGroup();
const updateGroupMutation = useUpdatePromptGroup({
onError: () => {
showToast({
status: 'error',
message: localize('com_ui_prompt_update_error'),
});
},
});
const makeProductionMutation = useMakePromptProduction();
const deletePromptMutation = useDeletePrompt({
onSuccess: (response) => {
@ -175,6 +187,17 @@ const PromptForm = () => {
[updateGroupMutation, group],
);
const debouncedUpdateCommand = useCallback(
debounce((command: string) => {
if (!group) {
return console.warn('Group not found');
}
updateGroupMutation.mutate({ id: group._id || '', payload: { command } });
}, 950),
[updateGroupMutation, group],
);
const { groupsQuery } = useOutletContext<ReturnType<typeof usePromptGroupsNav>>();
if (initialLoad) {
@ -282,14 +305,18 @@ const PromptForm = () => {
{isLoadingPrompts ? (
<Skeleton className="h-96" />
) : (
<>
<div className="flex flex-col gap-4">
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptVariables promptText={promptText} />
<Description
initialValue={group?.oneliner ?? ''}
onValueChange={debouncedUpdateOneliner}
/>
</>
<Command
initialValue={group?.command ?? ''}
onValueChange={debouncedUpdateCommand}
/>
</div>
)}
</div>
{/* Right Section */}

View file

@ -20,12 +20,12 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
}, [promptText]);
return (
<>
<div>
<h3 className="flex items-center gap-2 rounded-t-lg border border-border-medium py-2 pl-4 text-base font-semibold text-text-secondary">
<Variable className="icon-sm" />
{localize('com_ui_variables')}
</h3>
<div className="mb-4 flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4 md:min-h-16">
<div className="flex w-full flex-row flex-wrap rounded-b-lg border border-border-medium p-4 md:min-h-16">
{variables.length ? (
<div className="flex h-7 items-center">
{variables.map((variable, index) => (
@ -52,7 +52,7 @@ const PromptVariables = ({ promptText }: { promptText: string }) => {
{localize('com_ui_special_variables')}
</span>
</div>
</>
</div>
);
};