mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-03-16 07:02:22 +00: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
9 changed files with 169 additions and 31 deletions
frontend
|
@ -31,6 +31,7 @@
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-tag-input-component": "^2.0.2",
|
"react-tag-input-component": "^2.0.2",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
|
"react-tooltip": "^5.25.2",
|
||||||
"text-case": "^1.0.9",
|
"text-case": "^1.0.9",
|
||||||
"truncate": "^3.0.0",
|
"truncate": "^3.0.0",
|
||||||
"uuid": "^9.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 { Warning } from "@phosphor-icons/react";
|
||||||
import Jazzicon from "../../../../UserIcon";
|
import Jazzicon from "../../../../UserIcon";
|
||||||
|
import Actions from "./Actions";
|
||||||
import renderMarkdown from "@/utils/chat/markdown";
|
import renderMarkdown from "@/utils/chat/markdown";
|
||||||
import { userFromStorage } from "@/utils/request";
|
import { userFromStorage } from "@/utils/request";
|
||||||
import Citations from "../Citation";
|
import Citations from "../Citation";
|
||||||
import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants";
|
import { AI_BACKGROUND_COLOR, USER_BACKGROUND_COLOR } from "@/utils/constants";
|
||||||
import { v4 } from "uuid";
|
import { v4 } from "uuid";
|
||||||
import createDOMPurify from "dompurify";
|
import createDOMPurify from "dompurify";
|
||||||
const DOMPurify = createDOMPurify(window);
|
|
||||||
|
|
||||||
|
const DOMPurify = createDOMPurify(window);
|
||||||
const HistoricalMessage = forwardRef(
|
const HistoricalMessage = forwardRef(
|
||||||
(
|
(
|
||||||
{ uuid = v4(), message, role, workspace, sources = [], error = false },
|
{ uuid = v4(), message, role, workspace, sources = [], error = false },
|
||||||
|
@ -53,6 +54,12 @@ const HistoricalMessage = forwardRef(
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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} />}
|
{role === "assistant" && <Citations sources={sources} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,9 +17,12 @@ export default function ChatHistory({ history = [], workspace }) {
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const isBottom =
|
const diff =
|
||||||
chatHistoryRef.current.scrollHeight - chatHistoryRef.current.scrollTop ===
|
chatHistoryRef.current.scrollHeight -
|
||||||
|
chatHistoryRef.current.scrollTop -
|
||||||
chatHistoryRef.current.clientHeight;
|
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);
|
setIsAtBottom(isBottom);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -112,7 +115,6 @@ export default function ChatHistory({ history = [], workspace }) {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{showing && (
|
{showing && (
|
||||||
<ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} />
|
<ManageWorkspace hideModal={hideModal} providedSlug={workspace.slug} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -59,5 +59,41 @@ export default function WorkspaceChat({ loading, workspace }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setEventDelegatorForCodeSnippets();
|
||||||
return <ChatContainer workspace={workspace} knownHistory={history} />;
|
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 {
|
.rti--container {
|
||||||
@apply !bg-zinc-900 !text-white !placeholder-white !placeholder-opacity-60 !text-sm !rounded-lg !p-2.5;
|
@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({
|
const markdown = markdownIt({
|
||||||
html: true,
|
html: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
highlight: function (str, lang) {
|
highlight: function (code, lang) {
|
||||||
const uuid = v4();
|
const uuid = v4();
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
try {
|
try {
|
||||||
return (
|
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">` +
|
`<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">
|
||||||
hljs.highlight(lang, str, true).value +
|
<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>"
|
"</pre></div>"
|
||||||
);
|
);
|
||||||
} catch (__) {}
|
} catch (__) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">` +
|
`<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">
|
||||||
HTMLEncode(str) +
|
<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>"
|
"</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 = "") {
|
export default function renderMarkdown(text = "") {
|
||||||
return markdown.render(text);
|
return markdown.render(text);
|
||||||
}
|
}
|
||||||
|
|
|
@ -365,6 +365,26 @@
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d"
|
||||||
integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==
|
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":
|
"@humanwhocodes/config-array@^0.11.13":
|
||||||
version "0.11.13"
|
version "0.11.13"
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
|
||||||
|
@ -846,6 +866,11 @@ chokidar@^3.5.3:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.2"
|
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:
|
cliui@^8.0.1:
|
||||||
version "8.0.1"
|
version "8.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
|
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
|
||||||
|
@ -2543,6 +2568,14 @@ react-toastify@^9.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx "^1.1.1"
|
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:
|
react@^18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||||
|
|
Loading…
Add table
Reference in a new issue