mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-08 19:48:51 +01:00
📧 feat: Mention "@" Command Popover (#2635)
* feat: initial mockup * wip: activesetting, may use or not use * wip: mention with useCombobox usage * feat: connect textarea to new mention popover * refactor: consolidate icon logic for Landing/convos * refactor: cleanup URL logic * refactor(useTextarea): key up handler * wip: render desired mention options * refactor: improve mention detection * feat: modular chat the default option * WIP: first pass mention selection * feat: scroll mention items with keypad * chore(showMentionPopoverFamily): add typing to atomFamily * feat: removeAtSymbol * refactor(useListAssistantsQuery): use defaultOrderQuery as default param * feat: assistants mentioning * fix conversation switch errors * filter mention selections based on startup settings and available endpoints * fix: mentions model spec icon URL * style: archive icon * fix: convo renaming behavior on click * fix(Convo): toggle hover state * style: EditMenu refactor * fix: archive chats table * fix: errorsToString import * chore: remove comments * chore: remove comment * feat: mention descriptions * refactor: make sure continue hover button is always last, add correct fork button alt text
This commit is contained in:
parent
89b1e33be0
commit
b6d6343f54
35 changed files with 1048 additions and 217 deletions
8
client/src/components/Chat/Input/ActiveSetting.tsx
Normal file
8
client/src/components/Chat/Input/ActiveSetting.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default function ActiveSetting() {
|
||||
return (
|
||||
<div className="text-token-text-tertiary space-x-2 overflow-hidden text-ellipsis text-sm font-light">
|
||||
Talking to{' '}
|
||||
<span className="text-token-text-secondary font-medium">[latest] Tailwind CSS GPT</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,23 +17,28 @@ import { mainTextareaId } from '~/common';
|
|||
import StopButton from './StopButton';
|
||||
import SendButton from './SendButton';
|
||||
import FileRow from './Files/FileRow';
|
||||
import Mention from './Mention';
|
||||
import store from '~/store';
|
||||
|
||||
const ChatForm = ({ index = 0 }) => {
|
||||
const submitButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
|
||||
const [showMentionPopover, setShowMentionPopover] = useRecoilState(
|
||||
store.showMentionPopoverFamily(index),
|
||||
);
|
||||
const { requiresKey } = useRequiresKey();
|
||||
|
||||
const methods = useForm<{ text: string }>({
|
||||
defaultValues: { text: '' },
|
||||
});
|
||||
|
||||
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
disabled: !!requiresKey,
|
||||
});
|
||||
const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } =
|
||||
useTextarea({
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
disabled: !!requiresKey,
|
||||
});
|
||||
|
||||
const {
|
||||
ask,
|
||||
|
|
@ -92,6 +97,9 @@ const ChatForm = ({ index = 0 }) => {
|
|||
>
|
||||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||
<div className="flex w-full items-center">
|
||||
{showMentionPopover && (
|
||||
<Mention setShowMentionPopover={setShowMentionPopover} textAreaRef={textAreaRef} />
|
||||
)}
|
||||
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-medium 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)]:shadow-[0_2px_6px_rgba(0,0,0,.05)] dark:[&:has(textarea:focus)]:border-gray-500">
|
||||
<FileRow
|
||||
files={files}
|
||||
|
|
@ -114,6 +122,7 @@ const ChatForm = ({ index = 0 }) => {
|
|||
disabled={disableInputs}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
id={mainTextareaId}
|
||||
|
|
|
|||
148
client/src/components/Chat/Input/Mention.tsx
Normal file
148
client/src/components/Chat/Input/Mention.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { MentionOption } from '~/common';
|
||||
import { useAssistantsMapContext } from '~/Providers';
|
||||
import useMentions from '~/hooks/Input/useMentions';
|
||||
import { useLocalize, useCombobox } from '~/hooks';
|
||||
import { removeAtSymbolIfLast } from '~/utils';
|
||||
import MentionItem from './MentionItem';
|
||||
|
||||
export default function Mention({
|
||||
setShowMentionPopover,
|
||||
textAreaRef,
|
||||
}: {
|
||||
setShowMentionPopover: SetterOrUpdater<boolean>;
|
||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { options, modelsConfig, assistants, onSelectMention } = useMentions({ assistantMap });
|
||||
|
||||
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) {
|
||||
removeAtSymbolIfLast(textAreaRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) {
|
||||
setSearchValue('');
|
||||
setInputOptions(assistants);
|
||||
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(() => {
|
||||
const currentActiveItem = document.getElementById(`mention-item-${activeIndex}`);
|
||||
currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-16 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
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
placeholder={localize('com_ui_mention')}
|
||||
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[0] 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 overflow-y-auto">
|
||||
{(matches as MentionOption[]).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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
client/src/components/Chat/Input/MentionItem.tsx
Normal file
46
client/src/components/Chat/Input/MentionItem.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { Clock4 } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function MentionItem({
|
||||
name,
|
||||
onClick,
|
||||
index,
|
||||
icon,
|
||||
isActive,
|
||||
description,
|
||||
}: {
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
index: number;
|
||||
icon?: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div tabIndex={index} onClick={onClick} id={`mention-item-${index}`} className="cursor-pointer">
|
||||
<div
|
||||
className={cn(
|
||||
'hover:bg-token-main-surface-secondary text-token-text-primary bg-token-main-surface-secondary group flex h-10 items-center gap-2 rounded-lg px-2 text-sm font-medium dark:hover:bg-gray-600',
|
||||
index === 0 ? 'dark:bg-gray-600' : '',
|
||||
isActive ? 'dark:bg-gray-600' : '',
|
||||
)}
|
||||
>
|
||||
{icon ? icon : null}
|
||||
<div className="flex h-fit grow flex-row justify-between space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<span className="shrink-0 truncate">{name}</span>
|
||||
{description ? (
|
||||
<span className="text-token-text-tertiary flex-grow truncate text-sm font-light sm:max-w-xs lg:max-w-md">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 self-center">
|
||||
<Clock4 size={16} className="icon-sm" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue