chat message optimsiation, fixed copy code during generation

fixes 17, fixes 53
This commit is contained in:
Jing Hua 2023-03-11 19:18:00 +08:00
parent e5c35eb1a3
commit 443d4d3454
11 changed files with 437 additions and 270 deletions

View file

@ -9,15 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.0.1",
"highlight.js": "^11.7.0",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.5",
"react-scroll-to-bottom": "^4.2.0",
"rehype-highlight": "^6.0.0",
"rehype-katex": "^6.0.2",
"rehype-sanitize": "^5.0.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"zustand": "^4.3.6"

View file

@ -58,14 +58,14 @@ const ChatContent = () => {
<ChatTitle />
{messages?.length === 0 && <NewMessageButton messageIndex={-1} />}
{messages?.map((message, index) => (
<>
<React.Fragment key={index}>
<Message
role={message.role}
content={message.content}
messageIndex={index}
/>
<NewMessageButton messageIndex={index} />
</>
</React.Fragment>
))}
</div>

View file

@ -12,11 +12,8 @@ import ImageIcon from '@icon/ImageIcon';
import PdfIcon from '@icon/PdfIcon';
import MarkdownIcon from '@icon/MarkdownIcon';
const DownloadChat = ({
saveRef,
}: {
saveRef: React.RefObject<HTMLDivElement>;
}) => {
const DownloadChat = React.memo(
({ saveRef }: { saveRef: React.RefObject<HTMLDivElement> }) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
return (
<>
@ -107,6 +104,7 @@ const DownloadChat = ({
)}
</>
);
};
}
);
export default DownloadChat;

View file

@ -3,7 +3,7 @@ import { Role } from '@type/chat';
import SettingIcon from '@icon/SettingIcon';
import PersonIcon from '@icon/PersonIcon';
const Avatar = ({ role }: { role: Role }) => {
const Avatar = React.memo(({ role }: { role: Role }) => {
return (
<div className='w-[30px] flex flex-col relative items-end'>
{role === 'user' && <UserAvatar />}
@ -11,7 +11,7 @@ const Avatar = ({ role }: { role: Role }) => {
{role === 'system' && <SystemAvatar />}
</div>
);
};
});
const UserAvatar = () => {
return (

View file

@ -0,0 +1,66 @@
import React, { useRef, useState } from 'react';
import CopyIcon from '@icon/CopyIcon';
import TickIcon from '@icon/TickIcon';
const CodeBlock = ({
lang,
codeChildren,
}: {
lang: string;
codeChildren: React.ReactNode & React.ReactNode[];
}) => {
const codeRef = useRef<HTMLElement>(null);
return (
<div className='bg-black rounded-md'>
<CodeBar lang={lang} codeRef={codeRef} />
<div className='p-4 overflow-y-auto'>
<code ref={codeRef} className={`!whitespace-pre hljs language-${lang}`}>
{codeChildren}
</code>
</div>
</div>
);
};
const CodeBar = React.memo(
({
lang,
codeRef,
}: {
lang: string;
codeRef: React.RefObject<HTMLElement>;
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
return (
<div className='flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans'>
<span className=''>{lang}</span>
<button
className='flex ml-auto gap-2'
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString)
navigator.clipboard.writeText(codeString).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
});
}}
>
{isCopied ? (
<>
<TickIcon />
Copied!
</>
) : (
<>
<CopyIcon />
Copy code
</>
)}
</button>
</div>
);
}
);
export default CodeBlock;

View file

@ -30,11 +30,6 @@ const Message = React.memo(
className={`w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group ${
backgroundStyle[messageIndex % 2]
}`}
key={
messageIndex !== -1
? `${messageIndex}-${content}`
: 'sticky-message-text-area'
}
>
<div className='text-base gap-4 md:gap-6 m-auto md:max-w-2xl lg:max-w-2xl xl:max-w-3xl p-4 md:py-6 flex lg:px-0'>
<Avatar role={role} />

View file

@ -1,13 +1,18 @@
import React, { useEffect, useState } from 'react';
import React, {
DetailedHTMLProps,
HTMLAttributes,
useEffect,
useState,
} from 'react';
import ReactMarkdown from 'react-markdown';
import { CodeProps, ReactMarkdownProps } from 'react-markdown/lib/ast-to-react';
import rehypeKatex from 'rehype-katex';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import hljs from 'highlight.js';
import DOMPurify from 'dompurify';
import useStore from '@store/store';
import CopyIcon from '@icon/CopyIcon';
import EditIcon2 from '@icon/EditIcon2';
import DeleteIcon from '@icon/DeleteIcon';
import TickIcon from '@icon/TickIcon';
@ -20,6 +25,8 @@ import useSubmit from '@hooks/useSubmit';
import { ChatInterface } from '@type/chat';
import PopupModal from '@components/PopupModal';
import CodeBlock from './CodeBlock';
import { codeLanguageSubset } from '@constants/chat';
const MessageContent = ({
role,
@ -100,6 +107,14 @@ const ContentView = React.memo(
setChats(updatedChats);
};
const handleMoveUp = () => {
handleMove('up');
};
const handleMoveDown = () => {
handleMove('down');
};
const handleRefresh = () => {
const updatedChats: ChatInterface[] = JSON.parse(
JSON.stringify(useStore.getState().chats)
@ -114,70 +129,26 @@ const ContentView = React.memo(
<>
<div className='markdown prose w-full break-words dark:prose-invert dark'>
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[[rehypeKatex, { output: 'mathml' }]]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[
rehypeSanitize,
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: codeLanguageSubset,
},
],
]}
linkTarget='_new'
components={{
code({ node, inline, className, children, ...props }) {
if (inline) return <code>{children}</code>;
const [copied, setCopied] = useState<boolean>(false);
let highlight;
const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1];
const isMatch = lang && hljs.getLanguage(lang);
if (isMatch)
highlight = hljs.highlight(children.toString(), {
language: lang,
});
else highlight = hljs.highlightAuto(children.toString());
return (
<div className='bg-black rounded-md'>
<div className='flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans'>
<span className=''>{highlight.language}</span>
<button
className='flex ml-auto gap-2'
onClick={() => {
navigator.clipboard
.writeText(children.toString())
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 3000);
});
}}
>
{copied ? (
<>
<TickIcon />
Copied!
</>
) : (
<>
<CopyIcon />
Copy code
</>
)}
</button>
</div>
<div className='p-4 overflow-y-auto'>
<code
className={`!whitespace-pre hljs language-${highlight.language}`}
>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(highlight.value, {
USE_PROFILES: { html: true },
}),
}}
/>
</code>
</div>
</div>
);
},
p({ className, children, ...props }) {
return <p className='whitespace-pre-wrap'>{children}</p>;
},
code,
p,
}}
>
{content}
@ -189,11 +160,9 @@ const ContentView = React.memo(
{role === 'assistant' && messageIndex === lastMessageIndex && (
<RefreshButton onClick={handleRefresh} />
)}
{messageIndex !== 0 && (
<UpButton onClick={() => handleMove('up')} />
)}
{messageIndex !== 0 && <UpButton onClick={handleMoveUp} />}
{messageIndex !== lastMessageIndex && (
<DownButton onClick={() => handleMove('down')} />
<DownButton onClick={handleMoveDown} />
)}
<EditButton setIsEdit={setIsEdit} />
@ -219,6 +188,33 @@ const ContentView = React.memo(
}
);
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,
@ -238,23 +234,29 @@ const MessageButton = ({
);
};
const EditButton = ({
const EditButton = React.memo(
({
setIsEdit,
}: {
}: {
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return <MessageButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />;
};
}) => {
return (
<MessageButton icon={<EditIcon2 />} onClick={() => setIsEdit(true)} />
);
}
);
const DeleteButton = ({
const DeleteButton = React.memo(
({
setIsDelete,
}: {
}: {
setIsDelete: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
}) => {
return (
<MessageButton icon={<DeleteIcon />} onClick={() => setIsDelete(true)} />
);
};
}
);
const DownButton = ({
onClick,
@ -263,7 +265,6 @@ const DownButton = ({
}) => {
return <MessageButton icon={<DownChevronArrow />} onClick={onClick} />;
};
const UpButton = ({
onClick,
}: {
@ -276,7 +277,6 @@ const UpButton = ({
/>
);
};
const RefreshButton = ({
onClick,
}: {
@ -284,7 +284,6 @@ const RefreshButton = ({
}) => {
return <MessageButton icon={<RefreshIcon />} onClick={onClick} />;
};
const EditView = ({
content,
setIsEdit,
@ -394,6 +393,40 @@ const EditView = ({
rows={1}
></textarea>
</div>
<EditViewButtons
sticky={sticky}
handleSaveAndSubmit={handleSaveAndSubmit}
handleSave={handleSave}
setIsModalOpen={setIsModalOpen}
setIsEdit={setIsEdit}
/>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title='Warning'
message='Please be advised that by submitting this message, all subsequent messages will be deleted!'
handleConfirm={handleSaveAndSubmit}
/>
)}
</>
);
};
const EditViewButtons = React.memo(
({
sticky = false,
handleSaveAndSubmit,
handleSave,
setIsModalOpen,
setIsEdit,
}: {
sticky?: boolean;
handleSaveAndSubmit: () => void;
handleSave: () => void;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsEdit: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
return (
<div className='text-center mt-2 flex justify-center'>
{sticky && (
<button
@ -437,16 +470,8 @@ const EditView = ({
</button>
)}
</div>
{isModalOpen && (
<PopupModal
setIsModalOpen={setIsModalOpen}
title='Warning'
message='Please be advised that by submitting this message, all subsequent messages will be deleted!'
handleConfirm={handleSaveAndSubmit}
/>
)}
</>
);
};
}
);
export default MessageContent;

View file

@ -4,15 +4,16 @@ import useStore from '@store/store';
import DownChevronArrow from '@icon/DownChevronArrow';
import { ChatInterface, Role, roles } from '@type/chat';
const RoleSelector = ({
const RoleSelector = React.memo(
({
role,
messageIndex,
sticky,
}: {
}: {
role: Role;
messageIndex: number;
sticky?: boolean;
}) => {
}) => {
const setInputRole = useStore((state) => state.setInputRole);
const setChats = useStore((state) => state.setChats);
const currentChatIndex = useStore((state) => state.currentChatIndex);
@ -64,5 +65,6 @@ const RoleSelector = ({
</div>
</div>
);
};
}
);
export default RoleSelector;

View file

@ -3,7 +3,7 @@ import { useAtBottom, useScrollToBottom } from 'react-scroll-to-bottom';
import DownArrow from '@icon/DownArrow';
const ScrollToBottomButton = () => {
const ScrollToBottomButton = React.memo(() => {
const scrollToBottom = useScrollToBottom();
const [atBottom] = useAtBottom();
@ -17,6 +17,6 @@ const ScrollToBottomButton = () => {
<DownArrow />
</button>
);
};
});
export default ScrollToBottomButton;

View file

@ -24,3 +24,41 @@ export const generateDefaultChat = (title?: string): ChatInterface => ({
config: { ...defaultChatConfig },
titleSet: false,
});
export const codeLanguageSubset = [
'python',
'javascript',
'java',
'go',
'bash',
'c',
'cpp',
'csharp',
'css',
'diff',
'graphql',
'json',
'kotlin',
'less',
'lua',
'makefile',
'markdown',
'objectivec',
'perl',
'php',
'php-template',
'plaintext',
'python-repl',
'r',
'ruby',
'rust',
'scss',
'shell',
'sql',
'swift',
'typescript',
'vbnet',
'wasm',
'xml',
'yaml',
];

View file

@ -742,11 +742,6 @@ dompurify@^2.2.0:
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.5.tgz#0e89a27601f0bad978f9a924e7a05d5d2cccdd87"
integrity sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==
dompurify@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.1.tgz#a0933f38931b3238934dd632043b727e53004289"
integrity sha512-60tsgvPKwItxZZdfLmamp0MTcecCta3avOhsLgPZ0qcWt96OasFfhkeIRbJ6br5i0fQawT1/RBGB5L58/Jpwuw==
electron-to-chromium@^1.4.284:
version "1.4.317"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.317.tgz#9a3d38a1a37f26a417d3d95dafe198ff11ed072b"
@ -830,6 +825,13 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
fault@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
dependencies:
format "^0.2.0"
fflate@^0.4.8:
version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
@ -847,6 +849,11 @@ find-root@^1.1.0:
resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==
format@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==
fraction.js@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
@ -916,7 +923,14 @@ hast-util-parse-selector@^3.0.0:
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-text@^3.1.0:
hast-util-sanitize@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/hast-util-sanitize/-/hast-util-sanitize-4.1.0.tgz#d90f8521f5083547095c5c63a7e03150303e0286"
integrity sha512-Hd9tU0ltknMGRDv+d6Ro/4XKzBqQnP/EZrpiTbpFYfXv/uOhWeKc+2uajcbEvAEH98VZd7eII2PiXm13RihnLw==
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-text@^3.0.0, hast-util-to-text@^3.1.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-3.1.2.tgz#ecf30c47141f41e91a5d32d0b1e1859fd2ac04f2"
integrity sha512-tcllLfp23dJJ+ju5wCCZHVpzsQQ43+moJbqVX3jNWPB7z/KFC4FyZD6R7y94cHL6MQ33YtMZL8Z0aIXXI4XFTw==
@ -942,7 +956,7 @@ hastscript@^7.0.0:
property-information "^6.0.0"
space-separated-tokens "^2.0.0"
highlight.js@^11.7.0:
highlight.js@~11.7.0:
version "11.7.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.7.0.tgz#3ff0165bc843f8c9bce1fd89e2fda9143d24b11e"
integrity sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==
@ -1095,6 +1109,15 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lowlight@^2.0.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-2.8.1.tgz#5f54016ebd1b2f66b3d0b94d10ef6dd5df4f2e42"
integrity sha512-HCaGL61RKc1MYzEYn3rFoGkK0yslzCVDFJEanR19rc2L0mb8i58XM55jSRbzp9jcQrFzschPlwooC0vuNitk8Q==
dependencies:
"@types/hast" "^2.0.0"
fault "^2.0.0"
highlight.js "~11.7.0"
markdown-table@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd"
@ -1829,6 +1852,17 @@ regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
rehype-highlight@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/rehype-highlight/-/rehype-highlight-6.0.0.tgz#8097219d8813b51f4c2b6d92db27dac6cbc9a641"
integrity sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==
dependencies:
"@types/hast" "^2.0.0"
hast-util-to-text "^3.0.0"
lowlight "^2.0.0"
unified "^10.0.0"
unist-util-visit "^4.0.0"
rehype-katex@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/rehype-katex/-/rehype-katex-6.0.2.tgz#20197bbc10bdf79f6b999bffa6689d7f17226c35"
@ -1853,6 +1887,15 @@ rehype-parse@^8.0.0:
parse5 "^6.0.0"
unified "^10.0.0"
rehype-sanitize@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/rehype-sanitize/-/rehype-sanitize-5.0.1.tgz#dac01a7417bdd329260c74c74449697b4be5eb56"
integrity sha512-da/jIOjq8eYt/1r9GN6GwxIR3gde7OZ+WV8pheu1tL8K0D9KxM2AyMh+UEfke+FfdM3PvGHeYJU0Td5OWa7L5A==
dependencies:
"@types/hast" "^2.0.0"
hast-util-sanitize "^4.0.0"
unified "^10.0.0"
remark-gfm@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-3.0.1.tgz#0b180f095e3036545e9dddac0e8df3fa5cfee54f"