🛡️ 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
This commit is contained in:
Danny Avila 2025-05-15 17:46:48 -04:00 committed by GitHub
parent 7a91f6ca62
commit 2f4a03b581
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 126 additions and 112 deletions

View file

@ -535,6 +535,7 @@ export type NewConversationParams = {
buildDefault?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
};
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;

View file

@ -79,19 +79,19 @@ export default function HeaderOptions({
{!noSettings[endpoint] &&
interfaceConfig?.parameters === true &&
paramEndpoint === false && (
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
<TooltipAnchor
id="parameters-button"
aria-label={localize('com_ui_model_parameters')}
description={localize('com_ui_model_parameters')}
tabIndex={0}
role="button"
onClick={triggerAdvancedMode}
data-testid="parameters-button"
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
</TooltipAnchor>
)}
</div>
{interfaceConfig?.parameters === true && paramEndpoint === false && (
<OptionsPopover

View file

@ -58,10 +58,11 @@ export default function usePresets() {
}
setDefaultPreset(defaultPreset);
if (!conversation?.conversationId || conversation.conversationId === 'new') {
newConversation({ preset: defaultPreset, modelsData });
newConversation({ preset: defaultPreset, modelsData, disableParams: true });
}
hasLoaded.current = true;
// dependencies are stable and only needed once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [presetsQuery.data, user, modelsData]);
const setPresets = useCallback(
@ -102,7 +103,7 @@ export default function usePresets() {
if (data.defaultPreset && data.presetId !== _defaultPreset?.presetId) {
message = `${toastTitle} ${localize('com_endpoint_preset_default')}`;
setDefaultPreset(data);
newConversation({ preset: data });
newConversation({ preset: data, disableParams: true });
} else if (preset.defaultPreset === false) {
setDefaultPreset(null);
message = `${toastTitle} ${localize('com_endpoint_preset_default_removed')}`;
@ -185,6 +186,7 @@ export default function usePresets() {
newPreset.iconURL = newPreset.iconURL ?? null;
newPreset.modelLabel = newPreset.modelLabel ?? null;
const isModular = isCurrentModular && isNewModular && shouldSwitch;
const disableParams = newPreset.defaultPreset === true;
if (isExistingConversation && isModular) {
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
@ -205,11 +207,12 @@ export default function usePresets() {
preset: currentConvo,
keepLatestMessage: true,
keepAddedConvos: true,
disableParams,
});
return;
}
newConversation({ preset: newPreset, keepAddedConvos: isModular });
newConversation({ preset: newPreset, keepAddedConvos: isModular, disableParams });
};
const onChangePreset = (preset: TPreset) => {

View file

@ -258,35 +258,6 @@ export default function useQueryParams({
})();
}, [methods, submitMessage, conversation]);
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]);
useEffect(() => {
const processQueryParams = () => {
const queryParams: Record<string, string> = {};
@ -332,14 +303,15 @@ export default function useQueryParams({
/** Mark processing as complete and clean up as needed */
const success = () => {
const currentParams = new URLSearchParams(searchParams.toString());
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');
console.log('Parameters processed successfully', paramString);
clearInterval(intervalId);
// Only clean URL if there's no pending submission
@ -417,4 +389,33 @@ export default function useQueryParams({
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]);
}

View file

@ -225,6 +225,7 @@ export default function useSelectMention({
newPreset.iconURL = newPreset.iconURL ?? null;
newPreset.modelLabel = newPreset.modelLabel ?? null;
const isModular = isCurrentModular && isNewModular && shouldSwitch;
const disableParams = newPreset.defaultPreset === true;
if (isExistingConversation && isModular) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
template.spec = null;
@ -244,12 +245,17 @@ export default function useSelectMention({
preset: newPreset,
keepLatestMessage: true,
keepAddedConvos: true,
disableParams,
});
return;
}
logger.info('conversation', 'Switching conversation to new preset', template);
newConversation({ preset: newPreset, keepAddedConvos: isModular });
newConversation({
preset: newPreset,
keepAddedConvos: isModular,
disableParams,
});
},
[
modularChat,

View file

@ -71,6 +71,7 @@ const useNewConvo = (index = 0) => {
keepLatestMessage?: boolean,
keepAddedConvos?: boolean,
disableFocus?: boolean,
_disableParams?: boolean,
) => {
const modelsConfig = modelsData ?? modelsQuery.data;
const { endpoint = null } = conversation;
@ -87,6 +88,12 @@ const useNewConvo = (index = 0) => {
? defaultPreset
: preset;
const disableParams =
_disableParams ??
(activePreset?.presetId != null &&
activePreset.presetId &&
activePreset.presetId === defaultPreset?.presetId);
if (buildDefaultConversation) {
let defaultEndpoint = getDefaultEndpoint({
convoSetup: activePreset ?? conversation,
@ -148,6 +155,10 @@ const useNewConvo = (index = 0) => {
});
}
if (disableParams === true) {
conversation.disableParams = true;
}
if (!(keepAddedConvos ?? false)) {
clearAllConversations(true);
}
@ -160,7 +171,7 @@ const useNewConvo = (index = 0) => {
);
setConversation({
...conversation,
conversationId: 'new',
conversationId: Constants.NEW_CONVO as string,
});
} else {
logger.log('conversation', 'Setting conversation from `useNewConvo`', conversation);
@ -205,6 +216,7 @@ const useNewConvo = (index = 0) => {
buildDefault = true,
keepLatestMessage = false,
keepAddedConvos = false,
disableParams,
}: {
template?: Partial<TConversation>;
preset?: Partial<TPreset>;
@ -213,6 +225,7 @@ const useNewConvo = (index = 0) => {
disableFocus?: boolean;
keepLatestMessage?: boolean;
keepAddedConvos?: boolean;
disableParams?: boolean;
} = {}) {
pauseGlobalAudio();
if (!saveBadgesState) {
@ -282,17 +295,19 @@ const useNewConvo = (index = 0) => {
keepLatestMessage,
keepAddedConvos,
disableFocus,
disableParams,
);
},
[
pauseGlobalAudio,
startupConfig,
saveDrafts,
switchToConversation,
files,
setFiles,
saveDrafts,
mutateAsync,
resetBadges,
startupConfig,
saveBadgesState,
pauseGlobalAudio,
switchToConversation,
],
);

View file

@ -106,10 +106,13 @@ const conversationByIndex = atomFamily<TConversation | null, string | number>({
JSON.stringify(newValue),
);
const disableParams = newValue.disableParams === true;
const shouldUpdateParams =
index === 0 &&
!disableParams &&
newValue.createdAt === '' &&
JSON.stringify(newValue) !== JSON.stringify(oldValue) &&
(oldValue as TConversation)?.conversationId === 'new';
(oldValue as TConversation)?.conversationId === Constants.NEW_CONVO;
if (shouldUpdateParams) {
const newParams = createChatSearchParams(newValue);
@ -299,10 +302,10 @@ const conversationByKeySelector = selectorFamily({
key: 'conversationByKeySelector',
get:
(index: string | number) =>
({ get }) => {
const conversation = get(conversationByIndex(index));
return conversation;
},
({ get }) => {
const conversation = get(conversationByIndex(index));
return conversation;
},
});
function useClearSubmissionState() {
@ -361,24 +364,24 @@ const updateConversationSelector = selectorFamily({
get: () => () => null as Partial<TConversation> | null,
set:
(conversationId: string) =>
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
({ set, get }, newPartialConversation) => {
if (newPartialConversation instanceof DefaultValue) {
return;
}
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
const keys = get(conversationKeysAtom);
keys.forEach((key) => {
set(conversationByIndex(key), (prevConversation) => {
if (prevConversation && prevConversation.conversationId === conversationId) {
return {
...prevConversation,
...newPartialConversation,
};
}
return prevConversation;
});
},
});
},
});
export default {

View file

@ -1,6 +1,12 @@
import { isAgentsEndpoint, isAssistantsEndpoint, Constants } from 'librechat-data-provider';
import {
Constants,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TConversation, TPreset } from 'librechat-data-provider';
const allowedParams = Object.keys(tQueryParamsSchema.shape);
export default function createChatSearchParams(
input: TConversation | TPreset | Record<string, string> | null,
): URLSearchParams {
@ -10,25 +16,6 @@ export default function createChatSearchParams(
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<string, string>).forEach(([key, value]) => {
if (value != null && allowedParams.includes(key)) {
@ -64,20 +51,15 @@ export default function createChatSearchParams(
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,
};
const paramMap: Record<string, any> = {};
allowedParams.forEach((key) => {
if (key === 'agent_id' && conversation.agent_id === Constants.EPHEMERAL_AGENT_ID) {
return;
}
if (key !== 'endpoint' && key !== 'model') {
paramMap[key] = (conversation as any)[key];
}
});
return Object.entries(paramMap).reduce((params, [key, value]) => {
if (value != null) {

2
package-lock.json generated
View file

@ -45306,7 +45306,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.82",
"version": "0.7.83",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",

View file

@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.82",
"version": "0.7.83",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

View file

@ -52,6 +52,7 @@ export const excludedKeys = new Set([
'model',
'files',
'spec',
'disableParams',
]);
export enum SettingsViews {

View file

@ -358,7 +358,7 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
// continue;
}
setting.includeInput =
setting.type === SettingTypes.Number ? setting.includeInput ?? true : false; // Default to true if type is number
setting.type === SettingTypes.Number ? (setting.includeInput ?? true) : false; // Default to true if type is number
}
if (setting.component === ComponentTypes.Slider && setting.type === SettingTypes.Number) {
@ -445,7 +445,8 @@ export function validateSettingDefinitions(settings: SettingsConfiguration): voi
// Validate optionType and conversation schema
if (setting.optionType !== OptionTypes.Custom) {
const conversationSchema = tConversationSchema.shape[setting.key as keyof TConversation];
const conversationSchema =
tConversationSchema.shape[setting.key as keyof Omit<TConversation, 'disableParams'>];
if (!conversationSchema) {
errors.push({
code: ZodIssueCode.custom,

View file

@ -745,6 +745,7 @@ export type TSetOption = (
export type TConversation = z.infer<typeof tConversationSchema> & {
presetOverride?: Partial<TPreset>;
disableParams?: boolean;
};
export const tSharedLinkSchema = z.object({