feat: chat folders (#146)

* feat: folder

* folder functionality

* i18n
This commit is contained in:
Jing Hua 2023-03-27 21:41:45 +08:00 committed by GitHub
parent 404dae8dff
commit fe8d6ccced
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 573 additions and 153 deletions

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Indlæg på ShareGPT", "title": "Indlæg på ShareGPT",
"warning": "Vær opmærksom på, at ved at poste din samtale på ShareGPT, vil den blive offentligt tilgængelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes og kan blive arkiveret eller delt af andre. Vi råder dig til at overveje nøje og undgå at dele følsomme eller private oplysninger på denne platform." "warning": "Vær opmærksom på, at ved at poste din samtale på ShareGPT, vil den blive offentligt tilgængelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes og kan blive arkiveret eller delt af andre. Vi råder dig til at overveje nøje og undgå at dele følsomme eller private oplysninger på denne platform."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Post on ShareGPT", "title": "Post on ShareGPT",
"warning": "Please be aware that by posting your conversation on ShareGPT, it will become publicly accessible and viewable to anyone. Once posted, the conversation cannot be hidden or deleted, and may be archived or shared by others. We advise you to consider carefully and avoid sharing sensitive or private information on this platform." "warning": "Please be aware that by posting your conversation on ShareGPT, it will become publicly accessible and viewable to anyone. Once posted, the conversation cannot be hidden or deleted, and may be archived or shared by others. We advise you to consider carefully and avoid sharing sensitive or private information on this platform."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Publicar en ShareGPT", "title": "Publicar en ShareGPT",
"warning": "Por favor, tenga en cuenta que al publicar su conversación en ShareGPT, esta será accesible y visible para cualquiera. Una vez publicada, la conversación no se podrá ocultar ni eliminar, y puede ser archivada o compartida por otros. Le aconsejamos que lo considere detenidamente y evite compartir información sensible o privada en esta plataforma." "warning": "Por favor, tenga en cuenta que al publicar su conversación en ShareGPT, esta será accesible y visible para cualquiera. Una vez publicada, la conversación no se podrá ocultar ni eliminar, y puede ser archivada o compartida por otros. Le aconsejamos que lo considere detenidamente y evite compartir información sensible o privada en esta plataforma."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "ShareGPTに投稿", "title": "ShareGPTに投稿",
"warning": "ShareGPTに会話を投稿すると、誰でもアクセスして閲覧できるようになることに注意してください。一度投稿すると、会話は非表示にできず、削除もできません。また、他の人がアーカイブや共有する可能性があります。このプラットフォームで機密性のある情報や個人情報を共有しないように注意してください。" "warning": "ShareGPTに会話を投稿すると、誰でもアクセスして閲覧できるようになることに注意してください。一度投稿すると、会話は非表示にできず、削除もできません。また、他の人がアーカイブや共有する可能性があります。このプラットフォームで機密性のある情報や個人情報を共有しないように注意してください。"
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Siarkan di ShareGPT", "title": "Siarkan di ShareGPT",
"warning": "Sila ambil perhatian bahawa dengan menyiarkan perbualan anda di ShareGPT, ia akan menjadi boleh diakses dan dilihat oleh sesiapa sahaja. Setelah disiarkan, perbualan tidak boleh disembunyikan atau dipadam, dan mungkin diarkibkan atau dikongsi oleh orang lain. Kami menasihatkan anda untuk mempertimbangkan dengan teliti dan mengelakkan berkongsi maklumat sensitif atau peribadi di platform ini." "warning": "Sila ambil perhatian bahawa dengan menyiarkan perbualan anda di ShareGPT, ia akan menjadi boleh diakses dan dilihat oleh sesiapa sahaja. Setelah disiarkan, perbualan tidak boleh disembunyikan atau dipadam, dan mungkin diarkibkan atau dikongsi oleh orang lain. Kami menasihatkan anda untuk mempertimbangkan dengan teliti dan mengelakkan berkongsi maklumat sensitif atau peribadi di platform ini."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Innlegg på ShareGPT", "title": "Innlegg på ShareGPT",
"warning": "Vær oppmerksom på at ved å poste samtalen din på ShareGPT, vil den bli offentlig tilgjengelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes, og den kan bli arkivert eller delt av andre. Vi anbefaler deg å tenke nøye gjennom og unngå å dele sensitiv eller privat informasjon på denne plattformen." "warning": "Vær oppmerksom på at ved å poste samtalen din på ShareGPT, vil den bli offentlig tilgjengelig og synlig for alle. Når den er postet, kan samtalen ikke skjules eller slettes, og den kan bli arkivert eller delt av andre. Vi anbefaler deg å tenke nøye gjennom og unngå å dele sensitiv eller privat informasjon på denne plattformen."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "Inlägg på ShareGPT", "title": "Inlägg på ShareGPT",
"warning": "Var medveten om att genom att posta din konversation på ShareGPT kommer den att bli offentligt tillgänglig och synlig för alla. När den väl är postad kan konversationen varken döljas eller raderas och kan arkiveras eller delas av andra. Vi rekommenderar dig att tänka noggrant igenom och undvika att dela känslig eller privat information på denna plattform." "warning": "Var medveten om att genom att posta din konversation på ShareGPT kommer den att bli offentligt tillgänglig och synlig för alla. När den väl är postad kan konversationen varken döljas eller raderas och kan arkiveras eller delas av andra. Vi rekommenderar dig att tänka noggrant igenom och undvika att dela känslig eller privat information på denna plattform."
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "发布至 ShareGPT", "title": "发布至 ShareGPT",
"warning": "请注意,把您的对话发布到 ShareGPT 后,任何人都可以公开访问和查看。发布后,对话不能被隐藏或删除,且可能被其他人存档或分享。建议您慎重考虑,在这个平台上避免分享敏感或私密信息。" "warning": "请注意,把您的对话发布到 ShareGPT 后,任何人都可以公开访问和查看。发布后,对话不能被隐藏或删除,且可能被其他人存档或分享。建议您慎重考虑,在这个平台上避免分享敏感或私密信息。"
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "po 上 ShareGPT", "title": "po 上 ShareGPT",
"warning": "請注意,你將呢個傾偈 po 上 ShareGPT 之後,佢會係公開嘅,所有人都可以見到你寫嘅嘢。一旦 po 咗,呢個傾偈將冇得被隱藏或刪除,亦都可能畀人存檔同分享。我哋建議你諗清楚,唔好喺嗰度分享敏感或私人資料。" "warning": "請注意,你將呢個傾偈 po 上 ShareGPT 之後,佢會係公開嘅,所有人都可以見到你寫嘅嘢。一旦 po 咗,呢個傾偈將冇得被隱藏或刪除,亦都可能畀人存檔同分享。我哋建議你諗清楚,唔好喺嗰度分享敏感或私人資料。"
} },
"newFolder": "New Folder"
} }

View file

@ -32,5 +32,6 @@
"postOnShareGPT": { "postOnShareGPT": {
"title": "發佈至 ShareGPT", "title": "發佈至 ShareGPT",
"warning": "請注意,將您的對話發佈至 ShareGPT 後,任何人都可以公開訪問和查看。一旦發佈,對話將無法隱藏或刪除,並且可能被他人存檔或分享。我們建議您慎重考慮,並避免在此平台上分享敏感或私人信息。" "warning": "請注意,將您的對話發佈至 ShareGPT 後,任何人都可以公開訪問和查看。一旦發佈,對話將無法隱藏或刪除,並且可能被他人存檔或分享。我們建議您慎重考慮,並避免在此平台上分享敏感或私人信息。"
} },
"newFolder": "New Folder"
} }

View file

@ -0,0 +1,17 @@
import React from 'react';
const FolderIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
viewBox='0 0 1024 1024'
fill='currentColor'
height='1em'
width='1em'
{...props}
>
<path d='M880 298.4H521L403.7 186.2a8.15 8.15 0 00-5.5-2.2H144c-17.7 0-32 14.3-32 32v592c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V330.4c0-17.7-14.3-32-32-32zM840 768H184V256h188.5l119.6 114.4H840V768z' />
</svg>
);
};
export default FolderIcon;

View file

@ -0,0 +1,188 @@
import React, { useRef, useState } from 'react';
import useStore from '@store/store';
import DownChevronArrow from '@icon/DownChevronArrow';
import FolderIcon from '@icon/FolderIcon';
import { ChatHistoryInterface, ChatInterface } from '@type/chat';
import ChatHistory from './ChatHistory';
import EditIcon from '@icon/EditIcon';
import DeleteIcon from '@icon/DeleteIcon';
import CrossIcon from '@icon/CrossIcon';
import TickIcon from '@icon/TickIcon';
const ChatFolder = ({
folderName,
folderChats,
folderIndex,
}: {
folderName: string;
folderChats: ChatHistoryInterface[];
folderIndex: number;
}) => {
const setChats = useStore((state) => state.setChats);
const setFoldersName = useStore((state) => state.setFoldersName);
const setFoldersExpanded = useStore((state) => state.setFoldersExpanded);
const foldersExpanded = useStore((state) => state.foldersExpanded);
const inputRef = useRef<HTMLInputElement>(null);
const [_folderName, _setFolderName] = useState<string>(folderName);
const [isEdit, setIsEdit] = useState<boolean>(false);
const [isDelete, setIsDelete] = useState<boolean>(false);
// const [isExpanded, setIsExpanded] = useState<boolean>(
// useStore.getState().foldersExpanded[folderIndex]
// );
const [isHover, setIsHover] = useState<boolean>(false);
const handleTick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
if (isEdit) {
updatedChats.forEach((chat) => {
if (chat.folder === folderName) chat.folder = _folderName;
});
setChats(updatedChats);
const updatedFolderNames = [...useStore.getState().foldersName];
const pos = updatedFolderNames.indexOf(folderName);
if (pos !== -1) updatedFolderNames[pos] = _folderName;
setFoldersName(updatedFolderNames);
setIsEdit(false);
} else if (isDelete) {
updatedChats.forEach((chat) => {
if (chat.folder === folderName) delete chat.folder;
});
setChats(updatedChats);
setFoldersName(
useStore.getState().foldersName.filter((name) => name !== folderName)
);
setIsDelete(false);
}
};
const handleCross = () => {
setIsDelete(false);
setIsEdit(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer) {
e.stopPropagation();
setIsHover(false);
const updatedFoldersExpanded = [...foldersExpanded];
updatedFoldersExpanded[folderIndex] = true;
setFoldersExpanded(updatedFoldersExpanded);
const chatIndex = Number(e.dataTransfer.getData('chatIndex'));
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
updatedChats[chatIndex].folder = folderName;
setChats(updatedChats);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsHover(true);
};
const handleDragLeave = () => {
setIsHover(false);
};
const toggleExpanded = () => {
const updatedFoldersExpanded = [...foldersExpanded];
updatedFoldersExpanded[folderIndex] = !updatedFoldersExpanded[folderIndex];
setFoldersExpanded(updatedFoldersExpanded);
};
return (
<div
className={`w-full transition-colors ${isHover ? 'bg-gray-800/40' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div
className='flex py-3 px-3 items-center gap-3 relative rounded-md hover:bg-[#2A2B32] break-all cursor-pointer'
onClick={toggleExpanded}
>
<FolderIcon className='h-4 w-4' />
<div className='flex-1 text-ellipsis max-h-5 overflow-hidden break-all relative'>
{isEdit ? (
<input
type='text'
className='focus:outline-blue-600 text-sm border-none bg-transparent p-0 m-0 w-full'
value={_folderName}
onChange={(e) => {
_setFolderName(e.target.value);
}}
onClick={(e) => e.stopPropagation()}
ref={inputRef}
/>
) : (
_folderName
)}
</div>
<div
className='absolute flex right-1 z-10 text-gray-300 visible'
onClick={(e) => e.stopPropagation()}
>
{isDelete || isEdit ? (
<>
<button className='p-1 hover:text-white' onClick={handleTick}>
<TickIcon />
</button>
<button className='p-1 hover:text-white' onClick={handleCross}>
<CrossIcon />
</button>
</>
) : (
<>
<button
className='p-1 hover:text-white'
onClick={() => setIsEdit(true)}
>
<EditIcon />
</button>
<button
className='p-1 hover:text-white'
onClick={() => setIsDelete(true)}
>
<DeleteIcon />
</button>
<button className='p-1 hover:text-white' onClick={toggleExpanded}>
<DownChevronArrow
className={`${
foldersExpanded[folderIndex] ? 'rotate-180' : ''
} transition-transform`}
/>
</button>
</>
)}
</div>
</div>
<div className='ml-3 pl-1 border-l-2 border-gray-700 flex flex-col gap-1'>
{foldersExpanded[folderIndex] &&
folderChats.map((chat) => (
<ChatHistory
title={chat.title}
chatIndex={chat.index}
key={`${chat.title}-${chat.index}`}
/>
))}
</div>
</div>
);
};
export default ChatFolder;

View file

@ -0,0 +1,147 @@
import React, { useEffect, useRef, useState } from 'react';
import useInitialiseNewChat from '@hooks/useInitialiseNewChat';
import ChatIcon from '@icon/ChatIcon';
import CrossIcon from '@icon/CrossIcon';
import DeleteIcon from '@icon/DeleteIcon';
import EditIcon from '@icon/EditIcon';
import TickIcon from '@icon/TickIcon';
import useStore from '@store/store';
const ChatHistoryClass = {
normal:
'flex py-3 px-3 items-center gap-3 relative rounded-md bg-gray-900 hover:bg-[#2A2B32] break-all hover:pr-4 group transition-opacity',
active:
'flex py-3 px-3 items-center gap-3 relative rounded-md break-all pr-14 bg-gray-800 hover:bg-gray-800 group transition-opacity',
normalGradient:
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-900 group-hover:from-[#2A2B32]',
activeGradient:
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-800',
};
const ChatHistory = React.memo(
({ title, chatIndex }: { title: string; chatIndex: number }) => {
const initialiseNewChat = useInitialiseNewChat();
const setCurrentChatIndex = useStore((state) => state.setCurrentChatIndex);
const setChats = useStore((state) => state.setChats);
const active = useStore((state) => state.currentChatIndex === chatIndex);
const generating = useStore((state) => state.generating);
const [isDelete, setIsDelete] = useState<boolean>(false);
const [isEdit, setIsEdit] = useState<boolean>(false);
const [_title, _setTitle] = useState<string>(title);
const inputRef = useRef<HTMLInputElement>(null);
const handleTick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const updatedChats = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
if (isEdit) {
updatedChats[chatIndex].title = _title;
setChats(updatedChats);
setIsEdit(false);
} else if (isDelete) {
updatedChats.splice(chatIndex, 1);
if (updatedChats.length > 0) {
setCurrentChatIndex(0);
setChats(updatedChats);
} else {
initialiseNewChat();
}
setIsDelete(false);
}
};
const handleCross = () => {
setIsDelete(false);
setIsEdit(false);
};
const handleDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {
if (e.dataTransfer) {
e.dataTransfer.setData('chatIndex', String(chatIndex));
}
};
useEffect(() => {
if (inputRef && inputRef.current) inputRef.current.focus();
}, [isEdit]);
return (
<a
className={`${
active ? ChatHistoryClass.active : ChatHistoryClass.normal
} ${
generating
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer opacity-100'
}`}
onClick={() => {
if (!generating) setCurrentChatIndex(chatIndex);
}}
draggable
onDragStart={handleDragStart}
>
<ChatIcon />
<div className='flex-1 text-ellipsis max-h-5 overflow-hidden break-all relative'>
{isEdit ? (
<input
type='text'
className='focus:outline-blue-600 text-sm border-none bg-transparent p-0 m-0 w-full'
value={_title}
onChange={(e) => {
_setTitle(e.target.value);
}}
ref={inputRef}
/>
) : (
_title
)}
{isEdit || (
<div
className={
active
? ChatHistoryClass.activeGradient
: ChatHistoryClass.normalGradient
}
/>
)}
</div>
{active && (
<div className='absolute flex right-1 z-10 text-gray-300 visible'>
{isDelete || isEdit ? (
<>
<button className='p-1 hover:text-white' onClick={handleTick}>
<TickIcon />
</button>
<button className='p-1 hover:text-white' onClick={handleCross}>
<CrossIcon />
</button>
</>
) : (
<>
<button
className='p-1 hover:text-white'
onClick={() => setIsEdit(true)}
>
<EditIcon />
</button>
<button
className='p-1 hover:text-white'
onClick={() => setIsDelete(true)}
>
<DeleteIcon />
</button>
</>
)}
</div>
)}
</a>
);
}
);
export default ChatHistory;

View file

@ -2,21 +2,71 @@ import React, { useEffect, useRef, useState } from 'react';
import useStore from '@store/store'; import useStore from '@store/store';
import { shallow } from 'zustand/shallow'; import { shallow } from 'zustand/shallow';
import ChatIcon from '@icon/ChatIcon'; import NewFolder from './NewFolder';
import EditIcon from '@icon/EditIcon'; import ChatFolder from './ChatFolder';
import DeleteIcon from '@icon/DeleteIcon'; import ChatHistory from './ChatHistory';
import TickIcon from '@icon/TickIcon';
import CrossIcon from '@icon/CrossIcon';
import useInitialiseNewChat from '@hooks/useInitialiseNewChat'; import {
ChatHistoryInterface,
ChatHistoryFolderInterface,
ChatInterface,
} from '@type/chat';
const ChatHistoryList = () => { const ChatHistoryList = () => {
const currentChatIndex = useStore((state) => state.currentChatIndex); const currentChatIndex = useStore((state) => state.currentChatIndex);
const setChats = useStore((state) => state.setChats);
const chatTitles = useStore( const chatTitles = useStore(
(state) => state.chats?.map((chat) => chat.title), (state) => state.chats?.map((chat) => chat.title),
shallow shallow
); );
const [isHover, setIsHover] = useState<boolean>(false);
const [folders, setFolders] = useState<ChatHistoryFolderInterface>({});
const [noFolders, setNoFolders] = useState<ChatHistoryInterface[]>([]);
const chatsRef = useRef<ChatInterface[]>(useStore.getState().chats || []);
const foldersNameRef = useRef<string[]>(useStore.getState().foldersName);
const updateFolders = () => {
const _folders: ChatHistoryFolderInterface = {};
const _noFolders: ChatHistoryInterface[] = [];
const chats = useStore.getState().chats;
const foldersName = useStore.getState().foldersName;
foldersName.forEach((f) => (_folders[f] = []));
if (chats) {
chats.forEach((chat, index) => {
if (!chat.folder) {
_noFolders.push({ title: chat.title, index: index });
} else {
if (!_folders[chat.folder]) _folders[chat.folder] = [];
_folders[chat.folder].push({ title: chat.title, index: index });
}
});
}
setFolders(_folders);
setNoFolders(_noFolders);
};
useEffect(() => {
updateFolders();
useStore.subscribe((state) => {
if (
!state.generating &&
state.chats &&
state.chats !== chatsRef.current
) {
updateFolders();
chatsRef.current = state.chats;
} else if (state.foldersName !== foldersNameRef.current) {
updateFolders();
foldersNameRef.current = state.foldersName;
}
});
}, []);
useEffect(() => { useEffect(() => {
if ( if (
chatTitles && chatTitles &&
@ -24,22 +74,80 @@ const ChatHistoryList = () => {
currentChatIndex < chatTitles.length currentChatIndex < chatTitles.length
) { ) {
document.title = chatTitles[currentChatIndex]; document.title = chatTitles[currentChatIndex];
const chats = useStore.getState().chats;
if (chats) {
const folderIndex = useStore
.getState()
.foldersName.findIndex((f) => f === chats[currentChatIndex].folder);
if (folderIndex) {
const updatedFolderExpanded = [
...useStore.getState().foldersExpanded,
];
updatedFolderExpanded[folderIndex] = true;
useStore.getState().setFoldersExpanded(updatedFolderExpanded);
}
}
} }
}, [currentChatIndex, chatTitles]); }, [currentChatIndex, chatTitles]);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer) {
e.stopPropagation();
setIsHover(false);
const chatIndex = Number(e.dataTransfer.getData('chatIndex'));
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
delete updatedChats[chatIndex].folder;
setChats(updatedChats);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsHover(true);
};
const handleDragLeave = () => {
setIsHover(false);
};
const handleDragEnd = () => {
setIsHover(false);
};
return ( return (
<div className='flex-col flex-1 overflow-y-auto border-b border-white/20'> <div
className={`flex-col flex-1 overflow-y-auto border-b border-white/20 ${
isHover ? 'bg-gray-800/40' : ''
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragEnd={handleDragEnd}
>
<NewFolder />
<div className='flex flex-col gap-2 text-gray-100 text-sm'> <div className='flex flex-col gap-2 text-gray-100 text-sm'>
{chatTitles && {Object.keys(folders).map((folderName, folderIndex) => (
chatTitles.map((title, index) => ( <ChatFolder
folderName={folderName}
folderChats={folders[folderName]}
folderIndex={folderIndex}
key={folderName}
/>
))}
{noFolders.map(({ title, index }) => (
<ChatHistory <ChatHistory
title={title} title={title}
key={`${title}-${index}`} key={`${title}-${index}`}
chatIndex={index} chatIndex={index}
/> />
))} ))}
{/* <ShowMoreButton /> */}
</div> </div>
<div className='w-full h-10' />
</div> </div>
); );
}; };
@ -52,131 +160,4 @@ const ShowMoreButton = () => {
); );
}; };
const ChatHistoryClass = {
normal:
'flex py-3 px-3 items-center gap-3 relative rounded-md hover:bg-[#2A2B32] break-all hover:pr-4 group transition-opacity',
active:
'flex py-3 px-3 items-center gap-3 relative rounded-md break-all pr-14 bg-gray-800 hover:bg-gray-800 group transition-opacity',
normalGradient:
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-900 group-hover:from-[#2A2B32]',
activeGradient:
'absolute inset-y-0 right-0 w-8 z-10 bg-gradient-to-l from-gray-800',
};
const ChatHistory = React.memo(
({ title, chatIndex }: { title: string; chatIndex: number }) => {
const initialiseNewChat = useInitialiseNewChat();
const setCurrentChatIndex = useStore((state) => state.setCurrentChatIndex);
const setChats = useStore((state) => state.setChats);
const active = useStore((state) => state.currentChatIndex === chatIndex);
const generating = useStore((state) => state.generating);
const [isDelete, setIsDelete] = useState<boolean>(false);
const [isEdit, setIsEdit] = useState<boolean>(false);
const [_title, _setTitle] = useState<string>(title);
const inputRef = useRef<HTMLInputElement>(null);
const handleTick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const updatedChats = JSON.parse(
JSON.stringify(useStore.getState().chats)
);
if (isEdit) {
updatedChats[chatIndex].title = _title;
setChats(updatedChats);
setIsEdit(false);
} else if (isDelete) {
updatedChats.splice(chatIndex, 1);
if (updatedChats.length > 0) {
setCurrentChatIndex(0);
setChats(updatedChats);
} else {
initialiseNewChat();
}
setIsDelete(false);
}
};
const handleCross = () => {
setIsDelete(false);
setIsEdit(false);
};
useEffect(() => {
if (inputRef && inputRef.current) inputRef.current.focus();
}, [isEdit]);
return (
<a
className={`${
active ? ChatHistoryClass.active : ChatHistoryClass.normal
} ${
generating
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer opacity-100'
}`}
onClick={() => {
if (!generating) setCurrentChatIndex(chatIndex);
}}
>
<ChatIcon />
<div className='flex-1 text-ellipsis max-h-5 overflow-hidden break-all relative'>
{isEdit ? (
<input
type='text'
className='focus:outline-blue-600 text-sm border-none bg-transparent p-0 m-0 w-full'
value={_title}
onChange={(e) => {
_setTitle(e.target.value);
}}
ref={inputRef}
/>
) : (
_title
)}
{isEdit || (
<div
className={
active
? ChatHistoryClass.activeGradient
: ChatHistoryClass.normalGradient
}
/>
)}
</div>
{active && (
<div className='absolute flex right-1 z-10 text-gray-300 visible'>
{isDelete || isEdit ? (
<>
<button className='p-1 hover:text-white' onClick={handleTick}>
<TickIcon />
</button>
<button className='p-1 hover:text-white' onClick={handleCross}>
<CrossIcon />
</button>
</>
) : (
<>
<button
className='p-1 hover:text-white'
onClick={() => setIsEdit(true)}
>
<EditIcon />
</button>
<button
className='p-1 hover:text-white'
onClick={() => setIsDelete(true)}
>
<DeleteIcon />
</button>
</>
)}
</div>
)}
</a>
);
}
);
export default ChatHistoryList; export default ChatHistoryList;

View file

@ -0,0 +1,47 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import useStore from '@store/store';
import PlusIcon from '@icon/PlusIcon';
const NewFolder = () => {
const { t } = useTranslation();
const generating = useStore((state) => state.generating);
const setFoldersName = useStore((state) => state.setFoldersName);
const addFolder = () => {
let folderIndex = 1;
let name = `New Folder ${folderIndex}`;
while (
useStore
.getState()
.foldersName.some((_folderName) => _folderName === name)
) {
folderIndex += 1;
name = `New Folder ${folderIndex}`;
}
setFoldersName([name, ...useStore.getState().foldersName]);
};
return (
<a
className={`max-md:hidden flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white text-sm md:mb-2 flex-shrink-0 md:border md:border-white/20 transition-opacity ${
generating
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer opacity-100'
}`}
onClick={() => {
if (!generating) addFolder();
}}
>
<PlusIcon />{' '}
<span className='hidden md:inline-flex text-white text-sm'>
{t('newFolder')}
</span>
</a>
);
};
export default NewFolder;

View file

@ -7,11 +7,15 @@ export interface ChatSlice {
currentChatIndex: number; currentChatIndex: number;
generating: boolean; generating: boolean;
error: string; error: string;
foldersName: string[];
foldersExpanded: boolean[];
setMessages: (messages: MessageInterface[]) => void; setMessages: (messages: MessageInterface[]) => void;
setChats: (chats: ChatInterface[]) => void; setChats: (chats: ChatInterface[]) => void;
setCurrentChatIndex: (currentChatIndex: number) => void; setCurrentChatIndex: (currentChatIndex: number) => void;
setGenerating: (generating: boolean) => void; setGenerating: (generating: boolean) => void;
setError: (error: string) => void; setError: (error: string) => void;
setFoldersName: (foldersName: string[]) => void;
setFoldersExpanded: (foldersExpanded: boolean[]) => void;
} }
export const createChatSlice: StoreSlice<ChatSlice> = (set, get) => ({ export const createChatSlice: StoreSlice<ChatSlice> = (set, get) => ({
@ -19,6 +23,8 @@ export const createChatSlice: StoreSlice<ChatSlice> = (set, get) => ({
currentChatIndex: -1, currentChatIndex: -1,
generating: false, generating: false,
error: '', error: '',
foldersName: [],
foldersExpanded: [],
setMessages: (messages: MessageInterface[]) => { setMessages: (messages: MessageInterface[]) => {
set((prev: ChatSlice) => ({ set((prev: ChatSlice) => ({
...prev, ...prev,
@ -49,4 +55,16 @@ export const createChatSlice: StoreSlice<ChatSlice> = (set, get) => ({
error: error, error: error,
})); }));
}, },
setFoldersName: (foldersName: string[]) => {
set((prev: ChatSlice) => ({
...prev,
foldersName: foldersName,
}));
},
setFoldersExpanded: (foldersExpanded: boolean[]) => {
set((prev: ChatSlice) => ({
...prev,
foldersExpanded: foldersExpanded,
}));
},
}); });

View file

@ -59,6 +59,8 @@ const useStore = create<StoreState>()(
hideMenuOptions: state.hideMenuOptions, hideMenuOptions: state.hideMenuOptions,
firstVisit: state.firstVisit, firstVisit: state.firstVisit,
hideSideMenu: state.hideSideMenu, hideSideMenu: state.hideSideMenu,
foldersName: state.foldersName,
foldersExpanded: state.foldersExpanded,
}), }),
version: 7, version: 7,
migrate: (persistedState, version) => { migrate: (persistedState, version) => {

View file

@ -11,6 +11,7 @@ export interface MessageInterface {
export interface ChatInterface { export interface ChatInterface {
title: string; title: string;
folder?: string;
messages: MessageInterface[]; messages: MessageInterface[];
config: ConfigInterface; config: ConfigInterface;
titleSet: boolean; titleSet: boolean;
@ -25,6 +26,15 @@ export interface ConfigInterface {
frequency_penalty: number; frequency_penalty: number;
} }
export interface ChatHistoryInterface {
title: string;
index: number;
}
export interface ChatHistoryFolderInterface {
[folderName: string]: ChatHistoryInterface[];
}
export type ModelOptions = 'gpt-4' | 'gpt-4-32k' | 'gpt-3.5-turbo'; export type ModelOptions = 'gpt-4' | 'gpt-4-32k' | 'gpt-3.5-turbo';
// | 'gpt-3.5-turbo-0301'; // | 'gpt-3.5-turbo-0301';
// | 'gpt-4-0314' // | 'gpt-4-0314'