mirror of
https://github.com/NovaOSS/nova-betterchat.git
synced 2024-11-25 20:24:00 +01:00
refactor: message views
This commit is contained in:
parent
ece4778f88
commit
4f23af860e
|
@ -1,35 +1,8 @@
|
|||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import React, { useState } from 'react';
|
||||
import useStore from '@store/store';
|
||||
|
||||
import EditIcon2 from '@icon/EditIcon2';
|
||||
import DeleteIcon from '@icon/DeleteIcon';
|
||||
import TickIcon from '@icon/TickIcon';
|
||||
import CrossIcon from '@icon/CrossIcon';
|
||||
import RefreshIcon from '@icon/RefreshIcon';
|
||||
import DownChevronArrow from '@icon/DownChevronArrow';
|
||||
import CopyIcon from '@icon/CopyIcon';
|
||||
|
||||
import useSubmit from '@hooks/useSubmit';
|
||||
|
||||
import { ChatInterface } from '@type/chat';
|
||||
|
||||
import PopupModal from '@components/PopupModal';
|
||||
import TokenCount from '@components/TokenCount';
|
||||
import CommandPrompt from './CommandPrompt';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import { codeLanguageSubset } from '@constants/chat';
|
||||
import ContentView from './View/ContentView';
|
||||
import EditView from './View/EditView';
|
||||
|
||||
const MessageContent = ({
|
||||
role,
|
||||
|
@ -67,486 +40,4 @@ const MessageContent = ({
|
|||
);
|
||||
};
|
||||
|
||||
const ContentView = React.memo(
|
||||
({
|
||||
role,
|
||||
content,
|
||||
setIsEdit,
|
||||
messageIndex,
|
||||
}: {
|
||||
role: string;
|
||||
content: string;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
messageIndex: number;
|
||||
}) => {
|
||||
const { handleSubmit } = useSubmit();
|
||||
const [isDelete, setIsDelete] = useState<boolean>(false);
|
||||
const currentChatIndex = useStore((state) => state.currentChatIndex);
|
||||
const setChats = useStore((state) => state.setChats);
|
||||
const lastMessageIndex = useStore((state) =>
|
||||
state.chats ? state.chats[state.currentChatIndex].messages.length - 1 : 0
|
||||
);
|
||||
const inlineLatex = useStore((state) => state.inlineLatex);
|
||||
|
||||
const handleDelete = () => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
updatedChats[currentChatIndex].messages.splice(messageIndex, 1);
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const handleMove = (direction: 'up' | 'down') => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
const temp = updatedMessages[messageIndex];
|
||||
if (direction === 'up') {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex - 1];
|
||||
updatedMessages[messageIndex - 1] = temp;
|
||||
} else {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex + 1];
|
||||
updatedMessages[messageIndex + 1] = temp;
|
||||
}
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const handleMoveUp = () => {
|
||||
handleMove('up');
|
||||
};
|
||||
|
||||
const handleMoveDown = () => {
|
||||
handleMove('down');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
updatedMessages.splice(updatedMessages.length - 1, 1);
|
||||
setChats(updatedChats);
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='markdown prose w-full md:max-w-full break-words dark:prose-invert dark share-gpt-message'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: inlineLatex }],
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: codeLanguageSubset,
|
||||
},
|
||||
],
|
||||
]}
|
||||
linkTarget='_new'
|
||||
components={{
|
||||
code,
|
||||
p,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div className='flex justify-end gap-2 w-full mt-2'>
|
||||
{isDelete || (
|
||||
<>
|
||||
{!useStore.getState().generating &&
|
||||
role === 'assistant' &&
|
||||
messageIndex === lastMessageIndex && (
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
)}
|
||||
{messageIndex !== 0 && <UpButton onClick={handleMoveUp} />}
|
||||
{messageIndex !== lastMessageIndex && (
|
||||
<DownButton onClick={handleMoveDown} />
|
||||
)}
|
||||
|
||||
<CopyButton onClick={handleCopy} />
|
||||
<EditButton setIsEdit={setIsEdit} />
|
||||
<DeleteButton setIsDelete={setIsDelete} />
|
||||
</>
|
||||
)}
|
||||
{isDelete && (
|
||||
<>
|
||||
<button
|
||||
className='p-1 hover:text-white'
|
||||
onClick={() => setIsDelete(false)}
|
||||
>
|
||||
<CrossIcon />
|
||||
</button>
|
||||
<button className='p-1 hover:text-white' onClick={handleDelete}>
|
||||
<TickIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const code = React.memo((props: CodeProps) => {
|
||||
const { inline, className, children } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{children}</code>;
|
||||
} else {
|
||||
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
|
||||
}
|
||||
});
|
||||
|
||||
const p = React.memo(
|
||||
(
|
||||
props?: Omit<
|
||||
DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLParagraphElement>,
|
||||
HTMLParagraphElement
|
||||
>,
|
||||
'ref'
|
||||
> &
|
||||
ReactMarkdownProps
|
||||
) => {
|
||||
return <p className='whitespace-pre-wrap'>{props?.children}</p>;
|
||||
}
|
||||
);
|
||||
|
||||
const MessageButton = ({
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon: React.ReactElement;
|
||||
}) => {
|
||||
return (
|
||||
<div className='text-gray-400 flex self-end lg:self-center justify-center gap-3 md:gap-4 visible'>
|
||||
<button
|
||||
className='p-1 rounded-md hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible'
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EditButton = React.memo(
|
||||
({
|
||||
setIsEdit,
|
||||
}: {
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
return (
|
||||
<MessageButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const DeleteButton = React.memo(
|
||||
({
|
||||
setIsDelete,
|
||||
}: {
|
||||
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
return (
|
||||
<MessageButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const DownButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return <MessageButton icon={<DownChevronArrow />} onClick={onClick} />;
|
||||
};
|
||||
const UpButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return (
|
||||
<MessageButton
|
||||
icon={<DownChevronArrow className='rotate-180' />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RefreshButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return <MessageButton icon={<RefreshIcon />} onClick={onClick} />;
|
||||
};
|
||||
|
||||
const CopyButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<MessageButton
|
||||
icon={isCopied ? <TickIcon /> : <CopyIcon />}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
setIsCopied(true);
|
||||
window.setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EditView = ({
|
||||
content,
|
||||
setIsEdit,
|
||||
messageIndex,
|
||||
sticky,
|
||||
}: {
|
||||
content: string;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
messageIndex: number;
|
||||
sticky?: boolean;
|
||||
}) => {
|
||||
const inputRole = useStore((state) => state.inputRole);
|
||||
const setChats = useStore((state) => state.setChats);
|
||||
const currentChatIndex = useStore((state) => state.currentChatIndex);
|
||||
|
||||
const [_content, _setContent] = useState<string>(content);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const textareaRef = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resetTextAreaHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isMobile =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|playbook|silk/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
if (e.key === 'Enter' && !isMobile && !e.nativeEvent.isComposing) {
|
||||
const enterToSubmit = useStore.getState().enterToSubmit;
|
||||
if (sticky) {
|
||||
if (
|
||||
(enterToSubmit && !e.shiftKey) ||
|
||||
(!enterToSubmit && (e.ctrlKey || e.shiftKey))
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSaveAndSubmit();
|
||||
resetTextAreaHeight();
|
||||
}
|
||||
} else {
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSaveAndSubmit();
|
||||
resetTextAreaHeight();
|
||||
} else if (e.ctrlKey || e.shiftKey) handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (sticky && (_content === '' || useStore.getState().generating)) return;
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
if (sticky) {
|
||||
updatedMessages.push({ role: inputRole, content: _content });
|
||||
_setContent('');
|
||||
resetTextAreaHeight();
|
||||
} else {
|
||||
updatedMessages[messageIndex].content = _content;
|
||||
setIsEdit(false);
|
||||
}
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const { handleSubmit } = useSubmit();
|
||||
const handleSaveAndSubmit = () => {
|
||||
if (useStore.getState().generating) return;
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
if (sticky) {
|
||||
if (_content !== '') {
|
||||
updatedMessages.push({ role: inputRole, content: _content });
|
||||
}
|
||||
_setContent('');
|
||||
resetTextAreaHeight();
|
||||
} else {
|
||||
updatedMessages[messageIndex].content = _content;
|
||||
updatedChats[currentChatIndex].messages = updatedMessages.slice(
|
||||
0,
|
||||
messageIndex + 1
|
||||
);
|
||||
setIsEdit(false);
|
||||
}
|
||||
setChats(updatedChats);
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [_content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`w-full ${
|
||||
sticky
|
||||
? 'py-2 md:py-3 px-2 md:px-4 border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className='m-0 resize-none rounded-lg bg-transparent overflow-y-hidden focus:ring-0 focus-visible:ring-0 leading-7 w-full placeholder:text-gray-500/40'
|
||||
onChange={(e) => {
|
||||
_setContent(e.target.value);
|
||||
}}
|
||||
value={_content}
|
||||
placeholder={t('submitPlaceholder') as string}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
></textarea>
|
||||
</div>
|
||||
<EditViewButtons
|
||||
sticky={sticky}
|
||||
handleSaveAndSubmit={handleSaveAndSubmit}
|
||||
handleSave={handleSave}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
setIsEdit={setIsEdit}
|
||||
_setContent={_setContent}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<PopupModal
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
title={t('warning') as string}
|
||||
message={t('clearMessageWarning') as string}
|
||||
handleConfirm={handleSaveAndSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EditViewButtons = React.memo(
|
||||
({
|
||||
sticky = false,
|
||||
handleSaveAndSubmit,
|
||||
handleSave,
|
||||
setIsModalOpen,
|
||||
setIsEdit,
|
||||
_setContent,
|
||||
}: {
|
||||
sticky?: boolean;
|
||||
handleSaveAndSubmit: () => void;
|
||||
handleSave: () => void;
|
||||
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
_setContent: React.Dispatch<React.SetStateAction<string>>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const generating = useStore.getState().generating;
|
||||
const advancedMode = useStore((state) => state.advancedMode);
|
||||
|
||||
return (
|
||||
<div className='flex'>
|
||||
<div className='flex-1 text-center mt-2 flex justify-center'>
|
||||
{sticky && (
|
||||
<button
|
||||
className={`btn relative mr-2 btn-primary ${
|
||||
generating ? 'cursor-not-allowed opacity-40' : ''
|
||||
}`}
|
||||
onClick={handleSaveAndSubmit}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('saveAndSubmit')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn relative mr-2 ${
|
||||
sticky
|
||||
? `btn-neutral ${
|
||||
generating ? 'cursor-not-allowed opacity-40' : ''
|
||||
}`
|
||||
: 'btn-primary'
|
||||
}`}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('save')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{sticky || (
|
||||
<button
|
||||
className='btn relative mr-2 btn-neutral'
|
||||
onClick={() => {
|
||||
!generating && setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('saveAndSubmit')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sticky || (
|
||||
<button
|
||||
className='btn relative btn-neutral'
|
||||
onClick={() => setIsEdit(false)}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('cancel')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sticky && advancedMode && <TokenCount />}
|
||||
<CommandPrompt _setContent={_setContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default MessageContent;
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
const BaseButton = ({
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
icon: React.ReactElement;
|
||||
}) => {
|
||||
return (
|
||||
<div className='text-gray-400 flex self-end lg:self-center justify-center gap-3 md:gap-4 visible'>
|
||||
<button
|
||||
className='p-1 rounded-md hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible'
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseButton;
|
|
@ -0,0 +1,29 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import TickIcon from '@icon/TickIcon';
|
||||
import CopyIcon from '@icon/CopyIcon';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const CopyButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
icon={isCopied ? <TickIcon /> : <CopyIcon />}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
setIsCopied(true);
|
||||
window.setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
|
@ -0,0 +1,19 @@
|
|||
import React, { memo } from 'react';
|
||||
|
||||
import DeleteIcon from '@icon/DeleteIcon';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
setIsDelete,
|
||||
}: {
|
||||
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
return (
|
||||
<BaseButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default DeleteButton;
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
import DownChevronArrow from '@icon/DownChevronArrow';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const DownButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return <BaseButton icon={<DownChevronArrow />} onClick={onClick} />;
|
||||
};
|
||||
|
||||
export default DownButton;
|
|
@ -0,0 +1,17 @@
|
|||
import React, { memo } from 'react';
|
||||
|
||||
import EditIcon2 from '@icon/EditIcon2';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const EditButton = memo(
|
||||
({
|
||||
setIsEdit,
|
||||
}: {
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
return <BaseButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default EditButton;
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
|
||||
import RefreshIcon from '@icon/RefreshIcon';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const RefreshButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return <BaseButton icon={<RefreshIcon />} onClick={onClick} />;
|
||||
};
|
||||
|
||||
export default RefreshButton;
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
|
||||
import DownChevronArrow from '@icon/DownChevronArrow';
|
||||
|
||||
import BaseButton from './BaseButton';
|
||||
|
||||
const UpButton = ({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}) => {
|
||||
return (
|
||||
<BaseButton
|
||||
icon={<DownChevronArrow className='rotate-180' />}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpButton;
|
193
src/components/Chat/ChatContent/Message/View/ContentView.tsx
Normal file
193
src/components/Chat/ChatContent/Message/View/ContentView.tsx
Normal file
|
@ -0,0 +1,193 @@
|
|||
import React, {
|
||||
DetailedHTMLProps,
|
||||
HTMLAttributes,
|
||||
memo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
|
||||
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import useStore from '@store/store';
|
||||
|
||||
import TickIcon from '@icon/TickIcon';
|
||||
import CrossIcon from '@icon/CrossIcon';
|
||||
|
||||
import useSubmit from '@hooks/useSubmit';
|
||||
|
||||
import { ChatInterface } from '@type/chat';
|
||||
|
||||
import { codeLanguageSubset } from '@constants/chat';
|
||||
|
||||
import RefreshButton from './Button/RefreshButton';
|
||||
import UpButton from './Button/UpButton';
|
||||
import DownButton from './Button/DownButton';
|
||||
import CopyButton from './Button/CopyButton';
|
||||
import EditButton from './Button/EditButton';
|
||||
import DeleteButton from './Button/DeleteButton';
|
||||
import CodeBlock from '../CodeBlock';
|
||||
|
||||
const ContentView = memo(
|
||||
({
|
||||
role,
|
||||
content,
|
||||
setIsEdit,
|
||||
messageIndex,
|
||||
}: {
|
||||
role: string;
|
||||
content: string;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
messageIndex: number;
|
||||
}) => {
|
||||
const { handleSubmit } = useSubmit();
|
||||
const [isDelete, setIsDelete] = useState<boolean>(false);
|
||||
const currentChatIndex = useStore((state) => state.currentChatIndex);
|
||||
const setChats = useStore((state) => state.setChats);
|
||||
const lastMessageIndex = useStore((state) =>
|
||||
state.chats ? state.chats[state.currentChatIndex].messages.length - 1 : 0
|
||||
);
|
||||
const inlineLatex = useStore((state) => state.inlineLatex);
|
||||
|
||||
const handleDelete = () => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
updatedChats[currentChatIndex].messages.splice(messageIndex, 1);
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const handleMove = (direction: 'up' | 'down') => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
const temp = updatedMessages[messageIndex];
|
||||
if (direction === 'up') {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex - 1];
|
||||
updatedMessages[messageIndex - 1] = temp;
|
||||
} else {
|
||||
updatedMessages[messageIndex] = updatedMessages[messageIndex + 1];
|
||||
updatedMessages[messageIndex + 1] = temp;
|
||||
}
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const handleMoveUp = () => {
|
||||
handleMove('up');
|
||||
};
|
||||
|
||||
const handleMoveDown = () => {
|
||||
handleMove('down');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
updatedMessages.splice(updatedMessages.length - 1, 1);
|
||||
setChats(updatedChats);
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='markdown prose w-full md:max-w-full break-words dark:prose-invert dark share-gpt-message'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: inlineLatex }],
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: codeLanguageSubset,
|
||||
},
|
||||
],
|
||||
]}
|
||||
linkTarget='_new'
|
||||
components={{
|
||||
code,
|
||||
p,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div className='flex justify-end gap-2 w-full mt-2'>
|
||||
{isDelete || (
|
||||
<>
|
||||
{!useStore.getState().generating &&
|
||||
role === 'assistant' &&
|
||||
messageIndex === lastMessageIndex && (
|
||||
<RefreshButton onClick={handleRefresh} />
|
||||
)}
|
||||
{messageIndex !== 0 && <UpButton onClick={handleMoveUp} />}
|
||||
{messageIndex !== lastMessageIndex && (
|
||||
<DownButton onClick={handleMoveDown} />
|
||||
)}
|
||||
|
||||
<CopyButton onClick={handleCopy} />
|
||||
<EditButton setIsEdit={setIsEdit} />
|
||||
<DeleteButton setIsDelete={setIsDelete} />
|
||||
</>
|
||||
)}
|
||||
{isDelete && (
|
||||
<>
|
||||
<button
|
||||
className='p-1 hover:text-white'
|
||||
onClick={() => setIsDelete(false)}
|
||||
>
|
||||
<CrossIcon />
|
||||
</button>
|
||||
<button className='p-1 hover:text-white' onClick={handleDelete}>
|
||||
<TickIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const code = memo((props: CodeProps) => {
|
||||
const { inline, className, children } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{children}</code>;
|
||||
} else {
|
||||
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
|
||||
}
|
||||
});
|
||||
|
||||
const p = memo(
|
||||
(
|
||||
props?: Omit<
|
||||
DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLParagraphElement>,
|
||||
HTMLParagraphElement
|
||||
>,
|
||||
'ref'
|
||||
> &
|
||||
ReactMarkdownProps
|
||||
) => {
|
||||
return <p className='whitespace-pre-wrap'>{props?.children}</p>;
|
||||
}
|
||||
);
|
||||
|
||||
export default ContentView;
|
244
src/components/Chat/ChatContent/Message/View/EditView.tsx
Normal file
244
src/components/Chat/ChatContent/Message/View/EditView.tsx
Normal file
|
@ -0,0 +1,244 @@
|
|||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useStore from '@store/store';
|
||||
|
||||
import useSubmit from '@hooks/useSubmit';
|
||||
|
||||
import { ChatInterface } from '@type/chat';
|
||||
|
||||
import PopupModal from '@components/PopupModal';
|
||||
import TokenCount from '@components/TokenCount';
|
||||
import CommandPrompt from '../CommandPrompt';
|
||||
|
||||
const EditView = ({
|
||||
content,
|
||||
setIsEdit,
|
||||
messageIndex,
|
||||
sticky,
|
||||
}: {
|
||||
content: string;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
messageIndex: number;
|
||||
sticky?: boolean;
|
||||
}) => {
|
||||
const inputRole = useStore((state) => state.inputRole);
|
||||
const setChats = useStore((state) => state.setChats);
|
||||
const currentChatIndex = useStore((state) => state.currentChatIndex);
|
||||
|
||||
const [_content, _setContent] = useState<string>(content);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const textareaRef = React.createRef<HTMLTextAreaElement>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resetTextAreaHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isMobile =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|playbook|silk/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
if (e.key === 'Enter' && !isMobile && !e.nativeEvent.isComposing) {
|
||||
const enterToSubmit = useStore.getState().enterToSubmit;
|
||||
if (sticky) {
|
||||
if (
|
||||
(enterToSubmit && !e.shiftKey) ||
|
||||
(!enterToSubmit && (e.ctrlKey || e.shiftKey))
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSaveAndSubmit();
|
||||
resetTextAreaHeight();
|
||||
}
|
||||
} else {
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSaveAndSubmit();
|
||||
resetTextAreaHeight();
|
||||
} else if (e.ctrlKey || e.shiftKey) handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (sticky && (_content === '' || useStore.getState().generating)) return;
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
if (sticky) {
|
||||
updatedMessages.push({ role: inputRole, content: _content });
|
||||
_setContent('');
|
||||
resetTextAreaHeight();
|
||||
} else {
|
||||
updatedMessages[messageIndex].content = _content;
|
||||
setIsEdit(false);
|
||||
}
|
||||
setChats(updatedChats);
|
||||
};
|
||||
|
||||
const { handleSubmit } = useSubmit();
|
||||
const handleSaveAndSubmit = () => {
|
||||
if (useStore.getState().generating) return;
|
||||
const updatedChats: ChatInterface[] = JSON.parse(
|
||||
JSON.stringify(useStore.getState().chats)
|
||||
);
|
||||
const updatedMessages = updatedChats[currentChatIndex].messages;
|
||||
if (sticky) {
|
||||
if (_content !== '') {
|
||||
updatedMessages.push({ role: inputRole, content: _content });
|
||||
}
|
||||
_setContent('');
|
||||
resetTextAreaHeight();
|
||||
} else {
|
||||
updatedMessages[messageIndex].content = _content;
|
||||
updatedChats[currentChatIndex].messages = updatedMessages.slice(
|
||||
0,
|
||||
messageIndex + 1
|
||||
);
|
||||
setIsEdit(false);
|
||||
}
|
||||
setChats(updatedChats);
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, [_content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`w-full ${
|
||||
sticky
|
||||
? 'py-2 md:py-3 px-2 md:px-4 border border-black/10 bg-white dark:border-gray-900/50 dark:text-white dark:bg-gray-700 rounded-md shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className='m-0 resize-none rounded-lg bg-transparent overflow-y-hidden focus:ring-0 focus-visible:ring-0 leading-7 w-full placeholder:text-gray-500/40'
|
||||
onChange={(e) => {
|
||||
_setContent(e.target.value);
|
||||
}}
|
||||
value={_content}
|
||||
placeholder={t('submitPlaceholder') as string}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
></textarea>
|
||||
</div>
|
||||
<EditViewButtons
|
||||
sticky={sticky}
|
||||
handleSaveAndSubmit={handleSaveAndSubmit}
|
||||
handleSave={handleSave}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
setIsEdit={setIsEdit}
|
||||
_setContent={_setContent}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<PopupModal
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
title={t('warning') as string}
|
||||
message={t('clearMessageWarning') as string}
|
||||
handleConfirm={handleSaveAndSubmit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EditViewButtons = memo(
|
||||
({
|
||||
sticky = false,
|
||||
handleSaveAndSubmit,
|
||||
handleSave,
|
||||
setIsModalOpen,
|
||||
setIsEdit,
|
||||
_setContent,
|
||||
}: {
|
||||
sticky?: boolean;
|
||||
handleSaveAndSubmit: () => void;
|
||||
handleSave: () => void;
|
||||
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
_setContent: React.Dispatch<React.SetStateAction<string>>;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const generating = useStore.getState().generating;
|
||||
const advancedMode = useStore((state) => state.advancedMode);
|
||||
|
||||
return (
|
||||
<div className='flex'>
|
||||
<div className='flex-1 text-center mt-2 flex justify-center'>
|
||||
{sticky && (
|
||||
<button
|
||||
className={`btn relative mr-2 btn-primary ${
|
||||
generating ? 'cursor-not-allowed opacity-40' : ''
|
||||
}`}
|
||||
onClick={handleSaveAndSubmit}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('saveAndSubmit')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn relative mr-2 ${
|
||||
sticky
|
||||
? `btn-neutral ${
|
||||
generating ? 'cursor-not-allowed opacity-40' : ''
|
||||
}`
|
||||
: 'btn-primary'
|
||||
}`}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('save')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{sticky || (
|
||||
<button
|
||||
className='btn relative mr-2 btn-neutral'
|
||||
onClick={() => {
|
||||
!generating && setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('saveAndSubmit')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{sticky || (
|
||||
<button
|
||||
className='btn relative btn-neutral'
|
||||
onClick={() => setIsEdit(false)}
|
||||
>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
{t('cancel')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sticky && advancedMode && <TokenCount />}
|
||||
<CommandPrompt _setContent={_setContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EditView;
|
Loading…
Reference in a new issue