diff --git a/package.json b/package.json index 63327cc..5afa853 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/Chat/ChatContent/ChatContent.tsx b/src/components/Chat/ChatContent/ChatContent.tsx index eb2a261..53ce7b7 100644 --- a/src/components/Chat/ChatContent/ChatContent.tsx +++ b/src/components/Chat/ChatContent/ChatContent.tsx @@ -58,14 +58,14 @@ const ChatContent = () => { {messages?.length === 0 && } {messages?.map((message, index) => ( - <> + - + ))} diff --git a/src/components/Chat/ChatContent/DownloadChat.tsx b/src/components/Chat/ChatContent/DownloadChat.tsx index 2464e13..60d6a7f 100644 --- a/src/components/Chat/ChatContent/DownloadChat.tsx +++ b/src/components/Chat/ChatContent/DownloadChat.tsx @@ -12,101 +12,99 @@ import ImageIcon from '@icon/ImageIcon'; import PdfIcon from '@icon/PdfIcon'; import MarkdownIcon from '@icon/MarkdownIcon'; -const DownloadChat = ({ - saveRef, -}: { - saveRef: React.RefObject; -}) => { - const [isModalOpen, setIsModalOpen] = useState(false); - return ( - <> - - {isModalOpen && ( - }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + return ( + <> + - - + {isModalOpen && ( + +
+ -
-
- )} - - ); -}; + }} + > + + Image + + + + +
+ )} + + ); + } +); export default DownloadChat; diff --git a/src/components/Chat/ChatContent/Message/Avatar.tsx b/src/components/Chat/ChatContent/Message/Avatar.tsx index bf95c9f..0811d43 100644 --- a/src/components/Chat/ChatContent/Message/Avatar.tsx +++ b/src/components/Chat/ChatContent/Message/Avatar.tsx @@ -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 (
{role === 'user' && } @@ -11,7 +11,7 @@ const Avatar = ({ role }: { role: Role }) => { {role === 'system' && }
); -}; +}); const UserAvatar = () => { return ( diff --git a/src/components/Chat/ChatContent/Message/CodeBlock.tsx b/src/components/Chat/ChatContent/Message/CodeBlock.tsx new file mode 100644 index 0000000..f40f41e --- /dev/null +++ b/src/components/Chat/ChatContent/Message/CodeBlock.tsx @@ -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(null); + + return ( +
+ +
+ + {codeChildren} + +
+
+ ); +}; + +const CodeBar = React.memo( + ({ + lang, + codeRef, + }: { + lang: string; + codeRef: React.RefObject; + }) => { + const [isCopied, setIsCopied] = useState(false); + return ( +
+ {lang} + +
+ ); + } +); +export default CodeBlock; diff --git a/src/components/Chat/ChatContent/Message/Message.tsx b/src/components/Chat/ChatContent/Message/Message.tsx index 872cad7..9536d88 100644 --- a/src/components/Chat/ChatContent/Message/Message.tsx +++ b/src/components/Chat/ChatContent/Message/Message.tsx @@ -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' - } >
diff --git a/src/components/Chat/ChatContent/Message/MessageContent.tsx b/src/components/Chat/ChatContent/Message/MessageContent.tsx index b6f0eba..87e9b71 100644 --- a/src/components/Chat/ChatContent/Message/MessageContent.tsx +++ b/src/components/Chat/ChatContent/Message/MessageContent.tsx @@ -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( <>
{children}; - const [copied, setCopied] = useState(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 ( -
-
- {highlight.language} - -
-
- -
- -
-
- ); - }, - p({ className, children, ...props }) { - return

{children}

; - }, + code, + p, }} > {content} @@ -189,11 +160,9 @@ const ContentView = React.memo( {role === 'assistant' && messageIndex === lastMessageIndex && ( )} - {messageIndex !== 0 && ( - handleMove('up')} /> - )} + {messageIndex !== 0 && } {messageIndex !== lastMessageIndex && ( - handleMove('down')} /> + )} @@ -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 {children}; + } else { + return ; + } +}); + +const p = React.memo( + ( + props?: Omit< + DetailedHTMLProps< + HTMLAttributes, + HTMLParagraphElement + >, + 'ref' + > & + ReactMarkdownProps + ) => { + return

{props?.children}

; + } +); + const MessageButton = ({ onClick, icon, @@ -238,23 +234,29 @@ const MessageButton = ({ ); }; -const EditButton = ({ - setIsEdit, -}: { - setIsEdit: React.Dispatch>; -}) => { - return } onClick={() => setIsEdit(true)} />; -}; +const EditButton = React.memo( + ({ + setIsEdit, + }: { + setIsEdit: React.Dispatch>; + }) => { + return ( + } onClick={() => setIsEdit(true)} /> + ); + } +); -const DeleteButton = ({ - setIsDelete, -}: { - setIsDelete: React.Dispatch>; -}) => { - return ( - } onClick={() => setIsDelete(true)} /> - ); -}; +const DeleteButton = React.memo( + ({ + setIsDelete, + }: { + setIsDelete: React.Dispatch>; + }) => { + return ( + } onClick={() => setIsDelete(true)} /> + ); + } +); const DownButton = ({ onClick, @@ -263,7 +265,6 @@ const DownButton = ({ }) => { return } onClick={onClick} />; }; - const UpButton = ({ onClick, }: { @@ -276,7 +277,6 @@ const UpButton = ({ /> ); }; - const RefreshButton = ({ onClick, }: { @@ -284,7 +284,6 @@ const RefreshButton = ({ }) => { return } onClick={onClick} />; }; - const EditView = ({ content, setIsEdit, @@ -394,6 +393,40 @@ const EditView = ({ rows={1} >
+ + {isModalOpen && ( + + )} + + ); +}; + +const EditViewButtons = React.memo( + ({ + sticky = false, + handleSaveAndSubmit, + handleSave, + setIsModalOpen, + setIsEdit, + }: { + sticky?: boolean; + handleSaveAndSubmit: () => void; + handleSave: () => void; + setIsModalOpen: React.Dispatch>; + setIsEdit: React.Dispatch>; + }) => { + return (
{sticky && (
- {isModalOpen && ( - - )} - - ); -}; + ); + } +); export default MessageContent; diff --git a/src/components/Chat/ChatContent/Message/RoleSelector.tsx b/src/components/Chat/ChatContent/Message/RoleSelector.tsx index 3806b03..b127e45 100644 --- a/src/components/Chat/ChatContent/Message/RoleSelector.tsx +++ b/src/components/Chat/ChatContent/Message/RoleSelector.tsx @@ -4,65 +4,67 @@ import useStore from '@store/store'; import DownChevronArrow from '@icon/DownChevronArrow'; import { ChatInterface, Role, roles } from '@type/chat'; -const RoleSelector = ({ - 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); +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); - const [dropDown, setDropDown] = useState(false); + const [dropDown, setDropDown] = useState(false); - return ( -
- - -
- ); -}; + ); + } +); export default RoleSelector; diff --git a/src/components/Chat/ChatContent/ScrollToBottomButton.tsx b/src/components/Chat/ChatContent/ScrollToBottomButton.tsx index 976c005..cd31f26 100644 --- a/src/components/Chat/ChatContent/ScrollToBottomButton.tsx +++ b/src/components/Chat/ChatContent/ScrollToBottomButton.tsx @@ -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 = () => { ); -}; +}); export default ScrollToBottomButton; diff --git a/src/constants/chat.ts b/src/constants/chat.ts index e7a6779..6a8c692 100644 --- a/src/constants/chat.ts +++ b/src/constants/chat.ts @@ -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', +]; diff --git a/yarn.lock b/yarn.lock index 6f6e1b2..c132c1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"