| import { useState, useRef, useEffect } from 'react'; |
| import { useCombobox } from '@librechat/client'; |
| import { AutoSizer, List } from 'react-virtualized'; |
| import { EModelEndpoint } from 'librechat-data-provider'; |
| import type { TConversation } from 'librechat-data-provider'; |
| import type { MentionOption, ConvoGenerator } from '~/common'; |
| import type { SetterOrUpdater } from 'recoil'; |
| import useSelectMention from '~/hooks/Input/useSelectMention'; |
| import { useLocalize, TranslationKeys } from '~/hooks'; |
| import { useAssistantsMapContext } from '~/Providers'; |
| import useMentions from '~/hooks/Input/useMentions'; |
| import { removeCharIfLast } from '~/utils'; |
| import MentionItem from './MentionItem'; |
|
|
| const ROW_HEIGHT = 40; |
|
|
| export default function Mention({ |
| conversation, |
| setShowMentionPopover, |
| newConversation, |
| textAreaRef, |
| commandChar = '@', |
| placeholder = 'com_ui_mention', |
| includeAssistants = true, |
| }: { |
| conversation: TConversation | null; |
| setShowMentionPopover: SetterOrUpdater<boolean>; |
| newConversation: ConvoGenerator; |
| textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>; |
| commandChar?: string; |
| placeholder?: TranslationKeys; |
| includeAssistants?: boolean; |
| }) { |
| const localize = useLocalize(); |
| const assistantsMap = useAssistantsMapContext(); |
| const { |
| options, |
| presets, |
| modelSpecs, |
| agentsList, |
| modelsConfig, |
| endpointsConfig, |
| assistantListMap, |
| } = useMentions({ assistantMap: assistantsMap || {}, includeAssistants }); |
| const { onSelectMention } = useSelectMention({ |
| presets, |
| modelSpecs, |
| conversation, |
| assistantsMap, |
| endpointsConfig, |
| newConversation, |
| }); |
|
|
| const [activeIndex, setActiveIndex] = useState(0); |
| const timeoutRef = useRef<NodeJS.Timeout | null>(null); |
| const inputRef = useRef<HTMLInputElement | null>(null); |
| const [inputOptions, setInputOptions] = useState<MentionOption[]>(options); |
|
|
| const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({ |
| value: '', |
| options: inputOptions, |
| }); |
|
|
| const handleSelect = (mention?: MentionOption) => { |
| if (!mention) { |
| return; |
| } |
|
|
| const defaultSelect = () => { |
| setSearchValue(''); |
| setOpen(false); |
| setShowMentionPopover(false); |
| onSelectMention?.(mention); |
|
|
| if (textAreaRef.current) { |
| removeCharIfLast(textAreaRef.current, commandChar); |
| } |
| }; |
|
|
| if (mention.type === 'endpoint' && mention.value === EModelEndpoint.agents) { |
| setSearchValue(''); |
| setInputOptions(agentsList ?? []); |
| setActiveIndex(0); |
| inputRef.current?.focus(); |
| } else if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) { |
| setSearchValue(''); |
| setInputOptions(assistantListMap[EModelEndpoint.assistants] ?? []); |
| setActiveIndex(0); |
| inputRef.current?.focus(); |
| } else if (mention.type === 'endpoint' && mention.value === EModelEndpoint.azureAssistants) { |
| setSearchValue(''); |
| setInputOptions(assistantListMap[EModelEndpoint.azureAssistants] ?? []); |
| setActiveIndex(0); |
| inputRef.current?.focus(); |
| } else if (mention.type === 'endpoint') { |
| const models = (modelsConfig?.[mention.value || ''] ?? []).map((model) => ({ |
| value: mention.value, |
| label: model, |
| type: 'model', |
| })); |
|
|
| setActiveIndex(0); |
| setSearchValue(''); |
| setInputOptions(models); |
| inputRef.current?.focus(); |
| } else { |
| defaultSelect(); |
| } |
| }; |
|
|
| useEffect(() => { |
| if (!open) { |
| setInputOptions(options); |
| setActiveIndex(0); |
| } |
| }, [open, options]); |
|
|
| useEffect(() => { |
| return () => { |
| if (timeoutRef.current) { |
| clearTimeout(timeoutRef.current); |
| } |
| }; |
| }, []); |
|
|
| const type = commandChar !== '@' ? 'add-convo' : 'mention'; |
| useEffect(() => { |
| const currentActiveItem = document.getElementById(`${type}-item-${activeIndex}`); |
| currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); |
| }, [type, activeIndex]); |
|
|
| const rowRenderer = ({ |
| index, |
| key, |
| style, |
| }: { |
| index: number; |
| key: string; |
| style: React.CSSProperties; |
| }) => { |
| const mention = matches[index] as MentionOption; |
| return ( |
| <MentionItem |
| type={type} |
| index={index} |
| key={key} |
| style={style} |
| onClick={(e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| if (timeoutRef.current) { |
| clearTimeout(timeoutRef.current); |
| } |
| timeoutRef.current = null; |
| handleSelect(mention); |
| }} |
| name={mention.label ?? ''} |
| icon={mention.icon} |
| description={mention.description} |
| isActive={index === activeIndex} |
| /> |
| ); |
| }; |
|
|
| return ( |
| <div className="absolute bottom-28 z-10 w-full space-y-2"> |
| <div className="popover border-token-border-light rounded-2xl border bg-white p-2 shadow-lg dark:bg-gray-700"> |
| <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(placeholder)} |
| className="mb-1 w-full border-0 bg-white p-2 text-sm focus:outline-none dark:bg-gray-700 dark:text-gray-200" |
| autoComplete="off" |
| value={searchValue} |
| onKeyDown={(e) => { |
| if (e.key === 'Escape') { |
| setOpen(false); |
| setShowMentionPopover(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') { |
| const mentionOption = matches[activeIndex] as MentionOption | undefined; |
| if (mentionOption?.type === 'endpoint') { |
| e.preventDefault(); |
| } else if (e.key === 'Enter') { |
| e.preventDefault(); |
| } |
| handleSelect(matches[activeIndex] as MentionOption); |
| } else if (e.key === 'Backspace' && searchValue === '') { |
| setOpen(false); |
| setShowMentionPopover(false); |
| textAreaRef.current?.focus(); |
| } |
| }} |
| onChange={(e) => setSearchValue(e.target.value)} |
| onFocus={() => setOpen(true)} |
| onBlur={() => { |
| timeoutRef.current = setTimeout(() => { |
| setOpen(false); |
| setShowMentionPopover(false); |
| }, 150); |
| }} |
| /> |
| {open && ( |
| <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> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|