diff --git a/frontend/package.json b/frontend/package.json index 86e552ab..17d9af91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react-router-dom": "^6.3.0", "react-tag-input-component": "^2.0.2", "react-toastify": "^9.1.3", + "react-tooltip": "^5.25.2", "text-case": "^1.0.9", "truncate": "^3.0.0", "uuid": "^9.0.0" diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx new file mode 100644 index 00000000..12fa7dc7 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx @@ -0,0 +1,43 @@ +import useCopyText from "@/hooks/useCopyText"; +import { Check, ClipboardText } from "@phosphor-icons/react"; +import { memo } from "react"; +import { Tooltip } from "react-tooltip"; + +const Actions = ({ message }) => { + return ( +
+ + {/* Other actions to go here later. */} +
+ ); +}; + +function CopyMessage({ message }) { + const { copied, copyText } = useCopyText(); + return ( + <> +
+ +
+ + + ); +} + +export default memo(Actions); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index 4637b1cd..c39220f3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -1,14 +1,15 @@ -import { memo, forwardRef } from "react"; +import React, { memo, forwardRef } from "react"; import { Warning } from "@phosphor-icons/react"; import Jazzicon from "../../../../UserIcon"; +import Actions from "./Actions"; import renderMarkdown from "@/utils/chat/markdown"; import { userFromStorage } from "@/utils/request"; import Citations from "../Citation"; import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants"; import { v4 } from "uuid"; import createDOMPurify from "dompurify"; -const DOMPurify = createDOMPurify(window); +const DOMPurify = createDOMPurify(window); const HistoricalMessage = forwardRef( ( { uuid = v4(), message, role, workspace, sources = [], error = false }, @@ -53,6 +54,12 @@ const HistoricalMessage = forwardRef( /> )} + {role === "assistant" && ( +
+
+ +
+ )} {role === "assistant" && }
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 4a7cd482..358e520a 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx @@ -17,9 +17,12 @@ export default function ChatHistory({ history = [], workspace }) { }, [history]); const handleScroll = () => { - const isBottom = - chatHistoryRef.current.scrollHeight - chatHistoryRef.current.scrollTop === + const diff = + chatHistoryRef.current.scrollHeight - + chatHistoryRef.current.scrollTop - chatHistoryRef.current.clientHeight; + // Fuzzy margin for what qualifies as "bottom". Stronger than straight comparison since that may change over time. + const isBottom = diff <= 10; setIsAtBottom(isBottom); }; @@ -112,7 +115,6 @@ export default function ChatHistory({ history = [], workspace }) { /> ); })} - {showing && ( )} diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx index 3e129c2a..30bd494f 100644 --- a/frontend/src/components/WorkspaceChat/index.jsx +++ b/frontend/src/components/WorkspaceChat/index.jsx @@ -59,5 +59,41 @@ export default function WorkspaceChat({ loading, workspace }) { ); } + setEventDelegatorForCodeSnippets(); return ; } + +// Enables us to safely markdown and sanitize all responses without risk of injection +// but still be able to attach a handler to copy code snippets on all elements +// that are code snippets. +function copyCodeSnippet(uuid) { + const target = document.querySelector(`[data-code="${uuid}"]`); + if (!target) return false; + const markdown = + target.parentElement?.parentElement?.querySelector( + "pre:first-of-type" + )?.innerText; + if (!markdown) return false; + + window.navigator.clipboard.writeText(markdown); + target.classList.add("text-green-500"); + const originalText = target.innerHTML; + target.innerText = "Copied!"; + target.setAttribute("disabled", true); + + setTimeout(() => { + target.classList.remove("text-green-500"); + target.innerHTML = originalText; + target.removeAttribute("disabled"); + }, 2500); +} + +// Listens and hunts for all data-code-snippet clicks. +function setEventDelegatorForCodeSnippets() { + document?.addEventListener("click", function (e) { + const target = e.target.closest("[data-code-snippet]"); + const uuidCode = target?.dataset?.code; + if (!uuidCode) return false; + copyCodeSnippet(uuidCode); + }); +} diff --git a/frontend/src/hooks/useCopyText.js b/frontend/src/hooks/useCopyText.js new file mode 100644 index 00000000..04519b2e --- /dev/null +++ b/frontend/src/hooks/useCopyText.js @@ -0,0 +1,15 @@ +import { useState } from "react"; + +export default function useCopyText(delay = 2500) { + const [copied, setCopied] = useState(false); + const copyText = async (content) => { + if (!content) return; + navigator?.clipboard?.writeText(content); + setCopied(content); + setTimeout(() => { + setCopied(false); + }, delay); + }; + + return { copyText, copied }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1d1b2da8..e8d7e2d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -399,3 +399,7 @@ dialog::backdrop { .rti--container { @apply !bg-zinc-900 !text-white !placeholder-white !placeholder-opacity-60 !text-sm !rounded-lg !p-2.5; } + +.tooltip { + @apply !bg-black !text-white !py-2 !px-3 !rounded-md; +} diff --git a/frontend/src/utils/chat/markdown.js b/frontend/src/utils/chat/markdown.js index 53b6804f..ff4af77b 100644 --- a/frontend/src/utils/chat/markdown.js +++ b/frontend/src/utils/chat/markdown.js @@ -7,47 +7,44 @@ import { v4 } from "uuid"; const markdown = markdownIt({ html: true, typographer: true, - highlight: function (str, lang) { + highlight: function (code, lang) { const uuid = v4(); if (lang && hljs.getLanguage(lang)) { try { return ( - `
` +
-          hljs.highlight(lang, str, true).value +
+          `
+
+
+ ${lang || ""} +
+ +
+
` +
+          hljs.highlight(code, { language: lang, ignoreIllegals: true }).value +
           "
" ); } catch (__) {} } return ( - `
` +
-      HTMLEncode(str) +
+      `
+
+
+ +
+
` +
+      HTMLEncode(code) +
       "
" ); }, }); -window.copySnippet = function (uuid = "") { - const target = document.getElementById(`code-${uuid}`); - const markdown = - target.parentElement?.parentElement?.querySelector( - "pre:first-of-type" - )?.innerText; - if (!markdown) return false; - - window.navigator.clipboard.writeText(markdown); - target.classList.add("text-green-500"); - const originalText = target.innerHTML; - target.innerText = "Copied!"; - target.setAttribute("disabled", true); - - setTimeout(() => { - target.classList.remove("text-green-500"); - target.innerHTML = originalText; - target.removeAttribute("disabled"); - }, 5000); -}; - export default function renderMarkdown(text = "") { return markdown.render(text); } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c9181f15..fa1e7133 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -365,6 +365,26 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d" integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w== +"@floating-ui/core@^1.5.3": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.3.tgz#b6aa0827708d70971c8679a16cf680a515b8a52a" + integrity sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q== + dependencies: + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/dom@^1.0.0": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.4.tgz#28df1e1cb373884224a463235c218dcbd81a16bb" + integrity sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ== + dependencies: + "@floating-ui/core" "^1.5.3" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/utils@^0.2.0": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -846,6 +866,11 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +classnames@^2.3.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -2543,6 +2568,14 @@ react-toastify@^9.1.3: dependencies: clsx "^1.1.1" +react-tooltip@^5.25.2: + version "5.25.2" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.25.2.tgz#efb51845ec2e863045812ad1dc1927573922d629" + integrity sha512-MwZ3S9xcHpojZaKqjr5mTs0yp/YBPpKFcayY7MaaIIBr2QskkeeyelpY2YdGLxIMyEj4sxl0rGoK6dQIKvNLlw== + dependencies: + "@floating-ui/dom" "^1.0.0" + classnames "^2.3.0" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"