Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ui.MarkdownStream #1782

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion js/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BuildOptions, build } from "esbuild";
import { BuildOptions,build } from "esbuild";
import { sassPlugin } from "esbuild-sass-plugin";
import * as fs from "node:fs/promises";

Expand Down Expand Up @@ -74,6 +74,20 @@ const opts: Array<BuildOptions> = [
plugins: [sassPlugin({ type: "css", sourceMap: false })],
metafile: true,
},
{
entryPoints: {
"markdown-stream/markdown-stream": "markdown-stream/markdown-stream.ts",
},
minify: true,
sourcemap: true,
},
{
entryPoints: {
"markdown-stream/markdown-stream": "markdown-stream/markdown-stream.scss",
},
plugins: [sassPlugin({ type: "css", sourceMap: false })],
metafile: true,
},
{
entryPoints: {
"chat/chat": "chat/chat.ts",
Expand Down
10 changes: 0 additions & 10 deletions js/chat/_utils.ts

This file was deleted.

54 changes: 1 addition & 53 deletions js/chat/chat.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
@use "highlight_styles" as highlight_styles;

shiny-chat-container {
--shiny-chat-border: var(--bs-border-width, 1px) solid var(--bs-border-color, #e9ecef);
--shiny-chat-user-message-bg: RGBA(var(--bs-primary-rgb, 0, 123, 194), 0.06);
Expand Down Expand Up @@ -39,7 +37,7 @@ shiny-chat-message {
}
}
/* Vertically center the 2nd column (message content) */
.message-content {
shiny-markdown-stream {
align-self: center;
}
}
Expand Down Expand Up @@ -93,53 +91,3 @@ shiny-chat-input {
.shiny-busy:has(shiny-chat-input[disabled])::after {
display: none;
}

/* Code highlighting (for both light and dark mode) */
@include highlight_styles.atom_one_light;
[data-bs-theme="dark"] {
@include highlight_styles.atom_one_dark;
}

/*
Styling for the code-copy button (inspired by Quarto's code-copy feature)
*/
pre:has(.code-copy-button) {
position: relative;
}

.code-copy-button {
position: absolute;
top: 0;
right: 0;
border: 0;
margin-top: 5px;
margin-right: 5px;
background-color: transparent;

> .bi {
display: flex;
gap: 0.25em;

&::after {
content: "";
display: block;
height: 1rem;
width: 1rem;
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>');
background-color: var(--bs-body-color, #222);
}
}
}

.code-copy-button-checked {
> .bi::before {
content: "Copied!";
font-size: 0.75em;
vertical-align: 0.25em;
}

> .bi::after {
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>');
background-color: var(--bs-success, #198754);
}
}
116 changes: 8 additions & 108 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ import { LitElement, html } from "lit";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { property } from "lit/decorators.js";

import ClipboardJS from "clipboard";
import { sanitize } from "dompurify";
import hljs from "highlight.js/lib/common";
import { Renderer, parse } from "marked";
import { contentToHTML } from "../markdown-stream/markdown-stream";

import { createElement } from "./_utils";
import { LightElement, createElement } from "../utils/_utils";

type ContentType = "markdown" | "html" | "text";

Expand Down Expand Up @@ -57,17 +54,8 @@ const ICONS = {
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
dots_fade:
'<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>',
dot: '<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" class="chat-streaming-dot" style="margin-left:.25em;margin-top:-.25em"><circle cx="6" cy="6" r="6"/></svg>',
};

function createSVGIcon(icon: string): HTMLElement {
const parser = new DOMParser();
const svgDoc = parser.parseFromString(icon, "image/svg+xml");
return svgDoc.documentElement;
}

const SVG_DOT = createSVGIcon(ICONS.dot);

const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
el.dispatchEvent(
new CustomEvent("shiny-chat-request-scroll", {
Expand All @@ -78,112 +66,24 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
);
};

// For rendering chat output, we use typical Markdown behavior of passing through raw
// HTML (albeit sanitizing afterwards).
//
// For echoing chat input, we escape HTML. This is not for security reasons but just
// because it's confusing if the user is using tag-like syntax to demarcate parts of
// their prompt for other reasons (like <User>/<Assistant> for providing examples to the
// chat model), and those tags simply vanish.
const rendererEscapeHTML = new Renderer();
rendererEscapeHTML.html = (html: string) =>
html
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const markedEscapeOpts = { renderer: rendererEscapeHTML };

function contentToHTML(
content: string,
content_type: ContentType | "semi-markdown"
) {
if (content_type === "markdown") {
return unsafeHTML(sanitize(parse(content) as string));
} else if (content_type === "semi-markdown") {
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
} else if (content_type === "html") {
return unsafeHTML(sanitize(content));
} else if (content_type === "text") {
return content;
} else {
throw new Error(`Unknown content type: ${content_type}`);
}
}

// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
class LightElement extends LitElement {
createRenderRoot() {
return this;
}
}

class ChatMessage extends LightElement {
@property() content = "";
@property() content = "...";
@property() content_type: ContentType = "markdown";
@property({ type: Boolean, reflect: true }) streaming = false;

render(): ReturnType<LitElement["render"]> {
const content = contentToHTML(this.content, this.content_type);

const noContent = this.content.trim().length === 0;
const icon = noContent ? ICONS.dots_fade : ICONS.robot;

return html`
<div class="message-icon">${unsafeHTML(icon)}</div>
<div class="message-content">${content}</div>
<shiny-markdown-stream
content=${this.content}
content_type=${this.content_type}
streaming=${this.streaming}
></shiny-markdown-stream>
`;
}

updated(changedProperties: Map<string, unknown>): void {
if (changedProperties.has("content")) {
this.#highlightAndCodeCopy();
if (this.streaming) this.#appendStreamingDot();
// It's important that the scroll request happens at this point in time, since
// otherwise, the content may not be fully rendered yet
requestScroll(this, this.streaming);
}
if (changedProperties.has("streaming")) {
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
}
}

#appendStreamingDot(): void {
const content = this.querySelector(".message-content") as HTMLElement;
content.lastElementChild?.appendChild(SVG_DOT);
}

#removeStreamingDot(): void {
this.querySelector(".message-content svg.chat-streaming-dot")?.remove();
}

// Highlight code blocks after the element is rendered
#highlightAndCodeCopy(): void {
const el = this.querySelector("pre code");
if (!el) return;
this.querySelectorAll<HTMLElement>("pre code").forEach((el) => {
// Highlight the code
hljs.highlightElement(el);
// Add a button to the code block to copy to clipboard
const btn = createElement("button", {
class: "code-copy-button",
title: "Copy to clipboard",
});
btn.innerHTML = '<i class="bi"></i>';
el.prepend(btn);
// Add the clipboard functionality
const clipboard = new ClipboardJS(btn, { target: () => el });
clipboard.on("success", function (e: ClipboardJS.Event) {
btn.classList.add("code-copy-button-checked");
setTimeout(
() => btn.classList.remove("code-copy-button-checked"),
2000
);
e.clearSelection();
});
});
}
}

class ChatUserMessage extends LightElement {
Expand Down
File renamed without changes.
51 changes: 51 additions & 0 deletions js/markdown-stream/markdown-stream.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
@use "highlight_styles" as highlight_styles;

/* Code highlighting (for both light and dark mode) */
@include highlight_styles.atom_one_light;
[data-bs-theme="dark"] {
@include highlight_styles.atom_one_dark;
}

/*
Styling for the code-copy button (inspired by Quarto's code-copy feature)
*/
pre:has(.code-copy-button) {
position: relative;
}

.code-copy-button {
position: absolute;
top: 0;
right: 0;
border: 0;
margin-top: 5px;
margin-right: 5px;
background-color: transparent;

> .bi {
display: flex;
gap: 0.25em;

&::after {
content: "";
display: block;
height: 1rem;
width: 1rem;
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/><path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/></svg>');
background-color: var(--bs-body-color, #222);
}
}
}

.code-copy-button-checked {
> .bi::before {
content: "Copied!";
font-size: 0.75em;
vertical-align: 0.25em;
}

> .bi::after {
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>');
background-color: var(--bs-success, #198754);
}
}
Loading