📦 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

@ -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;