mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
refactor: basic message and send message. as well as model
THIS IS NOT FINISHED. DONT USE THIS
This commit is contained in:
parent
de8f519742
commit
c7c30d8bb5
24 changed files with 1057 additions and 1035 deletions
17
client/src/components/Input/Models/MenuItems.jsx
Normal file
17
client/src/components/Input/Models/MenuItems.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import ModelItem from './ModelItem';
|
||||
|
||||
export default function MenuItems({ models, onSelect }) {
|
||||
return (
|
||||
<>
|
||||
{models.map(modelItem => (
|
||||
<ModelItem
|
||||
key={modelItem._id}
|
||||
value={modelItem.value}
|
||||
onSelect={onSelect}
|
||||
model={modelItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
156
client/src/components/Input/Models/ModelDialog.jsx
Normal file
156
client/src/components/Input/Models/ModelDialog.jsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { Button } from '../../ui/Button.tsx';
|
||||
import { Input } from '../../ui/Input.tsx';
|
||||
import { Label } from '../../ui/Label.tsx';
|
||||
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '../../ui/Dialog.tsx';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export default function ModelDialog({ mutate, setModelSave, handleSaveState }) {
|
||||
const { newConversation } = store.useConversation();
|
||||
|
||||
const [chatGptLabel, setChatGptLabel] = useState('');
|
||||
const [promptPrefix, setPromptPrefix] = useState('');
|
||||
const [saveText, setSaveText] = useState('Save');
|
||||
const [required, setRequired] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const updateCustomGpt = manualSWR(`/api/customGpts/`, 'post');
|
||||
|
||||
const selectHandler = e => {
|
||||
if (chatGptLabel.length === 0) {
|
||||
e.preventDefault();
|
||||
setRequired(true);
|
||||
inputRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
handleSaveState(chatGptLabel.toLowerCase());
|
||||
|
||||
// Set new conversation
|
||||
newConversation({
|
||||
model: 'chatgptCustom',
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
});
|
||||
};
|
||||
|
||||
const saveHandler = e => {
|
||||
e.preventDefault();
|
||||
setModelSave(true);
|
||||
const value = chatGptLabel.toLowerCase();
|
||||
|
||||
if (chatGptLabel.length === 0) {
|
||||
setRequired(true);
|
||||
inputRef.current.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
updateCustomGpt.trigger({ value, chatGptLabel, promptPrefix });
|
||||
|
||||
mutate();
|
||||
setSaveText(prev => prev + 'd!');
|
||||
setTimeout(() => {
|
||||
setSaveText('Save');
|
||||
}, 2500);
|
||||
|
||||
// dispatch(setCustomGpt({ chatGptLabel, promptPrefix }));
|
||||
newConversation({
|
||||
model: 'chatgptCustom',
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
});
|
||||
};
|
||||
|
||||
// Commented by wtlyu
|
||||
// if (
|
||||
// chatGptLabel !== 'chatgptCustom' &&
|
||||
// modelMap[chatGptLabel.toLowerCase()] &&
|
||||
// !initial[chatGptLabel.toLowerCase()] &&
|
||||
// saveText === 'Save'
|
||||
// ) {
|
||||
// setSaveText('Update');
|
||||
// } else if (!modelMap[chatGptLabel.toLowerCase()] && saveText === 'Update') {
|
||||
// setSaveText('Save');
|
||||
// }
|
||||
|
||||
const requiredProp = required ? { required: true } : {};
|
||||
|
||||
return (
|
||||
<DialogContent className="shadow-2xl dark:bg-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-gray-800 dark:text-white">Customize ChatGPT</DialogTitle>
|
||||
<DialogDescription className="text-gray-600 dark:text-gray-300">
|
||||
Note: important instructions are often better placed in your message rather than the prefix.{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/docs/guides/chat/instructing-chat-models"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<u>More info here</u>
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label
|
||||
htmlFor="chatGptLabel"
|
||||
className="text-right"
|
||||
>
|
||||
Custom Name
|
||||
</Label>
|
||||
<Input
|
||||
id="chatGptLabel"
|
||||
value={chatGptLabel}
|
||||
ref={inputRef}
|
||||
onChange={e => setChatGptLabel(e.target.value)}
|
||||
placeholder="Set a custom name for ChatGPT"
|
||||
className=" col-span-3 shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none placeholder:text-gray-400 invalid:border-red-400 invalid:text-red-600 invalid:placeholder-red-600 invalid:placeholder-opacity-70 invalid:ring-opacity-10 focus:ring-0 focus:invalid:border-red-400 focus:invalid:ring-red-300 dark:border-none dark:bg-gray-700
|
||||
dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:invalid:border-red-600 dark:invalid:text-red-300 dark:invalid:placeholder-opacity-80 dark:focus:border-none dark:focus:border-transparent dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0 dark:focus:invalid:ring-red-600 dark:focus:invalid:ring-opacity-50"
|
||||
{...requiredProp}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label
|
||||
htmlFor="promptPrefix"
|
||||
className="text-right"
|
||||
>
|
||||
Prompt Prefix
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
id="promptPrefix"
|
||||
value={promptPrefix}
|
||||
onChange={e => setPromptPrefix(e.target.value)}
|
||||
placeholder="Set custom instructions. Defaults to: 'You are ChatGPT, a large language model trained by OpenAI.'"
|
||||
className="col-span-3 flex h-20 w-full resize-none rounded-md border border-gray-300 bg-transparent py-2 px-3 text-sm shadow-[0_0_10px_rgba(0,0,0,0.10)] outline-none placeholder:text-gray-400 focus:outline-none focus:ring-gray-400 focus:ring-opacity-20 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-none dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-none dark:focus:border-transparent dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose className="dark:hover:gray-400 border-gray-700">Cancel</DialogClose>
|
||||
<Button
|
||||
style={{ backgroundColor: 'rgb(16, 163, 127)' }}
|
||||
onClick={saveHandler}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border-none py-2 px-4 text-sm font-semibold text-white transition-colors dark:text-gray-200"
|
||||
>
|
||||
{saveText}
|
||||
</Button>
|
||||
<DialogClose
|
||||
onClick={selectHandler}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border-none bg-gray-900 py-2 px-4 text-sm font-semibold text-white transition-colors hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200 dark:focus:ring-gray-400 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
Select
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
180
client/src/components/Input/Models/ModelItem.jsx
Normal file
180
client/src/components/Input/Models/ModelItem.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { DropdownMenuRadioItem } from '../../ui/DropdownMenu.tsx';
|
||||
import { Circle } from 'lucide-react';
|
||||
import { DialogTrigger } from '../../ui/Dialog.tsx';
|
||||
import RenameButton from '../../Conversations/RenameButton';
|
||||
import TrashIcon from '../../svg/TrashIcon';
|
||||
import manualSWR from '~/utils/fetchers';
|
||||
import { getIconOfModel } from '~/utils';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export default function ModelItem({ model: _model, value, onSelect }) {
|
||||
const { name, model, _id: id, chatGptLabel = null, promptPrefix = null } = _model;
|
||||
const setCustomGPTModels = useSetRecoilState(store.customGPTModels);
|
||||
const currentConversation = useRecoilValue(store.conversation) || {};
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [currentName, setCurrentName] = useState(name);
|
||||
const [modelInput, setModelInput] = useState(name);
|
||||
const inputRef = useRef(null);
|
||||
const rename = manualSWR(`/api/customGpts`, 'post', res => {});
|
||||
const deleteCustom = manualSWR(`/api/customGpts/delete`, 'post', res => {
|
||||
const fetchedModels = res.data.map(modelItem => ({
|
||||
...modelItem,
|
||||
name: modelItem.chatGptLabel,
|
||||
model: 'chatgptCustom'
|
||||
}));
|
||||
|
||||
setCustomGPTModels(fetchedModels);
|
||||
});
|
||||
|
||||
const icon = getIconOfModel({
|
||||
size: 20,
|
||||
sender: chatGptLabel || model,
|
||||
isCreatedByUser: false,
|
||||
model,
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
error: false,
|
||||
className: 'mr-2'
|
||||
});
|
||||
|
||||
if (model !== 'chatgptCustom')
|
||||
// regular model
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
value={value}
|
||||
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{icon}
|
||||
{name}
|
||||
{model === 'chatgpt' && <sup>$</sup>}
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
else if (model === 'chatgptCustom' && chatGptLabel === null && promptPrefix === null)
|
||||
// base chatgptCustom model, click to add new chatgptCustom.
|
||||
return (
|
||||
<DialogTrigger className="w-full">
|
||||
<DropdownMenuRadioItem
|
||||
value={value}
|
||||
className="dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{icon}
|
||||
{name}
|
||||
<sup>$</sup>
|
||||
</DropdownMenuRadioItem>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
// else: a chatgptCustom model
|
||||
const handleMouseOver = () => {
|
||||
setIsHovering(true);
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
setIsHovering(false);
|
||||
};
|
||||
|
||||
const renameHandler = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setRenaming(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current.focus();
|
||||
}, 25);
|
||||
};
|
||||
|
||||
const onRename = e => {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
if (modelInput === name) {
|
||||
return;
|
||||
}
|
||||
rename.trigger({
|
||||
prevLabel: currentName,
|
||||
chatGptLabel: modelInput,
|
||||
value: modelInput.toLowerCase()
|
||||
});
|
||||
setCurrentName(modelInput);
|
||||
};
|
||||
|
||||
const onDelete = async e => {
|
||||
e.preventDefault();
|
||||
await deleteCustom.trigger({ _id: id });
|
||||
onSelect('chatgpt');
|
||||
};
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
onRename(e);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonClass = {
|
||||
className:
|
||||
'invisible group-hover:visible z-50 rounded-md m-0 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
};
|
||||
|
||||
const itemClass = {
|
||||
className:
|
||||
'relative flex group cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-medium outline-none hover:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700 dark:font-semibold dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
value={value}
|
||||
className={itemClass.className}
|
||||
onClick={e => {
|
||||
if (isHovering) {
|
||||
return;
|
||||
}
|
||||
onSelect('chatgptCustom', value);
|
||||
}}
|
||||
>
|
||||
{currentConversation?.chatGptLabel === value && (
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{icon}
|
||||
|
||||
{renaming === true ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
key={id}
|
||||
type="text"
|
||||
className="pointer-events-auto z-50 m-0 mr-2 w-3/4 border border-blue-500 bg-transparent p-0 text-sm leading-tight outline-none"
|
||||
value={modelInput}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onChange={e => setModelInput(e.target.value)}
|
||||
// onBlur={onRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<div className=" overflow-hidden">{modelInput}</div>
|
||||
)}
|
||||
|
||||
{value === 'chatgpt' && <sup>$</sup>}
|
||||
<RenameButton
|
||||
twcss={`ml-auto mr-2 ${buttonClass.className}`}
|
||||
onRename={onRename}
|
||||
renaming={renaming}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
renameHandler={renameHandler}
|
||||
/>
|
||||
<button
|
||||
{...buttonClass}
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
205
client/src/components/Input/Models/ModelMenu.jsx
Normal file
205
client/src/components/Input/Models/ModelMenu.jsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import axios from 'axios';
|
||||
import ModelDialog from './ModelDialog';
|
||||
import MenuItems from './MenuItems';
|
||||
import { swr } from '~/utils/fetchers';
|
||||
import { getIconOfModel } from '~/utils';
|
||||
|
||||
import { Button } from '../../ui/Button.tsx';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '../../ui/DropdownMenu.tsx';
|
||||
import { Dialog } from '../../ui/Dialog.tsx';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export default function ModelMenu() {
|
||||
const [modelSave, setModelSave] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const models = useRecoilValue(store.models);
|
||||
const availableModels = useRecoilValue(store.availableModels);
|
||||
const setCustomGPTModels = useSetRecoilState(store.customGPTModels);
|
||||
|
||||
const conversation = useRecoilValue(store.conversation) || {};
|
||||
const { model, promptPrefix, chatGptLabel, conversationId } = conversation;
|
||||
const { newConversation } = store.useConversation();
|
||||
|
||||
// fetch the list of saved chatgptCustom
|
||||
const { data, isLoading, mutate } = swr(`/api/customGpts`, res => {
|
||||
const fetchedModels = res.map(modelItem => ({
|
||||
...modelItem,
|
||||
name: modelItem.chatGptLabel,
|
||||
model: 'chatgptCustom'
|
||||
}));
|
||||
|
||||
setCustomGPTModels(fetchedModels);
|
||||
});
|
||||
|
||||
// useEffect(() => {
|
||||
// mutate();
|
||||
// try {
|
||||
// const lastSelected = JSON.parse(localStorage.getItem('model'));
|
||||
|
||||
// if (lastSelected === 'chatgptCustom') {
|
||||
// return;
|
||||
// } else if (initial[lastSelected]) {
|
||||
// dispatch(setModel(lastSelected));
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.log(err);
|
||||
// }
|
||||
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, []);
|
||||
|
||||
// update the default model when availableModels changes
|
||||
// typically, availableModels changes => modelsFilter or customGPTModels changes
|
||||
useEffect(() => {
|
||||
if (conversationId == 'new') {
|
||||
newConversation();
|
||||
}
|
||||
}, [availableModels]);
|
||||
|
||||
// save selected model to localstoreage
|
||||
useEffect(() => {
|
||||
if (model) localStorage.setItem('model', JSON.stringify({ model, chatGptLabel, promptPrefix }));
|
||||
}, [model]);
|
||||
|
||||
// set the current model
|
||||
const onChange = (newModel, value = null) => {
|
||||
setMenuOpen(false);
|
||||
|
||||
if (!newModel) {
|
||||
return;
|
||||
} else if (newModel === model && value === chatGptLabel) {
|
||||
// bypass if not changed
|
||||
return;
|
||||
} else if (newModel === 'chatgptCustom' && value === null) {
|
||||
// return;
|
||||
} else if (newModel !== 'chatgptCustom') {
|
||||
newConversation({
|
||||
model: newModel,
|
||||
chatGptLabel: null,
|
||||
promptPrefix: null
|
||||
});
|
||||
} else if (newModel === 'chatgptCustom') {
|
||||
const targetModel = models.find(element => element.value == value);
|
||||
if (targetModel) {
|
||||
const chatGptLabel = targetModel?.chatGptLabel;
|
||||
const promptPrefix = targetModel?.promptPrefix;
|
||||
newConversation({
|
||||
model: newModel,
|
||||
chatGptLabel,
|
||||
promptPrefix
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChange = open => {
|
||||
mutate();
|
||||
if (!open) {
|
||||
setModelSave(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveState = value => {
|
||||
if (!modelSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomGPTModels(value);
|
||||
setModelSave(false);
|
||||
};
|
||||
|
||||
const defaultColorProps = [
|
||||
'text-gray-500',
|
||||
'hover:bg-gray-100',
|
||||
'hover:bg-opacity-20',
|
||||
'disabled:hover:bg-transparent',
|
||||
'dark:data-[state=open]:bg-gray-800',
|
||||
'dark:hover:bg-opacity-20',
|
||||
'dark:hover:bg-gray-900',
|
||||
'dark:hover:text-gray-400',
|
||||
'dark:disabled:hover:bg-transparent'
|
||||
];
|
||||
|
||||
const chatgptColorProps = [
|
||||
'text-green-700',
|
||||
'data-[state=open]:bg-green-100',
|
||||
'dark:text-emerald-300',
|
||||
'hover:bg-green-100',
|
||||
'disabled:hover:bg-transparent',
|
||||
'dark:data-[state=open]:bg-green-900',
|
||||
'dark:hover:bg-opacity-50',
|
||||
'dark:hover:bg-green-900',
|
||||
'dark:hover:text-gray-100',
|
||||
'dark:disabled:hover:bg-transparent'
|
||||
];
|
||||
|
||||
const colorProps = model === 'chatgpt' ? chatgptColorProps : defaultColorProps;
|
||||
const icon = getIconOfModel({
|
||||
size: 32,
|
||||
sender: chatGptLabel || model,
|
||||
isCreatedByUser: false,
|
||||
model,
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
error: false,
|
||||
button: true
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange}>
|
||||
<DropdownMenu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
// style={{backgroundColor: 'rgb(16, 163, 127)'}}
|
||||
className={`absolute top-[0.25px] mb-0 ml-1 items-center rounded-md border-0 p-1 outline-none md:ml-0 ${colorProps.join(
|
||||
' '
|
||||
)} focus:ring-0 focus:ring-offset-0 disabled:top-[0.25px] dark:data-[state=open]:bg-opacity-50 md:top-1 md:left-1 md:pl-1 md:disabled:top-1`}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 dark:bg-gray-700"
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuLabel className="dark:text-gray-300">Select a Model</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={chatGptLabel || model}
|
||||
onValueChange={onChange}
|
||||
className="overflow-y-auto"
|
||||
>
|
||||
{availableModels.length ? (
|
||||
<MenuItems
|
||||
models={availableModels}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuLabel className="dark:text-gray-300">No model available.</DropdownMenuLabel>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModelDialog
|
||||
mutate={mutate}
|
||||
setModelSave={setModelSave}
|
||||
handleSaveState={handleSaveState}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue