Skip to content

Commit

Permalink
📦 feat: Model & Assistants Combobox for Side Panel (danny-avila#2380)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
danny-avila authored Apr 10, 2024
1 parent 52afc9f commit 1ebca83
Show file tree
Hide file tree
Showing 33 changed files with 2,847 additions and 459 deletions.
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -327,3 +333,8 @@ export interface ExtendedFile {
}

export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };

export interface SwitcherProps {
endpointKeyProvided: boolean;
isCollapsed: boolean;
}
3 changes: 2 additions & 1 deletion client/src/components/Chat/Input/ChatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }}
Expand Down
84 changes: 84 additions & 0 deletions client/src/components/SidePanel/AssistantSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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) ?? ''}
/>
}
/>
);
}
54 changes: 54 additions & 0 deletions client/src/components/SidePanel/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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
/>
}
/>
);
}
97 changes: 97 additions & 0 deletions client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 1ebca83

Please sign in to comment.