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>
This commit is contained in:
Sean Hatfield 2024-01-17 16:22:06 -08:00 committed by GitHub
parent c2c8fe9756
commit 56dc49966d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 169 additions and 31 deletions

View File

@ -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"

View File

@ -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);

View File

@ -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>

View File

@ -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} />
)}

View File

@ -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);
});
}

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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"