feat: Implement Default Preset Selection for Conversations 📌 (#1275)

* fix: type issues with icons

* refactor: use react query for presets, show toasts on preset crud, refactor mutations, remove presetsQuery from Root (breaking change)

* refactor: change preset titling

* refactor: update preset schemas and methods for necessary new properties `order` and `defaultPreset`

* feat: add `defaultPreset` Recoil value

* refactor(getPresetTitle): make logic cleaner and more concise

* feat: complete UI portion of defaultPreset feature, with animations added to preset items

* chore: remove console.logs()

* feat: complete default preset handling

* refactor: remove user sensitive values on logout

* fix: allow endpoint selection without default preset overwriting
This commit is contained in:
Danny Avila 2023-12-06 14:00:15 -05:00 committed by GitHub
parent fdb65366d7
commit ca64efec1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 681 additions and 270 deletions

View file

@ -1,10 +1,6 @@
import axios from 'axios';
import filenamify from 'filenamify';
import { useSetRecoilState } from 'recoil';
import exportFromJSON from 'export-from-json';
import { useRecoilState } from 'recoil';
import { useGetEndpointsQuery } from 'librechat-data-provider';
import type { TEditPresetProps } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset, mapEndpoints } from '~/utils';
import { cn, defaultTextProps, removeFocusOutlines, mapEndpoints } from '~/utils';
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
import PopoverButtons from '~/components/Endpoints/PopoverButtons';
import DialogTemplate from '~/components/ui/DialogTemplate';
@ -13,42 +9,21 @@ import { EndpointSettings } from '~/components/Endpoints';
import { useChatContext } from '~/Providers';
import store from '~/store';
const EditPresetDialog = ({ open, onOpenChange, title }: Omit<TEditPresetProps, 'preset'>) => {
const EditPresetDialog = ({
exportPreset,
submitPreset,
}: {
exportPreset: () => void;
submitPreset: () => void;
}) => {
const localize = useLocalize();
const { preset } = useChatContext();
const { setOption } = useSetIndexOptions(preset);
const [presetModalVisible, setPresetModalVisible] = useRecoilState(store.presetModalVisible);
// TODO: use React Query for presets data
const setPresets = useSetRecoilState(store.presets);
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
select: mapEndpoints,
});
const { setOption } = useSetIndexOptions(preset);
const localize = useLocalize();
const submitPreset = () => {
if (!preset) {
return;
}
axios({
method: 'post',
url: '/api/presets',
data: cleanupPreset({ preset }),
withCredentials: true,
}).then((res) => {
setPresets(res?.data);
});
};
const exportPreset = () => {
if (!preset) {
return;
}
const fileName = filenamify(preset?.title || 'preset');
exportFromJSON({
data: cleanupPreset({ preset }),
fileName,
exportType: exportFromJSON.types.json,
});
};
const { endpoint } = preset || {};
if (!endpoint) {
@ -56,9 +31,9 @@ const EditPresetDialog = ({ open, onOpenChange, title }: Omit<TEditPresetProps,
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={presetModalVisible} onOpenChange={setPresetModalVisible}>
<DialogTemplate
title={`${title || localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
title={`${localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
preset?.title
}`}
className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden lg:w-[950px] xl:h-[720px]"

View file

@ -1,18 +1,22 @@
import type { FC } from 'react';
import { Trash2 } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { Close } from '@radix-ui/react-popover';
import { Flipper, Flipped } from 'react-flip-toolkit';
import type { FC } from 'react';
import type { TPreset } from 'librechat-data-provider';
import FileUpload from '~/components/Input/EndpointMenu/FileUpload';
import { PinIcon, EditIcon, TrashIcon } from '~/components/svg';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, DialogTrigger } from '~/components/ui/';
import { EditIcon, TrashIcon } from '~/components/svg';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '../Endpoints/Icons';
import { getPresetTitle } from '~/utils';
import { useLocalize } from '~/hooks';
import store from '~/store';
const PresetItems: FC<{
presets: TPreset[];
onSetDefaultPreset: (preset: TPreset, remove?: boolean) => void;
onSelectPreset: (preset: TPreset) => void;
onChangePreset: (preset: TPreset) => void;
onDeletePreset: (preset: TPreset) => void;
@ -20,12 +24,14 @@ const PresetItems: FC<{
onFileSelected: (jsonData: Record<string, unknown>) => void;
}> = ({
presets,
onSetDefaultPreset,
onSelectPreset,
onChangePreset,
onDeletePreset,
clearAllPresets,
onFileSelected,
}) => {
const defaultPreset = useRecoilValue(store.defaultPreset);
const localize = useLocalize();
return (
<>
@ -35,11 +41,19 @@ const PresetItems: FC<{
tabIndex={-1}
>
<div className="flex h-full grow items-center justify-end gap-2">
<label
htmlFor="default-preset"
className="w-40 truncate rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors dark:bg-transparent dark:text-gray-300 sm:w-72"
>
{defaultPreset
? `${localize('com_endpoint_preset_default_item')} ${defaultPreset.title}`
: localize('com_endpoint_preset_default_none')}
</label>
<Dialog>
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-slate-200 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-slate-200 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
>
<Trash2 className="mr-1 flex w-[22px] items-center stroke-1" />
{localize('com_ui_clear')} {localize('com_ui_all')}
@ -71,56 +85,70 @@ const PresetItems: FC<{
</div>
</div>
)}
{presets &&
presets.length > 0 &&
presets.map((preset, i) => {
if (!preset) {
return null;
}
<Flipper flipKey={presets.map(({ presetId }) => presetId).join('.')}>
{presets &&
presets.length > 0 &&
presets.map((preset, i) => {
if (!preset || !preset.presetId) {
return null;
}
return (
<Close asChild key={`preset-${preset.presetId}`}>
<div key={`preset-${preset.presetId}`}>
<MenuItem
key={`preset-item-${preset.presetId}`}
textClassName="text-xs max-w-[200px] truncate md:max-w-full "
title={getPresetTitle(preset)}
disableHover={true}
onClick={() => onSelectPreset(preset)}
icon={icons[preset.endpoint ?? 'unknown']({ className: 'icon-md mr-1 ' })}
// value={preset.presetId}
selected={false}
data-testid={`preset-item-${preset}`}
// description="With DALL·E, browsing and analysis"
>
<div className="flex h-full items-center justify-end gap-1">
<button
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}}
return (
<Close asChild key={`preset-${preset.presetId}`}>
<div key={`preset-${preset.presetId}`}>
<Flipped flipId={preset.presetId}>
<MenuItem
key={`preset-item-${preset.presetId}`}
textClassName="text-xs max-w-[150px] sm:max-w-[200px] truncate md:max-w-full "
title={getPresetTitle(preset)}
disableHover={true}
onClick={() => onSelectPreset(preset)}
icon={icons[preset.endpoint ?? 'unknown']({
className: 'icon-md mr-1 dark:text-white',
})}
selected={false}
data-testid={`preset-item-${preset}`}
>
<EditIcon />
</button>
<button
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}}
>
<TrashIcon />
</button>
</div>
</MenuItem>
{i !== presets.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
<div className="flex h-full items-center justify-end gap-1">
<button
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSetDefaultPreset(preset, defaultPreset?.presetId === preset.presetId);
}}
>
<PinIcon unpin={defaultPreset?.presetId === preset.presetId} />
</button>
<button
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChangePreset(preset);
}}
>
<EditIcon />
</button>
<button
className="m-0 h-full rounded-md p-2 px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeletePreset(preset);
}}
>
<TrashIcon />
</button>
</div>
</MenuItem>
</Flipped>
{i !== presets.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
</Flipper>
</>
);
};

View file

@ -1,96 +1,25 @@
import type { FC } from 'react';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { BookCopy } from 'lucide-react';
import {
modularEndpoints,
useDeletePresetMutation,
useCreatePresetMutation,
} from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { useLocalize, useDefaultConvo, useNavigateToConvo } from '~/hooks';
import { useChatContext, useToastContext } from '~/Providers';
import { EditPresetDialog, PresetItems } from './Presets';
import { cleanupPreset, cn } from '~/utils';
import store from '~/store';
import { useLocalize, usePresets } from '~/hooks';
import { cn } from '~/utils';
const PresetsMenu: FC = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { conversation, newConversation, setPreset } = useChatContext();
const { navigateToConvo } = useNavigateToConvo();
const getDefaultConversation = useDefaultConvo();
const [presetModalVisible, setPresetModalVisible] = useState(false);
// TODO: rely on react query for presets data
const [presets, setPresets] = useRecoilState(store.presets);
const deletePresetsMutation = useDeletePresetMutation();
const createPresetMutation = useCreatePresetMutation();
const { endpoint } = conversation ?? {};
const importPreset = (jsonPreset: TPreset) => {
createPresetMutation.mutate(
{ ...jsonPreset },
{
onSuccess: (data) => {
setPresets(data);
},
onError: (error) => {
console.error('Error uploading the preset:', error);
},
},
);
};
const onFileSelected = (jsonData: Record<string, unknown>) => {
const jsonPreset = { ...cleanupPreset({ preset: jsonData }), presetId: null };
importPreset(jsonPreset);
};
const onSelectPreset = (newPreset: TPreset) => {
if (!newPreset) {
return;
}
showToast({
message: localize('com_endpoint_preset_selected'),
showIcon: false,
duration: 750,
});
if (
modularEndpoints.has(endpoint ?? '') &&
modularEndpoints.has(newPreset?.endpoint ?? '') &&
endpoint === newPreset?.endpoint
) {
const currentConvo = getDefaultConversation({
conversation: conversation ?? {},
preset: newPreset,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
navigateToConvo(currentConvo, false);
return;
}
console.log('preset', newPreset, endpoint);
newConversation({ preset: newPreset });
};
const onChangePreset = (preset: TPreset) => {
setPreset(preset);
setPresetModalVisible(true);
};
const clearAllPresets = () => {
deletePresetsMutation.mutate({ arg: {} });
};
const onDeletePreset = (preset: TPreset) => {
deletePresetsMutation.mutate({ arg: preset });
};
const {
presetsQuery,
onSetDefaultPreset,
onFileSelected,
onSelectPreset,
onChangePreset,
clearAllPresets,
onDeletePreset,
submitPreset,
exportPreset,
} = usePresets();
const presets = presetsQuery.data || [];
return (
<Root>
<Trigger asChild>
@ -125,6 +54,7 @@ const PresetsMenu: FC = () => {
>
<PresetItems
presets={presets}
onSetDefaultPreset={onSetDefaultPreset}
onSelectPreset={onSelectPreset}
onChangePreset={onChangePreset}
onDeletePreset={onDeletePreset}
@ -134,7 +64,7 @@ const PresetsMenu: FC = () => {
</Content>
</div>
</Portal>
<EditPresetDialog open={presetModalVisible} onOpenChange={setPresetModalVisible} />
<EditPresetDialog submitPreset={submitPreset} exportPreset={exportPreset} />
</Root>
);
};

View file

@ -15,7 +15,7 @@ type MenuItemProps = {
textClassName?: string;
disableHover?: boolean;
// hoverContent?: string;
};
} & Record<string, unknown>;
const MenuItem: FC<MenuItemProps> = ({
title,
@ -30,6 +30,7 @@ const MenuItem: FC<MenuItemProps> = ({
disableHover = false,
children,
onClick,
...rest
}) => {
return (
<div
@ -40,6 +41,7 @@ const MenuItem: FC<MenuItemProps> = ({
)}
tabIndex={-1}
onClick={onClick}
{...rest}
>
<div className="flex grow items-center justify-between gap-2">
<div>