Files
LibreChat/client/src/hooks/Input/useQueryParams.ts
Danny Avila 2f4a03b581 🛡️ fix: Preset and Validation Logic for URL Query Params (#7407)
* chore(store/families): linting

* refactor: Update `createChatSearchParams` to use `tQueryParamsSchema` for allowed parameters and add `modelLabel` to schema

* refactor: Enhance `useQueryParams` to streamline parameter processing and improve submission handling

* chore: linting

* fix: Add `disableParams` option to conversation handling and related schemas to prevent search params from updating due to use of default preset

* fix: Update `createChatSearchParams` to correctly ignore `agent_id` when it matches `EPHEMERAL_AGENT_ID`

* chore: revert modelLabel addition to query params, as no longer necessary due to `disableParams`

* fix: Refine logic for `disableParams` to ensure correct handling of active preset comparison

* fix: Add `disableParams` option to `NewConversationParams` and update related hooks for preset handling

* fix: Refactor validation logic in `validateSettingDefinitions` to improve handling of `includeInput` and update conversation schema

* fix: Bump version of `librechat-data-provider` to 0.7.83
2025-05-15 17:46:48 -04:00

422 lines
14 KiB
TypeScript

import { useEffect, useCallback, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
EModelEndpoint,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TPreset, TEndpointsConfig, TStartupConfig } from 'librechat-data-provider';
import type { ZodAny } from 'zod';
import { getConvoSwitchLogic, getModelSpecIconURL, removeUnavailableTools, logger } from '~/utils';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo';
import { useChatContext, useChatFormContext } from '~/Providers';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
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<string, string>) => {
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;
};
/**
* Hook that processes URL query parameters to initialize chat with specified settings and prompt.
* Handles model switching, prompt auto-filling, and optional auto-submission with race condition protection.
* Supports immediate or deferred submission based on whether settings need to be applied first.
*/
export default function useQueryParams({
textAreaRef,
}: {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
}) {
const maxAttempts = 50;
const attemptsRef = useRef(0);
const MAX_SETTINGS_WAIT_MS = 3000;
const processedRef = useRef(false);
const pendingSubmitRef = useRef(false);
const settingsAppliedRef = useRef(false);
const submissionHandledRef = useRef(false);
const promptTextRef = useRef<string | null>(null);
const validSettingsRef = useRef<TPreset | null>(null);
const settingsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const methods = useChatFormContext();
const [searchParams, setSearchParams] = useSearchParams();
const getDefaultConversation = useDefaultConvo();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);
const { submitMessage } = useSubmitMessage();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
/**
* Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
* Ensures tools compatibility and preserves existing conversation when appropriate.
*/
const newQueryConvo = useCallback(
(_newPreset?: TPreset) => {
if (!_newPreset) {
return;
}
let newPreset = removeUnavailableTools(_newPreset, availableTools);
if (newPreset.spec != null && newPreset.spec !== '') {
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const modelSpecs = startupConfig?.modelSpecs?.list ?? [];
const spec = modelSpecs.find((s) => s.name === newPreset.spec);
if (!spec) {
return;
}
const { preset } = spec;
preset.iconURL = getModelSpecIconURL(spec);
preset.spec = spec.name;
newPreset = preset;
}
let newEndpoint = newPreset.endpoint ?? '';
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
if (newEndpoint && endpointsConfig && !endpointsConfig[newEndpoint]) {
const normalizedNewEndpoint = newEndpoint.toLowerCase();
for (const [key, value] of Object.entries(endpointsConfig)) {
if (
value &&
value.type === EModelEndpoint.custom &&
key.toLowerCase() === normalizedNewEndpoint
) {
newEndpoint = key;
newPreset.endpoint = key;
newPreset.endpointType = EModelEndpoint.custom;
break;
}
}
}
const {
template,
shouldSwitch,
isNewModular,
newEndpointType,
isCurrentModular,
isExistingConversation,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
let resetParams = {};
if (newPreset.spec == null) {
template.spec = null;
template.iconURL = null;
template.modelLabel = null;
resetParams = { spec: null, iconURL: null, modelLabel: null };
newPreset = { ...newPreset, ...resetParams };
}
const isModular = isCurrentModular && isNewModular && shouldSwitch;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: {
...(conversation ?? {}),
endpointType: template.endpointType,
...resetParams,
},
preset: template,
cleanOutput: newPreset.spec != null && newPreset.spec !== '',
});
/* We don't reset the latest message, only when changing settings mid-converstion */
logger.log('conversation', 'Switching conversation from query params', currentConvo);
newConversation({
template: currentConvo,
preset: newPreset,
keepLatestMessage: true,
keepAddedConvos: true,
});
return;
}
newConversation({ preset: newPreset, keepAddedConvos: true });
},
[
queryClient,
modularChat,
conversation,
availableTools,
newConversation,
getDefaultConversation,
],
);
/**
* 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 areSettingsApplied = useCallback(() => {
if (!validSettingsRef.current || !conversation) {
return false;
}
for (const [key, value] of Object.entries(validSettingsRef.current)) {
if (['presetOverride', 'iconURL', 'spec', 'modelLabel'].includes(key)) {
continue;
}
if (conversation[key] !== value) {
return false;
}
}
return true;
}, [conversation]);
/**
* Processes message submission exactly once, preventing duplicate submissions.
* Sets the prompt text, submits the message, and cleans up URL parameters afterward.
* Has internal guards to ensure it only executes once regardless of how many times it's called.
*/
const processSubmission = useCallback(() => {
if (submissionHandledRef.current || !pendingSubmitRef.current || !promptTextRef.current) {
return;
}
submissionHandledRef.current = true;
pendingSubmitRef.current = false;
methods.setValue('text', promptTextRef.current, { shouldValidate: true });
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);
}
})();
}, [methods, submitMessage, conversation]);
useEffect(() => {
const processQueryParams = () => {
const queryParams: Record<string, string> = {};
searchParams.forEach((value, key) => {
queryParams[key] = value;
});
// Support both 'prompt' and 'q' as query parameters, with 'prompt' taking precedence
const decodedPrompt = queryParams.prompt || queryParams.q || '';
const shouldAutoSubmit = queryParams.submit?.toLowerCase() === 'true';
delete queryParams.prompt;
delete queryParams.q;
delete queryParams.submit;
const validSettings = processValidSettings(queryParams);
return { decodedPrompt, validSettings, shouldAutoSubmit };
};
const intervalId = setInterval(() => {
if (processedRef.current || attemptsRef.current >= maxAttempts) {
clearInterval(intervalId);
if (attemptsRef.current >= maxAttempts) {
console.warn('Max attempts reached, failed to process parameters');
}
return;
}
attemptsRef.current += 1;
if (!textAreaRef.current) {
return;
}
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
if (!startupConfig) {
return;
}
const { decodedPrompt, validSettings, shouldAutoSubmit } = processQueryParams();
if (!shouldAutoSubmit) {
submissionHandledRef.current = true;
}
/** 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);
clearInterval(intervalId);
// Only clean URL if there's no pending submission
if (!pendingSubmitRef.current) {
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
};
// Store settings for later comparison
if (Object.keys(validSettings).length > 0) {
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) {
// 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',
);
processSubmission();
}
}, MAX_SETTINGS_WAIT_MS);
} else {
methods.setValue('text', decodedPrompt, { shouldValidate: true });
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length);
methods.handleSubmit((data) => {
if (data.text?.trim()) {
submitMessage(data);
}
})();
}
} else if (decodedPrompt) {
methods.setValue('text', decodedPrompt, { shouldValidate: true });
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(decodedPrompt.length, decodedPrompt.length);
} else {
submissionHandledRef.current = true;
}
if (Object.keys(validSettings).length > 0) {
newQueryConvo(validSettings);
}
success();
}, 100);
return () => {
clearInterval(intervalId);
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
}
};
}, [
searchParams,
methods,
textAreaRef,
newQueryConvo,
newConversation,
submitMessage,
setSearchParams,
queryClient,
processSubmission,
]);
useEffect(() => {
// Only proceed if we've already processed URL parameters but haven't yet handled submission
if (
!processedRef.current ||
submissionHandledRef.current ||
settingsAppliedRef.current ||
!validSettingsRef.current ||
!conversation
) {
return;
}
const allSettingsApplied = areSettingsApplied();
if (allSettingsApplied) {
settingsAppliedRef.current = true;
if (pendingSubmitRef.current) {
if (settingsTimeoutRef.current) {
clearTimeout(settingsTimeoutRef.current);
settingsTimeoutRef.current = null;
}
console.log('Settings fully applied, processing submission');
processSubmission();
}
}
}, [conversation, processSubmission, areSettingsApplied]);
}