mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-05 06:20:10 +01:00
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:
parent
c2c8fe9756
commit
56dc49966d
@ -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"
|
||||
|
@ -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);
|
@ -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>
|
||||
|
@ -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} />
|
||||
)}
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
15
frontend/src/hooks/useCopyText.js
Normal file
15
frontend/src/hooks/useCopyText.js
Normal 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 };
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user