mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 02:10:15 +01:00
📦 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:
parent
f64a2cb0b0
commit
8e5f1ad575
33 changed files with 2850 additions and 462 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
68
client/src/hooks/Conversations/useParameterEffects.ts
Normal file
68
client/src/hooks/Conversations/useParameterEffects.ts
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
37
client/src/hooks/Input/useCombobox.ts
Normal file
37
client/src/hooks/Input/useCombobox.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
71
client/src/hooks/Nav/useSideNavLinks.ts
Normal file
71
client/src/hooks/Nav/useSideNavLinks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue