📦 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

@ -27,6 +27,7 @@
},
"homepage": "https://librechat.ai",
"dependencies": {
"@ariakit/react": "^0.4.5",
"@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4",
"@headlessui/react": "^1.7.13",
@ -65,6 +66,7 @@
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"lucide-react": "^0.220.0",
"match-sorter": "^6.3.4",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-dnd": "^16.0.1",

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);
}

413
package-lock.json generated
View file

@ -703,6 +703,7 @@
"version": "0.7.0",
"license": "ISC",
"dependencies": {
"@ariakit/react": "^0.4.5",
"@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4",
"@headlessui/react": "^1.7.13",
@ -741,6 +742,7 @@
"librechat-data-provider": "*",
"lodash": "^4.17.21",
"lucide-react": "^0.220.0",
"match-sorter": "^6.3.4",
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
@ -868,6 +870,41 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@ariakit/core": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@ariakit/core/-/core-0.4.5.tgz",
"integrity": "sha512-e294+bEcyzt/H/kO4fS5/czLAlkF7PY+Kul3q2z54VY+GGay8NlVs9UezAB7L4jUBlYRAXwp7/1Sq3R7b+MZ7w=="
},
"node_modules/@ariakit/react": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.5.tgz",
"integrity": "sha512-GUHxaOY1JZrJUHkuV20IY4NWcgknhqTQM0qCQcVZDCi+pJiWchUjTG+UyIr/Of02hU569qnQ7yovskCf+V3tNg==",
"dependencies": {
"@ariakit/react-core": "0.4.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ariakit"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@ariakit/react-core": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.5.tgz",
"integrity": "sha512-ciTYPwpj/+mdA+EstveEnoygbx5e4PXQJxfkLKy4lkTkDJJUS9GcbYhdnIFJVUta6P1YFvzkIKo+/y9mcbMKJg==",
"dependencies": {
"@ariakit/core": "0.4.5",
"@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz",
@ -4933,9 +4970,9 @@
"integrity": "sha512-941kjlHjfI97l6NuH/AwuXV4mHuVnRooDcHNSlzi98hz+4ug3wT4gJcWjSwSZHqeGAEn90lC9sFD+8a9d5Jvxg=="
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
@ -4949,9 +4986,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
@ -4965,9 +5002,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
@ -4981,9 +5018,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
@ -4997,9 +5034,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
@ -5013,9 +5050,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
@ -5029,9 +5066,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
@ -5045,9 +5082,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
@ -5061,9 +5098,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
@ -5077,9 +5114,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
@ -5093,9 +5130,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
@ -5109,9 +5146,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
@ -5125,9 +5162,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
@ -5141,9 +5178,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
@ -5157,9 +5194,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
@ -5173,9 +5210,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
@ -5189,9 +5226,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
@ -5205,9 +5242,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
@ -5221,9 +5258,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
@ -5237,9 +5274,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
@ -5253,9 +5290,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
@ -5269,9 +5306,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
@ -5285,9 +5322,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
@ -8165,9 +8202,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz",
"integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz",
"integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==",
"cpu": [
"arm"
],
@ -8178,9 +8215,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz",
"integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz",
"integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==",
"cpu": [
"arm64"
],
@ -8191,9 +8228,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz",
"integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz",
"integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==",
"cpu": [
"arm64"
],
@ -8204,9 +8241,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz",
"integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz",
"integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==",
"cpu": [
"x64"
],
@ -8217,9 +8254,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz",
"integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz",
"integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==",
"cpu": [
"arm"
],
@ -8230,9 +8267,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz",
"integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz",
"integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==",
"cpu": [
"arm64"
],
@ -8243,9 +8280,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz",
"integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz",
"integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==",
"cpu": [
"arm64"
],
@ -8255,10 +8292,23 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz",
"integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==",
"cpu": [
"ppc64le"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz",
"integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz",
"integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==",
"cpu": [
"riscv64"
],
@ -8268,10 +8318,23 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz",
"integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz",
"integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz",
"integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==",
"cpu": [
"x64"
],
@ -8282,9 +8345,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz",
"integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz",
"integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==",
"cpu": [
"x64"
],
@ -8295,9 +8358,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz",
"integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz",
"integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==",
"cpu": [
"arm64"
],
@ -8308,9 +8371,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz",
"integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz",
"integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==",
"cpu": [
"ia32"
],
@ -8321,9 +8384,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz",
"integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz",
"integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==",
"cpu": [
"x64"
],
@ -13198,9 +13261,9 @@
}
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"bin": {
@ -13210,29 +13273,29 @@
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/escalade": {
@ -17990,9 +18053,9 @@
}
},
"node_modules/katex": {
"version": "0.16.9",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.9.tgz",
"integrity": "sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==",
"version": "0.16.10",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz",
"integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
@ -19037,6 +19100,20 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/match-sorter": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz",
"integrity": "sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg==",
"dependencies": {
"@babel/runtime": "^7.23.8",
"remove-accents": "0.5.0"
}
},
"node_modules/match-sorter/node_modules/remove-accents": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
"integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
@ -21896,9 +21973,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@ -21916,7 +21993,7 @@
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -25136,9 +25213,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@ -27108,14 +27185,14 @@
}
},
"node_modules/vite": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.1.tgz",
"integrity": "sha512-wclpAgY3F1tR7t9LL5CcHC41YPkQIpKUGeIuT8MdNwNZr6OqOTLs7JX5vIHAtzqLWXts0T+GDrh9pN2arneKqg==",
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz",
"integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
"postcss": "^8.4.35",
"rollup": "^4.2.0"
"esbuild": "^0.20.1",
"postcss": "^8.4.38",
"rollup": "^4.13.0"
},
"bin": {
"vite": "bin/vite.js"
@ -27252,9 +27329,9 @@
}
},
"node_modules/vite/node_modules/rollup": {
"version": "4.9.6",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz",
"integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz",
"integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==",
"dev": true,
"dependencies": {
"@types/estree": "1.0.5"
@ -27267,19 +27344,21 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.9.6",
"@rollup/rollup-android-arm64": "4.9.6",
"@rollup/rollup-darwin-arm64": "4.9.6",
"@rollup/rollup-darwin-x64": "4.9.6",
"@rollup/rollup-linux-arm-gnueabihf": "4.9.6",
"@rollup/rollup-linux-arm64-gnu": "4.9.6",
"@rollup/rollup-linux-arm64-musl": "4.9.6",
"@rollup/rollup-linux-riscv64-gnu": "4.9.6",
"@rollup/rollup-linux-x64-gnu": "4.9.6",
"@rollup/rollup-linux-x64-musl": "4.9.6",
"@rollup/rollup-win32-arm64-msvc": "4.9.6",
"@rollup/rollup-win32-ia32-msvc": "4.9.6",
"@rollup/rollup-win32-x64-msvc": "4.9.6",
"@rollup/rollup-android-arm-eabi": "4.14.1",
"@rollup/rollup-android-arm64": "4.14.1",
"@rollup/rollup-darwin-arm64": "4.14.1",
"@rollup/rollup-darwin-x64": "4.14.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.14.1",
"@rollup/rollup-linux-arm64-gnu": "4.14.1",
"@rollup/rollup-linux-arm64-musl": "4.14.1",
"@rollup/rollup-linux-powerpc64le-gnu": "4.14.1",
"@rollup/rollup-linux-riscv64-gnu": "4.14.1",
"@rollup/rollup-linux-s390x-gnu": "4.14.1",
"@rollup/rollup-linux-x64-gnu": "4.14.1",
"@rollup/rollup-linux-x64-musl": "4.14.1",
"@rollup/rollup-win32-arm64-msvc": "4.14.1",
"@rollup/rollup-win32-ia32-msvc": "4.14.1",
"@rollup/rollup-win32-x64-msvc": "4.14.1",
"fsevents": "~2.3.2"
}
},
@ -28080,7 +28159,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.5.1",
"version": "0.5.2",
"license": "ISC",
"dependencies": {
"@types/js-yaml": "^4.0.9",

View file

@ -0,0 +1,525 @@
/* eslint-disable jest/no-conditional-expect */
import { ZodError, z } from 'zod';
import { generateDynamicSchema, validateSettingDefinitions } from '../src/generate';
import type { SettingsConfiguration } from '../src/generate';
describe('generateDynamicSchema', () => {
it('should generate a schema for number settings with range', () => {
const settings: SettingsConfiguration = [
{
key: 'testNumber',
description: 'A test number setting',
type: 'number',
default: 5,
range: { min: 1, max: 10, step: 1 },
component: 'slider',
optionType: 'conversation',
columnSpan: 2,
label: 'Test Number Slider',
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testNumber: 6 });
expect(result.success).toBeTruthy();
expect(result['data']).toEqual({ testNumber: 6 });
});
it('should generate a schema for boolean settings', () => {
const settings: SettingsConfiguration = [
{
key: 'testBoolean',
description: 'A test boolean setting',
type: 'boolean',
default: true,
component: 'switch',
optionType: 'model', // Only if relevant to your application's context
columnSpan: 1,
label: 'Test Boolean Switch',
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testBoolean: false });
expect(result.success).toBeTruthy();
expect(result['data']).toEqual({ testBoolean: false });
});
it('should generate a schema for string settings', () => {
const settings: SettingsConfiguration = [
{
key: 'testString',
description: 'A test string setting',
type: 'string',
default: 'default value',
component: 'input',
optionType: 'model', // Optional and only if relevant
columnSpan: 3,
label: 'Test String Input',
placeholder: 'Enter text here...',
minText: 0, // Optional
maxText: 100, // Optional
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testString: 'custom value' });
expect(result.success).toBeTruthy();
expect(result['data']).toEqual({ testString: 'custom value' });
});
it('should generate a schema for enum settings', () => {
const settings: SettingsConfiguration = [
{
key: 'testEnum',
description: 'A test enum setting',
type: 'enum',
default: 'option1',
options: ['option1', 'option2', 'option3'],
enumMappings: {
option1: 'First Option',
option2: 'Second Option',
option3: 'Third Option',
},
component: 'dropdown',
columnSpan: 2,
label: 'Test Enum Dropdown',
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testEnum: 'option2' });
expect(result.success).toBeTruthy();
expect(result['data']).toEqual({ testEnum: 'option2' });
});
it('should fail for incorrect enum value', () => {
const settings: SettingsConfiguration = [
{
key: 'testEnum',
description: 'A test enum setting',
type: 'enum',
default: 'option1',
options: ['option1', 'option2', 'option3'],
component: 'dropdown',
},
];
const schema = generateDynamicSchema(settings);
const result = schema.safeParse({ testEnum: 'option4' }); // This option does not exist
expect(result.success).toBeFalsy();
});
});
describe('validateSettingDefinitions', () => {
// Test for valid setting configurations
test('should not throw error for valid settings', () => {
const validSettings: SettingsConfiguration = [
{
key: 'themeColor',
component: 'input',
type: 'string',
default: '#ffffff',
label: 'Theme Color',
columns: 2,
columnSpan: 1,
optionType: 'model',
},
{
key: 'fontSize',
component: 'slider',
type: 'number',
range: { min: 8, max: 36 },
default: 14,
columnSpan: 2,
},
];
expect(() => validateSettingDefinitions(validSettings)).not.toThrow();
});
// Test for incorrectly configured columns
test('should throw error for invalid columns configuration', () => {
const invalidSettings: SettingsConfiguration = [
{
key: 'themeColor',
component: 'input',
type: 'string',
columns: 5,
},
];
expect(() => validateSettingDefinitions(invalidSettings)).toThrow(ZodError);
});
test('should correctly handle columnSpan defaulting based on columns', () => {
const settingsWithColumnAdjustment: SettingsConfiguration = [
{
key: 'fontSize',
component: 'slider',
type: 'number',
columns: 4,
range: { min: 8, max: 14 },
default: 11,
},
];
expect(() => validateSettingDefinitions(settingsWithColumnAdjustment)).not.toThrow();
});
// Test for label defaulting to key if not provided
test('label should default to key if not explicitly set', () => {
const settingsWithDefaultLabel: SettingsConfiguration = [
{ key: 'fontWeight', component: 'dropdown', type: 'string', options: ['normal', 'bold'] },
];
expect(() => validateSettingDefinitions(settingsWithDefaultLabel)).not.toThrow();
expect(settingsWithDefaultLabel[0].label).toBe('fontWeight');
});
// Test for minText and maxText in input/textarea component
test('should throw error for negative minText or maxText', () => {
const settingsWithNegativeTextLimits: SettingsConfiguration = [
{ key: 'biography', component: 'textarea', type: 'string', minText: -1 },
];
expect(() => validateSettingDefinitions(settingsWithNegativeTextLimits)).toThrow(ZodError);
});
// Validate optionType with tConversationSchema
test('should throw error for optionType "conversation" not matching schema', () => {
const settingsWithInvalidConversationOptionType: SettingsConfiguration = [
{ key: 'userAge', component: 'input', type: 'number', optionType: 'conversation' },
];
expect(() => validateSettingDefinitions(settingsWithInvalidConversationOptionType)).toThrow(
ZodError,
);
});
// Test for columnSpan defaulting and label defaulting to key
test('columnSpan defaults based on columns and label defaults to key if not set', () => {
const settings: SettingsConfiguration = [
{
key: 'textSize',
type: 'number',
component: 'slider',
range: { min: 10, max: 20 },
columns: 4,
},
];
validateSettingDefinitions(settings); // Perform validation which also mutates settings with default values
expect(settings[0].columnSpan).toBe(2); // Expects columnSpan to default based on columns
expect(settings[0].label).toBe('textSize'); // Expects label to default to key
});
// Test for errors thrown due to invalid columns value
test('throws error if columns value is out of range', () => {
const settings: SettingsConfiguration = [
{
key: 'themeMode',
type: 'string',
component: 'dropdown',
options: ['dark', 'light'],
columns: 5,
},
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Test range validation for slider component
test('slider component range validation', () => {
const settings: SettingsConfiguration = [
{ key: 'volume', type: 'number', component: 'slider' }, // Missing range
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Test options validation for enum type in slider component
test('slider component with enum type requires at least 2 options', () => {
const settings: SettingsConfiguration = [
{ key: 'color', type: 'enum', component: 'slider', options: ['red'] }, // Not enough options
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Test checkbox component options validation
test('checkbox component must have 1-2 options if options are provided', () => {
const settings: SettingsConfiguration = [
{
key: 'agreeToTerms',
type: 'boolean',
component: 'checkbox',
options: ['Yes', 'No', 'Maybe'],
}, // Too many options
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Test dropdown component options validation
test('dropdown component requires at least 2 options', () => {
const settings: SettingsConfiguration = [
{ key: 'country', type: 'enum', component: 'dropdown', options: ['USA'] }, // Not enough options
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Validate minText and maxText constraints in input and textarea
test('validate minText and maxText constraints', () => {
const settings: SettingsConfiguration = [
{ key: 'biography', type: 'string', component: 'textarea', minText: 10, maxText: 5 }, // Incorrect minText and maxText
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Validate optionType constraint with tConversationSchema
test('validate optionType constraint with tConversationSchema', () => {
const settings: SettingsConfiguration = [
{ key: 'userAge', type: 'number', component: 'input', optionType: 'conversation' }, // No corresponding schema in tConversationSchema
];
expect(() => validateSettingDefinitions(settings)).toThrow(ZodError);
});
// Validate correct handling of boolean settings with default values
test('correct handling of boolean settings with defaults', () => {
const settings: SettingsConfiguration = [
{ key: 'enableFeatureX', type: 'boolean', component: 'switch' }, // Missing default, should default to false
];
validateSettingDefinitions(settings); // This would populate default values where missing
expect(settings[0].default).toBe(false); // Expects default to be false for boolean without explicit default
});
// Validate that number slider without default uses middle of range
test('number slider without default uses middle of range', () => {
const settings: SettingsConfiguration = [
{ key: 'brightness', type: 'number', component: 'slider', range: { min: 0, max: 100 } }, // Missing default
];
validateSettingDefinitions(settings); // This would populate default values where missing
expect(settings[0].default).toBe(50); // Expects default to be midpoint of range
});
});
const settingsConfiguration: SettingsConfiguration = [
{
key: 'temperature',
description:
'Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.',
type: 'number',
default: 1,
range: {
min: 0,
max: 2,
step: 0.01,
},
component: 'slider',
},
{
key: 'top_p',
description:
'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We recommend altering this or temperature but not both.',
type: 'number',
default: 1,
range: {
min: 0,
max: 1,
step: 0.01,
},
component: 'slider',
},
{
key: 'presence_penalty',
description:
'Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model\'s likelihood to talk about new topics.',
type: 'number',
default: 0,
range: {
min: -2,
max: 2,
step: 0.01,
},
component: 'slider',
},
{
key: 'frequency_penalty',
description:
'Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model\'s likelihood to repeat the same line verbatim.',
type: 'number',
default: 0,
range: {
min: -2,
max: 2,
step: 0.01,
},
component: 'slider',
},
{
key: 'resendFiles',
description:
'Resend all previously attached files. Note: this will increase token cost and you may experience errors with many attachments.',
type: 'boolean',
default: true,
component: 'switch',
},
{
key: 'imageDetail',
description:
'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.',
type: 'enum',
default: 'auto',
options: ['low', 'high', 'auto'],
component: 'slider',
},
{
key: 'promptPrefix',
type: 'string',
default: '',
component: 'input',
placeholder: 'Set custom instructions to include in System Message. Default: none',
},
{
key: 'chatGptLabel',
type: 'string',
default: '',
component: 'input',
placeholder: 'Set a custom name for your AI',
},
];
describe('Settings Validation and Schema Generation', () => {
// Test 1: Validate settings definitions do not throw for valid configuration
test('validateSettingDefinitions does not throw for valid configuration', () => {
expect(() => validateSettingDefinitions(settingsConfiguration)).not.toThrow();
});
test('validateSettingDefinitions throws for invalid type in settings', () => {
const settingsWithInvalidType = [
...settingsConfiguration,
{
key: 'newSetting',
description: 'A setting with an unsupported type',
type: 'unsupportedType', // Assuming 'unsupportedType' is not supported
component: 'input',
},
];
expect(() =>
validateSettingDefinitions(settingsWithInvalidType as SettingsConfiguration),
).toThrow();
});
test('validateSettingDefinitions throws for missing required fields', () => {
const settingsMissingRequiredField = [
...settingsConfiguration,
{
key: 'incompleteSetting',
type: 'number',
// Missing 'component',
},
];
expect(() =>
validateSettingDefinitions(settingsMissingRequiredField as SettingsConfiguration),
).toThrow();
});
test('validateSettingDefinitions throws for default value out of range', () => {
const settingsOutOfRange = [
...settingsConfiguration,
{
key: 'rangeTestSetting',
description: 'A setting with default value out of specified range',
type: 'number',
default: 5,
range: {
min: 0,
max: 1,
},
component: 'slider',
},
];
expect(() => validateSettingDefinitions(settingsOutOfRange as SettingsConfiguration)).toThrow();
});
test('validateSettingDefinitions throws for enum setting with incorrect default', () => {
const settingsWithIncorrectEnumDefault = [
...settingsConfiguration,
{
key: 'enumSetting',
description: 'Enum setting with a default not in options',
type: 'enum',
default: 'unlistedOption',
options: ['option1', 'option2'],
component: 'dropdown',
},
];
expect(() =>
validateSettingDefinitions(settingsWithIncorrectEnumDefault as SettingsConfiguration),
).toThrow();
});
// Test 2: Generate dynamic schema and validate correct input
test('generateDynamicSchema generates a schema that validates correct input', () => {
const schema = generateDynamicSchema(settingsConfiguration);
const validInput = {
temperature: 0.5,
top_p: 0.8,
presence_penalty: 1,
frequency_penalty: -1,
resendFiles: true,
imageDetail: 'high',
promptPrefix: 'Hello, AI.',
chatGptLabel: 'My Custom AI',
};
expect(schema.parse(validInput)).toEqual(validInput);
});
// Test 3: Generate dynamic schema and catch invalid input
test('generateDynamicSchema generates a schema that catches invalid input and provides detailed errors', async () => {
const schema = generateDynamicSchema(settingsConfiguration);
const invalidInput: z.infer<typeof schema> = {
temperature: 2.5, // Out of range
top_p: -0.5, // Out of range
presence_penalty: 3, // Out of range
frequency_penalty: -3, // Out of range
resendFiles: 'yes', // Wrong type
imageDetail: 'ultra', // Invalid option
promptPrefix: 123, // Wrong type
chatGptLabel: true, // Wrong type
};
const result = schema.safeParse(invalidInput);
expect(result.success).toBeFalsy();
if (!result.success) {
const errorPaths = result.error.issues.map((issue) => issue.path.join('.'));
expect(errorPaths).toContain('temperature');
expect(errorPaths).toContain('top_p');
expect(errorPaths).toContain('presence_penalty');
expect(errorPaths).toContain('frequency_penalty');
expect(errorPaths).toContain('resendFiles');
expect(errorPaths).toContain('imageDetail');
expect(errorPaths).toContain('promptPrefix');
expect(errorPaths).toContain('chatGptLabel');
}
});
});

View file

@ -0,0 +1,474 @@
import { z, ZodError, ZodIssueCode } from 'zod';
import { tConversationSchema, googleSettings as google, openAISettings as openAI } from './schemas';
import type { ZodIssue } from 'zod';
import type { TConversation, TSetOption } from './schemas';
export type GoogleSettings = Partial<typeof google>;
export type OpenAISettings = Partial<typeof google>;
export type ComponentType = 'input' | 'textarea' | 'slider' | 'checkbox' | 'switch' | 'dropdown';
export type OptionType = 'conversation' | 'model' | 'custom';
export enum ComponentTypes {
Input = 'input',
Textarea = 'textarea',
Slider = 'slider',
Checkbox = 'checkbox',
Switch = 'switch',
Dropdown = 'dropdown',
}
export enum OptionTypes {
Conversation = 'conversation',
Model = 'model',
Custom = 'custom',
}
export interface SettingDefinition {
key: string;
description?: string;
type: 'number' | 'boolean' | 'string' | 'enum';
default?: number | boolean | string;
showDefault?: boolean;
options?: string[];
range?: SettingRange;
enumMappings?: Record<string, number | boolean | string>;
component: ComponentType;
optionType?: OptionType;
columnSpan?: number;
columns?: number;
label?: string;
placeholder?: string;
labelCode?: boolean;
placeholderCode?: boolean;
descriptionCode?: boolean;
minText?: number;
maxText?: number;
includeInput?: boolean; // Specific to slider component
}
export type DynamicSettingProps = Partial<SettingDefinition> & {
readonly?: boolean;
settingKey: string;
setOption: TSetOption;
defaultValue?: number | boolean | string;
};
const requiredSettingFields = ['key', 'type', 'component'];
export interface SettingRange {
min: number;
max: number;
step?: number;
}
export type SettingsConfiguration = SettingDefinition[];
export function generateDynamicSchema(settings: SettingsConfiguration) {
const schemaFields: { [key: string]: z.ZodTypeAny } = {};
for (const setting of settings) {
const { key, type, default: defaultValue, range, options, minText, maxText } = setting;
if (type === 'number') {
let schema = z.number();
if (range) {
schema = schema.min(range.min);
schema = schema.max(range.max);
}
if (typeof defaultValue === 'number') {
schemaFields[key] = schema.default(defaultValue);
} else {
schemaFields[key] = schema;
}
continue;
}
if (type === 'boolean') {
const schema = z.boolean();
if (typeof defaultValue === 'boolean') {
schemaFields[key] = schema.default(defaultValue);
} else {
schemaFields[key] = schema;
}
continue;
}
if (type === 'string') {
let schema = z.string();
if (minText) {
schema = schema.min(minText);
}
if (maxText) {
schema = schema.max(maxText);
}
if (typeof defaultValue === 'string') {
schemaFields[key] = schema.default(defaultValue);
} else {
schemaFields[key] = schema;
}
continue;
}
if (type === 'enum') {
if (!options || options.length === 0) {
console.warn(`Missing or empty 'options' for enum setting '${key}'.`);
continue;
}
const schema = z.enum(options as [string, ...string[]]);
if (typeof defaultValue === 'string') {
schemaFields[key] = schema.default(defaultValue);
} else {
schemaFields[key] = schema;
}
continue;
}
console.warn(`Unsupported setting type: ${type}`);
}
return z.object(schemaFields);
}
const ZodTypeToSettingType: Record<string, string | undefined> = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
};
const minColumns = 1;
const maxColumns = 4;
const minSliderOptions = 2;
const minDropdownOptions = 2;
/**
* Validates the provided setting using the constraints unique to each component type.
* @throws {ZodError} Throws a ZodError if any validation fails.
*/
export function validateSettingDefinitions(settings: SettingsConfiguration): void {
const errors: ZodIssue[] = [];
// Validate columns
const columnsSet = new Set<number>();
for (const setting of settings) {
if (setting.columns !== undefined) {
if (setting.columns < minColumns || setting.columns > maxColumns) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid columns value for setting ${setting.key}. Must be between ${minColumns} and ${maxColumns}.`,
path: ['columns'],
});
} else {
columnsSet.add(setting.columns);
}
}
}
const columns = columnsSet.size === 1 ? columnsSet.values().next().value : 2;
for (const setting of settings) {
for (const field of requiredSettingFields) {
if (setting[field as keyof SettingDefinition] === undefined) {
errors.push({
code: ZodIssueCode.custom,
message: `Missing required field ${field} for setting ${setting.key}.`,
path: [field],
});
}
}
// check accepted types
if (!['number', 'boolean', 'string', 'enum'].includes(setting.type)) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid type for setting ${setting.key}. Must be one of 'number', 'boolean', 'string', 'enum'.`,
path: ['type'],
});
}
// Predefined constraints based on components
if (setting.component === 'input' || setting.component === 'textarea') {
if (setting.type === 'number' && setting.component === 'textarea') {
errors.push({
code: ZodIssueCode.custom,
message: `Textarea component for setting ${setting.key} must have type string.`,
path: ['type'],
});
// continue;
}
if (
setting.minText !== undefined &&
setting.maxText !== undefined &&
setting.minText > setting.maxText
) {
errors.push({
code: ZodIssueCode.custom,
message: `For setting ${setting.key}, minText cannot be greater than maxText.`,
path: [setting.key, 'minText', 'maxText'],
});
// continue;
}
if (!setting.placeholder) {
setting.placeholder = '';
} // Default placeholder
}
if (setting.component === 'slider') {
if (setting.type === 'number' && !setting.range) {
errors.push({
code: ZodIssueCode.custom,
message: `Slider component for setting ${setting.key} must have a range if type is number.`,
path: ['range'],
});
// continue;
}
if (
setting.type === 'enum' &&
(!setting.options || setting.options.length < minSliderOptions)
) {
errors.push({
code: ZodIssueCode.custom,
message: `Slider component for setting ${setting.key} requires at least ${minSliderOptions} options for enum type.`,
path: ['options'],
});
// continue;
}
setting.includeInput = setting.type === 'number' ? setting.includeInput ?? true : false; // Default to true if type is number
}
if (setting.component === 'slider' && setting.type === 'number') {
if (setting.default === undefined && setting.range) {
// Set default to the middle of the range if unspecified
setting.default = Math.round((setting.range.min + setting.range.max) / 2);
}
}
if (setting.component === 'checkbox' || setting.component === 'switch') {
if (setting.options && setting.options.length > 2) {
errors.push({
code: ZodIssueCode.custom,
message: `Checkbox/Switch component for setting ${setting.key} must have 1-2 options.`,
path: ['options'],
});
// continue;
}
if (!setting.default && setting.type === 'boolean') {
setting.default = false; // Default to false if type is boolean
}
}
if (setting.component === 'dropdown') {
if (!setting.options || setting.options.length < minDropdownOptions) {
errors.push({
code: ZodIssueCode.custom,
message: `Dropdown component for setting ${setting.key} requires at least ${minDropdownOptions} options.`,
path: ['options'],
});
// continue;
}
if (!setting.default && setting.options && setting.options.length > 0) {
setting.default = setting.options[0]; // Default to first option if not specified
}
}
// Default columnSpan
if (!setting.columnSpan) {
setting.columnSpan = Math.floor(columns / 2);
}
// Default label to key
if (!setting.label) {
setting.label = setting.key;
}
// Validate minText and maxText for input/textarea
if (setting.component === 'input' || setting.component === 'textarea') {
if (setting.minText !== undefined && setting.minText < 0) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid minText value for setting ${setting.key}. Must be non-negative.`,
path: ['minText'],
});
}
if (setting.maxText !== undefined && setting.maxText < 0) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid maxText value for setting ${setting.key}. Must be non-negative.`,
path: ['maxText'],
});
}
}
// Validate optionType and conversation schema
if (setting.optionType !== OptionTypes.Custom) {
const conversationSchema = tConversationSchema.shape[setting.key as keyof TConversation];
if (!conversationSchema) {
errors.push({
code: ZodIssueCode.custom,
message: `Setting ${setting.key} with optionType "${setting.optionType}" must be defined in tConversationSchema.`,
path: ['optionType'],
});
} else {
const zodType = conversationSchema._def.typeName;
const settingTypeEquivalent = ZodTypeToSettingType[zodType] || null;
if (settingTypeEquivalent !== setting.type) {
errors.push({
code: ZodIssueCode.custom,
message: `Setting ${setting.key} with optionType "${setting.optionType}" must match the type defined in tConversationSchema.`,
path: ['optionType'],
});
}
}
}
/* Default value checks */
if (setting.type === 'number' && isNaN(setting.default as number)) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a number.`,
path: ['default'],
});
}
if (setting.type === 'boolean' && typeof setting.default !== 'boolean') {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a boolean.`,
path: ['default'],
});
}
if (
(setting.type === 'string' || setting.type === 'enum') &&
typeof setting.default !== 'string'
) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be a string.`,
path: ['default'],
});
}
if (
setting.type === 'enum' &&
setting.options &&
!setting.options.includes(setting.default as string)
) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${
setting.key
}. Must be one of the options: [${setting.options.join(', ')}].`,
path: ['default'],
});
}
if (
setting.type === 'number' &&
setting.range &&
typeof setting.default === 'number' &&
(setting.default < setting.range.min || setting.default > setting.range.max)
) {
errors.push({
code: ZodIssueCode.custom,
message: `Invalid default value for setting ${setting.key}. Must be within the range [${setting.range.min}, ${setting.range.max}].`,
path: ['default'],
});
}
}
if (errors.length > 0) {
throw new ZodError(errors);
}
}
export const generateOpenAISchema = (customOpenAI: OpenAISettings) => {
const defaults = { ...openAI, ...customOpenAI };
return tConversationSchema
.pick({
model: true,
chatGptLabel: true,
promptPrefix: true,
temperature: true,
top_p: true,
presence_penalty: true,
frequency_penalty: true,
resendFiles: true,
imageDetail: true,
})
.transform((obj) => ({
...obj,
model: obj.model ?? defaults.model.default,
chatGptLabel: obj.chatGptLabel ?? null,
promptPrefix: obj.promptPrefix ?? null,
temperature: obj.temperature ?? defaults.temperature.default,
top_p: obj.top_p ?? defaults.top_p.default,
presence_penalty: obj.presence_penalty ?? defaults.presence_penalty.default,
frequency_penalty: obj.frequency_penalty ?? defaults.frequency_penalty.default,
resendFiles:
typeof obj.resendFiles === 'boolean' ? obj.resendFiles : defaults.resendFiles.default,
imageDetail: obj.imageDetail ?? defaults.imageDetail.default,
}))
.catch(() => ({
model: defaults.model.default,
chatGptLabel: null,
promptPrefix: null,
temperature: defaults.temperature.default,
top_p: defaults.top_p.default,
presence_penalty: defaults.presence_penalty.default,
frequency_penalty: defaults.frequency_penalty.default,
resendFiles: defaults.resendFiles.default,
imageDetail: defaults.imageDetail.default,
}));
};
export const generateGoogleSchema = (customGoogle: GoogleSettings) => {
const defaults = { ...google, ...customGoogle };
return tConversationSchema
.pick({
model: true,
modelLabel: true,
promptPrefix: true,
examples: true,
temperature: true,
maxOutputTokens: true,
topP: true,
topK: true,
})
.transform((obj) => {
const isGeminiPro = obj?.model?.toLowerCase()?.includes('gemini-pro');
const maxOutputTokensMax = isGeminiPro
? defaults.maxOutputTokens.maxGeminiPro
: defaults.maxOutputTokens.max;
const maxOutputTokensDefault = isGeminiPro
? defaults.maxOutputTokens.defaultGeminiPro
: defaults.maxOutputTokens.default;
let maxOutputTokens = obj.maxOutputTokens ?? maxOutputTokensDefault;
maxOutputTokens = Math.min(maxOutputTokens, maxOutputTokensMax);
return {
...obj,
model: obj.model ?? defaults.model.default,
modelLabel: obj.modelLabel ?? null,
promptPrefix: obj.promptPrefix ?? null,
examples: obj.examples ?? [{ input: { content: '' }, output: { content: '' } }],
temperature: obj.temperature ?? defaults.temperature.default,
maxOutputTokens,
topP: obj.topP ?? defaults.topP.default,
topK: obj.topK ?? defaults.topK.default,
};
})
.catch(() => ({
model: defaults.model.default,
modelLabel: null,
promptPrefix: null,
examples: [{ input: { content: '' }, output: { content: '' } }],
temperature: defaults.temperature.default,
maxOutputTokens: defaults.maxOutputTokens.default,
topP: defaults.topP.default,
topK: defaults.topK.default,
}));
};

View file

@ -4,6 +4,7 @@ export * from './config';
export * from './file-config';
/* schema helpers */
export * from './parsers';
export * from './generate';
/* types (exports schemas from `./types` as they contain needed in other defs) */
export * from './types';
export * from './types/assistants';

View file

@ -17,6 +17,26 @@ export enum EModelEndpoint {
custom = 'custom',
}
export enum ImageDetail {
low = 'low',
auto = 'auto',
high = 'high',
}
export const imageDetailNumeric = {
[ImageDetail.low]: 0,
[ImageDetail.auto]: 1,
[ImageDetail.high]: 2,
};
export const imageDetailValue = {
0: ImageDetail.low,
1: ImageDetail.auto,
2: ImageDetail.high,
};
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const defaultAssistantFormValues = {
assistant: '',
id: '',
@ -46,38 +66,77 @@ export const ImageVisionTool: FunctionTool = {
export const isImageVisionTool = (tool: FunctionTool | FunctionToolCall) =>
tool.type === 'function' && tool.function?.name === ImageVisionTool?.function?.name;
export const endpointSettings = {
[EModelEndpoint.google]: {
model: {
default: 'chat-bison',
},
maxOutputTokens: {
min: 1,
max: 2048,
step: 1,
default: 1024,
maxGeminiPro: 8192,
defaultGeminiPro: 8192,
},
temperature: {
min: 0,
max: 1,
step: 0.01,
default: 0.2,
},
topP: {
min: 0,
max: 1,
step: 0.01,
default: 0.8,
},
topK: {
min: 1,
max: 40,
step: 0.01,
default: 40,
},
export const openAISettings = {
model: {
default: 'gpt-3.5-turbo',
},
temperature: {
min: 0,
max: 1,
step: 0.01,
default: 1,
},
top_p: {
min: 0,
max: 1,
step: 0.01,
default: 1,
},
presence_penalty: {
min: 0,
max: 2,
step: 0.01,
default: 0,
},
frequency_penalty: {
min: 0,
max: 2,
step: 0.01,
default: 0,
},
resendFiles: {
default: true,
},
imageDetail: {
default: ImageDetail.auto,
},
};
export const googleSettings = {
model: {
default: 'chat-bison',
},
maxOutputTokens: {
min: 1,
max: 2048,
step: 1,
default: 1024,
maxGeminiPro: 8192,
defaultGeminiPro: 8192,
},
temperature: {
min: 0,
max: 1,
step: 0.01,
default: 0.2,
},
topP: {
min: 0,
max: 1,
step: 0.01,
default: 0.8,
},
topK: {
min: 1,
max: 40,
step: 0.01,
default: 40,
},
};
export const endpointSettings = {
[EModelEndpoint.openAI]: openAISettings,
[EModelEndpoint.google]: googleSettings,
};
const google = endpointSettings[EModelEndpoint.google];
@ -86,26 +145,6 @@ export const eModelEndpointSchema = z.nativeEnum(EModelEndpoint);
export const extendedModelEndpointSchema = z.union([eModelEndpointSchema, z.string()]);
export enum ImageDetail {
low = 'low',
auto = 'auto',
high = 'high',
}
export const imageDetailNumeric = {
[ImageDetail.low]: 0,
[ImageDetail.auto]: 1,
[ImageDetail.high]: 2,
};
export const imageDetailValue = {
0: ImageDetail.low,
1: ImageDetail.auto,
2: ImageDetail.high,
};
export const eImageDetailSchema = z.nativeEnum(ImageDetail);
export const tPluginAuthConfigSchema = z.object({
authField: z.string(),
label: z.string(),
@ -278,12 +317,14 @@ export const tPresetUpdateSchema = tConversationSchema.merge(
export type TPreset = z.infer<typeof tPresetSchema>;
export type TSetOption = (
param: number | string,
) => (newValue: number | string | boolean | Partial<TPreset>) => void;
export type TConversation = z.infer<typeof tConversationSchema> & {
presetOverride?: Partial<TPreset>;
};
// type DefaultSchemaValues = Partial<typeof google>;
export const openAISchema = tConversationSchema
.pick({
model: true,
@ -298,26 +339,27 @@ export const openAISchema = tConversationSchema
})
.transform((obj) => ({
...obj,
model: obj.model ?? 'gpt-3.5-turbo',
model: obj.model ?? openAISettings.model.default,
chatGptLabel: obj.chatGptLabel ?? null,
promptPrefix: obj.promptPrefix ?? null,
temperature: obj.temperature ?? 1,
top_p: obj.top_p ?? 1,
presence_penalty: obj.presence_penalty ?? 0,
frequency_penalty: obj.frequency_penalty ?? 0,
resendFiles: typeof obj.resendFiles === 'boolean' ? obj.resendFiles : true,
imageDetail: obj.imageDetail ?? ImageDetail.auto,
temperature: obj.temperature ?? openAISettings.temperature.default,
top_p: obj.top_p ?? openAISettings.top_p.default,
presence_penalty: obj.presence_penalty ?? openAISettings.presence_penalty.default,
frequency_penalty: obj.frequency_penalty ?? openAISettings.frequency_penalty.default,
resendFiles:
typeof obj.resendFiles === 'boolean' ? obj.resendFiles : openAISettings.resendFiles.default,
imageDetail: obj.imageDetail ?? openAISettings.imageDetail.default,
}))
.catch(() => ({
model: 'gpt-3.5-turbo',
model: openAISettings.model.default,
chatGptLabel: null,
promptPrefix: null,
temperature: 1,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
resendFiles: true,
imageDetail: ImageDetail.auto,
temperature: openAISettings.temperature.default,
top_p: openAISettings.top_p.default,
presence_penalty: openAISettings.presence_penalty.default,
frequency_penalty: openAISettings.frequency_penalty.default,
resendFiles: openAISettings.resendFiles.default,
imageDetail: openAISettings.imageDetail.default,
}));
export const googleSchema = tConversationSchema
@ -674,53 +716,3 @@ export const compactPluginsSchema = tConversationSchema
return removeNullishValues(newObj);
})
.catch(() => ({}));
// const createGoogleSchema = (customGoogle: DefaultSchemaValues) => {
// const defaults = { ...google, ...customGoogle };
// return tConversationSchema
// .pick({
// model: true,
// modelLabel: true,
// promptPrefix: true,
// examples: true,
// temperature: true,
// maxOutputTokens: true,
// topP: true,
// topK: true,
// })
// .transform((obj) => {
// const isGeminiPro = obj?.model?.toLowerCase()?.includes('gemini-pro');
// const maxOutputTokensMax = isGeminiPro
// ? defaults.maxOutputTokens.maxGeminiPro
// : defaults.maxOutputTokens.max;
// const maxOutputTokensDefault = isGeminiPro
// ? defaults.maxOutputTokens.defaultGeminiPro
// : defaults.maxOutputTokens.default;
// let maxOutputTokens = obj.maxOutputTokens ?? maxOutputTokensDefault;
// maxOutputTokens = Math.min(maxOutputTokens, maxOutputTokensMax);
// return {
// ...obj,
// model: obj.model ?? defaults.model.default,
// modelLabel: obj.modelLabel ?? null,
// promptPrefix: obj.promptPrefix ?? null,
// examples: obj.examples ?? [{ input: { content: '' }, output: { content: '' } }],
// temperature: obj.temperature ?? defaults.temperature.default,
// maxOutputTokens,
// topP: obj.topP ?? defaults.topP.default,
// topK: obj.topK ?? defaults.topK.default,
// };
// })
// .catch(() => ({
// model: defaults.model.default,
// modelLabel: null,
// promptPrefix: null,
// examples: [{ input: { content: '' }, output: { content: '' } }],
// temperature: defaults.temperature.default,
// maxOutputTokens: defaults.maxOutputTokens.default,
// topP: defaults.topP.default,
// topK: defaults.topK.default,
// }));
// };