Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
3acbd4c
First pass at ui.MarkdownStream
cpsievert Nov 21, 2024
0d0fba6
Add streaming context
cpsievert Nov 21, 2024
584284f
Merge branch 'main' into markdown-stream-component
cpsievert Nov 21, 2024
9fbfb2e
Make examples more readable:
cpsievert Nov 26, 2024
ba86904
Address review feedback
cpsievert Nov 26, 2024
7cf0366
Merge branch 'main' into markdown-stream-component
cpsievert Jan 27, 2025
68233de
Remove .update() method
cpsievert Jan 27, 2025
93cce86
Show error message in shiny console if message can't be handled
cpsievert Jan 27, 2025
b304ee2
Implement new and improved auto-scroll logic
cpsievert Jan 28, 2025
34be9ba
Try to fix make-format
cpsievert Jan 28, 2025
19255a1
Remove file committed by mistake
cpsievert Jan 28, 2025
7b475dc
Handle google genai update to import
cpsievert Jan 28, 2025
c899dfb
Fix autoreload typing issue
cpsievert Jan 28, 2025
e129b2e
Avoid chat having to import markdown-stream assets
cpsievert Jan 28, 2025
99b58b3
plotly now requires anywidget
cpsievert Jan 28, 2025
40382f7
Merge branch 'main' into markdown-stream-component
cpsievert Jan 29, 2025
326b730
Slightly more complete examples
cpsievert Jan 29, 2025
65499d1
Cleanup error messages; ReturnTypes aren't needed
cpsievert Jan 29, 2025
4fb775d
Merge branch 'main' into markdown-stream-component
cpsievert Jan 29, 2025
20cf773
Cleanup docstrings
cpsievert Jan 29, 2025
5ab4f59
Let it be known that output_markdown_stream() is intentionally not in…
cpsievert Jan 29, 2025
f030b93
3.9 typing support
cpsievert Jan 29, 2025
a682747
Be slightly less agressive about updating scrollable element. Remove …
cpsievert Jan 29, 2025
2eb2698
Add a basic test
cpsievert Jan 29, 2025
a7d5d5e
Remove controller since their isn't much of anything worth providing.…
cpsievert Jan 30, 2025
c7d6e05
Re-build map file
cpsievert Jan 30, 2025
7caa2e4
Surface errors in a similar fashion to Chat()
cpsievert Jan 30, 2025
0afcb87
Make error messages a bit more informative in terms of who is causing…
cpsievert Jan 30, 2025
1d17a35
Update playwright test to include error notification
cpsievert Jan 30, 2025
3b4a5a4
Add height/width params; rename to markdown_stream_ui()
cpsievert Jan 30, 2025
cf36d18
Make stream() async so is non-blocking
cpsievert Jan 31, 2025
7c82077
Update playwright test
cpsievert Jan 31, 2025
cd0cf0e
Fix import
cpsievert Jan 31, 2025
570335c
Committed file by mistake
cpsievert Jan 31, 2025
7c0a536
Refactor MarkdownStream logic
cpsievert Jan 31, 2025
9d459ce
Allow for auto-scrolling to be disabled
cpsievert Jan 31, 2025
3413029
Tweak example code
cpsievert Jan 31, 2025
833edfc
Have .stream() return the ExtendedTask; Add a .clear() method
cpsievert Jan 31, 2025
5089b99
Have the stream task return the accumulated string result
cpsievert Jan 31, 2025
6b1e24d
More aggressively update scrollable element. Add test for obtaining s…
cpsievert Jan 31, 2025
37257ec
Missed a couple renamings
cpsievert Jan 31, 2025
d091ebd
Take a pass at addressing documentation feedback
cpsievert Jan 31, 2025
add7b2f
Address JS feedback
cpsievert Jan 31, 2025
0360e2c
Fix tests
cpsievert Feb 1, 2025
96d6db0
Take another pass at polishing docstrings
cpsievert Feb 1, 2025
a99b3a5
Go back to output_markdown_stream()
cpsievert Feb 1, 2025
2496cc0
Update API reference
cpsievert Feb 1, 2025
95f271a
Remove the .ui() entirely from shiny.ui.MarkdownStream
cpsievert Feb 1, 2025
187817b
Fix add_example usage
cpsievert Feb 1, 2025
3ff0537
Update changelog
cpsievert Feb 3, 2025
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;
}
Comment on lines +1 to +7
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not something to handle in this PR, but we should anticipate highlighting styles being something that people will want to configure.


/*
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