From cd4a3bd061ceb6259bc81ddf2d25d3bf16cd4b42 Mon Sep 17 00:00:00 2001 From: Matt Burnett Date: Thu, 24 Apr 2025 20:44:07 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20search=20parame?= =?UTF-8?q?ter=20updates=20and=20enhance=20chat=20component=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `useUpdateSearchParams` hook to manage URL search parameters dynamically. - Updated `Landing`, `NewChat`, and various parameter components to utilize the new hook for better state management. - Enhanced styling for dark mode support in chat components. - Refactored click handlers in `NewChat` to create chat search parameters for navigation. - Improved accessibility and usability in `DynamicCombobox`, `DynamicInput`, `DynamicSlider`, `DynamicSwitch`, and `DynamicTags` components. - Introduced `createChatSearchParams` utility to streamline query parameter creation for chat sessions. remove icon borders in dark mode --- client/src/components/Chat/Landing.tsx | 2 +- client/src/components/Nav/NewChat.tsx | 40 +-- .../SidePanel/Parameters/DynamicCombobox.tsx | 23 +- .../SidePanel/Parameters/DynamicInput.tsx | 11 +- .../SidePanel/Parameters/DynamicSlider.tsx | 49 ++- .../SidePanel/Parameters/DynamicSwitch.tsx | 12 +- .../SidePanel/Parameters/DynamicTags.tsx | 25 +- client/src/hooks/Chat/useChatFunctions.ts | 3 + client/src/hooks/Input/useQueryParams.ts | 21 +- client/src/hooks/SSE/useEventHandlers.ts | 41 ++- client/src/hooks/index.ts | 1 + client/src/hooks/useNewConvo.ts | 9 +- client/src/hooks/useUpdateSearchParams.ts | 40 +++ .../src/utils/createChatSearchParams.spec.ts | 310 ++++++++++++++++++ client/src/utils/createChatSearchParams.ts | 85 +++++ client/src/utils/index.ts | 1 + 16 files changed, 599 insertions(+), 74 deletions(-) create mode 100644 client/src/hooks/useUpdateSearchParams.ts create mode 100644 client/src/utils/createChatSearchParams.spec.ts create mode 100644 client/src/utils/createChatSearchParams.ts diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx index 032c59f538..c4eb4a904a 100644 --- a/client/src/components/Chat/Landing.tsx +++ b/client/src/components/Chat/Landing.tsx @@ -9,7 +9,7 @@ import { useLocalize, useAuthContext } from '~/hooks'; import { getIconEndpoint, getEntity } from '~/utils'; const containerClassName = - 'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black'; + 'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none '; function getTextSizeClass(text: string | undefined | null) { if (!text) { diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index a891fe9386..c43ff933d8 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants } from 'librechat-data-provider'; import type { TConversation, TMessage } from 'librechat-data-provider'; -import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils'; +import { createChatSearchParams, getEndpointField, getIconEndpoint, getIconKey } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import { useGetEndpointsQuery } from '~/data-provider'; import { useLocalize, useNewConvo } from '~/hooks'; @@ -57,7 +57,7 @@ const NewChatButtonIcon = React.memo(({ conversation }: { conversation: TConvers context="nav" /> ) : ( -
+
{endpoint && Icon && ( ) => { - if (event.button === 0 && !(event.ctrlKey || event.metaKey)) { - event.preventDefault(); - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); - newConvo(); - navigate('/c/new'); - toggleNav(); - } - }, - [queryClient, conversation, newConvo, navigate, toggleNav], - ); + const clickHandler = useCallback(() => { + queryClient.setQueryData( + [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], + [], + ); + const params = createChatSearchParams(defaultPreset ?? conversation); + const newRoute = params.size > 0 ? `/c/new?${params.toString()}` : '/c/new'; + + newConvo(); + navigate(newRoute); + toggleNav(); + }, [queryClient, conversation, newConvo, navigate, toggleNav, defaultPreset]); return (
-
- +
{subHeaders != null ? subHeaders : null}
diff --git a/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx b/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx index c0aca06149..d2875034e7 100644 --- a/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicCombobox.tsx @@ -3,7 +3,7 @@ import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, HoverCard, HoverCardTrigger } from '~/components/ui'; import ControlCombobox from '~/components/ui/ControlCombobox'; -import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; +import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks'; import { useChatContext } from '~/Providers'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -33,6 +33,7 @@ function DynamicCombobox({ }: DynamicSettingProps & { isCollapsed?: boolean; SelectIcon?: React.ReactNode }) { const localize = useLocalize(); const { preset } = useChatContext(); + const updateSearchParams = useUpdateSearchParams(); const [inputValue, setInputValue] = useState(null); const selectedValue = useMemo(() => { @@ -59,8 +60,10 @@ function DynamicCombobox({ } else { setOption(settingKey)(value); } + + updateSearchParams({ [settingKey]: value }); }, - [optionType, setOption, settingKey], + [optionType, setOption, settingKey, updateSearchParams], ); useParameterEffects({ @@ -93,7 +96,7 @@ function DynamicCombobox({ htmlFor={`${settingKey}-dynamic-combobox`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey} {showDefault && ( ({localize('com_endpoint_default')}: {defaultValue}) @@ -105,10 +108,14 @@ function DynamicCombobox({ {description && ( )} diff --git a/client/src/components/SidePanel/Parameters/DynamicInput.tsx b/client/src/components/SidePanel/Parameters/DynamicInput.tsx index e2c49a5d4a..79e8a5d43c 100644 --- a/client/src/components/SidePanel/Parameters/DynamicInput.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicInput.tsx @@ -1,6 +1,12 @@ import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; -import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; +import { + useLocalize, + useDebouncedInput, + useParameterEffects, + TranslationKeys, + useUpdateSearchParams, +} from '~/hooks'; import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui'; import { useChatContext } from '~/Providers'; import OptionHover from './OptionHover'; @@ -26,6 +32,7 @@ function DynamicInput({ }: DynamicSettingProps) { const localize = useLocalize(); const { preset } = useChatContext(); + const updateSearchParams = useUpdateSearchParams(); const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, @@ -47,6 +54,7 @@ function DynamicInput({ const value = e.target.value; if (type !== 'number') { setInputValue(e); + updateSearchParams({ [settingKey]: value }); return; } @@ -55,6 +63,7 @@ function DynamicInput({ } else if (!isNaN(Number(value))) { setInputValue(e, true); } + updateSearchParams({ [settingKey]: value }); }; return ( diff --git a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx index 92ab0a91f6..e440f62953 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx @@ -2,7 +2,13 @@ import { useMemo, useCallback } from 'react'; import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '~/components/ui'; -import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks'; +import { + useLocalize, + useDebouncedInput, + useParameterEffects, + TranslationKeys, + useUpdateSearchParams, +} from '~/hooks'; import { cn, defaultTextProps, optionText } from '~/utils'; import { ESide, defaultDebouncedDelay } from '~/common'; import { useChatContext } from '~/Providers'; @@ -31,6 +37,7 @@ function DynamicSlider({ () => (!range && options && options.length > 0) ?? false, [options, range], ); + const updateSearchParams = useUpdateSearchParams(); const [setInputValue, inputValue, setLocalValue] = useDebouncedInput({ optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, @@ -60,20 +67,26 @@ function DynamicSlider({ const enumToNumeric = useMemo(() => { if (isEnum && options) { - return options.reduce((acc, mapping, index) => { - acc[mapping] = index; - return acc; - }, {} as Record); + return options.reduce( + (acc, mapping, index) => { + acc[mapping] = index; + return acc; + }, + {} as Record, + ); } return {}; }, [isEnum, options]); const valueToEnumOption = useMemo(() => { if (isEnum && options) { - return options.reduce((acc, option, index) => { - acc[index] = option; - return acc; - }, {} as Record); + return options.reduce( + (acc, option, index) => { + acc[index] = option; + return acc; + }, + {} as Record, + ); } return {}; }, [isEnum, options]); @@ -85,8 +98,10 @@ function DynamicSlider({ } else { setInputValue(value); } + + updateSearchParams({ [settingKey]: value.toString() }); }, - [isEnum, setInputValue, valueToEnumOption], + [isEnum, setInputValue, valueToEnumOption, updateSearchParams, settingKey], ); const max = useMemo(() => { @@ -117,7 +132,7 @@ function DynamicSlider({ htmlFor={`${settingKey}-dynamic-setting`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '} {showDefault && ( ({localize('com_endpoint_default')}: {defaultValue}) @@ -132,7 +147,7 @@ function DynamicSlider({ onChange={(value) => setInputValue(Number(value))} max={range ? range.max : (options?.length ?? 0) - 1} min={range ? range.min : 0} - step={range ? range.step ?? 1 : 1} + step={range ? (range.step ?? 1) : 1} controls={false} className={cn( defaultTextProps, @@ -164,19 +179,23 @@ function DynamicSlider({ value={[ isEnum ? enumToNumeric[(selectedValue as number) ?? ''] - : (inputValue as number) ?? (defaultValue as number), + : ((inputValue as number) ?? (defaultValue as number)), ]} onValueChange={(value) => handleValueChange(value[0])} onDoubleClick={() => setInputValue(defaultValue as string | number)} max={max} min={range ? range.min : 0} - step={range ? range.step ?? 1 : 1} + step={range ? (range.step ?? 1) : 1} className="flex h-4 w-full" /> {description && ( )} diff --git a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx index f7a67fe71e..e92fff7a63 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'; import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui'; -import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; +import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks'; import { useChatContext } from '~/Providers'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -23,6 +23,7 @@ function DynamicSwitch({ }: DynamicSettingProps) { const localize = useLocalize(); const { preset } = useChatContext(); + const updateSearchParams = useUpdateSearchParams(); const [inputValue, setInputValue] = useState(!!(defaultValue as boolean | undefined)); useParameterEffects({ preset, @@ -47,6 +48,7 @@ function DynamicSwitch({ if (optionType === OptionTypes.Custom) { // TODO: custom logic, add to payload but not to conversation setInputValue(checked); + updateSearchParams({ [settingKey]: checked.toString() }); return; } setOption(settingKey)(checked); @@ -65,7 +67,7 @@ function DynamicSwitch({ htmlFor={`${settingKey}-dynamic-switch`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '} {showDefault && ( ({localize('com_endpoint_default')}:{' '} @@ -84,7 +86,11 @@ function DynamicSwitch({ {description && ( )} diff --git a/client/src/components/SidePanel/Parameters/DynamicTags.tsx b/client/src/components/SidePanel/Parameters/DynamicTags.tsx index 5fb6f43a42..cb6f108a85 100644 --- a/client/src/components/SidePanel/Parameters/DynamicTags.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicTags.tsx @@ -3,8 +3,8 @@ import { OptionTypes } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider'; import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui'; import { useChatContext, useToastContext } from '~/Providers'; -import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks'; -import { cn, defaultTextProps } from '~/utils'; +import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks'; +import { cn } from '~/utils'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -30,6 +30,7 @@ function DynamicTags({ const localize = useLocalize(); const { preset } = useChatContext(); const { showToast } = useToastContext(); + const updateSearchParams = useUpdateSearchParams(); const inputRef = useRef(null); const [tagText, setTagText] = useState(''); const [tags, setTags] = useState( @@ -41,11 +42,13 @@ function DynamicTags({ if (optionType === OptionTypes.Custom) { // TODO: custom logic, add to payload but not to conversation setTags(update); + updateSearchParams({ [settingKey]: update.join(',') }); return; } setOption(settingKey)(update); + updateSearchParams({ [settingKey]: update.join(',') }); }, - [optionType, setOption, settingKey], + [optionType, setOption, settingKey, updateSearchParams], ); const onTagClick = useCallback(() => { @@ -75,7 +78,7 @@ function DynamicTags({ if (minTags != null && currentTags.length <= minTags) { showToast({ - message: localize('com_ui_min_tags',{ 0: minTags + '' }), + message: localize('com_ui_min_tags', { 0: minTags + '' }), status: 'warning', }); return; @@ -126,7 +129,7 @@ function DynamicTags({ htmlFor={`${settingKey}-dynamic-input`} className="text-left text-sm font-medium" > - {labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '} + {labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '} {showDefault && ( ( @@ -174,7 +177,11 @@ function DynamicTags({ } }} onChange={(e) => setTagText(e.target.value)} - placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder} + placeholder={ + placeholderCode + ? (localize(placeholder as TranslationKeys) ?? placeholder) + : placeholder + } className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')} />
@@ -182,7 +189,11 @@ function DynamicTags({ {description && ( )} diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 770c8127d8..b75d97d6a2 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -25,6 +25,7 @@ import store, { useGetEphemeralAgent } from '~/store'; import { getArtifactsMode } from '~/utils/artifacts'; import { getEndpointField, logger } from '~/utils'; import useUserKey from '~/hooks/Input/useUserKey'; +import { useNavigate } from 'react-router-dom'; const logChatRequest = (request: Record) => { logger.log('=====================================\nAsk function called with:'); @@ -69,6 +70,7 @@ export default function useChatFunctions({ const codeArtifacts = useRecoilValue(store.codeArtifacts); const includeShadcnui = useRecoilValue(store.includeShadcnui); const customPromptMode = useRecoilValue(store.customPromptMode); + const navigate = useNavigate(); const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1)); const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index)); const setFilesToDelete = useSetFilesToDelete(); @@ -146,6 +148,7 @@ export default function useChatFunctions({ parentMessageId = Constants.NO_PARENT; currentMessages = []; conversationId = null; + navigate('/c/new'); } const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId; diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index a2f7344f0d..123314ce8c 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -73,7 +73,7 @@ export default function useQueryParams({ const attemptsRef = useRef(0); const processedRef = useRef(false); const methods = useChatFormContext(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const getDefaultConversation = useDefaultConvo(); const modularChat = useRecoilValue(store.modularChat); const availableTools = useRecoilValue(store.availableTools); @@ -222,8 +222,12 @@ export default function useQueryParams({ /** Clean up URL parameters after successful processing */ const success = () => { - const newUrl = window.location.pathname; - window.history.replaceState({}, '', newUrl); + const currentParams = new URLSearchParams(searchParams.toString()); + currentParams.delete('prompt'); + currentParams.delete('q'); + currentParams.delete('submit'); + + setSearchParams(currentParams, { replace: true }); processedRef.current = true; console.log('Parameters processed successfully'); clearInterval(intervalId); @@ -254,5 +258,14 @@ export default function useQueryParams({ return () => { clearInterval(intervalId); }; - }, [searchParams, methods, textAreaRef, newQueryConvo, newConversation, submitMessage]); + }, [ + searchParams, + methods, + textAreaRef, + newQueryConvo, + newConversation, + submitMessage, + setSearchParams, + queryClient, + ]); } diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 257331e485..047f3592a5 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -1,7 +1,7 @@ import { v4 } from 'uuid'; import { useCallback, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, @@ -172,6 +172,8 @@ export default function useEventHandlers({ const { announcePolite } = useLiveAnnouncer(); const applyAgentTemplate = useApplyNewAgentTemplate(); const setAbortScroll = useSetRecoilState(store.abortScroll); + const navigate = useNavigate(); + const location = useLocation(); const lastAnnouncementTimeRef = useRef(Date.now()); const { conversationId: paramId } = useParams(); @@ -421,6 +423,7 @@ export default function useEventHandlers({ announcePolite, setConversation, resetLatestMessage, + applyAgentTemplate, ], ); @@ -476,8 +479,8 @@ export default function useEventHandlers({ } if (setConversation && isAddedRequest !== true) { - if (window.location.pathname === '/c/new') { - window.history.pushState({}, '', '/c/' + conversation.conversationId); + if (location.pathname === '/c/new') { + navigate(`/c/${conversation.conversationId}`, { replace: true }); } setConversation((prevState) => { @@ -502,16 +505,18 @@ export default function useEventHandlers({ setIsSubmitting(false); }, [ - genTitle, - queryClient, - getMessages, - setMessages, - setCompleted, - isAddedRequest, - announcePolite, - setConversation, - setIsSubmitting, setShowStopButton, + setCompleted, + getMessages, + announcePolite, + genTitle, + setConversation, + isAddedRequest, + setIsSubmitting, + setMessages, + queryClient, + location.pathname, + navigate, ], ); @@ -599,7 +604,7 @@ export default function useEventHandlers({ setIsSubmitting(false); return; }, - [setMessages, paramId, setIsSubmitting, setCompleted, newConversation], + [setCompleted, setMessages, paramId, newConversation, setIsSubmitting, getMessages], ); const abortConversation = useCallback( @@ -698,7 +703,15 @@ export default function useEventHandlers({ setIsSubmitting(false); } }, - [token, setIsSubmitting, finalHandler, cancelHandler, setMessages, newConversation], + [ + finalHandler, + newConversation, + setIsSubmitting, + token, + cancelHandler, + getMessages, + setMessages, + ], ); return { diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 83cdb7fbc4..9f05f5a2f4 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -35,3 +35,4 @@ export { default as useOnClickOutside } from './useOnClickOutside'; export { default as useSpeechToText } from './Input/useSpeechToText'; export { default as useTextToSpeech } from './Input/useTextToSpeech'; export { default as useGenerationsByLatest } from './useGenerationsByLatest'; +export { default as useUpdateSearchParams } from './useUpdateSearchParams'; diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index f5933cc547..8e1e4f8e5f 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -25,6 +25,7 @@ import { getDefaultModelSpec, getModelSpecIconURL, updateLastSelectedModel, + createChatSearchParams, } from '~/utils'; import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import useAssistantListMap from './Assistants/useAssistantListMap'; @@ -164,7 +165,13 @@ const useNewConvo = (index = 0) => { if (appTitle) { document.title = appTitle; } - navigate(`/c/${Constants.NEW_CONVO}`); + const params = createChatSearchParams(conversation); + const newRoute = + params.size > 0 + ? `/c/${Constants.NEW_CONVO}?${params.toString()}` + : `/c/${Constants.NEW_CONVO}`; + + navigate(newRoute); } clearTimeout(timeoutIdRef.current); diff --git a/client/src/hooks/useUpdateSearchParams.ts b/client/src/hooks/useUpdateSearchParams.ts new file mode 100644 index 0000000000..e3e34bd374 --- /dev/null +++ b/client/src/hooks/useUpdateSearchParams.ts @@ -0,0 +1,40 @@ +import { useCallback, useRef } from 'react'; +import { useSearchParams, useLocation } from 'react-router-dom'; +import { createChatSearchParams } from '~/utils'; + +export const useUpdateSearchParams = () => { + const lastParamsRef = useRef>({}); + const [, setSearchParams] = useSearchParams(); + const location = useLocation(); + + const updateSearchParams = useCallback( + (record: Record | null) => { + if (record == null || location.pathname !== '/c/new') { + return; + } + + setSearchParams( + (params) => { + const currentParams = Object.fromEntries(params.entries()); + const newSearchParams = createChatSearchParams(record); + const newParams = Object.fromEntries(newSearchParams.entries()); + const mergedParams = { ...currentParams, ...newParams }; + + // If the new params are the same as the last params, don't update the search params + if (JSON.stringify(lastParamsRef.current) === JSON.stringify(newParams)) { + return currentParams; + } + + lastParamsRef.current = { ...newParams }; + return mergedParams; + }, + { replace: true }, + ); + }, + [setSearchParams, location.pathname], + ); + + return updateSearchParams; +}; + +export default useUpdateSearchParams; diff --git a/client/src/utils/createChatSearchParams.spec.ts b/client/src/utils/createChatSearchParams.spec.ts new file mode 100644 index 0000000000..19b6812774 --- /dev/null +++ b/client/src/utils/createChatSearchParams.spec.ts @@ -0,0 +1,310 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TConversation, TPreset } from 'librechat-data-provider'; +import createChatSearchParams from './createChatSearchParams'; + +describe('createChatSearchParams', () => { + describe('conversation inputs', () => { + it('handles basic conversation properties', () => { + const conversation: Partial = { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + temperature: 0.7, + }; + + const result = createChatSearchParams(conversation as TConversation); + expect(result.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(result.get('model')).toBe('gpt-4'); + expect(result.get('temperature')).toBe('0.7'); + }); + + it('applies only the endpoint property when other conversation fields are absent', () => { + const endpointOnly = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + } as TConversation); + expect(endpointOnly.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(endpointOnly.has('model')).toBe(false); + expect(endpointOnly.has('endpoint')).toBe(true); + }); + + it('applies only the model property when other conversation fields are absent', () => { + const modelOnly = createChatSearchParams({ model: 'gpt-4' } as TConversation); + expect(modelOnly.has('endpoint')).toBe(false); + expect(modelOnly.get('model')).toBe('gpt-4'); + expect(modelOnly.has('model')).toBe(true); + }); + + it('used to include endpoint and model with assistant_id but now only includes assistant_id', () => { + const withAssistantId = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + assistant_id: 'asst_123', + temperature: 0.7, + } as TConversation); + + expect(withAssistantId.get('assistant_id')).toBe('asst_123'); + expect(withAssistantId.has('endpoint')).toBe(false); + expect(withAssistantId.has('model')).toBe(false); + expect(withAssistantId.has('temperature')).toBe(false); + }); + + it('used to include endpoint and model with agent_id but now only includes agent_id', () => { + const withAgentId = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + agent_id: 'agent_123', + temperature: 0.7, + } as TConversation); + + expect(withAgentId.get('agent_id')).toBe('agent_123'); + expect(withAgentId.has('endpoint')).toBe(false); + expect(withAgentId.has('model')).toBe(false); + expect(withAgentId.has('temperature')).toBe(false); + }); + + it('when assistant_id is present, only include that param (excluding endpoint, model, etc)', () => { + const withAssistantId = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + assistant_id: 'asst_123', + temperature: 0.7, + } as TConversation); + + expect(withAssistantId.get('assistant_id')).toBe('asst_123'); + expect(withAssistantId.has('endpoint')).toBe(false); + expect(withAssistantId.has('model')).toBe(false); + expect(withAssistantId.has('temperature')).toBe(false); + expect([...withAssistantId.entries()].length).toBe(1); + }); + + it('when agent_id is present, only include that param (excluding endpoint, model, etc)', () => { + const withAgentId = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + agent_id: 'agent_123', + temperature: 0.7, + } as TConversation); + + expect(withAgentId.get('agent_id')).toBe('agent_123'); + expect(withAgentId.has('endpoint')).toBe(false); + expect(withAgentId.has('model')).toBe(false); + expect(withAgentId.has('temperature')).toBe(false); + expect([...withAgentId.entries()].length).toBe(1); + }); + + it('handles stop arrays correctly by joining with commas', () => { + const withStopArray = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + stop: ['stop1', 'stop2'], + } as TConversation); + + expect(withStopArray.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(withStopArray.get('model')).toBe('gpt-4'); + expect(withStopArray.get('stop')).toBe('stop1,stop2'); + }); + + it('filters out non-supported array properties', () => { + const withOtherArray = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + otherArrayProp: ['value1', 'value2'], + } as any); + + expect(withOtherArray.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(withOtherArray.get('model')).toBe('gpt-4'); + expect(withOtherArray.has('otherArrayProp')).toBe(false); + }); + + it('includes empty arrays in output params', () => { + const result = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + stop: [], + }); + + expect(result.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(result.has('stop')).toBe(true); + expect(result.get('stop')).toBe(''); + }); + + it('handles non-stop arrays correctly in paramMap', () => { + const conversation: any = { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + top_p: ['0.7', '0.8'], + }; + + const result = createChatSearchParams(conversation); + + const expectedJson = JSON.stringify(['0.7', '0.8']); + expect(result.get('top_p')).toBe(expectedJson); + expect(result.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(result.get('model')).toBe('gpt-4'); + }); + + it('includes empty non-stop arrays as serialized empty arrays', () => { + const result = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + temperature: 0.7, + top_p: [], + } as any); + + expect(result.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(result.get('model')).toBe('gpt-4'); + expect(result.get('temperature')).toBe('0.7'); + expect(result.has('top_p')).toBe(true); + expect(result.get('top_p')).toBe('[]'); + }); + + it('excludes parameters with null or undefined values from the output', () => { + const result = createChatSearchParams({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4', + temperature: 0.7, + top_p: undefined, + presence_penalty: undefined, + frequency_penalty: null, + } as any); + + expect(result.get('endpoint')).toBe(EModelEndpoint.openAI); + expect(result.get('model')).toBe('gpt-4'); + expect(result.get('temperature')).toBe('0.7'); + expect(result.has('top_p')).toBe(false); + expect(result.has('presence_penalty')).toBe(false); + expect(result.has('frequency_penalty')).toBe(false); + expect(result).toBeDefined(); + }); + + it('handles float parameter values correctly', () => { + const result = createChatSearchParams({ + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + frequency_penalty: 0.25, + temperature: 0.75, + }); + + expect(result.get('endpoint')).toBe(EModelEndpoint.google); + expect(result.get('model')).toBe('gemini-pro'); + expect(result.get('frequency_penalty')).toBe('0.25'); + expect(result.get('temperature')).toBe('0.75'); + }); + + it('handles integer parameter values correctly', () => { + const result = createChatSearchParams({ + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + topK: 40, + maxOutputTokens: 2048, + }); + + expect(result.get('endpoint')).toBe(EModelEndpoint.google); + expect(result.get('model')).toBe('gemini-pro'); + expect(result.get('topK')).toBe('40'); + expect(result.get('maxOutputTokens')).toBe('2048'); + }); + }); + + describe('preset inputs', () => { + it('handles preset objects correctly', () => { + const preset: Partial = { + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + temperature: 0.5, + topP: 0.8, + }; + + const result = createChatSearchParams(preset as TPreset); + expect(result.get('endpoint')).toBe(EModelEndpoint.google); + expect(result.get('model')).toBe('gemini-pro'); + expect(result.get('temperature')).toBe('0.5'); + expect(result.get('topP')).toBe('0.8'); + }); + }); + + describe('record inputs', () => { + it('includes allowed parameters from Record inputs', () => { + const record: Record = { + endpoint: EModelEndpoint.anthropic, + model: 'claude-2', + temperature: '0.8', + top_p: '0.95', + extraParam: 'should-not-be-included', + invalidParam1: 'value1', + invalidParam2: 'value2', + }; + + const result = createChatSearchParams(record); + expect(result.get('endpoint')).toBe(EModelEndpoint.anthropic); + expect(result.get('model')).toBe('claude-2'); + expect(result.get('temperature')).toBe('0.8'); + expect(result.get('top_p')).toBe('0.95'); + }); + + it('excludes disallowed parameters from Record inputs', () => { + const record: Record = { + endpoint: EModelEndpoint.anthropic, + model: 'claude-2', + extraParam: 'should-not-be-included', + invalidParam1: 'value1', + invalidParam2: 'value2', + }; + + const result = createChatSearchParams(record); + expect(result.has('extraParam')).toBe(false); + expect(result.has('invalidParam1')).toBe(false); + expect(result.has('invalidParam2')).toBe(false); + expect(result.toString().includes('invalidParam')).toBe(false); + expect(result.toString().includes('extraParam')).toBe(false); + }); + + it('includes valid values from Record inputs', () => { + const record: Record = { + temperature: '0.7', + top_p: null, + frequency_penalty: undefined, + }; + + const result = createChatSearchParams(record); + expect(result.get('temperature')).toBe('0.7'); + }); + + it('excludes null or undefined values from Record inputs', () => { + const record: Record = { + temperature: '0.7', + top_p: null, + frequency_penalty: undefined, + }; + + const result = createChatSearchParams(record); + expect(result.has('top_p')).toBe(false); + expect(result.has('frequency_penalty')).toBe(false); + }); + + it('handles generic object without endpoint or model properties', () => { + const customObject = { + temperature: '0.5', + top_p: '0.7', + customProperty: 'value', + }; + + const result = createChatSearchParams(customObject); + expect(result.get('temperature')).toBe('0.5'); + expect(result.get('top_p')).toBe('0.7'); + expect(result.has('customProperty')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns an empty URLSearchParams instance when input is null', () => { + const result = createChatSearchParams(null); + expect(result.toString()).toBe(''); + expect(result instanceof URLSearchParams).toBe(true); + }); + + it('returns an empty URLSearchParams instance for an empty object input', () => { + const result = createChatSearchParams({}); + expect(result.toString()).toBe(''); + expect(result instanceof URLSearchParams).toBe(true); + }); + }); +}); diff --git a/client/src/utils/createChatSearchParams.ts b/client/src/utils/createChatSearchParams.ts new file mode 100644 index 0000000000..17a5847e12 --- /dev/null +++ b/client/src/utils/createChatSearchParams.ts @@ -0,0 +1,85 @@ +import type { TConversation, TPreset } from 'librechat-data-provider'; + +export default function createChatSearchParams( + input: TConversation | TPreset | Record | null, +): URLSearchParams { + if (input == null) { + return new URLSearchParams(); + } + + const params = new URLSearchParams(); + + // Define the allowable parameters + const allowedParams = [ + 'endpoint', + 'model', + 'temperature', + 'presence_penalty', + 'frequency_penalty', + 'stop', + 'top_p', + 'max_tokens', + 'topP', + 'topK', + 'maxOutputTokens', + 'promptCache', + 'region', + 'maxTokens', + 'agent_id', + 'assistant_id', + ]; + + // Handle Record directly + if (input && typeof input === 'object' && !('endpoint' in input) && !('model' in input)) { + Object.entries(input as Record).forEach(([key, value]) => { + if (value != null && allowedParams.includes(key)) { + params.set(key, value); + } + }); + return params; + } + + const conversation = input as TConversation | TPreset; + + // If agent_id or assistant_id are present, they take precedence over all other params + if (conversation.agent_id) { + return new URLSearchParams({ agent_id: String(conversation.agent_id) }); + } else if (conversation.assistant_id) { + return new URLSearchParams({ assistant_id: String(conversation.assistant_id) }); + } + + // Otherwise, set regular params + if (conversation.endpoint) { + params.set('endpoint', conversation.endpoint); + } + if (conversation.model) { + params.set('model', conversation.model); + } + + const paramMap = { + temperature: conversation.temperature, + presence_penalty: conversation.presence_penalty, + frequency_penalty: conversation.frequency_penalty, + stop: conversation.stop, + top_p: conversation.top_p, + max_tokens: conversation.max_tokens, + topP: conversation.topP, + topK: conversation.topK, + maxOutputTokens: conversation.maxOutputTokens, + promptCache: conversation.promptCache, + region: conversation.region, + maxTokens: conversation.maxTokens, + }; + + return Object.entries(paramMap).reduce((params, [key, value]) => { + if (value != null) { + if (Array.isArray(value)) { + params.set(key, key === 'stop' ? value.join(',') : JSON.stringify(value)); + } else { + params.set(key, String(value)); + } + } + + return params; + }, params); +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 1a295837f4..7518aeeb11 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -22,6 +22,7 @@ export { default as getLoginError } from './getLoginError'; export { default as cleanupPreset } from './cleanupPreset'; export { default as buildDefaultConvo } from './buildDefaultConvo'; export { default as getDefaultEndpoint } from './getDefaultEndpoint'; +export { default as createChatSearchParams } from './createChatSearchParams'; export const languages = [ 'java',