| import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react'; | |
| import { AutoSizer, List } from 'react-virtualized'; | |
| import { Spinner, useCombobox } from '@librechat/client'; | |
| import { useSetRecoilState, useRecoilValue } from 'recoil'; | |
| import type { TPromptGroup } from 'librechat-data-provider'; | |
| import type { PromptOption } from '~/common'; | |
| import { removeCharIfLast, detectVariables } from '~/utils'; | |
| import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; | |
| import { usePromptGroupsContext } from '~/Providers'; | |
| import MentionItem from './MentionItem'; | |
| import { useLocalize } from '~/hooks'; | |
| 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} | |
| /> | |
| </> | |
| ); | |
| }, | |
| ); | |
| const ROW_HEIGHT = 40; | |
| function PromptsCommand({ | |
| index, | |
| textAreaRef, | |
| submitPrompt, | |
| }: { | |
| index: number; | |
| textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>; | |
| submitPrompt: (textPrompt: string) => void; | |
| }) { | |
| const localize = useLocalize(); | |
| const { allPromptGroups, hasAccess } = usePromptGroupsContext(); | |
| const { data, isLoading } = allPromptGroups; | |
| 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 group = promptsMap?.[mention.id]; | |
| if (!group) { | |
| return; | |
| } | |
| const hasVariables = detectVariables(group.productionPrompt?.prompt ?? ''); | |
| if (hasVariables) { | |
| if (e && e.key === 'Tab') { | |
| e.preventDefault(); | |
| } | |
| setVariableGroup(group); | |
| setVariableDialogOpen(true); | |
| return; | |
| } else { | |
| 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]); | |
| if (!hasAccess) { | |
| return null; | |
| } | |
| const rowRenderer = ({ | |
| index, | |
| key, | |
| style, | |
| }: { | |
| index: number; | |
| key: string; | |
| style: React.CSSProperties; | |
| }) => { | |
| const mention = matches[index] as PromptOption; | |
| return ( | |
| <MentionItem | |
| index={index} | |
| type="prompt" | |
| key={key} | |
| style={style} | |
| 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 ( | |
| <PopoverContainer | |
| index={index} | |
| isVariableDialogOpen={isVariableDialogOpen} | |
| variableGroup={variableGroup} | |
| setVariableDialogOpen={setVariableDialogOpen} | |
| > | |
| <div className="absolute bottom-28 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 | |
| // The user expects focus to transition to the input field when the popover is opened | |
| // eslint-disable-next-line jsx-a11y/no-autofocus | |
| 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 ( | |
| <div className="max-h-40"> | |
| <AutoSizer disableHeight> | |
| {({ width }) => ( | |
| <List | |
| width={width} | |
| overscanRowCount={5} | |
| rowHeight={ROW_HEIGHT} | |
| rowCount={matches.length} | |
| rowRenderer={rowRenderer} | |
| scrollToIndex={activeIndex} | |
| height={Math.min(matches.length * ROW_HEIGHT, 160)} | |
| /> | |
| )} | |
| </AutoSizer> | |
| </div> | |
| ); | |
| } | |
| return null; | |
| })()} | |
| </div> | |
| </div> | |
| </div> | |
| </PopoverContainer> | |
| ); | |
| } | |
| export default memo(PromptsCommand); | |