fix copy button invalid & copy button (invisible) duplication bug in chatbot (#4350)

* fix a copy button duplication bug in chatbot

* fix copy button invalid issue

* Update CHANGELOG.md: Fixed Copy Button

* fix changelog typo

* switch to static HTML + event delegation for copy button

* fix

* format + notebooks

* format + notebooks

---------

Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
Co-authored-by: pngwn <hello@pngwn.io>
This commit is contained in:
binary-husky 2023-06-01 21:51:36 +08:00 committed by GitHub
parent 01d334b0b9
commit 2a30deed84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 290 additions and 60 deletions

View File

@ -14,6 +14,7 @@
- Prevent path traversal in `/file` routes by [@abidlabs](https://github.com/abidlabs) in [PR 4370](https://github.com/gradio-app/gradio/pull/4370).
- Do not send HF token to other domains via `/proxy` route by [@abidlabs](https://github.com/abidlabs) in [PR 4368](https://github.com/gradio-app/gradio/pull/4368).
- Replace default `markedjs` sanitize function with DOMPurify sanitizer for `gr.Chatbot()` by [@dawoodkhan82](https://github.com/dawoodkhan82) in [PR 4360](https://github.com/gradio-app/gradio/pull/4360)
- Prevent the creation of duplicate copy buttons in the chatbot and ensure copy buttons work in non-secure contexts by [@binary-husky](https://github.com/binary-husky) in [PR 4350](https://github.com/gradio-app/gradio/pull/4350).
## Other Changes:

View File

@ -1 +1 @@
{"cells": [{"cell_type": "markdown", "id": 302934307671667531413257853548643485645, "metadata": {}, "source": ["# Gradio Demo: chatbot_simple"]}, {"cell_type": "code", "execution_count": null, "id": 272996653310673477252411125948039410165, "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": 288918539441861185822528903084949547379, "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import random\n", "import time\n", "\n", "with gr.Blocks() as demo:\n", " chatbot = gr.Chatbot()\n", " msg = gr.Textbox()\n", " clear = gr.Button(\"Clear\")\n", "\n", " def respond(message, chat_history):\n", " bot_message = random.choice([\"How are you?\", \"I love you\", \"I'm very hungry\"])\n", " chat_history.append((message, bot_message))\n", " time.sleep(1)\n", " return \"\", chat_history\n", "\n", " msg.submit(respond, [msg, chatbot], [msg, chatbot])\n", " clear.click(lambda: None, None, chatbot, queue=False)\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
{"cells": [{"cell_type": "markdown", "id": 302934307671667531413257853548643485645, "metadata": {}, "source": ["# Gradio Demo: chatbot_simple"]}, {"cell_type": "code", "execution_count": null, "id": 272996653310673477252411125948039410165, "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": 288918539441861185822528903084949547379, "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import random\n", "import time\n", "\n", "md = \"\"\"This is some code:\n", "\n", "<h1>hello</h1>\n", "\n", "```py\n", "def fn(x, y, z):\n", " print(x, y, z)\n", "\"\"\"\n", "\n", "with gr.Blocks() as demo:\n", " chatbot = gr.Chatbot()\n", " msg = gr.Textbox()\n", " clear = gr.Button(\"Clear\")\n", "\n", " def respond(message, chat_history):\n", " bot_message = random.choice([\"How are you?\", \"I love you\", \"I'm very hungry\"])\n", " chat_history.append((message, md))\n", " time.sleep(1)\n", " return \"\", chat_history\n", "\n", " msg.submit(respond, [msg, chatbot], [msg, chatbot])\n", " clear.click(lambda: None, None, chatbot, queue=False)\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}

View File

@ -2,6 +2,15 @@ import gradio as gr
import random
import time
md = """This is some code:
<h1>hello</h1>
```py
def fn(x, y, z):
print(x, y, z)
"""
with gr.Blocks() as demo:
chatbot = gr.Chatbot()
msg = gr.Textbox()
@ -9,7 +18,7 @@ with gr.Blocks() as demo:
def respond(message, chat_history):
bot_message = random.choice(["How are you?", "I love you", "I'm very hungry"])
chat_history.append((message, bot_message))
chat_history.append((message, md))
time.sleep(1)
return "", chat_history

View File

@ -8,16 +8,17 @@
"license": "ISC",
"private": true,
"dependencies": {
"@gradio/theme": "workspace:^0.0.1",
"@gradio/utils": "workspace:^0.0.1",
"@gradio/upload": "workspace:^0.0.1",
"@gradio/icons": "workspace:^0.0.1",
"marked": "^5.0.1",
"@gradio/theme": "workspace:^0.0.1",
"@gradio/upload": "workspace:^0.0.1",
"@gradio/utils": "workspace:^0.0.1",
"@types/katex": "^0.16.0",
"@types/marked": "^4.3.0",
"prismjs": "1.29.0",
"@types/prismjs": "1.26.0",
"katex": "^0.16.7",
"@types/katex": "^0.16.0",
"marked": "^5.0.1",
"marked-highlight": "^2.0.1",
"prismjs": "1.29.0",
"dompurify": "^3.0.3",
"@types/dompurify": "^3.0.2"
}

View File

@ -1,8 +1,5 @@
<script lang="ts">
import { marked } from "marked";
import Prism from "prismjs";
import "prismjs/components/prism-python";
import "prismjs/components/prism-latex";
import { marked, copy } from "./utils";
import "katex/dist/katex.min.css";
import DOMPurify from "dompurify";
import render_math_in_element from "katex/dist/contrib/auto-render.js";
@ -10,7 +7,6 @@
import type { Styles, SelectData } from "@gradio/utils";
import type { ThemeMode } from "js/app/src/components/types";
import type { FileData } from "@gradio/upload";
import Copy from "./Copy.svelte";
const code_highlight_css = {
light: () => import("prismjs/themes/prism.css"),
@ -34,26 +30,6 @@
} else {
code_highlight_css.light();
}
const marked_renderer = new marked.Renderer();
marked.setOptions({
renderer: marked_renderer,
gfm: true,
breaks: true,
pedantic: false,
sanitize: false,
smartLists: true,
smartypants: false
});
marked.setOptions({
highlight: (code: string, lang: string) => {
if (Prism.languages[lang]) {
return Prism.highlight(code, Prism.languages[lang], lang);
} else {
return code;
}
}
});
let div: HTMLDivElement;
let autoscroll: Boolean;
@ -77,25 +53,6 @@
});
});
}
div.querySelectorAll("pre > code").forEach((n) => {
let code_node = n as HTMLElement;
const copy_div = document.createElement("div");
new Copy({
target: copy_div,
props: {
value: code_node.innerText.trimEnd()
}
});
let node = n.parentElement as HTMLElement;
copy_div.style.position = "absolute";
copy_div.style.right = "0";
copy_div.style.top = "0";
copy_div.style.zIndex = "1";
copy_div.style.padding = "var(--spacing-md)";
copy_div.style.borderBottomLeftRadius = "var(--radius-sm)";
node.style.position = "relative";
node.appendChild(copy_div);
});
render_math_in_element(div, {
delimiters: [
@ -112,6 +69,17 @@
dispatch("change");
}
}
function handle_select(
i: number,
j: number,
message: string | FileData | null
) {
dispatch("select", {
index: [i, j],
value: message
});
}
</script>
<div
@ -120,7 +88,7 @@
style:max-height={`${style.height}px`}
bind:this={div}
>
<div class="message-wrap">
<div class="message-wrap" use:copy>
{#if value !== null}
{#each value as message_pair, i}
{#each message_pair as message, j}
@ -131,11 +99,7 @@
class="message {j == 0 ? 'user' : 'bot'}"
class:hide={message === null}
class:selectable
on:click={() =>
dispatch("select", {
index: [i, j],
value: message
})}
on:click={() => handle_select(i, j, message)}
>
{#if typeof message === "string"}
{@html DOMPurify.sanitize(marked.parse(message))}
@ -371,4 +335,43 @@
.message-wrap :global(span.katex) {
font-size: var(--text-lg);
}
/* Copy button */
.message-wrap :global(code > button) {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
z-index: 1;
cursor: pointer;
border-bottom-left-radius: var(--radius-sm);
padding: 5px;
padding: var(--spacing-md);
width: 22px;
height: 22px;
}
.message-wrap :global(code > button > span) {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
width: 12px;
height: 12px;
}
.message-wrap :global(.check) {
position: absolute;
top: 0;
right: 0;
opacity: 0;
z-index: var(--layer-top);
transition: opacity 0.2s;
background: var(--background-fill-primary);
padding: var(--size-1);
width: 100%;
height: 100%;
color: var(--body-text-color);
}
.message-wrap :global(pre) {
position: relative;
}
</style>

View File

@ -19,6 +19,24 @@
if ("clipboard" in navigator) {
await navigator.clipboard.writeText(value);
copy_feedback();
} else {
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "absolute";
textArea.style.left = "-999999px";
document.body.prepend(textArea);
textArea.select();
try {
document.execCommand("copy");
copy_feedback();
} catch (error) {
console.error(error);
} finally {
textArea.remove();
}
}
}
@ -28,9 +46,7 @@
</script>
<button on:click={handle_copy} title="copy">
<!-- {#if !copied} -->
<span class="copy-text" class:copied><Copy /> </span>
<!-- {/if} -->
{#if copied}
<span class="check" transition:fade><Check /></span>
{/if}

189
js/chatbot/src/utils.ts Normal file
View File

@ -0,0 +1,189 @@
import { marked, Renderer } from "marked";
import { markedHighlight } from "marked-highlight";
import Prism from "prismjs";
import "prismjs/components/prism-python";
import "prismjs/components/prism-latex";
const copy_icon = `<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 32 32"
><path
fill="currentColor"
d="M28 10v18H10V10h18m0-2H10a2 2 0 0 0-2 2v18a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2Z"
/><path fill="currentColor" d="M4 18H2V4a2 2 0 0 1 2-2h14v2H4Z" /></svg>`;
const check_icon = `<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg>`;
const copy_button = `<button title="copy" class="copy_code_button">
<span class="copy-text">${copy_icon}</span>
<span class="check">${check_icon}</span>
</button>`;
const escape_test = /[&<>"']/;
const escape_replace = new RegExp(escape_test.source, "g");
const escape_test_no_encode =
/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
const escape_replace_no_encode = new RegExp(escape_test_no_encode.source, "g");
const escape_replacements: Record<string, any> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
};
const getEscapeReplacement = (ch: string) => escape_replacements[ch] || "";
function escape(html: string, encode?: boolean) {
if (encode) {
if (escape_test.test(html)) {
return html.replace(escape_replace, getEscapeReplacement);
}
} else {
if (escape_test_no_encode.test(html)) {
return html.replace(escape_replace_no_encode, getEscapeReplacement);
}
}
return html;
}
const renderer: Partial<
Omit<marked.Renderer<false>, "constructor" | "options">
> = {
code(
this: Renderer,
code: string,
infostring: string | undefined,
escaped: boolean
) {
const lang = (infostring ?? "").match(/\S*/)?.[0] ?? "";
if (this.options.highlight) {
const out = this.options.highlight(code, lang);
if (out != null && out !== code) {
escaped = true;
code = out;
}
}
code = code.replace(/\n$/, "") + "\n";
if (!lang) {
return (
"<pre><code>" +
copy_button +
(escaped ? code : escape(code, true)) +
"</code></pre>\n"
);
}
return (
'<pre><code class="' +
this.options.langPrefix +
escape(lang) +
'">' +
copy_button +
(escaped ? code : escape(code, true)) +
"</code></pre>\n"
);
}
};
marked.use(
{
gfm: true,
breaks: true,
pedantic: false,
smartLists: true,
headerIds: false,
mangle: false
},
markedHighlight({
highlight: (code: string, lang: string) => {
if (Prism.languages[lang]) {
return Prism.highlight(code, Prism.languages[lang], lang);
} else {
return code;
}
}
}),
{ renderer }
);
export function copy(node: HTMLDivElement) {
node.addEventListener("click", handle_copy);
async function handle_copy(event: MouseEvent) {
const path = event.composedPath() as HTMLButtonElement[];
const [copy_button] = path.filter(
(e) => e?.tagName === "BUTTON" && e.classList.contains("copy_code_button")
);
if (copy_button) {
event.stopImmediatePropagation();
const copy_text = copy_button.parentElement!.innerText.trim();
const copy_sucess_button = Array.from(
copy_button.children
)[1] as HTMLDivElement;
const copied = await copy_to_clipboard(copy_text);
if (copied) copy_feedback(copy_sucess_button);
function copy_feedback(copy_sucess_button: HTMLDivElement) {
copy_sucess_button.style.opacity = "1";
setTimeout(() => {
copy_sucess_button.style.opacity = "0";
}, 2000);
}
}
}
return {
destroy() {
node.removeEventListener("click", handle_copy);
}
};
}
async function copy_to_clipboard(value: string) {
let copied = false;
if ("clipboard" in navigator) {
await navigator.clipboard.writeText(value);
copied = true;
} else {
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "absolute";
textArea.style.left = "-999999px";
document.body.prepend(textArea);
textArea.select();
try {
document.execCommand("copy");
copied = true;
} catch (error) {
console.error(error);
copied = false;
} finally {
textArea.remove();
}
}
return copied;
}
export { marked };

View File

@ -391,6 +391,9 @@ importers:
marked:
specifier: ^5.0.1
version: 5.0.1
marked-highlight:
specifier: ^2.0.1
version: 2.0.1(marked@5.0.1)
prismjs:
specifier: 1.29.0
version: 1.29.0
@ -4436,6 +4439,14 @@ packages:
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/marked-highlight@2.0.1(marked@5.0.1):
resolution: {integrity: sha512-LDUfR/zDvD+dJ+lQOWHkxvBLNxiXcaN8pBtwJ/i4pI0bkDC/Ef6Mz1qUrAuHXfnpdr2rabdMpVFhqFuU+5Mskg==}
peerDependencies:
marked: ^4 || ^5
dependencies:
marked: 5.0.1
dev: false
/marked@5.0.1:
resolution: {integrity: sha512-Nn9peC4lvIZdcfp8Uze6xk4ZYowkcj/K6+e/6rLHadhtjqeip/bYRxMgt3124IGGJ3RPs2uX5YVmAGbUutY18g==}
engines: {node: '>= 18'}