diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index a8a75c8b04..3cb71373e6 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -4,8 +4,8 @@ import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { QueryKeys, Constants } from 'librechat-data-provider'; import type { TMessage, TStartupConfig } from 'librechat-data-provider'; +import { createChatSearchParams, getDefaultModelSpec, getModelSpecPreset } from '~/utils'; import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg'; -import { getDefaultModelSpec, getModelSpecPreset } from '~/utils'; import { TooltipAnchor, Button } from '~/components/ui'; import { useLocalize, useNewConvo } from '~/hooks'; import store from '~/store'; @@ -28,6 +28,7 @@ export default function NewChat({ const { newConversation: newConvo } = useNewConvo(index); const navigate = useNavigate(); const localize = useLocalize(); + const defaultPreset = useRecoilValue(store.defaultPreset); const { conversation } = store.useCreateConversationAtom(index); const clickHandler: React.MouseEventHandler = useCallback( @@ -40,13 +41,18 @@ export default function NewChat({ [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], [], ); + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); + const defaultSpec = getDefaultModelSpec(startupConfig); + const preset = defaultSpec != null ? getModelSpecPreset(defaultSpec) : defaultPreset; + const params = createChatSearchParams(preset ?? conversation); + const newRoute = params.size > 0 ? `/c/new?${params.toString()}` : '/c/new'; newConvo(); - navigate('/c/new'); + navigate(newRoute); if (isSmallScreen) { toggleNav(); } }, - [queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen], + [queryClient, conversation, newConvo, navigate, toggleNav, defaultPreset, isSmallScreen], ); return ( 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/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/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 d27f80a306..24c291213e 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -25,6 +25,7 @@ import { getModelSpecPreset, getDefaultModelSpec, 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..a25ce284cf --- /dev/null +++ b/client/src/utils/createChatSearchParams.ts @@ -0,0 +1,93 @@ +import { isAgentsEndpoint, isAssistantsEndpoint, Constants } from 'librechat-data-provider'; +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(); + + const allowedParams = [ + 'endpoint', + 'model', + 'temperature', + 'presence_penalty', + 'frequency_penalty', + 'stop', + 'top_p', + 'max_tokens', + 'topP', + 'topK', + 'maxOutputTokens', + 'promptCache', + 'region', + 'maxTokens', + 'agent_id', + 'assistant_id', + ]; + + 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; + const endpoint = conversation.endpoint; + if (conversation.spec) { + return new URLSearchParams({ spec: conversation.spec }); + } + if ( + isAgentsEndpoint(endpoint) && + conversation.agent_id && + conversation.agent_id !== Constants.EPHEMERAL_AGENT_ID + ) { + return new URLSearchParams({ agent_id: String(conversation.agent_id) }); + } else if (isAssistantsEndpoint(endpoint) && conversation.assistant_id) { + return new URLSearchParams({ assistant_id: String(conversation.assistant_id) }); + } else if (isAgentsEndpoint(endpoint) && !conversation.agent_id) { + return params; + } else if (isAssistantsEndpoint(endpoint) && !conversation.assistant_id) { + return params; + } + + if (endpoint) { + params.set('endpoint', 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',