mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 03:10:15 +01:00
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:
parent
fdb65366d7
commit
ca64efec1b
32 changed files with 681 additions and 270 deletions
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue