diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index a0613f113c..2dcdf9c903 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -168,12 +168,11 @@ const AuthContextProvider = ({ if (token) { const storedRedirect = sessionStorage.getItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY); - setUserContext({ - user, - token, - isAuthenticated: true, - redirect: storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : '/c/new', - }); + const currentUrl = `${window.location.pathname}${window.location.search}`; + const fallbackRedirect = isSafeRedirect(currentUrl) ? currentUrl : '/c/new'; + const redirect = + storedRedirect && isSafeRedirect(storedRedirect) ? storedRedirect : fallbackRedirect; + setUserContext({ user, token, isAuthenticated: true, redirect }); return; } console.log('Token is not present. User is not authenticated.'); diff --git a/client/src/hooks/Input/useQueryParams.spec.ts b/client/src/hooks/Input/useQueryParams.spec.ts index 927df94941..f8b30b2eda 100644 --- a/client/src/hooks/Input/useQueryParams.spec.ts +++ b/client/src/hooks/Input/useQueryParams.spec.ts @@ -220,9 +220,14 @@ describe('useQueryParams', () => { handleSubmit: jest.fn((callback) => () => callback({ text: 'test message' })), }); - // Mock startup config to allow processing (useQueryClient as jest.Mock).mockReturnValue({ - getQueryData: jest.fn().mockReturnValue({ modelSpecs: { list: [] } }), + getQueryData: jest.fn().mockImplementation((key) => { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { + return { modelSpecs: { list: [] } }; + } + return null; + }), }); setUrlParams({ q: 'hello world' }); @@ -241,7 +246,11 @@ describe('useQueryParams', () => { 'hello world', expect.objectContaining({ shouldValidate: true }), ); - expect(window.history.replaceState).toHaveBeenCalled(); + const mockSetSearchParams = (useSearchParams as jest.Mock).mock.results[0].value[1]; + const [params, options] = mockSetSearchParams.mock.calls[0]; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.toString()).toBe(''); + expect(options).toEqual(expect.objectContaining({ replace: true })); }); it('should auto-submit message when submit=true and no settings to apply', () => { @@ -266,9 +275,14 @@ describe('useQueryParams', () => { submitMessage: mockSubmitMessage, }); - // Mock startup config to allow processing (useQueryClient as jest.Mock).mockReturnValue({ - getQueryData: jest.fn().mockReturnValue({ modelSpecs: { list: [] } }), + getQueryData: jest.fn().mockImplementation((key) => { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { + return { modelSpecs: { list: [] } }; + } + return null; + }), }); setUrlParams({ q: 'hello world', submit: 'true' }); @@ -304,13 +318,14 @@ describe('useQueryParams', () => { } as unknown as HTMLTextAreaElement, }; - // Mock getQueryData to return array format for startupConfig + // Mock getQueryData to return array format for startupConfig and endpoints const mockGetQueryData = jest.fn().mockImplementation((key) => { - if (Array.isArray(key) && key[0] === 'startupConfig') { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { return { modelSpecs: { list: [] } }; } - if (key === 'startupConfig') { - return { modelSpecs: { list: [] } }; + if (k === 'endpoints') { + return {}; } return null; }); @@ -396,14 +411,15 @@ describe('useQueryParams', () => { newConversation: mockNewConversation, }); - // Mock startup config to allow processing + // Mock startup config and endpoints to allow processing (useQueryClient as jest.Mock).mockReturnValue({ getQueryData: jest.fn().mockImplementation((key) => { - if (Array.isArray(key) && key[0] === 'startupConfig') { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { return { modelSpecs: { list: [] } }; } - if (key === 'startupConfig') { - return { modelSpecs: { list: [] } }; + if (k === 'endpoints') { + return {}; } return null; }), @@ -454,9 +470,14 @@ describe('useQueryParams', () => { submitMessage: mockSubmitMessage, }); - // Mock startup config to allow processing (useQueryClient as jest.Mock).mockReturnValue({ - getQueryData: jest.fn().mockReturnValue({ modelSpecs: { list: [] } }), + getQueryData: jest.fn().mockImplementation((key) => { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { + return { modelSpecs: { list: [] } }; + } + return null; + }), }); setUrlParams({ model: 'gpt-4' }); // No submit=true @@ -500,9 +521,14 @@ describe('useQueryParams', () => { submitMessage: mockSubmitMessage, }); - // Mock startup config to allow processing (useQueryClient as jest.Mock).mockReturnValue({ - getQueryData: jest.fn().mockReturnValue({ modelSpecs: { list: [] } }), + getQueryData: jest.fn().mockImplementation((key) => { + const k = Array.isArray(key) ? key[0] : key; + if (k === 'startupConfig') { + return { modelSpecs: { list: [] } }; + } + return null; + }), }); setUrlParams({}); // Empty params @@ -524,6 +550,10 @@ describe('useQueryParams', () => { expect(mockSetValue).not.toHaveBeenCalled(); expect(mockHandleSubmit).not.toHaveBeenCalled(); expect(mockSubmitMessage).not.toHaveBeenCalled(); - expect(window.history.replaceState).toHaveBeenCalled(); + const mockSetSearchParams = (useSearchParams as jest.Mock).mock.results[0].value[1]; + const [params, options] = mockSetSearchParams.mock.calls[0]; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.toString()).toBe(''); + expect(options).toEqual(expect.objectContaining({ replace: true })); }); }); diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts index 7c9ff58042..b29f408a3a 100644 --- a/client/src/hooks/Input/useQueryParams.ts +++ b/client/src/hooks/Input/useQueryParams.ts @@ -2,24 +2,17 @@ import { useEffect, useCallback, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import { useSearchParams } from 'react-router-dom'; import { QueryClient, useQueryClient } from '@tanstack/react-query'; -import { - QueryKeys, - EModelEndpoint, - isAgentsEndpoint, - tQueryParamsSchema, - isAssistantsEndpoint, - PermissionBits, -} from 'librechat-data-provider'; +import { QueryKeys, EModelEndpoint, PermissionBits } from 'librechat-data-provider'; import type { AgentListResponse, TEndpointsConfig, TStartupConfig, TPreset, } from 'librechat-data-provider'; -import type { ZodAny } from 'zod'; import { clearModelForNonEphemeralAgent, removeUnavailableTools, + processValidSettings, getModelSpecIconURL, getConvoSwitchLogic, logger, @@ -29,62 +22,6 @@ import { useChatContext, useChatFormContext } from '~/Providers'; import { useGetAgentByIdQuery } from '~/data-provider'; import store from '~/store'; -/** - * Parses query parameter values, converting strings to their appropriate types. - * Handles boolean strings, numbers, and preserves regular strings. - */ -const parseQueryValue = (value: string) => { - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - if (!isNaN(Number(value))) { - return Number(value); - } - return value; -}; - -/** - * Processes and validates URL query parameters using schema definitions. - * Extracts valid settings based on tQueryParamsSchema and handles special endpoint cases - * for assistants and agents. - */ -const processValidSettings = (queryParams: Record) => { - const validSettings = {} as TPreset; - - Object.entries(queryParams).forEach(([key, value]) => { - try { - const schema = tQueryParamsSchema.shape[key] as ZodAny | undefined; - if (schema) { - const parsedValue = parseQueryValue(value); - const validValue = schema.parse(parsedValue); - validSettings[key] = validValue; - } - } catch (error) { - console.warn(`Invalid value for setting ${key}:`, error); - } - }); - - if ( - validSettings.assistant_id != null && - validSettings.assistant_id && - !isAssistantsEndpoint(validSettings.endpoint) - ) { - validSettings.endpoint = EModelEndpoint.assistants; - } - if ( - validSettings.agent_id != null && - validSettings.agent_id && - !isAgentsEndpoint(validSettings.endpoint) - ) { - validSettings.endpoint = EModelEndpoint.agents; - } - - return validSettings; -}; - const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => { const editCacheKey = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }]; const editCache = queryClient.getQueryData(editCacheKey); @@ -244,13 +181,12 @@ export default function useQueryParams({ ], ); - /** - * Checks if all settings from URL parameters have been successfully applied to the conversation. - * Compares values from validSettings against the current conversation state, handling special properties. - * Returns true only when all relevant settings match the target values. - */ + const conversationRef = useRef(conversation); + conversationRef.current = conversation; + const areSettingsApplied = useCallback(() => { - if (!validSettingsRef.current || !conversation) { + const convo = conversationRef.current; + if (!validSettingsRef.current || !convo) { return false; } @@ -259,13 +195,13 @@ export default function useQueryParams({ continue; } - if (conversation[key] !== value) { + if (convo[key] !== value) { return false; } } return true; - }, [conversation]); + }, []); /** * Processes message submission exactly once, preventing duplicate submissions. @@ -285,14 +221,12 @@ export default function useQueryParams({ methods.handleSubmit((data) => { if (data.text?.trim()) { submitMessage(data); - - const newUrl = window.location.pathname; - window.history.replaceState({}, '', newUrl); - - console.log('Message submitted with conversation state:', conversation); + logger.log('conversation', 'Message submitted from query params'); } })(); - }, [methods, submitMessage, conversation]); + + setSearchParams(new URLSearchParams(), { replace: true }); + }, [methods, submitMessage, setSearchParams]); useEffect(() => { const processQueryParams = () => { @@ -332,6 +266,7 @@ export default function useQueryParams({ } const { decodedPrompt, validSettings, shouldAutoSubmit } = processQueryParams(); + const hasSettings = Object.keys(validSettings).length > 0; if (!shouldAutoSubmit) { submissionHandledRef.current = true; @@ -339,45 +274,36 @@ export default function useQueryParams({ /** Mark processing as complete and clean up as needed */ const success = () => { - const paramString = searchParams.toString(); - const currentParams = new URLSearchParams(paramString); - currentParams.delete('prompt'); - currentParams.delete('q'); - currentParams.delete('submit'); - - setSearchParams(currentParams, { replace: true }); processedRef.current = true; - console.log('Parameters processed successfully', paramString); + logger.log('conversation', 'Query parameters processed successfully'); clearInterval(intervalId); - // Only clean URL if there's no pending submission + // Defer URL cleanup until after submission completes (processSubmission handles it) if (!pendingSubmitRef.current) { - const newUrl = window.location.pathname; - window.history.replaceState({}, '', newUrl); + setSearchParams(new URLSearchParams(), { replace: true }); } }; - // Store settings for later comparison - if (Object.keys(validSettings).length > 0) { + if (hasSettings) { validSettingsRef.current = validSettings; } - // Save the prompt text for later use if needed if (decodedPrompt) { promptTextRef.current = decodedPrompt; } // Handle auto-submission if (shouldAutoSubmit && decodedPrompt) { - if (Object.keys(validSettings).length > 0) { + if (hasSettings) { // Settings are changing, defer submission pendingSubmitRef.current = true; // Set a timeout to handle the case where settings might never fully apply settingsTimeoutRef.current = setTimeout(() => { if (!submissionHandledRef.current && pendingSubmitRef.current) { - console.warn( - 'Settings application timeout reached, proceeding with submission anyway', + logger.log( + 'conversation', + 'Settings application timeout, proceeding with submission', ); processSubmission(); } @@ -401,7 +327,7 @@ export default function useQueryParams({ submissionHandledRef.current = true; } - if (Object.keys(validSettings).length > 0) { + if (hasSettings && !areSettingsApplied()) { newQueryConvo(validSettings); } @@ -424,6 +350,7 @@ export default function useQueryParams({ setSearchParams, queryClient, processSubmission, + areSettingsApplied, ]); useEffect(() => { @@ -438,9 +365,7 @@ export default function useQueryParams({ return; } - const allSettingsApplied = areSettingsApplied(); - - if (allSettingsApplied) { + if (areSettingsApplied()) { settingsAppliedRef.current = true; if (pendingSubmitRef.current) { @@ -449,7 +374,7 @@ export default function useQueryParams({ settingsTimeoutRef.current = null; } - console.log('Settings fully applied, processing submission'); + logger.log('conversation', 'Settings fully applied, processing submission'); processSubmission(); } } diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx index 4819f0f6d4..0b1c5944e6 100644 --- a/client/src/hooks/__tests__/AuthContext.spec.tsx +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -313,8 +313,12 @@ describe('AuthContextProvider — silentRefresh post-login redirect', () => { jest.useRealTimers(); }); - it('navigates to /c/new when no stored redirect exists', () => { + it('navigates to current URL when no stored redirect exists', () => { jest.useFakeTimers(); + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/c/new', search: '' }, + writable: true, + }); renderProviderLive(); @@ -361,8 +365,12 @@ describe('AuthContextProvider — silentRefresh post-login redirect', () => { jest.useRealTimers(); }); - it('falls back to /c/new for unsafe stored redirect', () => { + it('falls back to current URL for unsafe stored redirect', () => { jest.useFakeTimers(); + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/c/new', search: '' }, + writable: true, + }); sessionStorage.setItem(SESSION_KEY, 'https://evil.com/steal'); renderProviderLive(); diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index c8fea73470..dcb58c3f49 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; import { useRecoilCallback, useRecoilValue } from 'recoil'; import { Spinner, useToastContext } from '@librechat/client'; +import { useParams, useSearchParams } from 'react-router-dom'; import { Constants, EModelEndpoint } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TPreset } from 'librechat-data-provider'; @@ -13,7 +13,13 @@ import { useLocalize, } from '~/hooks'; import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider'; -import { getDefaultModelSpec, getModelSpecPreset, logger, isNotFoundError } from '~/utils'; +import { + getDefaultModelSpec, + getModelSpecPreset, + processValidSettings, + logger, + isNotFoundError, +} from '~/utils'; import { ToolCallsMapProvider } from '~/Providers'; import ChatView from '~/components/Chat/ChatView'; import { NotificationSeverity } from '~/common'; @@ -36,6 +42,7 @@ export default function ChatRoute() { useAppStartup({ startupConfig, user }); const index = 0; + const [searchParams] = useSearchParams(); const { conversationId = '' } = useParams(); useIdChangeEffect(conversationId); const { hasSetConversation, conversation } = store.useCreateConversationAtom(index); @@ -80,14 +87,34 @@ export default function ChatRoute() { return; } - if (conversationId === Constants.NEW_CONVO && endpointsQuery.data && modelsQuery.data) { + const isNewConvo = conversationId === Constants.NEW_CONVO; + + const getNewConvoPreset = () => { const result = getDefaultModelSpec(startupConfig); const spec = result?.default ?? result?.last; + const specPreset = spec ? getModelSpecPreset(spec) : undefined; + + const queryParams: Record = {}; + searchParams.forEach((value, key) => { + if (key !== 'prompt' && key !== 'q' && key !== 'submit') { + queryParams[key] = value; + } + }); + const querySettings = processValidSettings(queryParams); + + return Object.keys(querySettings).length > 0 + ? { ...specPreset, ...querySettings } + : specPreset; + }; + + if (isNewConvo && endpointsQuery.data && modelsQuery.data) { + const preset = getNewConvoPreset(); + logger.log('conversation', 'ChatRoute, new convo effect', conversation); newConversation({ modelsData: modelsQuery.data, template: conversation ? conversation : undefined, - ...(spec ? { preset: getModelSpecPreset(spec) } : {}), + ...(preset ? { preset } : {}), }); hasSetConversation.current = true; @@ -125,17 +152,17 @@ export default function ChatRoute() { }); hasSetConversation.current = true; } else if ( - conversationId === Constants.NEW_CONVO && + isNewConvo && assistantListMap[EModelEndpoint.assistants] && assistantListMap[EModelEndpoint.azureAssistants] ) { - const result = getDefaultModelSpec(startupConfig); - const spec = result?.default ?? result?.last; + const preset = getNewConvoPreset(); + logger.log('conversation', 'ChatRoute new convo, assistants effect', conversation); newConversation({ modelsData: modelsQuery.data, template: conversation ? conversation : undefined, - ...(spec ? { preset: getModelSpecPreset(spec) } : {}), + ...(preset ? { preset } : {}), }); hasSetConversation.current = true; } else if ( diff --git a/client/src/utils/createChatSearchParams.ts b/client/src/utils/createChatSearchParams.ts index 4e59b20507..64d327f43f 100644 --- a/client/src/utils/createChatSearchParams.ts +++ b/client/src/utils/createChatSearchParams.ts @@ -1,11 +1,65 @@ import { + EModelEndpoint, isAgentsEndpoint, tQueryParamsSchema, isAssistantsEndpoint, } from 'librechat-data-provider'; -import type { TConversation, TPreset } from 'librechat-data-provider'; +import type { TPreset, TConversation } from 'librechat-data-provider'; +import type { ZodAny } from 'zod'; import { isEphemeralAgent } from '~/common'; +const parseQueryValue = (value: string) => { + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + if (!isNaN(Number(value))) { + return Number(value); + } + return value; +}; + +/** + * Processes and validates URL query parameters using schema definitions. + * Extracts valid settings based on tQueryParamsSchema and handles special endpoint cases + * for assistants and agents. + */ +export function processValidSettings(queryParams: Record) { + const validSettings = {} as TPreset; + + for (const [key, value] of Object.entries(queryParams)) { + try { + const schema = tQueryParamsSchema.shape[key] as ZodAny | undefined; + if (schema) { + const parsedValue = parseQueryValue(value); + const validValue = schema.parse(parsedValue); + validSettings[key] = validValue; + } + } catch (error) { + console.warn(`Invalid value for setting ${key}:`, error); + } + } + + if ( + validSettings.assistant_id != null && + validSettings.assistant_id && + !isAssistantsEndpoint(validSettings.endpoint) + ) { + validSettings.endpoint = EModelEndpoint.assistants; + } + if ( + validSettings.agent_id != null && + validSettings.agent_id && + !isAgentsEndpoint(validSettings.endpoint) + ) { + validSettings.endpoint = EModelEndpoint.agents; + } + + return validSettings; +} + const allowedParams = Object.keys(tQueryParamsSchema.shape); export default function createChatSearchParams( input: TConversation | TPreset | Record | null, diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 6f081c7300..8946951ed8 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -34,7 +34,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 { default as createChatSearchParams, processValidSettings } from './createChatSearchParams'; export { getThemeFromEnv } from './getThemeFromEnv'; export const languages = [