diff --git a/client/src/components/Chat/Footer.tsx b/client/src/components/Chat/Footer.tsx index 47271e520a..6095a95eda 100644 --- a/client/src/components/Chat/Footer.tsx +++ b/client/src/components/Chat/Footer.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import { useLocalize } from '~/hooks'; @@ -53,12 +54,13 @@ export default function Footer() {
{footerElements.map((contentRender, index) => { const isLastElement = index === footerElements.length - 1; - return ( - <> + {contentRender} - {!isLastElement &&
} - + {!isLastElement && ( +
+ )} + ); })}
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 4c46a64687..f67f82eda3 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, memo } from 'react'; import { parseISO, isToday } from 'date-fns'; import { useLocation } from 'react-router-dom'; import { TConversation } from 'librechat-data-provider'; @@ -6,7 +6,7 @@ import { groupConversationsByDate } from '~/utils'; import Conversation from './Conversation'; import Convo from './Convo'; -export default function Conversations({ +const Conversations = ({ conversations, moveToTop, toggleNav, @@ -14,7 +14,7 @@ export default function Conversations({ conversations: TConversation[]; moveToTop: () => void; toggleNav: () => void; -}) { +}) => { const location = useLocation(); const { pathname } = location; const ConvoItem = pathname.includes('chat') ? Conversation : Convo; @@ -64,4 +64,6 @@ export default function Conversations({
); -} +}; + +export default memo(Conversations); diff --git a/client/src/components/Endpoints/Settings/OpenAI.tsx b/client/src/components/Endpoints/Settings/OpenAI.tsx index 4563668e08..f5ba96f857 100644 --- a/client/src/components/Endpoints/Settings/OpenAI.tsx +++ b/client/src/components/Endpoints/Settings/OpenAI.tsx @@ -11,16 +11,13 @@ import { HoverCardTrigger, } from '~/components/ui'; import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/'; +import { useLocalize, useDebouncedInput } from '~/hooks'; import type { TModelSelectProps } from '~/common'; import OptionHover from './OptionHover'; -import { useLocalize } from '~/hooks'; import { ESide } from '~/common'; export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) { const localize = useLocalize(); - if (!conversation) { - return null; - } const { endpoint, endpointType, @@ -33,14 +30,43 @@ export default function Settings({ conversation, setOption, models, readonly }: presence_penalty: presP, resendImages, imageDetail, - } = conversation; + } = conversation ?? {}; + const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({ + setOption, + optionKey: 'chatGptLabel', + initialValue: chatGptLabel, + }); + const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({ + setOption, + optionKey: 'promptPrefix', + initialValue: promptPrefix, + }); + const [setTemperature, temperatureValue] = useDebouncedInput({ + setOption, + optionKey: 'temperature', + initialValue: temperature, + }); + const [setTopP, topPValue] = useDebouncedInput({ + setOption, + optionKey: 'top_p', + initialValue: topP, + }); + const [setFreqP, freqPValue] = useDebouncedInput({ + setOption, + optionKey: 'frequency_penalty', + initialValue: freqP, + }); + const [setPresP, presPValue] = useDebouncedInput({ + setOption, + optionKey: 'presence_penalty', + initialValue: presP, + }); + + if (!conversation) { + return null; + } + const setModel = setOption('model'); - const setChatGptLabel = setOption('chatGptLabel'); - const setPromptPrefix = setOption('promptPrefix'); - const setTemperature = setOption('temperature'); - const setTopP = setOption('top_p'); - const setFreqP = setOption('frequency_penalty'); - const setPresP = setOption('presence_penalty'); const setResendImages = setOption('resendImages'); const setImageDetail = setOption('imageDetail'); @@ -67,8 +93,8 @@ export default function Settings({ conversation, setOption, models, readonly }: setChatGptLabel(e.target.value ?? null)} + value={(chatGptLabelValue as string) || ''} + onChange={setChatGptLabel} placeholder={localize('com_endpoint_openai_custom_name_placeholder')} className={cn( defaultTextProps, @@ -86,8 +112,8 @@ export default function Settings({ conversation, setOption, models, readonly }: setPromptPrefix(e.target.value ?? null)} + value={(promptPrefixValue as string) || ''} + onChange={setPromptPrefix} placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')} className={cn( defaultTextProps, @@ -110,8 +136,8 @@ export default function Settings({ conversation, setOption, models, readonly }: setTemperature(Number(value))} + value={temperatureValue as number} + onChange={setTemperature} max={2} min={0} step={0.01} @@ -127,7 +153,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setTemperature(value[0])} doubleClickHandler={() => setTemperature(1)} max={2} @@ -148,7 +174,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setTopP(Number(value))} max={1} min={0} @@ -165,7 +191,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setTopP(value[0])} doubleClickHandler={() => setTopP(1)} max={1} @@ -187,7 +213,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setFreqP(Number(value))} max={2} min={-2} @@ -204,7 +230,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setFreqP(value[0])} doubleClickHandler={() => setFreqP(0)} max={2} @@ -226,7 +252,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setPresP(Number(value))} max={2} min={-2} @@ -243,7 +269,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setPresP(value[0])} doubleClickHandler={() => setPresP(0)} max={2} diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index c73ce74f17..c21c15d9a2 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -31,6 +31,14 @@ const Nav = ({ navVisible, setNavVisible }) => { const [newUser, setNewUser] = useLocalStorage('newUser', true); const [isToggleHovering, setIsToggleHovering] = useState(false); + const handleMouseEnter = useCallback(() => { + setIsHovering(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovering(false); + }, []); + useEffect(() => { if (isSmallScreen) { setNavWidth('320px'); @@ -144,8 +152,8 @@ const Nav = ({ navVisible, setNavVisible }) => { '-mr-2 flex-1 flex-col overflow-y-auto pr-2 transition-opacity duration-500', isHovering ? '' : 'scrollbar-transparent', )} - onMouseEnter={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} ref={containerRef} > - - + + ); } diff --git a/client/src/components/svg/Clipboard.tsx b/client/src/components/svg/Clipboard.tsx index 21696d36f3..e18a9f6651 100644 --- a/client/src/components/svg/Clipboard.tsx +++ b/client/src/components/svg/Clipboard.tsx @@ -13,7 +13,12 @@ export default function Clipboard() { width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + ); } diff --git a/client/src/components/svg/EditIcon.tsx b/client/src/components/svg/EditIcon.tsx index b251d1aa10..084562b273 100644 --- a/client/src/components/svg/EditIcon.tsx +++ b/client/src/components/svg/EditIcon.tsx @@ -13,7 +13,12 @@ export default function EditIcon() { width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + ); } diff --git a/client/src/components/svg/RegenerateIcon.tsx b/client/src/components/svg/RegenerateIcon.tsx index 0602c0ab64..f4e5c8ce17 100644 --- a/client/src/components/svg/RegenerateIcon.tsx +++ b/client/src/components/svg/RegenerateIcon.tsx @@ -13,7 +13,12 @@ export default function RegenerateIcon({ className = '' }: { className?: string width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + ); } diff --git a/client/src/components/svg/RenameIcon.tsx b/client/src/components/svg/RenameIcon.tsx index c3426dfbbb..17b4106d49 100644 --- a/client/src/components/svg/RenameIcon.tsx +++ b/client/src/components/svg/RenameIcon.tsx @@ -11,7 +11,12 @@ export default function RenameIcon() { width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + ); } diff --git a/client/src/components/svg/TrashIcon.tsx b/client/src/components/svg/TrashIcon.tsx index 65a73f6cfc..56c8516987 100644 --- a/client/src/components/svg/TrashIcon.tsx +++ b/client/src/components/svg/TrashIcon.tsx @@ -11,7 +11,12 @@ export default function TrashIcon() { width="1em" xmlns="http://www.w3.org/2000/svg" > - - + + ); } diff --git a/client/src/components/ui/MultiSearch.tsx b/client/src/components/ui/MultiSearch.tsx new file mode 100644 index 0000000000..86ee74e7c6 --- /dev/null +++ b/client/src/components/ui/MultiSearch.tsx @@ -0,0 +1,102 @@ +import { Search, X } from 'lucide-react'; +import React, { useState, useMemo, useCallback } from 'react'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +// This is a generic that can be added to Menu and Select components + +export default function MultiSearch({ + value, + onChange, + placeholder, +}: { + value: string | null; + onChange: (filter: string) => void; + placeholder?: string; +}) { + const localize = useLocalize(); + const onChangeHandler: React.ChangeEventHandler = useCallback( + (e) => onChange(e.target.value), + [], + ); + + return ( +
+ + +
+ onChange('')} + /> +
+
+ ); +} + +/** + * Helper function that will take a multiSearch input + * @param node + */ +function defaultGetStringKey(node: unknown): string { + if (typeof node === 'string') { + return node.toUpperCase(); + } + // This should be a noop, but it's here for redundancy + return ''; +} + +/** + * Hook for conditionally making a multi-element list component into a sortable component + * Returns a RenderNode for search input when search functionality is available + * @param availableOptions + * @param placeholder + * @param getTextKeyOverride + * @returns + */ +export function useMultiSearch( + availableOptions: OptionsType, + placeholder?: string, + getTextKeyOverride?: (node: OptionsType[0]) => string, +): [OptionsType, React.ReactNode] { + const [filterValue, setFilterValue] = useState(null); + + // We conditionally show the search when there's more than 10 elements in the menu + const shouldShowSearch = availableOptions.length > 10; + + // Define the helper function used to enable search + // If this is invalidly described, we will assume developer error - tf. avoid rendering + const getTextKeyHelper = getTextKeyOverride || defaultGetStringKey; + + // Iterate said options + const filteredOptions = useMemo(() => { + if (!shouldShowSearch || !filterValue || !availableOptions.length) { + // Don't render if available options aren't present, there's no filter active + return availableOptions; + } + // Filter through the values, using a simple text-based search + // nothing too fancy, but we can add a better search algo later if we need + const upperFilterValue = filterValue.toUpperCase(); + + return availableOptions.filter((value) => + getTextKeyHelper(value).includes(upperFilterValue), + ) as OptionsType; + }, [availableOptions, getTextKeyHelper, filterValue, shouldShowSearch]); + + const onSearchChange = useCallback((nextFilterValue) => setFilterValue(nextFilterValue), []); + + const searchRender = shouldShowSearch ? ( + + ) : null; + + return [filteredOptions, searchRender]; +} diff --git a/client/src/components/ui/MultiSelectDropDown.tsx b/client/src/components/ui/MultiSelectDropDown.tsx index 7df6edccac..c821db1c03 100644 --- a/client/src/components/ui/MultiSelectDropDown.tsx +++ b/client/src/components/ui/MultiSelectDropDown.tsx @@ -3,6 +3,7 @@ import { Listbox, Transition } from '@headlessui/react'; import { Wrench, ArrowRight } from 'lucide-react'; import { CheckMark } from '~/components/svg'; import useOnClickOutside from '~/hooks/useOnClickOutside'; +import { useMultiSearch } from './MultiSearch'; import { cn } from '~/utils/'; import type { TPlugin } from 'librechat-data-provider'; @@ -43,6 +44,13 @@ function MultiSelectDropDown({ setIsOpen(true); }; + // Detemine if we should to convert this component into a searchable select. If we have enough elements, a search + // input will appear near the top of the menu, allowing correct filtering of different model menu items. This will + // reset once the component is unmounted (as per a normal search) + const [filteredValues, searchRender] = useMultiSearch(availableValues); + const hasSearchRender = Boolean(searchRender); + const options = hasSearchRender ? filteredValues : availableValues; + const transitionProps = { className: 'top-full mt-3' }; if (showAbove) { transitionProps.className = 'bottom-full mb-3'; @@ -136,9 +144,12 @@ function MultiSelectDropDown({ > - {availableValues.map((option, i: number) => { + {searchRender} + {options.map((option, i: number) => { if (!option) { return null; } diff --git a/client/src/components/ui/MultiSelectPop.tsx b/client/src/components/ui/MultiSelectPop.tsx index b83b631e32..ae6e06910f 100644 --- a/client/src/components/ui/MultiSelectPop.tsx +++ b/client/src/components/ui/MultiSelectPop.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { Wrench } from 'lucide-react'; import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover'; import type { TPlugin } from 'librechat-data-provider'; import MenuItem from '~/components/Chat/Menus/UI/MenuItem'; +import { useMultiSearch } from './MultiSearch'; import { cn } from '~/utils/'; type SelectDropDownProps = { @@ -35,6 +36,11 @@ function MultiSelectPop({ const title = _title; const excludeIds = ['select-plugin', 'plugins-label', 'selected-plugins']; + // Detemine if we should to convert this component into a searchable select + const [filteredValues, searchRender] = useMultiSearch(availableValues); + const hasSearchRender = Boolean(searchRender); + const options = hasSearchRender ? filteredValues : availableValues; + return (
@@ -106,9 +112,13 @@ function MultiSelectPop({ - {availableValues.map((option) => { + {searchRender} + {options.map((option) => { if (!option) { return null; } diff --git a/client/src/components/ui/SelectDropDown.tsx b/client/src/components/ui/SelectDropDown.tsx index 5157408856..4fcddec1d8 100644 --- a/client/src/components/ui/SelectDropDown.tsx +++ b/client/src/components/ui/SelectDropDown.tsx @@ -4,6 +4,7 @@ import type { Option } from '~/common'; import CheckMark from '../svg/CheckMark'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils/'; +import { useMultiSearch } from './MultiSearch'; type SelectDropDownProps = { id?: string; @@ -57,6 +58,13 @@ function SelectDropDown({ title = localize('com_ui_model'); } + // Detemine if we should to convert this component into a searchable select. If we have enough elements, a search + // input will appear near the top of the menu, allowing correct filtering of different model menu items. This will + // reset once the component is unmounted (as per a normal search) + const [filteredValues, searchRender] = useMultiSearch(availableValues); + const hasSearchRender = Boolean(searchRender); + const options = hasSearchRender ? filteredValues : availableValues; + return (
@@ -122,7 +130,7 @@ function SelectDropDown({ > @@ -138,7 +146,8 @@ function SelectDropDown({ {renderOption()} )} - {availableValues.map((option: string | Option, i: number) => { + {searchRender} + {options.map((option: string | Option, i: number) => { if (!option) { return null; } diff --git a/client/src/components/ui/SelectDropDownPop.tsx b/client/src/components/ui/SelectDropDownPop.tsx index 88c434028e..94a4002bd2 100644 --- a/client/src/components/ui/SelectDropDownPop.tsx +++ b/client/src/components/ui/SelectDropDownPop.tsx @@ -4,6 +4,7 @@ import MenuItem from '~/components/Chat/Menus/UI/MenuItem'; import type { Option } from '~/common'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils/'; +import { useMultiSearch } from './MultiSearch'; type SelectDropDownProps = { id?: string; @@ -42,6 +43,13 @@ function SelectDropDownPop({ title = localize('com_ui_model'); } + // Detemine if we should to convert this component into a searchable select. If we have enough elements, a search + // input will appear near the top of the menu, allowing correct filtering of different model menu items. This will + // reset once the component is unmounted (as per a normal search) + const [filteredValues, searchRender] = useMultiSearch(availableValues); + const hasSearchRender = Boolean(searchRender); + const options = hasSearchRender ? filteredValues : availableValues; + return (
@@ -95,9 +103,13 @@ function SelectDropDownPop({ - {availableValues.map((option) => { + {searchRender} + {options.map((option) => { return ( , + (e: React.ChangeEvent | unknown) => void, unknown, SetterOrUpdater, // (newValue: string) => void, @@ -35,9 +35,12 @@ function useDebouncedInput({ ); /** An onChange handler that updates the local state and the debounced option */ - const onChange: React.ChangeEventHandler = useCallback( - (e) => { - const newValue: unknown = e.target.value; + const onChange = useCallback( + (e: React.ChangeEvent | unknown) => { + const newValue: unknown = + typeof e !== 'object' + ? e + : (e as React.ChangeEvent)?.target.value; setValue(newValue); setDebouncedOption(newValue); }, diff --git a/client/src/localization/languages/Eng.tsx b/client/src/localization/languages/Eng.tsx index cc8fd21739..72f836dd9b 100644 --- a/client/src/localization/languages/Eng.tsx +++ b/client/src/localization/languages/Eng.tsx @@ -58,6 +58,7 @@ export default { com_ui_close: 'Close', com_ui_model: 'Model', com_ui_select_model: 'Select a model', + com_ui_select_search_model: 'Search model by name', com_ui_use_prompt: 'Use prompt', com_ui_prev: 'Prev', com_ui_next: 'Next', diff --git a/client/src/style.css b/client/src/style.css index 0dacd1e44b..247e1d6e41 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1083,24 +1083,6 @@ button { padding: 0.25rem 0.5rem; } -::-webkit-scrollbar { - height: 0.1em; - width: 0.5rem; -} - -.scrollbar-trigger:hover ::-webkit-scrollbar-thumb { - visibility: hide; -} - -::-webkit-scrollbar-thumb { - background-color: hsla(0,0%,100%,.1); - border-radius: 9999px; -} - -.scrollbar-transparent::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.1); -} - .bg-token-surface-secondary { background-color: #f7f7f8; background-color: var(--surface-secondary); @@ -1112,13 +1094,32 @@ button { --tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to); } +/* Webkit scrollbar */ +::-webkit-scrollbar { + height: 0.1em; + width: 0.5rem; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 9999px; +} + +.dark ::-webkit-scrollbar-thumb { + background-color: hsla(0, 0%, 100%, 0.1); +} + ::-webkit-scrollbar-track { background-color: transparent; border-radius: 9999px; } -::-webkit-scrollbar-thumb:hover { - background-color: hsla(0,0%,100%,.3); - + +.scrollbar-transparent::-webkit-scrollbar-thumb { + background-color: transparent; +} + +.dark .scrollbar-transparent::-webkit-scrollbar-thumb { + background-color: transparent; } body, diff --git a/client/src/utils/cn.ts b/client/src/utils/cn.ts index 4633af85a1..daa125f191 100644 --- a/client/src/utils/cn.ts +++ b/client/src/utils/cn.ts @@ -1,6 +1,11 @@ import { twMerge } from 'tailwind-merge'; import { clsx } from 'clsx'; -export default function cn(...inputs: string[]) { +/** + * Merges the tailwind clases (using twMerge). Conditionally removes false values + * @param inputs The tailwind classes to merge + * @returns className string to apply to an element or HOC + */ +export default function cn(...inputs: Array) { return twMerge(clsx(inputs)); }