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>

View file

@ -1,14 +1,17 @@
import React, { useEffect, useState } from 'react';
import { useCreatePresetMutation } from 'librechat-data-provider';
import type { TEditPresetProps } from '~/common';
import { Dialog, Input, Label } from '~/components/ui/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { cn, defaultTextPropsLabel, removeFocusOutlines, cleanupPreset } from '~/utils/';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { Dialog, Input, Label } from '~/components/ui/';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) => {
const [title, setTitle] = useState<string>(preset?.title || 'My Preset');
const createPresetMutation = useCreatePresetMutation();
const { showToast } = useToastContext();
const localize = useLocalize();
const submitPreset = () => {
@ -18,7 +21,24 @@ const SaveAsPresetDialog = ({ open, onOpenChange, preset }: TEditPresetProps) =>
title,
},
});
createPresetMutation.mutate(_preset);
const toastTitle = _preset.title
? `\`${_preset.title}\``
: localize('com_endpoint_preset_title');
createPresetMutation.mutate(_preset, {
onSuccess: () => {
showToast({
message: `${toastTitle} ${localize('com_endpoint_preset_saved')}`,
});
},
onError: () => {
showToast({
message: localize('com_endpoint_preset_save_error'),
severity: NotificationSeverity.ERROR,
});
},
});
};
useEffect(() => {

View file

@ -8,6 +8,10 @@ const Logout = forwardRef(() => {
const localize = useLocalize();
const handleLogout = () => {
localStorage.removeItem('lastConversationSetup');
localStorage.removeItem('lastSelectedTools');
localStorage.removeItem('lastAssistant');
localStorage.removeItem('autoScroll');
logout();
};

View file

@ -1,5 +1,11 @@
import { cn } from '~/utils';
export default function AnthropicIcon({ size = 25, className = '' }) {
export default function AnthropicIcon({
size = 25,
className = '',
}: {
size?: number;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 16"

View file

@ -1,7 +1,13 @@
/* eslint-disable indent */
import { cn } from '~/utils/';
export default function AzureMinimalIcon({ size = 25, className = 'h-4 w-4' }) {
export default function AzureMinimalIcon({
size = 25,
className = 'h-4 w-4',
}: {
size?: number;
className?: string;
}) {
const height = size;
const width = size;

View file

@ -1,6 +1,12 @@
import { cn } from '~/utils/';
export default function GPTIcon({ size = 25, className = '' }) {
export default function GPTIcon({
size = 25,
className = '',
}: {
size?: number;
className?: string;
}) {
const unit = '41';
const height = size;
const width = size;

View file

@ -1,4 +1,10 @@
export default function MinimalPlugin({ size, className = 'icon-md' }) {
export default function MinimalPlugin({
size,
className = 'icon-md',
}: {
size?: number;
className?: string;
}) {
return (
<svg
width={size}

View file

@ -0,0 +1,53 @@
export default function PinIcon({ unpin = false }: { unpin?: boolean }) {
if (unpin) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-sm"
>
<path
d="M15 15V17.5585C15 18.4193 14.4491 19.1836 13.6325 19.4558L13.1726 19.6091C12.454 19.8487 11.6616 19.6616 11.126 19.126L4.87403 12.874C4.33837 12.3384 4.15132 11.546 4.39088 10.8274L4.54415 10.3675C4.81638 9.55086 5.58066 9 6.44152 9H9M12 6.2L13.6277 3.92116C14.3461 2.91549 15.7955 2.79552 16.6694 3.66942L20.3306 7.33058C21.2045 8.20448 21.0845 9.65392 20.0788 10.3723L18 11.8571"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 16L3 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 4L20 20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
return (
<svg
className="icon-sm"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.4845 2.8798C16.1773 1.57258 14.0107 1.74534 12.9272 3.24318L9.79772 7.56923C9.60945 7.82948 9.30775 7.9836 8.98654 7.9836H6.44673C3.74061 7.9836 2.27414 11.6759 4.16948 13.5713L6.59116 15.993L2.29324 20.2909C1.90225 20.6819 1.90225 21.3158 2.29324 21.7068C2.68422 22.0977 3.31812 22.0977 3.70911 21.7068L8.00703 17.4088L10.4287 19.8305C12.3241 21.7259 16.0164 20.2594 16.0164 17.5533V15.0135C16.0164 14.6923 16.1705 14.3906 16.4308 14.2023L20.7568 11.0728C22.2547 9.98926 22.4274 7.8227 21.1202 6.51549L17.4845 2.8798ZM11.8446 18.4147C12.4994 19.0694 14.0141 18.4928 14.0141 17.5533V15.0135C14.0141 14.0499 14.4764 13.1447 15.2572 12.58L19.5832 9.45047C20.0825 9.08928 20.1401 8.3671 19.7043 7.93136L16.0686 4.29567C15.6329 3.85993 14.9107 3.91751 14.5495 4.4168L11.4201 8.74285C10.8553 9.52359 9.95016 9.98594 8.98654 9.98594H6.44673C5.5072 9.98594 4.93059 11.5006 5.58535 12.1554L11.8446 18.4147Z"
fill="currentColor"
/>
</svg>
);
}

View file

@ -25,6 +25,7 @@ export { default as SendIcon } from './SendIcon';
export { default as LinkIcon } from './LinkIcon';
export { default as DotsIcon } from './DotsIcon';
export { default as GearIcon } from './GearIcon';
export { default as PinIcon } from './PinIcon';
export { default as TrashIcon } from './TrashIcon';
export { default as MinimalPlugin } from './MinimalPlugin';
export { default as AzureMinimalIcon } from './AzureMinimalIcon';