feat: Add Google Parameters, Ollama/Openrouter Reasoning, & UI Optimizations (#5456)

* feat: Google Model Parameters

* fix: dynamic input number value, previously coerced by zod schema

* refactor: support openrouter reasoning tokens and XML for thinking directive to conform to ollama

* fix: virtualize combobox to prevent performance drop on re-renders of long model/agent/assistant lists

* refactor: simplify Fork component by removing unnecessary chat context index

* fix: prevent rendering of Thinking component when children are null

* refactor: update Markdown component to replace <think> tags and simplify remarkPlugins configuration

* refactor: reorder remarkPlugins to improve plugin configuration in Markdown component
This commit is contained in:
Danny Avila 2025-01-24 18:15:47 -05:00 committed by GitHub
parent 7818ae5c60
commit af430e46f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 200 additions and 50 deletions

View file

@ -1253,6 +1253,12 @@ ${convo}
delete modelOptions.stop; delete modelOptions.stop;
} }
let reasoningKey = 'reasoning_content';
if (this.useOpenRouter) {
modelOptions.include_reasoning = true;
reasoningKey = 'reasoning';
}
if (modelOptions.stream) { if (modelOptions.stream) {
streamPromise = new Promise((resolve) => { streamPromise = new Promise((resolve) => {
streamResolve = resolve; streamResolve = resolve;
@ -1291,14 +1297,14 @@ ${convo}
let reasoningCompleted = false; let reasoningCompleted = false;
for await (const chunk of stream) { for await (const chunk of stream) {
if (chunk?.choices?.[0]?.delta?.reasoning_content) { if (chunk?.choices?.[0]?.delta?.[reasoningKey]) {
if (reasoningTokens.length === 0) { if (reasoningTokens.length === 0) {
const thinkingDirective = ':::thinking\n'; const thinkingDirective = '<think>\n';
intermediateReply.push(thinkingDirective); intermediateReply.push(thinkingDirective);
reasoningTokens.push(thinkingDirective); reasoningTokens.push(thinkingDirective);
onProgress(thinkingDirective); onProgress(thinkingDirective);
} }
const reasoning_content = chunk?.choices?.[0]?.delta?.reasoning_content || ''; const reasoning_content = chunk?.choices?.[0]?.delta?.[reasoningKey] || '';
intermediateReply.push(reasoning_content); intermediateReply.push(reasoning_content);
reasoningTokens.push(reasoning_content); reasoningTokens.push(reasoning_content);
onProgress(reasoning_content); onProgress(reasoning_content);
@ -1307,7 +1313,7 @@ ${convo}
const token = chunk?.choices?.[0]?.delta?.content || ''; const token = chunk?.choices?.[0]?.delta?.content || '';
if (!reasoningCompleted && reasoningTokens.length > 0 && token) { if (!reasoningCompleted && reasoningTokens.length > 0 && token) {
reasoningCompleted = true; reasoningCompleted = true;
const separatorTokens = '\n:::\n'; const separatorTokens = '\n</think>\n';
reasoningTokens.push(separatorTokens); reasoningTokens.push(separatorTokens);
onProgress(separatorTokens); onProgress(separatorTokens);
} }

View file

@ -90,6 +90,7 @@
"react-speech-recognition": "^3.10.0", "react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.4.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-virtualized": "^9.22.6",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",

View file

@ -16,6 +16,10 @@ const Thinking = ({ children }: ThinkingProps) => {
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
}; };
if (children == null) {
return null;
}
return ( return (
<div className="mb-3"> <div className="mb-3">
<button <button

View file

@ -157,13 +157,13 @@ type TContentProps = {
const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => { const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentProps) => {
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing); const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const codeArtifacts = useRecoilValue<boolean>(store.codeArtifacts);
const isInitializing = content === ''; const isInitializing = content === '';
let currentContent = content; let currentContent = content;
if (!isInitializing) { if (!isInitializing) {
currentContent = currentContent.replace('z-index: 1;', '') || ''; currentContent = currentContent.replace('<think>', ':::thinking') || '';
currentContent = currentContent.replace('</think>', ':::') || '';
currentContent = LaTeXParsing ? preprocessLaTeX(currentContent) : currentContent; currentContent = LaTeXParsing ? preprocessLaTeX(currentContent) : currentContent;
} }
@ -189,15 +189,13 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
); );
} }
const remarkPlugins: Pluggable[] = codeArtifacts const remarkPlugins: Pluggable[] = [
? [ supersub,
supersub, remarkGfm,
remarkGfm, remarkDirective,
[remarkMath, { singleDollarTextMath: true }], artifactPlugin,
remarkDirective, [remarkMath, { singleDollarTextMath: true }],
artifactPlugin, ];
]
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
return ( return (
<ArtifactProvider> <ArtifactProvider>

View file

@ -12,9 +12,9 @@ import {
HoverCardContent, HoverCardContent,
} from '~/components/ui'; } from '~/components/ui';
import OptionHover from '~/components/SidePanel/Parameters/OptionHover'; import OptionHover from '~/components/SidePanel/Parameters/OptionHover';
import { useToastContext, useChatContext } from '~/Providers';
import { useLocalize, useNavigateToConvo } from '~/hooks'; import { useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider'; import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { ESide } from '~/common'; import { ESide } from '~/common';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
@ -112,10 +112,9 @@ export default function Fork({
latestMessageId?: string; latestMessageId?: string;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { index } = useChatContext();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const { navigateToConvo } = useNavigateToConvo(index); const { navigateToConvo } = useNavigateToConvo();
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting); const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
const [activeSetting, setActiveSetting] = useState(optionLabels.default); const [activeSetting, setActiveSetting] = useState(optionLabels.default);

View file

@ -2,10 +2,10 @@ import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider'; import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks'; import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui'; import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui';
import { cn, defaultTextProps } from '~/utils';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover'; import OptionHover from './OptionHover';
import { ESide } from '~/common'; import { ESide } from '~/common';
import { cn } from '~/utils';
function DynamicInput({ function DynamicInput({
label = '', label = '',
@ -50,7 +50,7 @@ function DynamicInput({
const value = e.target.value; const value = e.target.value;
if (type === 'number') { if (type === 'number') {
if (!isNaN(Number(value))) { if (!isNaN(Number(value))) {
setInputValue(e); setInputValue(e, true);
} }
} else { } else {
setInputValue(e); setInputValue(e);
@ -70,7 +70,7 @@ function DynamicInput({
htmlFor={`${settingKey}-dynamic-input`} htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium" className="text-left text-sm font-medium"
> >
{labelCode ? localize(label) ?? label : label || settingKey}{' '} {labelCode ? localize(label) || label : label || settingKey}{' '}
{showDefault && ( {showDefault && (
<small className="opacity-40"> <small className="opacity-40">
( (
@ -87,7 +87,7 @@ function DynamicInput({
disabled={readonly} disabled={readonly}
value={inputValue ?? ''} value={inputValue ?? ''}
onChange={handleInputChange} onChange={handleInputChange}
placeholder={placeholderCode ? localize(placeholder) ?? placeholder : placeholder} placeholder={placeholderCode ? localize(placeholder) || placeholder : placeholder}
className={cn( className={cn(
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2', 'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
)} )}
@ -95,7 +95,7 @@ function DynamicInput({
</HoverCardTrigger> </HoverCardTrigger>
{description && ( {description && (
<OptionHover <OptionHover
description={descriptionCode ? localize(description) ?? description : description} description={descriptionCode ? localize(description) || description : description}
side={ESide.Left} side={ESide.Left}
/> />
)} )}

View file

@ -2,6 +2,7 @@ import {
ImageDetail, ImageDetail,
EModelEndpoint, EModelEndpoint,
openAISettings, openAISettings,
googleSettings,
BedrockProviders, BedrockProviders,
anthropicSettings, anthropicSettings,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
@ -352,6 +353,87 @@ const meta: Record<string, SettingDefinition> = {
}), }),
}; };
const google: Record<string, SettingDefinition> = {
temperature: createDefinition(baseDefinitions.temperature, {
default: googleSettings.temperature.default,
range: {
min: googleSettings.temperature.min,
max: googleSettings.temperature.max,
step: googleSettings.temperature.step,
},
}),
topP: createDefinition(baseDefinitions.topP, {
default: googleSettings.topP.default,
range: {
min: googleSettings.topP.min,
max: googleSettings.topP.max,
step: googleSettings.topP.step,
},
}),
topK: {
key: 'topK',
label: 'com_endpoint_top_k',
labelCode: true,
description: 'com_endpoint_google_topk',
descriptionCode: true,
type: 'number',
default: googleSettings.topK.default,
range: {
min: googleSettings.topK.min,
max: googleSettings.topK.max,
step: googleSettings.topK.step,
},
component: 'slider',
optionType: 'model',
columnSpan: 4,
},
maxOutputTokens: {
key: 'maxOutputTokens',
label: 'com_endpoint_max_output_tokens',
labelCode: true,
type: 'number',
component: 'input',
description: 'com_endpoint_google_maxoutputtokens',
descriptionCode: true,
placeholder: 'com_nav_theme_system',
placeholderCode: true,
default: googleSettings.maxOutputTokens.default,
range: {
min: googleSettings.maxOutputTokens.min,
max: googleSettings.maxOutputTokens.max,
step: googleSettings.maxOutputTokens.step,
},
optionType: 'model',
columnSpan: 2,
},
};
const googleConfig: SettingsConfiguration = [
librechat.modelLabel,
librechat.promptPrefix,
librechat.maxContextTokens,
google.maxOutputTokens,
google.temperature,
google.topP,
google.topK,
librechat.resendFiles,
];
const googleCol1: SettingsConfiguration = [
baseDefinitions.model as SettingDefinition,
librechat.modelLabel,
librechat.promptPrefix,
];
const googleCol2: SettingsConfiguration = [
librechat.maxContextTokens,
google.maxOutputTokens,
google.temperature,
google.topP,
google.topK,
librechat.resendFiles,
];
const openAI: SettingsConfiguration = [ const openAI: SettingsConfiguration = [
openAIParams.chatGptLabel, openAIParams.chatGptLabel,
librechat.promptPrefix, librechat.promptPrefix,
@ -529,6 +611,7 @@ export const settings: Record<string, SettingsConfiguration | undefined> = {
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneral, [`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneral, [`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneral,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneral, [`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneral,
[EModelEndpoint.google]: googleConfig,
}; };
const openAIColumns = { const openAIColumns = {
@ -571,6 +654,10 @@ export const presetSettings: Record<
[`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneralColumns, [`${EModelEndpoint.bedrock}-${BedrockProviders.Meta}`]: bedrockGeneralColumns,
[`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneralColumns, [`${EModelEndpoint.bedrock}-${BedrockProviders.AI21}`]: bedrockGeneralColumns,
[`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneralColumns, [`${EModelEndpoint.bedrock}-${BedrockProviders.Amazon}`]: bedrockGeneralColumns,
[EModelEndpoint.google]: {
col1: googleCol1,
col2: googleCol2,
},
}; };
export const agentSettings: Record<string, SettingsConfiguration | undefined> = Object.entries( export const agentSettings: Record<string, SettingsConfiguration | undefined> = Object.entries(

View file

@ -1,5 +1,6 @@
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import { AutoSizer, List } from 'react-virtualized';
import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react'; import { startTransition, useMemo, useState, useEffect, useRef, memo } from 'react';
import { cn } from '~/utils'; import { cn } from '~/utils';
import type { OptionWithIcon } from '~/common'; import type { OptionWithIcon } from '~/common';
@ -17,6 +18,8 @@ interface ControlComboboxProps {
SelectIcon?: React.ReactNode; SelectIcon?: React.ReactNode;
} }
const ROW_HEIGHT = 36;
function ControlCombobox({ function ControlCombobox({
selectedValue, selectedValue,
displayValue, displayValue,
@ -45,6 +48,39 @@ function ControlCombobox({
} }
}, [isCollapsed]); }, [isCollapsed]);
const rowRenderer = ({
index,
key,
style,
}: {
index: number;
key: string;
style: React.CSSProperties;
}) => {
const item = matches[index];
return (
<Ariakit.SelectItem
key={key}
value={`${item.value ?? ''}`}
aria-label={`${item.label ?? item.value ?? ''}`}
className={cn(
'flex cursor-pointer items-center px-3 text-sm',
'text-text-primary hover:bg-surface-tertiary',
'data-[active-item]:bg-surface-tertiary',
)}
render={<Ariakit.ComboboxItem />}
style={style}
>
{item.icon != null && (
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{item.icon}
</div>
)}
<span className="flex-grow truncate text-left">{item.label}</span>
</Ariakit.SelectItem>
);
};
return ( return (
<div className="flex w-full items-center justify-center px-1"> <div className="flex w-full items-center justify-center px-1">
<Ariakit.ComboboxProvider <Ariakit.ComboboxProvider
@ -93,28 +129,20 @@ function ControlCombobox({
/> />
</div> </div>
</div> </div>
<Ariakit.ComboboxList className="max-h-[50vh] overflow-auto"> <div className="max-h-[50vh]">
{matches.map((item) => ( <AutoSizer disableHeight>
<Ariakit.SelectItem {({ width }) => (
key={item.value} <List
value={`${item.value ?? ''}`} width={width}
aria-label={`${item.label ?? item.value ?? ''}`} height={Math.min(matches.length * ROW_HEIGHT, 300)}
className={cn( rowCount={matches.length}
'flex cursor-pointer items-center px-3 py-2 text-sm', rowHeight={ROW_HEIGHT}
'text-text-primary hover:bg-surface-tertiary', rowRenderer={rowRenderer}
'data-[active-item]:bg-surface-tertiary', overscanRowCount={5}
)} />
render={<Ariakit.ComboboxItem />} )}
> </AutoSizer>
{item.icon != null && ( </div>
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
{item.icon}
</div>
)}
<span className="flex-grow truncate text-left">{item.label}</span>
</Ariakit.SelectItem>
))}
</Ariakit.ComboboxList>
</Ariakit.SelectPopover> </Ariakit.SelectPopover>
</Ariakit.SelectProvider> </Ariakit.SelectProvider>
</Ariakit.ComboboxProvider> </Ariakit.ComboboxProvider>

View file

@ -20,7 +20,7 @@ function useDebouncedInput<T = unknown>({
initialValue: T; initialValue: T;
delay?: number; delay?: number;
}): [ }): [
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T) => void, (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T, numeric?: boolean) => void,
T, T,
SetterOrUpdater<T>, SetterOrUpdater<T>,
// (newValue: string) => void, // (newValue: string) => void,
@ -37,12 +37,15 @@ function useDebouncedInput<T = unknown>({
/** An onChange handler that updates the local state and the debounced option */ /** An onChange handler that updates the local state and the debounced option */
const onChange = useCallback( const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T) => { (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T, numeric?: boolean) => {
const newValue: T = let newValue: T =
typeof e !== 'object' typeof e !== 'object'
? e ? e
: ((e as React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>).target : ((e as React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>).target
.value as unknown as T); .value as unknown as T);
if (numeric === true) {
newValue = Number(newValue) as unknown as T;
}
setValue(newValue); setValue(newValue);
setDebouncedOption(newValue); setDebouncedOption(newValue);
}, },

25
package-lock.json generated
View file

@ -952,6 +952,7 @@
"react-speech-recognition": "^3.10.0", "react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0", "react-textarea-autosize": "^8.4.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"react-virtualized": "^9.22.6",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"rehype-highlight": "^6.0.0", "rehype-highlight": "^6.0.0",
@ -29460,6 +29461,11 @@
"react-dom": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x" "react-dom": "^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x"
} }
}, },
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz",
@ -29760,6 +29766,23 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/react-virtualized": {
"version": "9.22.6",
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz",
"integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==",
"dependencies": {
"@babel/runtime": "^7.7.2",
"clsx": "^1.0.4",
"dom-helpers": "^5.1.3",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -35067,7 +35090,7 @@
}, },
"packages/data-provider": { "packages/data-provider": {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.7.693", "version": "0.7.694",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",

View file

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

View file

@ -38,6 +38,7 @@ export const paramEndpoints = new Set<EModelEndpoint | string>([
EModelEndpoint.azureOpenAI, EModelEndpoint.azureOpenAI,
EModelEndpoint.anthropic, EModelEndpoint.anthropic,
EModelEndpoint.custom, EModelEndpoint.custom,
EModelEndpoint.google,
]); ]);
export enum BedrockProviders { export enum BedrockProviders {