diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 8b91a9ac3a..b4accee5ac 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -1,6 +1,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); -const { registerSchema, errorsToString } = require('~/strategies/validators'); +const { errorsToString } = require('librechat-data-provider'); +const { registerSchema } = require('~/strategies/validators'); const isDomainAllowed = require('./isDomainAllowed'); const Token = require('~/models/schema/tokenSchema'); const { sendEmail } = require('~/server/utils'); diff --git a/client/src/common/types.ts b/client/src/common/types.ts index e76b79bdb2..d8f408ab87 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -324,6 +324,7 @@ export type Option = Record & { }; export type OptionWithIcon = Option & { icon?: React.ReactNode }; +export type MentionOption = OptionWithIcon & { type: string; value: string; description?: string }; export type TOptionSettings = { showExamples?: boolean; diff --git a/client/src/components/Chat/Input/ActiveSetting.tsx b/client/src/components/Chat/Input/ActiveSetting.tsx new file mode 100644 index 0000000000..24f8791ffa --- /dev/null +++ b/client/src/components/Chat/Input/ActiveSetting.tsx @@ -0,0 +1,8 @@ +export default function ActiveSetting() { + return ( +
+ Talking to{' '} + [latest] Tailwind CSS GPT +
+ ); +} diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 69e4819a99..16343cd0e3 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -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(null); const textAreaRef = useRef(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 }) => { >
+ {showMentionPopover && ( + + )}
{ disabled={disableInputs} onPaste={handlePaste} onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} id={mainTextareaId} diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx new file mode 100644 index 0000000000..582dd83464 --- /dev/null +++ b/client/src/components/Chat/Input/Mention.tsx @@ -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; + textAreaRef: React.MutableRefObject; +}) { + const localize = useLocalize(); + const assistantMap = useAssistantsMapContext(); + const { options, modelsConfig, assistants, onSelectMention } = useMentions({ assistantMap }); + + const [activeIndex, setActiveIndex] = useState(0); + const timeoutRef = useRef(null); + const inputRef = useRef(null); + const [inputOptions, setInputOptions] = useState(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 ( +
+
+ { + 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 && ( +
+ {(matches as MentionOption[]).map((mention, index) => ( + { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = null; + handleSelect(mention); + }} + name={mention.label ?? ''} + icon={mention.icon} + description={mention.description} + isActive={index === activeIndex} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/components/Chat/Input/MentionItem.tsx b/client/src/components/Chat/Input/MentionItem.tsx new file mode 100644 index 0000000000..ce88ba60e8 --- /dev/null +++ b/client/src/components/Chat/Input/MentionItem.tsx @@ -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 ( +
+
+ {icon ? icon : null} +
+
+ {name} + {description ? ( + + {description} + + ) : null} +
+ + + +
+
+
+ ); +} diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index b3a53cbce5..22f2cee022 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -2,11 +2,10 @@ import { EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query'; import type { ReactNode } from 'react'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui'; -import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils'; import { useChatContext, useAssistantsMapContext } from '~/Providers'; -import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; -import { icons } from './Menus/Endpoints/Icons'; +import ConvoIcon from '~/components/Endpoints/ConvoIcon'; import { BirthdayIcon } from '~/components/svg'; +import { getIconEndpoint, cn } from '~/utils'; import { useLocalize } from '~/hooks'; export default function Landing({ Header }: { Header?: ReactNode }) { @@ -31,52 +30,35 @@ export default function Landing({ Header }: { Header?: ReactNode }) { const iconURL = conversation?.iconURL; endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); - const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); - const Icon = icons[iconKey]; - const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? '']; const assistantName = (assistant && assistant?.name) || ''; const assistantDesc = (assistant && assistant?.description) || ''; const avatar = (assistant && (assistant?.metadata?.avatar as string)) || ''; - let className = + const containerClassName = 'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black'; - if (assistantName && avatar) { - className = 'shadow-stroke overflow-hidden rounded-full'; - } - return (
{Header && Header}
-
- {iconURL && iconURL.includes('http') ? ( - - ) : ( -
- {endpoint && - Icon && - Icon({ - size: 41, - context: 'landing', - className: 'h-2/3 w-2/3', - iconURL: endpointIconURL, - assistantName, - endpoint, - avatar, - })} -
+
+ {(startupConfig?.showBirthdayIcon ?? false) && ( diff --git a/client/src/components/Chat/Menus/Endpoints/Icons.tsx b/client/src/components/Chat/Menus/Endpoints/Icons.tsx index c77edfdf57..4a700bd977 100644 --- a/client/src/components/Chat/Menus/Endpoints/Icons.tsx +++ b/client/src/components/Chat/Menus/Endpoints/Icons.tsx @@ -29,7 +29,7 @@ export const icons = { return ( {assistantName} ) : null} + {continueSupported ? ( ) : null} -
); } diff --git a/client/src/components/Conversations/ArchiveButton.tsx b/client/src/components/Conversations/ArchiveButton.tsx index ad535ca57b..695a452847 100644 --- a/client/src/components/Conversations/ArchiveButton.tsx +++ b/client/src/components/Conversations/ArchiveButton.tsx @@ -59,7 +59,7 @@ export default function ArchiveButton({ ); }; const classProp: { className?: string } = { - className: 'p-1 hover:text-black dark:hover:text-white', + className: 'z-50 hover:text-black dark:hover:text-white', }; if (twcss) { classProp.className = twcss; @@ -69,7 +69,7 @@ export default function ArchiveButton({ - {icon} + {icon} {localize(`com_ui_${label}`)} diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 7e4d7092e2..6e752353ac 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -4,18 +4,19 @@ import { useState, useRef, useMemo } from 'react'; import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; -import { MinimalIcon, ConvoIconURL } from '~/components/Endpoints'; import { useUpdateConversationMutation } from '~/data-provider'; +import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import { useConversations, useNavigateToConvo } from '~/hooks'; -import { getEndpointField, getIconEndpoint } from '~/utils'; import { NotificationSeverity } from '~/common'; +import { ArchiveIcon } from '~/components/svg'; import { useToastContext } from '~/Providers'; -import DeleteButton from './DeleteButton'; -import RenameButton from './RenameButton'; -import store from '~/store'; import EditMenuButton from './EditMenuButton'; import ArchiveButton from './ArchiveButton'; -import { Archive } from 'lucide-react'; +import DeleteButton from './DeleteButton'; +import RenameButton from './RenameButton'; +import HoverToggle from './HoverToggle'; +import { cn } from '~/utils'; +import store from '~/store'; type KeyEvent = KeyboardEvent; @@ -102,128 +103,91 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa ); }; - const iconURL = conversation.iconURL ?? ''; - let endpoint = conversation.endpoint; - endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); - - const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); - const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - - let icon: React.ReactNode | null = null; - if (iconURL && iconURL.includes('http')) { - icon = ConvoIconURL({ - preset: conversation, - context: 'menu-item', - endpointIconURL, - }); - } else { - icon = MinimalIcon({ - size: 20, - iconURL: endpointIconURL, - endpoint, - endpointType, - model: conversation.model, - error: false, - className: 'mr-0', - isCreatedByUser: false, - chatGptLabel: undefined, - modelLabel: undefined, - jailbreak: undefined, - }); - } - const handleKeyDown = (e: KeyEvent) => { - if (e.key === 'Enter') { + if (e.key === 'Escape') { + setTitleInput(title); + setRenaming(false); + } else if (e.key === 'Enter') { onRename(e); } }; - const activeConvo = + const isActiveConvo = currentConvoId === conversationId || (isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new'); - const aProps = { - className: - 'group relative rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg bg-gray-200 dark:bg-gray-700 py-2 px-2', - }; - - if (!activeConvo) { - aProps.className = - 'group relative grow overflow-hidden whitespace-nowrap rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 py-2 px-2'; - } - return ( - - {icon} -
- {renaming === true ? ( +
+ {renaming ? ( +
setTitleInput(e.target.value)} onBlur={onRename} onKeyDown={handleKeyDown} /> - ) : ( - title - )} -
- {activeConvo ? ( -
- ) : ( -
- )} - {activeConvo ? ( -
- {!renaming && ( - -
-
- -
-
- -
-
-
- )} - {!renaming && ( - } - /> - )}
) : ( -
+ + + + + + } + /> + )} - + + + {!renaming && ( +
{title}
+ )} + {isActiveConvo ? ( +
+ ) : ( + ); } diff --git a/client/src/components/Conversations/DeleteButton.tsx b/client/src/components/Conversations/DeleteButton.tsx index 0e30fc42cd..c1a0f5c55d 100644 --- a/client/src/components/Conversations/DeleteButton.tsx +++ b/client/src/components/Conversations/DeleteButton.tsx @@ -22,8 +22,8 @@ export default function DeleteButton({ renaming, retainView, title, - twcss, appendLabel = false, + className = '', }) { const localize = useLocalize(); const queryClient = useQueryClient(); @@ -45,13 +45,6 @@ export default function DeleteButton({ deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' }); }, [conversationId, deleteConvoMutation, queryClient]); - const classProp: { className?: string } = { - className: 'p-1 hover:text-black dark:hover:text-white', - }; - if (twcss) { - classProp.className = twcss; - } - const renderDeleteButton = () => { if (appendLabel) { return ( @@ -79,7 +72,7 @@ export default function DeleteButton({ return ( - + = ({ children }: EditMenuButtonProps) => { const localize = useLocalize(); + const { setPopoverActive } = useToggle(); return ( - + setPopoverActive(open)}>
- + @@ -42,7 +44,11 @@ const EditMenuButton: FC = ({ children }: EditMenuButtonPro {children} diff --git a/client/src/components/Conversations/Fork.tsx b/client/src/components/Conversations/Fork.tsx index 84a8483bed..cb0a62d1a7 100644 --- a/client/src/components/Conversations/Fork.tsx +++ b/client/src/components/Conversations/Fork.tsx @@ -182,7 +182,7 @@ export default function Fork({ } }} type="button" - title={localize('com_ui_continue')} + title={localize('com_ui_fork')} > diff --git a/client/src/components/Conversations/HoverToggle.tsx b/client/src/components/Conversations/HoverToggle.tsx new file mode 100644 index 0000000000..bb74c685c1 --- /dev/null +++ b/client/src/components/Conversations/HoverToggle.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { ToggleContext } from './ToggleContext'; +import { cn } from '~/utils'; + +const HoverToggle = ({ + children, + isActiveConvo, +}: { + children: React.ReactNode; + isActiveConvo: boolean; +}) => { + const [isPopoverActive, setIsPopoverActive] = useState(false); + const setPopoverActive = (value: boolean) => setIsPopoverActive(value); + return ( + +
+ {children} +
+
+ ); +}; + +export default HoverToggle; diff --git a/client/src/components/Conversations/RenameButton.tsx b/client/src/components/Conversations/RenameButton.tsx index ddca75a964..7c60e23584 100644 --- a/client/src/components/Conversations/RenameButton.tsx +++ b/client/src/components/Conversations/RenameButton.tsx @@ -6,7 +6,6 @@ interface RenameButtonProps { renaming: boolean; renameHandler: (e: MouseEvent) => void; onRename: (e: MouseEvent) => void; - twcss?: string; appendLabel?: boolean; } @@ -14,19 +13,16 @@ export default function RenameButton({ renaming, renameHandler, onRename, - twcss, appendLabel = false, }: RenameButtonProps): ReactElement { const localize = useLocalize(); const handler = renaming ? onRename : renameHandler; - const classProp: { className?: string } = { - className: 'p-1 hover:text-black dark:hover:text-white', - }; - if (twcss) { - classProp.className = twcss; - } + return ( -