📦 feat: Model & Assistants Combobox for Side Panel (#2380)

* WIP: dynamic settings

* WIP: update tests and validations

* refactor(SidePanel): use hook for Links

* WIP: dynamic settings, slider implemented

* feat(useDebouncedInput): dynamic typing with generic

* refactor(generate): add `custom` optionType to be non-conforming to conversation schema

* feat: DynamicDropdown

* refactor(DynamicSlider): custom optionType handling and useEffect for conversation updates elsewhere

* refactor(Panel): add more test cases

* chore(DynamicSlider): note

* refactor(useDebouncedInput): import defaultDebouncedDelay from ~/common`

* WIP: implement remaining ComponentTypes

* chore: add com_sidepanel_parameters

* refactor: add langCode handling for dynamic settings

* chore(useOriginNavigate): change path to '/c/'

* refactor: explicit textarea focus on new convo, share textarea idea via ~/common

* refactor: useParameterEffects: reset if convo or preset Ids change, share and maintain statefulness in side panel

* wip: combobox

* chore: minor styling for Select components

* wip: combobox select styling for side panel

* feat: complete combobox

* refactor: model select for side panel switcher

* refactor(Combobox): add portal

* chore: comment out dynamic parameters panel for future PR and delete prompt files

* refactor(Combobox): add icon field for options, change hover bg-color, add displayValue

* fix(useNewConvo): proper textarea focus with setTimeout

* refactor(AssistantSwitcher): use Combobox

* refactor(ModelSwitcher): add textarea focus on model switch
This commit is contained in:
Danny Avila 2024-04-10 14:27:22 -04:00 committed by GitHub
parent f64a2cb0b0
commit 8e5f1ad575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2850 additions and 462 deletions

View file

@ -2,6 +2,7 @@ import { FileSources } from 'librechat-data-provider';
import type { ColumnDef } from '@tanstack/react-table';
import type { SetterOrUpdater } from 'recoil';
import type {
TSetOption as SetOption,
TConversation,
TMessage,
TPreset,
@ -20,6 +21,8 @@ export type GenericSetter<T> = (value: T | ((currentValue: T) => T)) => void;
export type LastSelectedModels = Record<EModelEndpoint, string>;
export const mainTextareaId = 'prompt-textarea';
export enum IconContext {
landing = 'landing',
menuItem = 'menu-item',
@ -89,15 +92,16 @@ export type AssistantPanelProps = {
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & ColumnMeta;
export type TSetOption = (
param: number | string,
) => (newValue: number | string | boolean | Partial<TPreset>) => void;
export type TSetOption = SetOption;
export type TSetExample = (
i: number,
type: string,
newValue: number | string | boolean | null,
) => void;
export const defaultDebouncedDelay = 450;
export enum ESide {
Top = 'top',
Right = 'right',
@ -304,6 +308,8 @@ export type Option = Record<string, unknown> & {
value: string | number | null;
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };
export type TOptionSettings = {
showExamples?: boolean;
isCodeChat?: boolean;
@ -327,3 +333,8 @@ export interface ExtendedFile {
}
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
export interface SwitcherProps {
endpointKeyProvided: boolean;
isCollapsed: boolean;
}

View file

@ -13,6 +13,7 @@ import { TextareaAutosize } from '~/components/ui';
import { useGetFileConfig } from '~/data-provider';
import { cn, removeFocusOutlines } from '~/utils';
import AttachFile from './Files/AttachFile';
import { mainTextareaId } from '~/common';
import StopButton from './StopButton';
import SendButton from './SendButton';
import FileRow from './Files/FileRow';
@ -119,7 +120,7 @@ const ChatForm = ({ index = 0 }) => {
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id="prompt-textarea"
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
style={{ height: 44, overflowY: 'auto' }}

View file

@ -0,0 +1,84 @@
import { useEffect, useMemo } from 'react';
import { Combobox } from '~/components/ui';
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
import type { SwitcherProps } from '~/common';
import { useSetIndexOptions, useSelectAssistant, useLocalize } from '~/hooks';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useListAssistantsQuery } from '~/data-provider';
import Icon from '~/components/Endpoints/Icon';
export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const { setOption } = useSetIndexOptions();
const { index, conversation } = useChatContext();
/* `selectedAssistant` must be defined with `null` to cause re-render on update */
const { assistant_id: selectedAssistant = null, endpoint } = conversation ?? {};
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
select: (res) => res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
});
const assistantMap = useAssistantsMapContext();
const { onSelect } = useSelectAssistant();
useEffect(() => {
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
const assistant_id =
localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id ?? '';
const assistant = assistantMap?.[assistant_id];
if (!assistant) {
return;
}
if (endpoint !== EModelEndpoint.assistants) {
return;
}
setOption('model')(assistant.model);
setOption('assistant_id')(assistant_id);
}
}, [index, assistants, selectedAssistant, assistantMap, endpoint, setOption]);
const currentAssistant = assistantMap?.[selectedAssistant ?? ''];
const assistantOptions = useMemo(() => {
return assistants.map((assistant) => {
return {
label: assistant.name ?? '',
value: assistant.id,
icon: (
<Icon
isCreatedByUser={false}
endpoint={EModelEndpoint.assistants}
assistantName={assistant.name ?? ''}
iconURL={(assistant.metadata?.avatar as string) ?? ''}
/>
),
};
});
}, [assistants]);
return (
<Combobox
selectedValue={currentAssistant?.id ?? ''}
displayValue={
assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
localize('com_sidepanel_select_assistant')
}
selectPlaceholder={localize('com_sidepanel_select_assistant')}
searchPlaceholder={localize('com_assistants_search_name')}
isCollapsed={isCollapsed}
ariaLabel={'assistant'}
setValue={onSelect}
items={assistantOptions}
SelectIcon={
<Icon
isCreatedByUser={false}
endpoint={EModelEndpoint.assistants}
assistantName={currentAssistant?.name ?? ''}
iconURL={(currentAssistant?.metadata?.avatar as string) ?? ''}
/>
}
/>
);
}

View file

@ -0,0 +1,54 @@
import { useMemo, useRef, useCallback } from 'react';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { useSetIndexOptions, useLocalize } from '~/hooks';
import type { SwitcherProps } from '~/common';
import { useChatContext } from '~/Providers';
import { Combobox } from '~/components/ui';
import { mainTextareaId } from '~/common';
export default function ModelSwitcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const modelsQuery = useGetModelsQuery();
const { conversation } = useChatContext();
const { setOption } = useSetIndexOptions();
const timeoutIdRef = useRef<NodeJS.Timeout>();
const { endpoint, model = null } = conversation ?? {};
const models = useMemo(() => {
return modelsQuery?.data?.[endpoint ?? ''] ?? [];
}, [modelsQuery, endpoint]);
const setModel = useCallback(
(model: string) => {
setOption('model')(model);
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
textarea.focus();
}
}, 150);
},
[setOption],
);
return (
<Combobox
selectPlaceholder={localize('com_ui_select_model')}
searchPlaceholder={localize('com_ui_select_search_model')}
isCollapsed={isCollapsed}
ariaLabel={'model'}
selectedValue={model ?? ''}
setValue={setModel}
items={models}
SelectIcon={
<MinimalIcon
isCreatedByUser={false}
endpoint={endpoint}
// iconURL={} // for future preset icons
/>
}
/>
);
}

View file

@ -0,0 +1,97 @@
// client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx
import { useMemo, useState } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Checkbox, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
function DynamicCheckbox({
label,
settingKey,
defaultValue,
description,
columnSpan,
setOption,
optionType,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setOption(settingKey)(checked);
};
useParameterEffects({
preset,
settingKey,
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate: true,
});
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
columnSpan ? `col-span-${columnSpan}` : 'col-span-full'
}`}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center">
<div className="flex justify-start gap-4">
<Label
htmlFor={`${settingKey}-dynamic-checkbox`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}:{' '}
{defaultValue ? localize('com_ui_yes') : localize('com_ui_no')})
</small>
)}
</Label>
<Checkbox
id={`${settingKey}-dynamic-checkbox`}
disabled={readonly}
checked={selectedValue}
onCheckedChange={handleCheckedChange}
className="mt-[2px] focus:ring-opacity-20 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:focus:ring-gray-600 dark:focus:ring-opacity-50 dark:focus:ring-offset-0"
/>
</div>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicCheckbox;

View file

@ -0,0 +1,106 @@
import { useMemo, useState } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, HoverCard, HoverCardTrigger, SelectDropDown } from '~/components/ui';
import { useLocalize, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
import { cn } from '~/utils';
function DynamicDropdown({
label,
settingKey,
defaultValue,
description,
columnSpan,
setOption,
optionType,
options,
// type: _type,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const [inputValue, setInputValue] = useState<string | null>(null);
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
const handleChange = (value: string) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(value);
return;
}
setOption(settingKey)(value);
};
useParameterEffects({
preset,
settingKey,
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate: true,
});
if (!options || options.length === 0) {
return null;
}
return (
<div
className={cn(
'flex flex-col items-center justify-start gap-6',
columnSpan ? `col-span-${columnSpan}` : 'col-span-full',
)}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-dropdown`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue})
</small>
)}
</Label>
</div>
<SelectDropDown
showLabel={false}
emptyTitle={true}
disabled={readonly}
value={selectedValue}
setValue={handleChange}
availableValues={options}
containerClassName="w-full"
id={`${settingKey}-dynamic-dropdown`}
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicDropdown;

View file

@ -0,0 +1,93 @@
// client/src/components/SidePanel/Parameters/DynamicInput.tsx
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui';
import { cn, defaultTextProps } from '~/utils';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
function DynamicInput({
label,
settingKey,
defaultValue,
description,
columnSpan,
setOption,
optionType,
placeholder,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
placeholderCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const [setInputValue, inputValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
initialValue:
optionType !== OptionTypes.Custom
? (conversation?.[settingKey] as string)
: (defaultValue as string),
setter: () => ({}),
setOption,
});
useParameterEffects({
preset,
settingKey,
defaultValue: typeof defaultValue === 'undefined' ? '' : defaultValue,
conversation,
inputValue,
setInputValue,
});
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
columnSpan ? `col-span-${columnSpan}` : 'col-span-full'
}`}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
(
{typeof defaultValue === 'undefined' || !(defaultValue as string)?.length
? localize('com_endpoint_default_blank')
: `${localize('com_endpoint_default')}: ${defaultValue}`}
)
</small>
)}
</Label>
</div>
<Input
id={`${settingKey}-dynamic-input`}
disabled={readonly}
value={inputValue ?? ''}
onChange={setInputValue}
placeholder={placeholderCode ? localize(placeholder ?? '') || placeholder : placeholder}
className={cn(defaultTextProps, 'flex h-10 max-h-10 w-full resize-none px-3 py-2')}
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicInput;

View file

@ -0,0 +1,175 @@
import { useMemo, useCallback } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps, optionText } from '~/utils';
import { ESide, defaultDebouncedDelay } from '~/common';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
function DynamicSlider({
label,
settingKey,
defaultValue,
range,
description,
columnSpan,
setOption,
optionType,
options,
readonly = false,
showDefault = true,
includeInput = true,
labelCode,
descriptionCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const isEnum = useMemo(() => !range && options && options.length > 0, [options, range]);
const [setInputValue, inputValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
initialValue: optionType !== OptionTypes.Custom ? conversation?.[settingKey] : defaultValue,
setter: () => ({}),
setOption,
delay: isEnum ? 0 : defaultDebouncedDelay,
});
useParameterEffects({
preset,
settingKey,
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate: isEnum,
});
const selectedValue = useMemo(() => {
if (isEnum) {
return conversation?.[settingKey] ?? defaultValue;
}
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}, [conversation, defaultValue, settingKey, inputValue, isEnum]);
const enumToNumeric = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, mapping, index) => {
acc[mapping] = index;
return acc;
}, {} as Record<string, number>);
}
return {};
}, [isEnum, options]);
const valueToEnumOption = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, option, index) => {
acc[index] = option;
return acc;
}, {} as Record<number, string>);
}
return {};
}, [isEnum, options]);
const handleValueChange = useCallback(
(value: number) => {
if (isEnum) {
setInputValue(valueToEnumOption[value]);
} else {
setInputValue(value);
}
},
[isEnum, setInputValue, valueToEnumOption],
);
if (!range && !isEnum) {
return null;
}
return (
<div
className={cn(
'flex flex-col items-center justify-start gap-6',
columnSpan ? `col-span-${columnSpan}` : 'col-span-full',
)}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label
htmlFor={`${settingKey}-dynamic-setting`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue})
</small>
)}
</Label>
{includeInput && !isEnum ? (
<InputNumber
id={`${settingKey}-dynamic-setting-input-number`}
disabled={readonly}
value={inputValue ?? defaultValue}
onChange={(value) => setInputValue(Number(value))}
max={range ? range.max : (options?.length ?? 0) - 1}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
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-gray-200',
),
)}
/>
) : (
<Input
id={`${settingKey}-dynamic-setting-input`}
disabled={readonly}
value={selectedValue ?? defaultValue}
onChange={() => ({})}
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-gray-200',
),
)}
/>
)}
</div>
<Slider
id={`${settingKey}-dynamic-setting-slider`}
disabled={readonly}
value={[
isEnum
? enumToNumeric[(selectedValue as number) ?? '']
: (inputValue as number) ?? (defaultValue as number),
]}
onValueChange={(value) => handleValueChange(value[0])}
doubleClickHandler={() => setInputValue(defaultValue as string | number)}
max={isEnum && options ? options.length - 1 : range ? range.max : 0}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
className="flex h-4 w-full"
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicSlider;

View file

@ -0,0 +1,94 @@
import { useState, useMemo } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useParameterEffects } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
function DynamicSwitch({
label,
settingKey,
defaultValue,
description,
columnSpan,
setOption,
optionType,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
useParameterEffects({
preset,
settingKey,
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate: true,
});
const selectedValue = useMemo(() => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
return inputValue;
}
return conversation?.[settingKey] ?? defaultValue;
}, [conversation, defaultValue, optionType, settingKey, inputValue]);
const handleCheckedChange = (checked: boolean) => {
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
return;
}
setOption(settingKey)(checked);
};
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
columnSpan ? `col-span-${columnSpan}` : 'col-span-full'
}`}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
<Label
htmlFor={`${settingKey}-dynamic-switch`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue ? 'com_ui_on' : 'com_ui_off'})
</small>
)}
</Label>
</div>
<Switch
id={`${settingKey}-dynamic-switch`}
checked={selectedValue}
onCheckedChange={handleCheckedChange}
disabled={readonly}
className="flex"
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicSwitch;

View file

@ -0,0 +1,97 @@
// client/src/components/SidePanel/Parameters/DynamicTextarea.tsx
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps } from '~/utils';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
function DynamicTextarea({
label,
settingKey,
defaultValue,
description,
columnSpan,
setOption,
optionType,
placeholder,
readonly = false,
showDefault = true,
labelCode,
descriptionCode,
placeholderCode,
}: DynamicSettingProps) {
const localize = useLocalize();
const { conversation = { conversationId: null }, preset } = useChatContext();
const [setInputValue, inputValue] = useDebouncedInput<string | null>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
initialValue:
optionType !== OptionTypes.Custom
? (conversation?.[settingKey] as string)
: (defaultValue as string),
setter: () => ({}),
setOption,
});
useParameterEffects({
preset,
settingKey,
defaultValue: typeof defaultValue === 'undefined' ? '' : defaultValue,
conversation,
inputValue,
setInputValue,
});
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
columnSpan ? `col-span-${columnSpan}` : 'col-span-full'
}`}
>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-textarea`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label ?? '') || label : label ?? settingKey}{' '}
{showDefault && (
<small className="opacity-40">
(
{typeof defaultValue === 'undefined' || !(defaultValue as string)?.length
? localize('com_endpoint_default_blank')
: `${localize('com_endpoint_default')}: ${defaultValue}`}
)
</small>
)}
</Label>
</div>
<TextareaAutosize
id={`${settingKey}-dynamic-textarea`}
disabled={readonly}
value={inputValue ?? ''}
onChange={setInputValue}
placeholder={placeholderCode ? localize(placeholder ?? '') || placeholder : placeholder}
className={cn(
defaultTextProps,
// TODO: configurable max height
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
)}
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description) || description : description}
side={ESide.Left}
/>
)}
</HoverCard>
</div>
);
}
export default DynamicTextarea;

View file

@ -0,0 +1,26 @@
import React from 'react';
import { HoverCardPortal, HoverCardContent } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
type TOptionHoverProps = {
description: string;
langCode?: boolean;
side: ESide;
};
function OptionHover({ side, description, langCode }: TOptionHoverProps) {
const localize = useLocalize();
const text = langCode ? localize(description) : description;
return (
<HoverCardPortal>
<HoverCardContent side={side} className="z-[999] w-80 dark:bg-gray-700" sideOffset={30}>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{text}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
);
}
export default OptionHover;

View file

@ -0,0 +1,215 @@
import { ComponentTypes } from 'librechat-data-provider';
import type {
DynamicSettingProps,
SettingDefinition,
SettingsConfiguration,
} from 'librechat-data-provider';
import { useSetIndexOptions } from '~/hooks';
import DynamicDropdown from './DynamicDropdown';
import DynamicCheckbox from './DynamicCheckbox';
import DynamicTextarea from './DynamicTextarea';
import DynamicSlider from './DynamicSlider';
import DynamicSwitch from './DynamicSwitch';
import DynamicInput from './DynamicInput';
const settingsConfiguration: SettingsConfiguration = [
{
key: 'temperature',
label: 'com_endpoint_temperature',
labelCode: true,
description: 'com_endpoint_openai_temp',
descriptionCode: true,
type: 'number',
default: 1,
range: {
min: 0,
max: 2,
step: 0.01,
},
component: 'slider',
optionType: 'model',
// columnSpan: 2,
// includeInput: false,
},
{
key: 'top_p',
label: 'com_endpoint_top_p',
labelCode: true,
description: 'com_endpoint_openai_topp',
descriptionCode: true,
type: 'number',
default: 1,
range: {
min: 0,
max: 1,
step: 0.01,
},
component: 'slider',
optionType: 'model',
},
{
key: 'presence_penalty',
label: 'com_endpoint_presence_penalty',
labelCode: true,
description: 'com_endpoint_openai_pres',
descriptionCode: true,
type: 'number',
default: 0,
range: {
min: -2,
max: 2,
step: 0.01,
},
component: 'slider',
optionType: 'model',
},
{
key: 'frequency_penalty',
label: 'com_endpoint_frequency_penalty',
labelCode: true,
description: 'com_endpoint_openai_freq',
descriptionCode: true,
type: 'number',
default: 0,
range: {
min: -2,
max: 2,
step: 0.01,
},
component: 'slider',
optionType: 'model',
},
{
key: 'chatGptLabel',
label: 'com_endpoint_custom_name',
labelCode: true,
type: 'string',
default: '',
component: 'input',
placeholder: 'com_endpoint_openai_custom_name_placeholder',
placeholderCode: true,
optionType: 'conversation',
},
{
key: 'promptPrefix',
label: 'com_endpoint_prompt_prefix',
labelCode: true,
type: 'string',
default: '',
component: 'textarea',
placeholder: 'com_endpoint_openai_prompt_prefix_placeholder',
placeholderCode: true,
optionType: 'conversation',
// columnSpan: 2,
},
{
key: 'resendFiles',
label: 'com_endpoint_plug_resend_files',
labelCode: true,
description: 'com_endpoint_openai_resend_files',
descriptionCode: true,
type: 'boolean',
default: true,
component: 'switch',
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
{
key: 'imageDetail',
label: 'com_endpoint_plug_image_detail',
labelCode: true,
description: 'com_endpoint_openai_detail',
descriptionCode: true,
type: 'enum',
default: 'auto',
options: ['low', 'auto', 'high'],
optionType: 'conversation',
component: 'slider',
showDefault: false,
columnSpan: 2,
},
];
const componentMapping: Record<ComponentTypes, React.ComponentType<DynamicSettingProps>> = {
[ComponentTypes.Slider]: DynamicSlider,
[ComponentTypes.Dropdown]: DynamicDropdown,
[ComponentTypes.Switch]: DynamicSwitch,
[ComponentTypes.Textarea]: DynamicTextarea,
[ComponentTypes.Input]: DynamicInput,
[ComponentTypes.Checkbox]: DynamicCheckbox,
};
export default function Parameters() {
const { setOption } = useSetIndexOptions();
const temperature = settingsConfiguration.find(
(setting) => setting.key === 'temperature',
) as SettingDefinition;
const TempComponent = componentMapping[temperature.component];
const { key: temp, default: tempDefault, ...tempSettings } = temperature;
const imageDetail = settingsConfiguration.find(
(setting) => setting.key === 'imageDetail',
) as SettingDefinition;
const DetailComponent = componentMapping[imageDetail.component];
const { key: detail, default: detailDefault, ...detailSettings } = imageDetail;
const resendFiles = settingsConfiguration.find(
(setting) => setting.key === 'resendFiles',
) as SettingDefinition;
const Switch = componentMapping[resendFiles.component];
const { key: switchKey, default: switchDefault, ...switchSettings } = resendFiles;
const promptPrefix = settingsConfiguration.find(
(setting) => setting.key === 'promptPrefix',
) as SettingDefinition;
const Textarea = componentMapping[promptPrefix.component];
const { key: textareaKey, default: textareaDefault, ...textareaSettings } = promptPrefix;
const chatGptLabel = settingsConfiguration.find(
(setting) => setting.key === 'chatGptLabel',
) as SettingDefinition;
const Input = componentMapping[chatGptLabel.component];
const { key: inputKey, default: inputDefault, ...inputSettings } = chatGptLabel;
return (
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="grid grid-cols-4 gap-6">
{' '}
{/* This is the parent element containing all settings */}
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}
<Input
settingKey={inputKey}
defaultValue={inputDefault}
{...inputSettings}
setOption={setOption}
/>
<Textarea
settingKey={textareaKey}
defaultValue={textareaDefault}
{...textareaSettings}
setOption={setOption}
/>
<TempComponent
settingKey={temp}
defaultValue={tempDefault}
{...tempSettings}
setOption={setOption}
/>
<Switch
settingKey={switchKey}
defaultValue={switchDefault}
{...switchSettings}
setOption={setOption}
/>
<DetailComponent
settingKey={detail}
defaultValue={detailDefault}
{...detailSettings}
setOption={setOption}
/>
</div>
</div>
);
}

View file

@ -1,18 +1,15 @@
import throttle from 'lodash/throttle';
import { ArrowRightToLine } from 'lucide-react';
import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import { useGetEndpointsQuery, useUserKeyQuery } from 'librechat-data-provider/react-query';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { EModelEndpoint, type TEndpointsConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/components/ui/Resizable';
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
import { Blocks, AttachmentIcon } from '~/components/svg';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useMediaQuery, useLocalStorage } from '~/hooks';
import { Separator } from '~/components/ui/Separator';
import NavToggle from '~/components/Nav/NavToggle';
import PanelSwitch from './Builder/PanelSwitch';
import FilesPanel from './Files/Panel';
import { useChatContext } from '~/Providers';
import Switcher from './Switcher';
import { cn } from '~/utils';
import Nav from './Nav';
@ -43,6 +40,8 @@ const SidePanel = ({
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(EModelEndpoint.assistants);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { conversation } = useChatContext();
const { endpoint } = conversation ?? {};
const panelRef = useRef<ImperativePanelHandle>(null);
@ -52,49 +51,25 @@ const SidePanel = ({
}, []);
const assistants = useMemo(() => endpointsConfig?.[EModelEndpoint.assistants], [endpointsConfig]);
const userProvidesKey = useMemo(() => !!assistants?.userProvide, [assistants]);
const userProvidesKey = useMemo(
() => !!endpointsConfig?.[endpoint ?? '']?.userProvide,
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!keyExpiry?.expiresAt : true),
[keyExpiry?.expiresAt, userProvidesKey],
);
const Links = useMemo(() => {
const links: NavLink[] = [];
if (assistants && assistants.disableBuilder !== true && keyProvided) {
links.push({
title: 'com_sidepanel_assistant_builder',
label: '',
icon: Blocks,
id: 'assistants',
Component: PanelSwitch,
});
}
const hidePanel = useCallback(() => {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
}, []);
links.push({
title: 'com_sidepanel_attach_files',
label: '',
icon: AttachmentIcon,
id: 'files',
Component: FilesPanel,
});
links.push({
title: 'com_sidepanel_hide_panel',
label: '',
icon: ArrowRightToLine,
onClick: () => {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
},
id: 'hide-panel',
});
return links;
}, [assistants, keyProvided]);
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint });
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledSaveLayout = useCallback(
@ -206,18 +181,15 @@ const SidePanel = ({
: 'opacity-100',
)}
>
{keyProvided && (
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-white dark:bg-gray-850',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>
<Switcher isCollapsed={isCollapsed} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
</div>
)}
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-white dark:bg-gray-850',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>
<Switcher isCollapsed={isCollapsed} endpointKeyProvided={keyProvided} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
</div>
<Nav
resize={panelRef.current?.resize}
isCollapsed={isCollapsed}

View file

@ -1,104 +1,20 @@
import { useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/Select';
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
import { useSetIndexOptions, useSelectAssistant, useLocalize } from '~/hooks';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import { useListAssistantsQuery } from '~/data-provider';
import Icon from '~/components/Endpoints/Icon';
import { cn } from '~/utils';
import { EModelEndpoint } from 'librechat-data-provider';
import type { SwitcherProps } from '~/common';
import AssistantSwitcher from './AssistantSwitcher';
import { useChatContext } from '~/Providers';
import ModelSwitcher from './ModelSwitcher';
interface SwitcherProps {
isCollapsed: boolean;
}
export default function Switcher({ isCollapsed }: SwitcherProps) {
const localize = useLocalize();
const { setOption } = useSetIndexOptions();
const { index, conversation } = useChatContext();
/* `selectedAssistant` must be defined with `null` to cause re-render on update */
const { assistant_id: selectedAssistant = null, endpoint } = conversation ?? {};
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
select: (res) => res.data.map(({ id, name, metadata }) => ({ id, name, metadata })),
});
const assistantMap = useAssistantsMapContext();
const { onSelect } = useSelectAssistant();
useEffect(() => {
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
const assistant_id =
localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id ?? '';
const assistant = assistantMap?.[assistant_id];
if (!assistant) {
return;
}
if (endpoint !== EModelEndpoint.assistants) {
return;
}
setOption('model')(assistant.model);
setOption('assistant_id')(assistant_id);
}
}, [index, assistants, selectedAssistant, assistantMap, endpoint, setOption]);
const currentAssistant = assistantMap?.[selectedAssistant ?? ''];
return (
<Select value={selectedAssistant as string | undefined} onValueChange={onSelect}>
<SelectTrigger
className={cn(
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
isCollapsed
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
: '',
'bg-white text-black hover:bg-gray-50 dark:bg-gray-850 dark:text-white',
)}
aria-label={localize('com_sidepanel_select_assistant')}
>
<SelectValue placeholder={localize('com_sidepanel_select_assistant')}>
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
<Icon
isCreatedByUser={false}
endpoint={EModelEndpoint.assistants}
assistantName={currentAssistant?.name ?? ''}
iconURL={(currentAssistant?.metadata?.avatar as string) ?? ''}
/>
</div>
<span className={cn('ml-2', isCollapsed ? 'hidden' : '')} style={{ userSelect: 'none' }}>
{assistants.find((assistant) => assistant.id === selectedAssistant)?.name ??
localize('com_sidepanel_select_assistant')}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800">
{assistants.map((assistant) => (
<SelectItem
key={assistant.id}
value={assistant.id}
className="hover:bg-gray-50 dark:text-white"
>
<div className="[&_svg]:text-foreground flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
<div className="assistant-item overflow-hidden rounded-full ">
<Icon
isCreatedByUser={false}
endpoint={EModelEndpoint.assistants}
assistantName={assistant.name ?? ''}
iconURL={(assistant.metadata?.avatar as string) ?? ''}
/>
</div>
{assistant.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
export default function Switcher(props: SwitcherProps) {
const { conversation } = useChatContext();
const { endpoint } = conversation ?? {};
if (!props.endpointKeyProvided) {
return null;
}
if (endpoint === EModelEndpoint.assistants) {
return <AssistantSwitcher {...props} />;
}
return <ModelSwitcher {...props} />;
}

View file

@ -0,0 +1,168 @@
import { startTransition, useMemo } from 'react';
import * as RadixSelect from '@radix-ui/react-select';
import { Search as SearchIcon } from 'lucide-react';
import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons';
import {
Combobox,
ComboboxItem,
ComboboxList,
ComboboxProvider,
ComboboxCancel,
} from '@ariakit/react';
import type { OptionWithIcon } from '~/common';
import { SelectTrigger, SelectValue } from './Select';
import useCombobox from '~/hooks/Input/useCombobox';
import { cn } from '~/utils';
export default function ComboboxComponent({
selectedValue,
displayValue,
items,
setValue,
ariaLabel,
searchPlaceholder,
selectPlaceholder,
isCollapsed,
SelectIcon,
}: {
ariaLabel: string;
displayValue?: string;
selectedValue: string;
searchPlaceholder?: string;
selectPlaceholder?: string;
items: OptionWithIcon[] | string[];
setValue: (value: string) => void;
isCollapsed: boolean;
SelectIcon?: React.ReactNode;
}) {
const options: OptionWithIcon[] = useMemo(() => {
if (!items) {
return [];
}
return items.map((option: string | OptionWithIcon) => {
if (typeof option === 'string') {
return { label: option, value: option };
}
return option;
});
}, [items]);
const { open, setOpen, setSearchValue, matches } = useCombobox({
value: selectedValue,
options,
});
return (
<RadixSelect.Root
value={selectedValue}
onValueChange={setValue}
open={open}
onOpenChange={setOpen}
>
<ComboboxProvider
open={open}
setOpen={setOpen}
resetValueOnHide
includesBaseElement={false}
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
>
<SelectTrigger
aria-label={ariaLabel}
className={cn(
'flex items-center gap-2 [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
isCollapsed
? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden'
: '',
'bg-white text-black hover:bg-gray-50 dark:bg-gray-850 dark:text-white',
)}
>
<SelectValue placeholder={selectPlaceholder}>
<div className="assistant-item flex items-center justify-center overflow-hidden rounded-full">
{SelectIcon ? SelectIcon : <ChevronDownIcon />}
</div>
<span
className={cn('ml-2', isCollapsed ? 'hidden' : '')}
style={{ userSelect: 'none' }}
>
{selectedValue
? displayValue ?? selectedValue
: selectPlaceholder && selectPlaceholder}
</span>
</SelectValue>
</SelectTrigger>
<RadixSelect.Portal>
<RadixSelect.Content
role="dialog"
aria-label={ariaLabel + 's'}
position="popper"
sideOffset={4}
alignOffset={-16}
className={cn(
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-600',
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
'bg-white dark:bg-gray-700',
)}
>
<div className="group sticky left-0 top-0 z-10 flex h-12 items-center gap-2 bg-gradient-to-b from-white from-65% to-transparent px-2 px-3 py-2 text-black transition-colors duration-300 focus:bg-gradient-to-b focus:from-white focus:to-white/50 dark:from-gray-700 dark:to-transparent dark:text-white dark:focus:from-white/10 dark:focus:to-white/20">
<SearchIcon className="h-4 w-4 text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300" />
<Combobox
autoSelect
placeholder={searchPlaceholder}
className="flex-1 rounded-md border-none bg-transparent px-2.5 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-700/10 dark:focus:ring-gray-200/10"
// Ariakit's Combobox manually triggers a blur event on virtually
// blurred items, making them work as if they had actual DOM
// focus. These blur events might happen after the corresponding
// focus events in the capture phase, leading Radix Select to
// close the popover. This happens because Radix Select relies on
// the order of these captured events to discern if the focus was
// outside the element. Since we don't have access to the
// onInteractOutside prop in the Radix SelectContent component to
// stop this behavior, we can turn off Ariakit's behavior here.
onBlurCapture={(event) => {
event.preventDefault();
event.stopPropagation();
}}
/>
<ComboboxCancel
hideWhenEmpty={true}
className="relative flex h-5 w-5 items-center justify-end text-gray-500 transition-colors duration-300 dark:group-focus-within:text-gray-300 dark:group-hover:text-gray-300"
/>
</div>
<ComboboxList className="overflow-y-auto p-1 py-2">
{matches.map(({ label, value, icon }) => (
<RadixSelect.Item key={value} value={`${value ?? ''}`} asChild>
<ComboboxItem
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'rounded-lg hover:bg-gray-100/50 hover:bg-gray-50 dark:text-white dark:hover:bg-gray-600',
)}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>
<div className="[&_svg]:text-foreground flex items-center justify-center gap-3 dark:text-white [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0">
<div className="assistant-item overflow-hidden rounded-full ">
{icon && icon}
</div>
{label}
</div>
</RadixSelect.ItemText>
</ComboboxItem>
</RadixSelect.Item>
))}
</ComboboxList>
</RadixSelect.Content>
</RadixSelect.Portal>
</ComboboxProvider>
</RadixSelect.Root>
);
}

View file

@ -1,8 +1,8 @@
'use client';
import * as React from 'react';
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import { cn } from '~/utils';
@ -39,7 +39,10 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1 dark:text-white',
className,
)}
{...props}
>
<ChevronUpIcon />
@ -53,7 +56,10 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className = '', ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
className={cn(
'flex cursor-default items-center justify-center py-1 dark:text-white',
className,
)}
{...props}
>
<ChevronDownIcon />

View file

@ -20,11 +20,12 @@ export * from './Templates';
export * from './Textarea';
export * from './TextareaAutosize';
export * from './Tooltip';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as FileUpload } from './FileUpload';
export { default as DelayedRender } from './DelayedRender';
export { default as ThemeSelector } from './ThemeSelector';
export { default as SelectDropDown } from './SelectDropDown';
export { default as MultiSelectPop } from './MultiSelectPop';
export { default as SelectDropDownPop } from './SelectDropDownPop';
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
export { default as ThemeSelector } from './ThemeSelector';

View file

@ -1,3 +1,4 @@
export { default as usePresets } from './usePresets';
export { default as useGetSender } from './useGetSender';
export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useParameterEffects } from './useParameterEffects';

View file

@ -2,29 +2,30 @@ import debounce from 'lodash/debounce';
import React, { useState, useCallback } from 'react';
import type { SetterOrUpdater } from 'recoil';
import type { TSetOption } from '~/common';
import { defaultDebouncedDelay } from '~/common';
/** A custom hook that accepts a setOption function and an option key (e.g., 'title').
It manages a local state for the option value, a debounced setter function for that value,
and returns the local state value, its setter, and an onChange handler suitable for inputs. */
function useDebouncedInput({
function useDebouncedInput<T = unknown>({
setOption,
setter,
optionKey,
initialValue,
delay = 450,
delay = defaultDebouncedDelay,
}: {
setOption?: TSetOption;
setter?: SetterOrUpdater<string>;
setter?: SetterOrUpdater<T>;
optionKey?: string | number;
initialValue: unknown;
initialValue: T;
delay?: number;
}): [
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | unknown) => void,
unknown,
SetterOrUpdater<string>,
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T) => void,
T,
SetterOrUpdater<T>,
// (newValue: string) => void,
] {
const [value, setValue] = useState(initialValue);
const [value, setValue] = useState<T>(initialValue);
/** A debounced function to call the passed setOption with the optionKey and new value.
*
@ -36,11 +37,12 @@ function useDebouncedInput({
/** An onChange handler that updates the local state and the debounced option */
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | unknown) => {
const newValue: unknown =
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | T) => {
const newValue: T =
typeof e !== 'object'
? e
: (e as React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>)?.target.value;
: ((e as React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>)?.target
.value as unknown as T);
setValue(newValue);
setDebouncedOption(newValue);
},

View file

@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react';
import type { DynamicSettingProps, TConversation, TPreset } from 'librechat-data-provider';
import { defaultDebouncedDelay } from '~/common';
function useParameterEffects<T = unknown>({
preset,
settingKey,
defaultValue,
conversation,
inputValue,
setInputValue,
preventDelayedUpdate = false,
}: Pick<DynamicSettingProps, 'settingKey' | 'defaultValue'> & {
preset: TPreset | null;
conversation: TConversation | { conversationId: null } | null;
inputValue: T;
setInputValue: (inputValue: T) => void;
preventDelayedUpdate?: boolean;
}) {
const idRef = useRef<string | null>(null);
const presetIdRef = useRef<string | null>(null);
/** Updates the local state inputValue if global (conversation) is updated elsewhere */
useEffect(() => {
if (preventDelayedUpdate) {
return;
}
const timeout = setTimeout(() => {
if (conversation?.[settingKey] === inputValue) {
return;
}
setInputValue(conversation?.[settingKey]);
}, defaultDebouncedDelay * 1.25);
return () => clearTimeout(timeout);
}, [setInputValue, preventDelayedUpdate, conversation, inputValue, settingKey]);
/** Resets the local state if conversationId changed */
useEffect(() => {
if (!conversation?.conversationId) {
return;
}
if (idRef.current === conversation?.conversationId) {
return;
}
idRef.current = conversation?.conversationId;
setInputValue(defaultValue as T);
}, [setInputValue, conversation?.conversationId, defaultValue]);
/** Resets the local state if presetId changed */
useEffect(() => {
if (!preset?.presetId) {
return;
}
if (presetIdRef.current === preset?.presetId) {
return;
}
presetIdRef.current = preset?.presetId;
setInputValue(defaultValue as T);
}, [setInputValue, preset?.presetId, defaultValue]);
}
export default useParameterEffects;

View file

@ -1,5 +1,6 @@
export { default as useUserKey } from './useUserKey';
export { default as useDebounce } from './useDebounce';
export { default as useTextarea } from './useTextarea';
export { default as useCombobox } from './useCombobox';
export { default as useRequiresKey } from './useRequiresKey';
export { default as useMultipleKeys } from './useMultipleKeys';

View file

@ -0,0 +1,37 @@
import { useMemo, useState } from 'react';
import { matchSorter } from 'match-sorter';
import type { OptionWithIcon } from '~/common';
export default function useCombobox({
value,
options,
}: {
value: string;
options: OptionWithIcon[];
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const matches = useMemo(() => {
if (!searchValue) {
return options;
}
const keys = ['label', 'value'];
const matches = matchSorter(options, searchValue, { keys });
// Radix Select does not work if we don't render the selected item, so we
// make sure to include it in the list of matches.
const selectedItem = options.find((currentItem) => currentItem.value === value);
if (selectedItem && !matches.includes(selectedItem)) {
matches.push(selectedItem);
}
return matches;
}, [searchValue, value, options]);
return {
open,
setOpen,
searchValue,
setSearchValue,
matches,
};
}

View file

@ -0,0 +1,71 @@
import { useMemo } from 'react';
import {
ArrowRightToLine,
// Settings2,
} from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
// import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import { Blocks, AttachmentIcon } from '~/components/svg';
export default function useSideNavLinks({
hidePanel,
assistants,
keyProvided,
endpoint,
}: {
hidePanel: () => void;
assistants?: TConfig | null;
keyProvided: boolean;
endpoint?: EModelEndpoint | null;
}) {
const Links = useMemo(() => {
const links: NavLink[] = [];
// if (endpoint !== EModelEndpoint.assistants) {
// links.push({
// title: 'com_sidepanel_parameters',
// label: '',
// icon: Settings2,
// id: 'parameters',
// Component: Parameters,
// });
// }
if (
endpoint === EModelEndpoint.assistants &&
assistants &&
assistants.disableBuilder !== true &&
keyProvided
) {
links.push({
title: 'com_sidepanel_assistant_builder',
label: '',
icon: Blocks,
id: 'assistants',
Component: PanelSwitch,
});
}
links.push({
title: 'com_sidepanel_attach_files',
label: '',
icon: AttachmentIcon,
id: 'files',
Component: FilesPanel,
});
links.push({
title: 'com_sidepanel_hide_panel',
label: '',
icon: ArrowRightToLine,
onClick: hidePanel,
id: 'hide-panel',
});
return links;
}, [assistants, keyProvided, hidePanel, endpoint]);
return Links;
}

View file

@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { EModelEndpoint, FileSources, defaultOrderQuery } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
@ -24,6 +24,7 @@ import {
import { useDeleteFilesMutation, useListAssistantsQuery } from '~/data-provider';
import useOriginNavigate from './useOriginNavigate';
import useSetStorage from './useSetStorage';
import { mainTextareaId } from '~/common';
import store from '~/store';
const useNewConvo = (index = 0) => {
@ -36,6 +37,7 @@ const useNewConvo = (index = 0) => {
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const modelsQuery = useGetModelsQuery();
const timeoutIdRef = useRef<NodeJS.Timeout>();
const { data: assistants = [] } = useListAssistantsQuery(defaultOrderQuery, {
select: (res) =>
@ -137,6 +139,14 @@ const useNewConvo = (index = 0) => {
}
navigate('new');
}
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = setTimeout(() => {
const textarea = document.getElementById(mainTextareaId);
if (textarea) {
textarea.focus();
}
}, 150);
},
[endpointsConfig, defaultPreset, assistants, modelsQuery.data],
);

View file

@ -9,7 +9,7 @@ const useOriginNavigate = () => {
return;
}
const path = location.pathname.match(/^\/[^/]+\//);
_navigate(`${path ? path[0] : '/chat/'}${url}`, opts);
_navigate(`${path ? path[0] : '/c/'}${url}`, opts);
};
return navigate;

View file

@ -7,6 +7,7 @@ export default {
com_files_filter: 'Filter files...',
com_files_number_selected: '{0} of {1} file(s) selected',
com_sidepanel_select_assistant: 'Select an Assistant',
com_sidepanel_parameters: 'Parameters',
com_sidepanel_assistant_builder: 'Assistant Builder',
com_sidepanel_hide_panel: 'Hide Panel',
com_sidepanel_attach_files: 'Attach Files',
@ -68,6 +69,10 @@ export default {
'May occasionally produce harmful instructions or biased content',
com_ui_limitation_limited_2021: 'Limited knowledge of world and events after 2021',
com_ui_experimental: 'Experimental Features',
com_ui_on: 'On',
com_ui_off: 'Off',
com_ui_yes: 'Yes',
com_ui_no: 'No',
com_ui_ascending: 'Asc',
com_ui_descending: 'Desc',
com_ui_show_all: 'Show All',
@ -261,7 +266,7 @@ export default {
'Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.',
com_endpoint_openai_detail:
'The resolution for Vision requests. "Low" is cheaper and faster, "High" is more detailed and expensive, and "Auto" will automatically choose between the two based on the image resolution.',
com_endpoint_openai_custom_name_placeholder: 'Set a custom name for ChatGPT',
com_endpoint_openai_custom_name_placeholder: 'Set a custom name for the AI',
com_endpoint_openai_prompt_prefix_placeholder:
'Set custom instructions to include in System Message. Default: none',
com_endpoint_anthropic_temp:

View file

@ -60,3 +60,7 @@ export const optionText =
export const defaultTextPropsLabel =
'rounded-md border border-gray-300 bg-transparent text-sm shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-600 dark:focus:outline-none';
export function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}