From 56dc49966d663b6d17c71e7e531e808bac415ca2 Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Wed, 17 Jan 2024 16:22:06 -0800 Subject: [PATCH] add copy feature to assistant chat message (#611) * add copy feature to assistant chat message * fix tooltip not hiding on mobile * fix: add tooltips chore: breakout actions to extendable component + memoize add CopyText to hook we can reuse fix: Copy on code snippets broken, moved to event listener fix: highlightjs patch for new API support feat: add copy response support --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/package.json | 1 + .../HistoricalMessage/Actions/index.jsx | 43 ++++++++++++++++ .../ChatHistory/HistoricalMessage/index.jsx | 11 ++++- .../ChatContainer/ChatHistory/index.jsx | 8 +-- .../src/components/WorkspaceChat/index.jsx | 36 ++++++++++++++ frontend/src/hooks/useCopyText.js | 15 ++++++ frontend/src/index.css | 4 ++ frontend/src/utils/chat/markdown.js | 49 +++++++++---------- frontend/yarn.lock | 33 +++++++++++++ 9 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx create mode 100644 frontend/src/hooks/useCopyText.js diff --git a/frontend/package.json b/frontend/package.json index 86e552ab7..17d9af913 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 000000000..12fa7dc73 --- /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 ( + <div className="flex justify-start items-center gap-x-4"> + <CopyMessage message={message} /> + {/* Other actions to go here later. */} + </div> + ); +}; + +function CopyMessage({ message }) { + const { copied, copyText } = useCopyText(); + return ( + <> + <div className="mt-3 relative"> + <button + data-tooltip-id="copy-assistant-text" + data-tooltip-content="Copy" + className="text-zinc-300" + onClick={() => copyText(message)} + > + {copied ? ( + <Check size={18} className="mb-1" /> + ) : ( + <ClipboardText size={18} className="mb-1" /> + )} + </button> + </div> + <Tooltip + id="copy-assistant-text" + place="bottom" + delayShow={300} + className="tooltip !text-xs" + /> + </> + ); +} + +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 4637b1cd7..c39220f37 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( /> )} </div> + {role === "assistant" && ( + <div className="flex gap-x-5"> + <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden" /> + <Actions message={DOMPurify.sanitize(message)} /> + </div> + )} {role === "assistant" && <Citations sources={sources} />} </div> </div> diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/index.jsx index 4a7cd4827..358e520a1 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 && ( <ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} /> )} diff --git a/frontend/src/components/WorkspaceChat/index.jsx b/frontend/src/components/WorkspaceChat/index.jsx index 3e129c2a6..30bd494f3 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 <ChatContainer workspace={workspace} knownHistory={history} />; } + +// 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 000000000..04519b2ef --- /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 1d1b2da85..e8d7e2d8c 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 53b6804fe..ff4af77bc 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 ( - `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pt-10 pb-4 relative font-mono font-normal text-sm text-slate-200"><div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"><button id="code-${uuid}" onclick='window.copySnippet("${uuid}");' class="flex ml-auto gap-2"><svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>Copy code</button></div><pre class="whitespace-pre-wrap">` + - hljs.highlight(lang, str, true).value + + `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pb-4 relative font-mono font-normal text-sm text-slate-200"> + <div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"> + <div class="flex gap-2"> + <code class="text-xs">${lang || ""}</code> + </div> + <button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2"> + <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg> + <p>Copy code</p> + </button> + </div> + <pre class="whitespace-pre-wrap">` + + hljs.highlight(code, { language: lang, ignoreIllegals: true }).value + "</pre></div>" ); } catch (__) {} } return ( - `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pt-10 pb-4 relative font-mono font-normal text-sm text-slate-200"><div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"><button id="code-${uuid}" onclick='window.copySnippet("${uuid}");' class="flex ml-auto gap-2"><svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>Copy code</button></div><pre class="whitespace-pre-wrap">` + - HTMLEncode(str) + + `<div class="whitespace-pre-line w-full rounded-lg bg-black-900 px-4 pb-4 relative font-mono font-normal text-sm text-slate-200"> + <div class="w-full flex items-center absolute top-0 left-0 text-slate-200 bg-stone-800 px-4 py-2 text-xs font-sans justify-between rounded-t-md"> + <div class="flex gap-2"><code class="text-xs"></code></div> + <button data-code-snippet data-code="code-${uuid}" class="flex items-center gap-x-2"> + <svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg> + <p>Copy code</p> + </button> + </div> + <pre class="whitespace-pre-wrap">` + + HTMLEncode(code) + "</pre></div>" ); }, }); -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 c9181f15f..fa1e71331 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"