🧮 fix: Render Google Settings From the Shared Schema and Bound Them Per Model (#14989)

* fix: render Google settings from the shared schema and bound them correctly

Google was the last endpoint hand-rolling its own sliders. The schema was
already there and already wired, only the frontend never used it, so
rendering from it replaces 315 lines with the body OpenAI, Anthropic and
Bedrock share.

That closed a functional gap rather than only moving code: the old form
exposed six fields where the schema declares fifteen, so Resend Files,
Thinking, Thinking Budget, Thinking Level, Grounding with Google Search,
URL Context and File Token Limit were unreachable from a Google preset.
resendFiles is added to the Google schema so its switch round-trips, and
the endpoint type is resolved from the endpoints config, since a preset
for a Google-compatible endpoint need not carry endpointType and would
otherwise blank the panel.

Sharing the controls also meant inheriting their gaps, which this fixes:

- Number settings declared a range that nothing enforced, so a value past
  the provider's ceiling was persisted and rejected later. clampSettingRange
  applies it, and generateDynamicSchema validates the same rule, so the
  definition is the single source of truth for both.
- Thinking budget bounds are per model. The generic range capped 2.5 Pro
  below its documented 32,768 and admitted Flash values above 24,576.
  positiveMin carries the documented floors while -1 stays typeable as the
  decide-automatically sentinel.
- Ranges the model narrowed are marked modelSpecific, so a switch to a
  model that ignores the parameter cannot rewrite a value set for another.
- useDebouncedInput rebuilt its debouncer every render, because neither
  setOption nor the inline setter is memoized, so pending edits were never
  really superseded and a flush reached an instance holding nothing. The
  callbacks move to refs, and the text and slider controls flush on blur or
  value commit so Save and Export cannot read a stale preset.
- Controls reset to their definition default on a conversation or preset
  change and only recovered ~560ms later, which showed saved values as
  defaults and could write the default back.

The debounce regression test fails against the previous memo dependencies
and passes with the refs, so the flush is verified rather than assumed.

* fix: keep the context token bounds on the Google setting

The bounds came from the hand-rolled Google editor, but they were added to
the shared definition every endpoint renders, so blurring the field clamped
OpenAI, Anthropic, Bedrock and custom endpoints to a window that is only
Gemini's. Custom endpoints may declare context windows outside it.

* fix: agree with the generated schema across the sentinel gap

A stored value between range.min and zero passed through clampSettingRange
unchanged, though the schema admits only the sentinel or the positive floor,
so normalization could preserve a value the provider then rejects. Validate
a configured default against the same rule.

* fix: keep positiveMin on configured parameter definitions

The runtime schema for customParams.paramDefinitions retained only min, max
and step, so a configured positive floor was stripped before the UI saw it
while the shared SettingRange type advertised it.

* fix: commit a double-click slider reset immediately

The browser dispatches dblclick after the second pointer release, so the
value commit has already flushed and the reset sat in the debouncer. Saving
or exporting inside that window read the value the slider no longer showed.

* fix: normalize an out-of-range stored value on mount

The applied-range ref started at the first range, so the effect returned
immediately and a budget saved under the shared range stayed displayed and
savable when the selected model no longer allowed it.

* fix: normalize on navigation and keep sliders out of the sentinel gap

The parameters panel stays mounted across conversations, so a legacy budget
could arrive under a range that never changed; keying the normalization on
the conversation or preset identity as well catches it. After a navigation
the local value still belongs to the conversation being left, so the
incoming stored value is what gets normalized.

A slider steps straight through the gap between a sentinel minimum and its
positive floor, which the generated schema rejects, so the committed value
is clamped. It is also set before the flush: the keyboard path commits
before it reports the change, so the flush alone had nothing to write.

* fix: close the remaining paths into the sentinel gap

Applying a preset over the open conversation replaces the stored value
without changing the conversation id or the model, so normalization now also
triggers on a stored value that arrives differing from the local one. A
value the user typed reaches the conversation through this same field and
matches by the time it lands, so it stays on the blur-clamped path.

The slider's adjacent number input only flushed on blur, so a typed value
could sit in the gap the track is now kept out of.

A configured positiveMin above the maximum admits nothing but the sentinel
while the clamp maps every non-negative input onto a maximum the generated
schema rejects, so both the config schema and the definition validator
refuse it.

* fix: keep a non-negative sentinel and a loadable slider default

The minimum is the sentinel whatever its sign, and the generated schema
admits it outright, so a range like { min: 0, positiveMin: 10 } no longer
has its 0 lifted to the floor by the clamp.

The synthesized slider default took the midpoint of the whole range, which
for a sentinel range lands in the gap the validation added alongside it, so
an otherwise coherent custom definition failed to load. It now takes the
midpoint of the admissible interval.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-08-26 03:21:28 +02:00 committed by GitHub
parent 0383030817
commit 227a99ede8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1154 additions and 336 deletions

View file

@ -1,314 +1,127 @@
import TextareaAutosize from 'react-textarea-autosize';
import { EModelEndpoint, endpointSettings } from 'librechat-data-provider';
import { useEffect, useMemo, useRef } from 'react';
import {
Input,
Label,
Slider,
HoverCard,
InputNumber,
SelectDropDown,
HoverCardTrigger,
} from '@librechat/client';
import type { TModelSelectProps, OnInputNumberChange } from '~/common';
import { cn, defaultTextProps, optionText, removeFocusOutlines, removeFocusRings } from '~/utils';
import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover';
import { useLocalize, useDebouncedInput } from '~/hooks';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
getEndpointField,
getSettingsKeys,
presetSettings,
applyModelAwareDefaults,
clampSettingRange,
} from 'librechat-data-provider';
import type { SettingsConfiguration, SettingDefinition } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { componentMapping } from '~/components/SidePanel/Parameters/components';
import { useGetEndpointsQuery } from '~/data-provider';
export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) {
const localize = useLocalize();
const google = endpointSettings[EModelEndpoint.google];
const {
model,
modelLabel,
promptPrefix,
temperature,
topP,
topK,
maxContextTokens,
maxOutputTokens,
} = conversation ?? {};
export default function GoogleSettings({
conversation,
setOption,
models,
readonly,
}: TModelSelectProps) {
const { data: endpointsConfig } = useGetEndpointsQuery();
const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput<number | null | undefined>(
{
setOption,
optionKey: 'maxContextTokens',
initialValue: maxContextTokens,
},
);
const parameters = useMemo(() => {
/** A preset for a Google-compatible endpoint need not carry `endpointType`,
* so the configured type is resolved the same way EndpointSettings resolves
* it to pick this component. Falling back to the endpoint's custom name
* would miss `presetSettings` entirely and blank the panel. */
const endpointType =
getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ??
conversation?.endpointType;
const model = conversation?.model ?? '';
const [combinedKey, endpointKey] = getSettingsKeys(
endpointType ?? conversation?.endpoint ?? '',
model,
);
const columns = presetSettings[combinedKey] ?? presetSettings[endpointKey];
if (!columns) {
return undefined;
}
if (!conversation) {
/** Google's max output token ceiling moved with Gemini 2.5/3, so the raw
* definition's default is stale for current models. */
const withModelDefaults = (settings: SettingsConfiguration) =>
applyModelAwareDefaults(settings, endpointKey, model);
const col1 = withModelDefaults(columns.col1);
const col2 = withModelDefaults(columns.col2);
return { col1, col2 };
}, [conversation?.endpoint, conversation?.endpointType, conversation?.model, endpointsConfig]);
/** Only the rendered definition follows the model; the value already stored
* in the preset does not. Switching a 2.5 Pro preset to Flash would leave a
* 32,768 thinking budget in place, past the new ceiling, and Save would
* persist it without the untouched field ever being focused. */
const appliedModelRef = useRef<string | undefined>(conversation?.model ?? undefined);
const model = conversation?.model ?? undefined;
useEffect(() => {
if (appliedModelRef.current === model) {
return;
}
appliedModelRef.current = model;
if (!parameters || !setOption) {
return;
}
for (const setting of [...parameters.col1, ...parameters.col2]) {
if (setting == null || setting.type !== 'number' || setting.range == null) {
continue;
}
/** Same marker the field-level clamp uses: a model that does not narrow
* this parameter leaves the shared fallback range in place. */
if (setting.range.modelSpecific !== true) {
continue;
}
const stored = conversation?.[setting.key];
if (typeof stored !== 'number') {
continue;
}
const clamped = clampSettingRange(stored, setting.range);
if (clamped !== stored) {
setOption(setting.key)(clamped);
}
}
}, [model, parameters, conversation, setOption]);
if (!parameters) {
return null;
}
const setModel = setOption('model');
const setModelLabel = setOption('modelLabel');
const setPromptPrefix = setOption('promptPrefix');
const setTemperature = setOption('temperature');
const setTopP = setOption('topP');
const setTopK = setOption('topK');
const setMaxOutputTokens = setOption('maxOutputTokens');
const maxOutputTokensDefault = google.maxOutputTokens.reset(model ?? '');
const renderComponent = (setting: SettingDefinition | undefined) => {
if (!setting) {
return null;
}
const Component = componentMapping[setting.component];
if (!Component) {
return null;
}
const { key, default: defaultValue, ...rest } = setting;
const props = {
key,
settingKey: key,
defaultValue,
...rest,
readonly,
setOption,
conversation,
};
if (key === 'model') {
return <Component {...props} options={models} />;
}
return <Component {...props} />;
};
return (
<div className="grid grid-cols-5 gap-6">
<div className="col-span-5 flex flex-col items-center justify-start gap-6 sm:col-span-3">
<div className="grid w-full items-center gap-2">
<SelectDropDown
title={localize('com_ui_model')}
value={model ?? ''}
setValue={setModel}
availableValues={models}
disabled={readonly}
className={cn(defaultTextProps, 'flex w-full resize-none', removeFocusRings)}
containerClassName="flex w-full resize-none"
/>
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
<div className="flex flex-col gap-6 md:col-span-3">
{parameters.col1.map(renderComponent)}
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="modelLabel" className="text-left text-sm font-medium">
{localize('com_endpoint_custom_name')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
</Label>
<Input
id="modelLabel"
disabled={readonly}
value={modelLabel || ''}
onChange={(e) => setModelLabel(e.target.value ?? null)}
placeholder={localize('com_endpoint_google_custom_name_placeholder')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
removeFocusOutlines,
)}
/>
<div className="flex flex-col gap-6 md:col-span-2">
{parameters.col2.map(renderComponent)}
</div>
<div className="grid w-full items-center gap-2">
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
{localize('com_endpoint_prompt_prefix')}{' '}
<small className="opacity-40">({localize('com_endpoint_default_blank')})</small>
</Label>
<TextareaAutosize
id="promptPrefix"
disabled={readonly}
value={promptPrefix || ''}
onChange={(e) => setPromptPrefix(e.target.value ?? null)}
placeholder={localize('com_endpoint_prompt_prefix_placeholder')}
className={cn(
defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
)}
/>
</div>
</div>
<div className="col-span-5 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-2">
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="mt-1 flex w-full justify-between">
<Label htmlFor="max-context-tokens" className="text-left text-sm font-medium">
{localize('com_endpoint_context_tokens')}{' '}
</Label>
<InputNumber
id="max-context-tokens"
stringMode={false}
disabled={readonly}
value={maxContextTokensValue as number}
onChange={setMaxContextTokens as OnInputNumberChange}
placeholder={localize('com_nav_theme_system')}
min={10}
max={2000000}
step={1000}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light',
'w-1/3',
),
)}
/>
</div>
</HoverCardTrigger>
<OptionHoverAlt
description="com_endpoint_context_info"
langCode={true}
side={ESide.Left}
/>
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
{localize('com_endpoint_temperature')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default')}: {google.temperature.default})
</small>
</Label>
<InputNumber
id="temp-int"
disabled={readonly}
value={temperature}
onChange={(value) => setTemperature(value ?? google.temperature.default)}
max={google.temperature.max}
min={google.temperature.min}
step={google.temperature.step}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[temperature ?? google.temperature.default]}
onValueChange={(value) => setTemperature(value[0])}
onDoubleClick={() => setTemperature(google.temperature.default)}
max={google.temperature.max}
min={google.temperature.min}
step={google.temperature.step}
className="flex h-4 w-full"
aria-labelledby="temp-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="temp" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_p')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: google.topP.default + '' })})
</small>
</Label>
<InputNumber
id="top-p-int"
disabled={readonly}
value={topP}
onChange={(value) => setTopP(value ?? google.topP.default)}
max={google.topP.max}
min={google.topP.min}
step={google.topP.step}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[topP ?? google.topP.default]}
onValueChange={(value) => setTopP(value[0])}
onDoubleClick={() => setTopP(google.topP.default)}
max={google.topP.max}
min={google.topP.min}
step={google.topP.step}
className="flex h-4 w-full"
aria-labelledby="top-p-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topp" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="top-k-int" className="text-left text-sm font-medium">
{localize('com_endpoint_top_k')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: google.topK.default + '' })})
</small>
</Label>
<InputNumber
id="top-k-int"
disabled={readonly}
value={topK}
onChange={(value) => setTopK(value ?? google.topK.default)}
max={google.topK.max}
min={google.topK.min}
step={google.topK.step}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[topK ?? google.topK.default]}
onValueChange={(value) => setTopK(value[0])}
onDoubleClick={() => setTopK(google.topK.default)}
max={google.topK.max}
min={google.topK.min}
step={google.topK.step}
className="flex h-4 w-full"
aria-labelledby="top-k-int"
/>
</HoverCardTrigger>
<OptionHover endpoint={conversation.endpoint ?? ''} type="topk" side={ESide.Left} />
</HoverCard>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label htmlFor="max-tokens-int" className="text-left text-sm font-medium">
{localize('com_endpoint_max_output_tokens')}{' '}
<small className="opacity-40">
(
{localize('com_endpoint_default_with_num', {
0: maxOutputTokensDefault + '',
})}
)
</small>
</Label>
<InputNumber
id="max-tokens-int"
disabled={readonly}
value={maxOutputTokens}
onChange={(value) => setMaxOutputTokens(Number(value))}
max={google.maxOutputTokens.max}
min={google.maxOutputTokens.min}
step={google.maxOutputTokens.step}
controls={false}
className={cn(
defaultTextProps,
cn(
optionText,
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-border-light',
),
)}
/>
</div>
<Slider
disabled={readonly}
value={[maxOutputTokens ?? maxOutputTokensDefault]}
onValueChange={(value) => setMaxOutputTokens(value[0])}
onDoubleClick={() => setMaxOutputTokens(maxOutputTokensDefault)}
max={google.maxOutputTokens.max}
min={google.maxOutputTokens.min}
step={google.maxOutputTokens.step}
className="flex h-4 w-full"
aria-labelledby="max-tokens-int"
/>
</HoverCardTrigger>
<OptionHover
endpoint={conversation.endpoint ?? ''}
type="maxoutputtokens"
side={ESide.Left}
/>
</HoverCard>
</div>
</div>
);

View file

@ -1,5 +1,6 @@
import { OptionTypes, SettingTypes } from 'librechat-data-provider';
import { useEffect, useRef } from 'react';
import { Label, Input, HoverCard, HoverCardTrigger } from '@librechat/client';
import { OptionTypes, SettingTypes, clampSettingRange } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import { cn, sanitizeIntegerInput } from '~/utils';
@ -28,7 +29,9 @@ function DynamicInput({
const localize = useLocalize();
const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput<
string | number
>({
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
@ -61,6 +64,101 @@ function DynamicInput({
setInputValue(e, type === SettingTypes.String ? false : !isNaN(Number(e.target.value)));
};
/** The schema declares a range for number fields, but nothing enforced it, so
* a value past the endpoint's ceiling (a Google output limit above 65535,
* say) was persisted and only rejected later by the provider. Clamping on
* blur rather than on change leaves partially typed numbers alone. */
const handleInputBlur = () => {
/** Clicking Save blurs the field first, so committing the pending edit here
* is what stops submitPreset reading the value from before it. */
flushInputValue();
if (type !== SettingTypes.Number || range == null) {
return;
}
if (inputValue === '' || inputValue == null || inputValue === '-') {
return;
}
const numeric = Number(inputValue);
if (Number.isNaN(numeric)) {
return;
}
const clamped = clampSettingRange(numeric, range);
if (clamped === numeric) {
return;
}
/** Two writes, because one alone is not enough. Going through the debounced
* setter supersedes the out-of-range value typing already queued (lodash
* debounce keeps only the latest args) but would not land for another
* delay, so a Save clicked inside that window would still read the bad
* value. Calling setOption directly closes that window; the later trailing
* invocation then rewrites the same clamped value. */
setInputValue(clamped, true);
setOption?.(settingKey)(clamped);
};
/** A model switch can change this field's bounds while a write typed just
* before it is still queued. Clamping only what is already committed would
* let that queued value land afterwards, past the new ceiling. Re-clamping
* through the same debouncer replaces its arguments, so the queued write
* becomes the corrected one. */
const rangeKey = range != null ? `${range.min}:${range.max}:${range.positiveMin ?? ''}` : '';
/** The panel stays mounted while navigating, so the stored value can be
* replaced under an unchanged range. */
const identityKey = preset?.presetId ?? conversation?.conversationId ?? '';
/** Null rather than the first pair, so a stored value that the shared range
* allowed but the selected model does not is normalized on mount too, not
* only once the user switches models or blurs the field. */
const normalizedRef = useRef<{
identity: string;
range: string;
stored: unknown;
} | null>(null);
const storedValue = conversation?.[settingKey];
useEffect(() => {
const normalized = normalizedRef.current;
const identityChanged = normalized == null || normalized.identity !== identityKey;
const rangeChanged = normalized == null || normalized.range !== rangeKey;
/** Applying a preset over the open conversation replaces the stored value
* without touching either key, and useParameterEffects then pushes it into
* this field. The user's own edits reach the conversation through this
* same field, so by the time they land the two agree and the value stays
* on the blur-clamped path rather than being corrected mid-edit. */
const replacedExternally =
normalized != null && normalized.stored !== storedValue && storedValue !== inputValue;
normalizedRef.current = { identity: identityKey, range: rangeKey, stored: storedValue };
if (!identityChanged && !rangeChanged && !replacedExternally) {
return;
}
if (type !== SettingTypes.Number || range == null) {
return;
}
/** Only a range the model actually narrowed should rewrite a stored value.
* A model that ignores this parameter leaves the shared fallback in place,
* and clamping to that would discard a value set for another model. */
if (range.modelSpecific !== true) {
return;
}
/** A range change is the one trigger where the local value is what matters:
* a write typed just before the model switch is still queued in it. The
* others are a value arriving from outside this field, which the local one
* has not caught up to yet. */
const candidate = identityChanged || replacedExternally ? storedValue : inputValue;
if (candidate === '' || candidate == null || candidate === '-') {
return;
}
const numeric = Number(candidate);
if (Number.isNaN(numeric)) {
return;
}
const clamped = clampSettingRange(numeric, range);
if (clamped === numeric) {
return;
}
setInputValue(clamped, true);
setOption?.(settingKey)(clamped);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identityKey, rangeKey, storedValue]);
const placeholderText = placeholderCode
? localize(placeholder as TranslationKeys) || placeholder
: placeholder;
@ -96,6 +194,7 @@ function DynamicInput({
inputMode={type === 'number' ? 'numeric' : undefined}
value={inputValue ?? defaultValue ?? ''}
onChange={handleInputChange}
onBlur={handleInputBlur}
placeholder={placeholderText}
className={cn(
'flex h-9 max-h-9 w-full resize-none rounded-lg border border-border-light bg-surface-secondary px-3 py-2',

View file

@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import { OptionTypes, clampSettingRange } from 'librechat-data-provider';
import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '@librechat/client';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
@ -33,7 +33,9 @@ function DynamicSlider({
[options, range],
);
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput<
string | number
>({
optionKey: settingKey,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
@ -123,6 +125,19 @@ function DynamicSlider({
return String(defaultValue ?? '');
}, [defaultValue, enumMappings, localize]);
/** A typed value can land in the gap between a sentinel minimum and its
* positive floor, which the generated schema rejects. Corrected on blur
* rather than while the number is still being typed. */
const handleNumberBlur = useCallback(() => {
if (range != null && inputValue != null && inputValue !== '') {
const numeric = Number(inputValue);
if (Number.isFinite(numeric)) {
setInputValue(clampSettingRange(numeric, range));
}
}
flushInputValue();
}, [range, inputValue, setInputValue, flushInputValue]);
const handleValueChange = useCallback(
(value: number) => {
if (isEnum) {
@ -175,6 +190,9 @@ function DynamicSlider({
disabled={readonly}
value={inputValue ?? defaultValue}
onChange={(value) => setInputValue(Number(value))}
/** Clicking Save blurs this first, so the pending edit is
* committed before submitPreset reads the preset. */
onBlur={handleNumberBlur}
max={range ? range.max : (options?.length ?? 0) - 1}
min={range ? range.min : 0}
step={range ? (range.step ?? 1) : 1}
@ -214,7 +232,28 @@ function DynamicSlider({
: ((inputValue as number) ?? (defaultValue as number)),
]}
onValueChange={(value) => handleValueChange(value[0])}
onDoubleClick={() => setInputValue(defaultValue as string | number)}
/** Fires once the drag or keypress settles, which is the point the
* chosen value should be in the preset rather than pending. It is
* set again here before flushing because the keyboard path commits
* before it reports the change, leaving the debouncer empty for a
* flush that only follows the drag path. The track also steps
* straight through the gap between a sentinel minimum and its
* positive floor, which the generated schema rejects, so the
* released value has to land outside it. */
onValueCommit={(value) => {
if (!isEnum && range != null) {
setInputValue(clampSettingRange(value[0], range));
}
flushInputValue();
}}
/** The browser dispatches this after the second release, so the
* commit above has already fired and the reset would otherwise sit
* in the debouncer while an action clicked next reads the old
* value. */
onDoubleClick={() => {
setInputValue(defaultValue as string | number);
flushInputValue();
}}
max={max}
aria-label={localize(label as TranslationKeys)}
min={range ? range.min : 0}

View file

@ -26,7 +26,9 @@ function DynamicTextarea({
const localize = useLocalize();
const { preset } = useChatContext();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | null>({
const [setInputValue, inputValue, setLocalValue, flushInputValue] = useDebouncedInput<
string | null
>({
optionKey: settingKey,
initialValue:
optionType !== OptionTypes.Custom
@ -75,6 +77,9 @@ function DynamicTextarea({
disabled={readonly}
value={inputValue ?? ''}
onChange={setInputValue}
/** Clicking Save blurs this first, so the pending edit is committed
* before submitPreset reads the preset. */
onBlur={flushInputValue}
aria-label={localize(label as TranslationKeys)}
placeholder={
placeholderCode

View file

@ -13,10 +13,12 @@ function setup({
type,
range,
settingKey,
conversation = {},
}: {
type: 'number' | 'string';
range?: SettingRange;
settingKey: string;
conversation?: Record<string, unknown>;
}) {
const commit = jest.fn();
const setOption = jest.fn(() => commit) as unknown as TSetOption;
@ -27,13 +29,35 @@ function setup({
type={type}
range={range}
setOption={setOption}
conversation={{}}
conversation={conversation}
/>
</ChatContext.Provider>,
);
return { input: screen.getByRole('textbox'), commit };
}
function setupNavigable(range: SettingRange, conversation: Record<string, unknown>) {
const commit = jest.fn();
const setOption = jest.fn(() => commit) as unknown as TSetOption;
const tree = (next: Record<string, unknown>) => (
<ChatContext.Provider value={chatContextValue}>
<DynamicInput
settingKey="thinkingBudget"
type="number"
range={range}
setOption={setOption}
conversation={next}
/>
</ChatContext.Provider>
);
const { rerender } = render(tree(conversation));
return {
commit,
input: () => screen.getByRole('textbox'),
navigate: (next: Record<string, unknown>) => rerender(tree(next)),
};
}
describe('DynamicInput', () => {
beforeEach(() => {
jest.useFakeTimers();
@ -110,6 +134,173 @@ describe('DynamicInput', () => {
expect(commit).toHaveBeenLastCalledWith(-1);
});
it('keeps thinkingBudget -1 on blur when a model-specific positive floor is set', () => {
const { input, commit } = setup({
type: 'number',
range: { min: -1, max: 32768, step: 1, positiveMin: 128 },
settingKey: 'thinkingBudget',
});
fireEvent.change(input, { target: { value: '-1' } });
fireEvent.blur(input);
expect(input).toHaveValue('-1');
act(() => {
jest.advanceTimersByTime(500);
});
expect(commit).toHaveBeenLastCalledWith(-1);
});
/**
* A preset saved while the shared range was in force can hold a budget the
* selected model rejects. Waiting for a model switch or a blur would leave it
* displayed, savable and sendable.
*/
it('normalizes a stored value the selected model no longer allows on mount', () => {
const { input, commit } = setup({
type: 'number',
range: { min: -1, max: 24576, step: 1, positiveMin: 0, modelSpecific: true },
settingKey: 'thinkingBudget',
conversation: { thinkingBudget: 32000 },
});
expect(input).toHaveValue('24576');
expect(commit).toHaveBeenLastCalledWith(24576);
});
it('leaves a stored value inside the model range untouched on mount', () => {
const { input, commit } = setup({
type: 'number',
range: { min: -1, max: 24576, step: 1, positiveMin: 0, modelSpecific: true },
settingKey: 'thinkingBudget',
conversation: { thinkingBudget: 2048 },
});
expect(input).toHaveValue('2048');
expect(commit).not.toHaveBeenCalled();
});
/** The shared fallback stays in place for models that ignore the parameter,
* so clamping to it would discard a value set for another model. */
it('does not normalize on mount against a range the model did not narrow', () => {
const { input, commit } = setup({
type: 'number',
range: { min: -1, max: 24576, step: 1 },
settingKey: 'thinkingBudget',
conversation: { thinkingBudget: 32000 },
});
expect(input).toHaveValue('32000');
expect(commit).not.toHaveBeenCalled();
});
/**
* The panel stays mounted across conversations, so a legacy value can arrive
* under a range that never changed. Keying only on the range left it above
* the model ceiling, savable and sendable.
*/
it('normalizes when a navigation brings in a value the same range rejects', () => {
const range: SettingRange = {
min: -1,
max: 24576,
step: 1,
positiveMin: 0,
modelSpecific: true,
};
const { navigate, input, commit } = setupNavigable(range, {
conversationId: 'convo-a',
thinkingBudget: 2048,
});
expect(commit).not.toHaveBeenCalled();
navigate({ conversationId: 'convo-b', thinkingBudget: 32000 });
expect(input()).toHaveValue('24576');
expect(commit).toHaveBeenLastCalledWith(24576);
});
it('leaves a navigation alone when the incoming value fits the range', () => {
const range: SettingRange = {
min: -1,
max: 24576,
step: 1,
positiveMin: 0,
modelSpecific: true,
};
const { navigate, commit } = setupNavigable(range, {
conversationId: 'convo-a',
thinkingBudget: 2048,
});
navigate({ conversationId: 'convo-b', thinkingBudget: 4096 });
expect(commit).not.toHaveBeenCalled();
});
/**
* Applying a preset over the open conversation keeps the conversation id and
* the model, so neither key moves; the value simply arrives.
*/
it('normalizes a stored value replaced under an unchanged conversation', () => {
const range: SettingRange = {
min: -1,
max: 24576,
step: 1,
positiveMin: 0,
modelSpecific: true,
};
const { navigate, input, commit } = setupNavigable(range, {
conversationId: 'convo-a',
thinkingBudget: 2048,
});
navigate({ conversationId: 'convo-a', thinkingBudget: 32000 });
expect(input()).toHaveValue('24576');
expect(commit).toHaveBeenLastCalledWith(24576);
});
/**
* A typed value reaches the conversation through this same field, so the two
* agree by the time it lands. Correcting it there would fight the user
* mid-edit, which is why clamping belongs on blur.
*/
it('leaves the value the user typed to the blur clamp when it lands', () => {
const range: SettingRange = {
min: -1,
max: 32768,
step: 1,
positiveMin: 128,
modelSpecific: true,
};
const { navigate, input, commit } = setupNavigable(range, {
conversationId: 'convo-a',
thinkingBudget: 2048,
});
fireEvent.change(input(), { target: { value: '50' } });
/** The debounced write reaching the conversation, which is what the
* normalization would otherwise read as an external replacement. */
navigate({ conversationId: 'convo-a', thinkingBudget: 50 });
expect(input()).toHaveValue('50');
expect(commit).not.toHaveBeenCalledWith(128);
});
it('clamps a positive thinkingBudget below the model floor on blur', () => {
const { input, commit } = setup({
type: 'number',
range: { min: -1, max: 32768, step: 1, positiveMin: 128 },
settingKey: 'thinkingBudget',
});
fireEvent.change(input, { target: { value: '50' } });
fireEvent.blur(input);
expect(input).toHaveValue('128');
expect(commit).toHaveBeenLastCalledWith(128);
});
it('drops the minus sign when the range does not permit negatives', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const { input, commit } = setup({ type: 'number', settingKey: 'max_tokens' });

View file

@ -0,0 +1,128 @@
import React from 'react';
import { render, screen, act, fireEvent } from '@testing-library/react';
import type { TSetOption, SettingRange } from 'librechat-data-provider';
import DynamicSlider from '../DynamicSlider';
import { ChatContext } from '~/Providers';
type ChatContextValue = React.ContextType<typeof ChatContext>;
const chatContextValue = { preset: null } as unknown as ChatContextValue;
const range: SettingRange = { min: 0, max: 2, step: 0.01 };
function setup(conversationValue: number) {
const commit = jest.fn();
const setOption = jest.fn(() => commit) as unknown as TSetOption;
render(
<ChatContext.Provider value={chatContextValue}>
<DynamicSlider
settingKey="temperature"
label="Temperature"
type="number"
range={range}
defaultValue={1}
setOption={setOption}
conversation={{ temperature: conversationValue }}
/>
</ChatContext.Provider>,
);
return { slider: screen.getByRole('slider'), commit };
}
const sentinelRange: SettingRange = { min: -1, max: 32768, step: 1, positiveMin: 128 };
function setupSentinel(conversationValue: number) {
const commit = jest.fn();
const setOption = jest.fn(() => commit) as unknown as TSetOption;
render(
<ChatContext.Provider value={chatContextValue}>
<DynamicSlider
settingKey="thinkingBudget"
label="Thinking budget"
type="number"
range={sentinelRange}
defaultValue={-1}
setOption={setOption}
conversation={{ thinkingBudget: conversationValue }}
/>
</ChatContext.Provider>,
);
return { slider: screen.getByRole('slider'), commit };
}
describe('DynamicSlider', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
/**
* The browser dispatches `dblclick` after the second pointer release, so the
* commit has already flushed by the time the reset is queued. An action
* clicked in the debounce window would otherwise read the pre-reset value.
*/
it('commits a double-click reset without waiting for the debounce', () => {
const { slider, commit } = setup(0.2);
fireEvent.doubleClick(slider);
expect(commit).toHaveBeenCalledWith(1);
});
it('still lands the reset exactly once after the debounce elapses', () => {
const { slider, commit } = setup(0.2);
fireEvent.doubleClick(slider);
act(() => {
jest.advanceTimersByTime(1000);
});
expect(commit).toHaveBeenCalledTimes(1);
expect(commit).toHaveBeenLastCalledWith(1);
});
/**
* A configured sentinel range leaves a gap the track steps straight through,
* and `generateDynamicSchema` rejects exactly those values, so the UI must not
* be able to persist one.
*/
it('lifts a committed value out of the sentinel gap', () => {
const { slider, commit } = setupSentinel(-1);
fireEvent.keyDown(slider, { key: 'ArrowRight' });
expect(commit).toHaveBeenLastCalledWith(128);
});
it('leaves a committed value outside the gap alone', () => {
const { slider, commit } = setupSentinel(2048);
fireEvent.keyDown(slider, { key: 'ArrowRight' });
expect(commit).toHaveBeenLastCalledWith(2049);
});
/** The adjacent number input reaches the same gap the track does. */
it('lifts a typed value out of the sentinel gap on blur', () => {
const { commit } = setupSentinel(-1);
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '50' } });
fireEvent.blur(input);
expect(commit).toHaveBeenLastCalledWith(128);
});
it('leaves a typed value outside the gap alone on blur', () => {
const { commit } = setupSentinel(-1);
const input = screen.getByRole('spinbutton');
fireEvent.change(input, { target: { value: '2048' } });
fireEvent.blur(input);
expect(commit).toHaveBeenLastCalledWith(2048);
});
});

View file

@ -0,0 +1,65 @@
import { renderHook, act } from '@testing-library/react';
import useDebouncedInput from '../useDebouncedInput';
describe('useDebouncedInput', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
/** Callers do not memoize either callback: setOption is a plain arrow in
* useSetIndexOptions and the dynamic settings pass `setter` inline. If the
* debouncer depended on them it would be rebuilt every render, so the flush
* would reach an instance holding nothing while the previous instance's
* timer still fired the superseded value. */
it('flushes the pending edit even though the callbacks change identity', () => {
const committed: unknown[] = [];
const render = () =>
renderHook(
({ tick }: { tick: number }) =>
useDebouncedInput<string>({
optionKey: 'promptPrefix',
initialValue: '',
/** A new function every render, as the real call sites pass. */
setOption: () => (value: unknown) => committed.push([tick, value]),
setter: () => ({}) as never,
}),
{ initialProps: { tick: 0 } },
);
const { result, rerender } = render();
act(() => {
result.current[0]('edited', false);
});
rerender({ tick: 1 });
expect(committed).toHaveLength(0);
act(() => {
result.current[3]();
});
expect(committed).toHaveLength(1);
expect((committed[0] as unknown[])[1]).toBe('edited');
});
it('does not commit before the delay elapses', () => {
const committed: unknown[] = [];
const { result } = renderHook(() =>
useDebouncedInput<string>({
optionKey: 'promptPrefix',
initialValue: '',
setOption: () => (value: unknown) => committed.push(value),
setter: () => ({}) as never,
}),
);
act(() => {
result.current[0]('typed', false);
});
expect(committed).toHaveLength(0);
act(() => {
jest.advanceTimersByTime(1000);
});
expect(committed).toEqual(['typed']);
});
});

View file

@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import debounce from 'lodash/debounce';
import React, { useState, useCallback, useMemo } from 'react';
import type { SetterOrUpdater } from 'recoil';
import type { TSetOption } from '~/common';
import { defaultDebouncedDelay } from '~/common';
@ -23,16 +23,38 @@ function useDebouncedInput<T = unknown>({
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T, numeric?: boolean) => void,
T,
SetterOrUpdater<T>,
// (newValue: string) => void,
() => void,
] {
const [value, setValue] = useState<T>(initialValue);
/** A debounced function to call the passed setOption with the optionKey and new value.
*
Note: We use useMemo to ensure our debounced function is stable across renders and properly typed. */
/** The callbacks are read through refs so the debounced function itself stays
* stable. Neither is memoized by its caller: `setOption` is a plain arrow in
* useSetIndexOptions and the dynamic settings pass `setter: () => ({})`
* inline, so depending on them rebuilt the debouncer on every render. Each
* render then produced a fresh instance while the previous one kept its
* timer, which left `flush()` pointing at an instance with nothing pending
* and let a superseded value land after a correction. */
const setOptionRef = useRef(setOption);
const setterRef = useRef(setter);
setOptionRef.current = setOption;
setterRef.current = setter;
/** A debounced function to call the passed setOption with the optionKey and new value. */
const setDebouncedOption = useMemo(
() => debounce(setOption && optionKey ? setOption(optionKey) : setter || (() => {}), delay),
[setOption, optionKey, setter, delay],
() =>
debounce((newValue: T) => {
const currentSetOption = setOptionRef.current;
if (currentSetOption && optionKey != null) {
/** T is the caller's field type; TSetOption is typed against the whole
* conversation union, which does not narrow per key. The previous
* form passed the setter to debounce directly and inherited the same
* looseness. */
(currentSetOption(optionKey) as (value: T) => void)(newValue);
return;
}
setterRef.current?.(newValue);
}, delay),
[optionKey, delay],
);
/** An onChange handler that updates the local state and the debounced option */
@ -52,7 +74,13 @@ function useDebouncedInput<T = unknown>({
},
[setDebouncedOption],
);
return [onChange, value, setValue];
/** Lets a caller commit a pending edit immediately, e.g. on blur, so a Save
* clicked in the same gesture does not read the pre-edit value. */
const flush = useCallback(() => {
setDebouncedOption.flush();
}, [setDebouncedOption]);
return [onChange, value, setValue, flush];
}
export default useDebouncedInput;

View file

@ -47,8 +47,12 @@ function useParameterEffects<T = unknown>({
}
idRef.current = conversationId;
setInputValue(defaultValue as T);
}, [setInputValue, conversation?.conversationId, defaultValue]);
/** Seed from what the conversation already holds, falling back to the
* definition default. Resetting to the default unconditionally made saved
* values read as defaults until the delayed sync below corrected them, and
* editing inside that window wrote the default back. */
setInputValue((conversation?.[settingKey] ?? defaultValue) as T);
}, [setInputValue, conversation, conversation?.conversationId, settingKey, defaultValue]);
/** Resets the local state if presetId changed */
useEffect(() => {
@ -62,8 +66,8 @@ function useParameterEffects<T = unknown>({
}
presetIdRef.current = presetId;
setInputValue(defaultValue as T);
}, [setInputValue, preset?.presetId, defaultValue]);
setInputValue((preset?.[settingKey] ?? conversation?.[settingKey] ?? defaultValue) as T);
}, [setInputValue, preset, preset?.presetId, conversation, settingKey, defaultValue]);
}
export default useParameterEffects;

View file

@ -286,7 +286,6 @@
"com_endpoint_export": "Export",
"com_endpoint_export_share": "Export/Share",
"com_endpoint_frequency_penalty": "Frequency Penalty",
"com_endpoint_google_custom_name_placeholder": "Set a custom name for Google",
"com_endpoint_google_maxoutputtokens": "Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.",
"com_endpoint_google_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.",
"com_endpoint_google_thinking": "Enables or disables reasoning. Supported by Gemini 2.5 and 3 series. Note: Gemini 3 Pro cannot fully disable thinking.",
@ -352,7 +351,6 @@
"com_endpoint_prompt_prefix": "Custom Instructions",
"com_endpoint_prompt_prefix_assistants": "Additional Instructions",
"com_endpoint_prompt_prefix_assistants_placeholder": "Set additional instructions or context on top of the Assistant's main instructions. Ignored if empty.",
"com_endpoint_prompt_prefix_placeholder": "Set custom instructions or context. Ignored if empty.",
"com_endpoint_reasoning_context": "Reasoning Context",
"com_endpoint_reasoning_effort": "Reasoning Effort",
"com_endpoint_reasoning_mode": "Reasoning Mode",