Skip to content

Commit 3acbd4c

Browse files
committed
First pass at ui.MarkdownStream
1 parent f6b92d8 commit 3acbd4c

File tree

22 files changed

+698
-205
lines changed

22 files changed

+698
-205
lines changed

js/build.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BuildOptions, build } from "esbuild";
1+
import { BuildOptions,build } from "esbuild";
22
import { sassPlugin } from "esbuild-sass-plugin";
33
import * as fs from "node:fs/promises";
44

@@ -74,6 +74,20 @@ const opts: Array<BuildOptions> = [
7474
plugins: [sassPlugin({ type: "css", sourceMap: false })],
7575
metafile: true,
7676
},
77+
{
78+
entryPoints: {
79+
"markdown-stream/markdown-stream": "markdown-stream/markdown-stream.ts",
80+
},
81+
minify: true,
82+
sourcemap: true,
83+
},
84+
{
85+
entryPoints: {
86+
"markdown-stream/markdown-stream": "markdown-stream/markdown-stream.scss",
87+
},
88+
plugins: [sassPlugin({ type: "css", sourceMap: false })],
89+
metafile: true,
90+
},
7791
{
7892
entryPoints: {
7993
"chat/chat": "chat/chat.ts",

js/chat/_utils.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

js/chat/chat.scss

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
@use "highlight_styles" as highlight_styles;
2-
31
shiny-chat-container {
42
--shiny-chat-border: var(--bs-border-width, 1px) solid var(--bs-border-color, #e9ecef);
53
--shiny-chat-user-message-bg: RGBA(var(--bs-primary-rgb, 0, 123, 194), 0.06);
@@ -37,7 +35,7 @@ shiny-chat-container {
3735
}
3836
}
3937
/* Vertically center the 2nd column (message content) */
40-
.message-content {
38+
shiny-markdown {
4139
align-self: center;
4240
}
4341
}
@@ -93,53 +91,3 @@ shiny-chat-container {
9391
.shiny-busy:has(shiny-chat-input[disabled])::after {
9492
display: none;
9593
}
96-
97-
/* Code highlighting (for both light and dark mode) */
98-
@include highlight_styles.atom_one_light;
99-
[data-bs-theme="dark"] {
100-
@include highlight_styles.atom_one_dark;
101-
}
102-
103-
/*
104-
Styling for the code-copy button (inspired by Quarto's code-copy feature)
105-
*/
106-
pre:has(.code-copy-button) {
107-
position: relative;
108-
}
109-
110-
.code-copy-button {
111-
position: absolute;
112-
top: 0;
113-
right: 0;
114-
border: 0;
115-
margin-top: 5px;
116-
margin-right: 5px;
117-
background-color: transparent;
118-
119-
> .bi {
120-
display: flex;
121-
gap: 0.25em;
122-
123-
&::after {
124-
content: "";
125-
display: block;
126-
height: 1rem;
127-
width: 1rem;
128-
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>');
129-
background-color: var(--bs-body-color, #222);
130-
}
131-
}
132-
}
133-
134-
.code-copy-button-checked {
135-
> .bi::before {
136-
content: "Copied!";
137-
font-size: 0.75em;
138-
vertical-align: 0.25em;
139-
}
140-
141-
> .bi::after {
142-
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>');
143-
background-color: var(--bs-success, #198754);
144-
}
145-
}

js/chat/chat.ts

Lines changed: 8 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ import { LitElement, html } from "lit";
22
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
33
import { property } from "lit/decorators.js";
44

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

10-
import { createElement } from "./_utils";
7+
import { LightElement, createElement } from "../utils/_utils";
118

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

@@ -57,17 +54,8 @@ const ICONS = {
5754
// https://github.com/n3r4zzurr0/svg-spinners/blob/main/svg-css/3-dots-fade.svg
5855
dots_fade:
5956
'<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>',
60-
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>',
6157
};
6258

63-
function createSVGIcon(icon: string): HTMLElement {
64-
const parser = new DOMParser();
65-
const svgDoc = parser.parseFromString(icon, "image/svg+xml");
66-
return svgDoc.documentElement;
67-
}
68-
69-
const SVG_DOT = createSVGIcon(ICONS.dot);
70-
7159
const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
7260
el.dispatchEvent(
7361
new CustomEvent("shiny-chat-request-scroll", {
@@ -78,112 +66,24 @@ const requestScroll = (el: HTMLElement, cancelIfScrolledUp = false) => {
7866
);
7967
};
8068

81-
// For rendering chat output, we use typical Markdown behavior of passing through raw
82-
// HTML (albeit sanitizing afterwards).
83-
//
84-
// For echoing chat input, we escape HTML. This is not for security reasons but just
85-
// because it's confusing if the user is using tag-like syntax to demarcate parts of
86-
// their prompt for other reasons (like <User>/<Assistant> for providing examples to the
87-
// chat model), and those tags simply vanish.
88-
const rendererEscapeHTML = new Renderer();
89-
rendererEscapeHTML.html = (html: string) =>
90-
html
91-
.replaceAll("&", "&amp;")
92-
.replaceAll("<", "&lt;")
93-
.replaceAll(">", "&gt;")
94-
.replaceAll('"', "&quot;")
95-
.replaceAll("'", "&#039;");
96-
const markedEscapeOpts = { renderer: rendererEscapeHTML };
97-
98-
function contentToHTML(
99-
content: string,
100-
content_type: ContentType | "semi-markdown"
101-
) {
102-
if (content_type === "markdown") {
103-
return unsafeHTML(sanitize(parse(content) as string));
104-
} else if (content_type === "semi-markdown") {
105-
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
106-
} else if (content_type === "html") {
107-
return unsafeHTML(sanitize(content));
108-
} else if (content_type === "text") {
109-
return content;
110-
} else {
111-
throw new Error(`Unknown content type: ${content_type}`);
112-
}
113-
}
114-
115-
// https://lit.dev/docs/components/shadow-dom/#implementing-createrenderroot
116-
class LightElement extends LitElement {
117-
createRenderRoot() {
118-
return this;
119-
}
120-
}
121-
12269
class ChatMessage extends LightElement {
123-
@property() content = "";
70+
@property() content = "...";
12471
@property() content_type: ContentType = "markdown";
12572
@property({ type: Boolean, reflect: true }) streaming = false;
12673

12774
render(): ReturnType<LitElement["render"]> {
128-
const content = contentToHTML(this.content, this.content_type);
129-
13075
const noContent = this.content.trim().length === 0;
13176
const icon = noContent ? ICONS.dots_fade : ICONS.robot;
13277

13378
return html`
13479
<div class="message-icon">${unsafeHTML(icon)}</div>
135-
<div class="message-content">${content}</div>
80+
<shiny-markdown
81+
content=${this.content}
82+
content_type=${this.content_type}
83+
streaming=${this.streaming}
84+
></shiny-markdown>
13685
`;
13786
}
138-
139-
updated(changedProperties: Map<string, unknown>): void {
140-
if (changedProperties.has("content")) {
141-
this.#highlightAndCodeCopy();
142-
if (this.streaming) this.#appendStreamingDot();
143-
// It's important that the scroll request happens at this point in time, since
144-
// otherwise, the content may not be fully rendered yet
145-
requestScroll(this, this.streaming);
146-
}
147-
if (changedProperties.has("streaming")) {
148-
this.streaming ? this.#appendStreamingDot() : this.#removeStreamingDot();
149-
}
150-
}
151-
152-
#appendStreamingDot(): void {
153-
const content = this.querySelector(".message-content") as HTMLElement;
154-
content.lastElementChild?.appendChild(SVG_DOT);
155-
}
156-
157-
#removeStreamingDot(): void {
158-
this.querySelector(".message-content svg.chat-streaming-dot")?.remove();
159-
}
160-
161-
// Highlight code blocks after the element is rendered
162-
#highlightAndCodeCopy(): void {
163-
const el = this.querySelector("pre code");
164-
if (!el) return;
165-
this.querySelectorAll<HTMLElement>("pre code").forEach((el) => {
166-
// Highlight the code
167-
hljs.highlightElement(el);
168-
// Add a button to the code block to copy to clipboard
169-
const btn = createElement("button", {
170-
class: "code-copy-button",
171-
title: "Copy to clipboard",
172-
});
173-
btn.innerHTML = '<i class="bi"></i>';
174-
el.prepend(btn);
175-
// Add the clipboard functionality
176-
const clipboard = new ClipboardJS(btn, { target: () => el });
177-
clipboard.on("success", function (e: ClipboardJS.Event) {
178-
btn.classList.add("code-copy-button-checked");
179-
setTimeout(
180-
() => btn.classList.remove("code-copy-button-checked"),
181-
2000
182-
);
183-
e.clearSelection();
184-
});
185-
});
186-
}
18787
}
18888

18989
class ChatUserMessage extends LightElement {
File renamed without changes.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
@use "highlight_styles" as highlight_styles;
2+
3+
/* Code highlighting (for both light and dark mode) */
4+
@include highlight_styles.atom_one_light;
5+
[data-bs-theme="dark"] {
6+
@include highlight_styles.atom_one_dark;
7+
}
8+
9+
/*
10+
Styling for the code-copy button (inspired by Quarto's code-copy feature)
11+
*/
12+
pre:has(.code-copy-button) {
13+
position: relative;
14+
}
15+
16+
.code-copy-button {
17+
position: absolute;
18+
top: 0;
19+
right: 0;
20+
border: 0;
21+
margin-top: 5px;
22+
margin-right: 5px;
23+
background-color: transparent;
24+
25+
> .bi {
26+
display: flex;
27+
gap: 0.25em;
28+
29+
&::after {
30+
content: "";
31+
display: block;
32+
height: 1rem;
33+
width: 1rem;
34+
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>');
35+
background-color: var(--bs-body-color, #222);
36+
}
37+
}
38+
}
39+
40+
.code-copy-button-checked {
41+
> .bi::before {
42+
content: "Copied!";
43+
font-size: 0.75em;
44+
vertical-align: 0.25em;
45+
}
46+
47+
> .bi::after {
48+
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>');
49+
background-color: var(--bs-success, #198754);
50+
}
51+
}

0 commit comments

Comments
 (0)