Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4fecb16
Support shiny UI content inside of Chat() and MarkdownStream()
cpsievert Feb 21, 2025
71d5f1c
Also support statically rendered htmlwidgets in R
cpsievert Feb 24, 2025
298accc
Don't sanitize custom elements
cpsievert Feb 24, 2025
b59c931
fix import lints
cpsievert Feb 25, 2025
9b3f95e
Don't include html_deps in client message unless they're actually pre…
cpsievert Feb 25, 2025
6e5d29d
do the same for transformed messages
cpsievert Feb 25, 2025
8de4d68
and the same for ChatMessage
cpsievert Feb 25, 2025
85d0a5a
add playwright tests
cpsievert Feb 25, 2025
e820a9d
ignore missing type stubs
cpsievert Feb 25, 2025
1a06708
Account for the fact that message.obj might be null
cpsievert Feb 25, 2025
017c17a
Update docstrings
cpsievert Feb 25, 2025
f8be19e
Merge branch 'main' into chat-shiny-bind
cpsievert Mar 6, 2025
287a030
Address feedback
cpsievert Mar 10, 2025
c066da0
Actually handle TagChild values properly in chat_ui()
cpsievert Mar 10, 2025
b8dfce0
Handle UI elements correctly in output_markdown_stream()
cpsievert Mar 11, 2025
59e3a38
Slightly more complete test for Shiny UI in startup message
cpsievert Mar 11, 2025
e9aa501
Need to unbind before content gets updated; add test for MarkdownStre…
cpsievert Mar 11, 2025
ca9ef0b
Merge branch 'main' into chat-shiny-bind
cpsievert Mar 11, 2025
f4a52f4
Fix test
cpsievert Mar 11, 2025
1219082
Improve comment
cpsievert Mar 11, 2025
6bff574
Merge branch 'main' into chat-shiny-bind
cpsievert Mar 12, 2025
2f46b2c
To the window...
cpsievert Mar 12, 2025
1675212
fix(Chat)!: Move away from inheriting from TypedDict for internal Cha…
cpsievert Mar 12, 2025
5965f14
Address feedback
cpsievert Mar 12, 2025
bbaf639
Update changelog
cpsievert Mar 12, 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
19 changes: 15 additions & 4 deletions js/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { property } from "lit/decorators.js";
import {
LightElement,
createElement,
renderDependencies,
showShinyClientMessage,
} from "../utils/_utils";

import type { HtmlDep } from "../utils/_utils";

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

type Message = {
Expand All @@ -18,10 +21,13 @@ type Message = {
icon?: string;
operation: "append" | null;
};

type ShinyChatMessage = {
id: string;
handler: string;
obj: Message;
// Message keys will create custom element attributes, but html_deps are handled
// separately
obj: (Message & { html_deps?: HtmlDep[] }) | null;
};

type UpdateUserInput = {
Expand Down Expand Up @@ -59,7 +65,8 @@ const ICONS = {

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

Expand All @@ -72,7 +79,7 @@ class ChatMessage extends LightElement {
<div class="message-icon">${unsafeHTML(icon)}</div>
<shiny-markdown-stream
content=${this.content}
content-type=${this.content_type}
content-type=${this.contentType}
?streaming=${this.streaming}
auto-scroll
.onContentChange=${this.#onContentChange.bind(this)}
Expand Down Expand Up @@ -529,7 +536,11 @@ customElements.define(CHAT_CONTAINER_TAG, ChatContainer);

window.Shiny.addCustomMessageHandler(
"shinyChatMessage",
function (message: ShinyChatMessage) {
async function (message: ShinyChatMessage) {
if (message.obj?.html_deps) {
await renderDependencies(message.obj.html_deps);
}

const evt = new CustomEvent(message.handler, {
detail: message.obj,
});
Expand Down
73 changes: 67 additions & 6 deletions js/markdown-stream/markdown-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,28 @@ 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 {
LightElement,
createElement,
createSVGIcon,
renderDependencies,
sanitizeHTML,
showShinyClientMessage,
throttle,
} from "../utils/_utils";

import type { HtmlDep } from "../utils/_utils";

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

type ContentMessage = {
id: string;
content: string;
operation: "append" | "replace";
html_deps?: HtmlDep[];
};

type IsStreamingMessage = {
Expand Down Expand Up @@ -59,11 +64,11 @@ const markedEscapeOpts = { renderer: rendererEscapeHTML };

function contentToHTML(content: string, content_type: ContentType) {
if (content_type === "markdown") {
return unsafeHTML(sanitize(parse(content) as string));
return unsafeHTML(sanitizeHTML(parse(content) as string));
} else if (content_type === "semi-markdown") {
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
return unsafeHTML(sanitizeHTML(parse(content, markedEscapeOpts) as string));
} else if (content_type === "html") {
return unsafeHTML(sanitize(content));
return unsafeHTML(sanitizeHTML(content));
} else if (content_type === "text") {
return content;
} else {
Expand Down Expand Up @@ -94,6 +99,8 @@ class MarkdownElement extends LightElement {
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("content")) {
this.#isContentBeingAdded = true;

MarkdownElement.#doUnBind(this);
}
super.willUpdate(changedProperties);
}
Expand All @@ -106,7 +113,14 @@ class MarkdownElement extends LightElement {
} catch (error) {
console.warn("Failed to highlight code:", error);
}
if (this.streaming) this.#appendStreamingDot();

// Render Shiny HTML dependencies and bind inputs/outputs
if (this.streaming) {
this.#appendStreamingDot();
MarkdownElement._throttledBind(this);
} else {
MarkdownElement.#doBind(this);
}

// Update scrollable element after content has been added
this.#updateScrollableElement();
Expand Down Expand Up @@ -148,6 +162,47 @@ class MarkdownElement extends LightElement {
this.querySelector(`svg.${SVG_DOT_CLASS}`)?.remove();
}

static async #doUnBind(el: HTMLElement): Promise<void> {
if (!window?.Shiny?.unbindAll) return;

try {
window.Shiny.unbindAll(el);
} catch (err) {
showShinyClientMessage({
status: "error",
message: `Failed to unbind Shiny inputs/outputs: ${err}`,
});
}
}

static async #doBind(el: HTMLElement): Promise<void> {
if (!window?.Shiny?.initializeInputs) return;
if (!window?.Shiny?.bindAll) return;

try {
window.Shiny.initializeInputs(el);
} catch (err) {
showShinyClientMessage({
status: "error",
message: `Failed to initialize Shiny inputs: ${err}`,
});
}

try {
await window.Shiny.bindAll(el);
} catch (err) {
showShinyClientMessage({
status: "error",
message: `Failed to bind Shiny inputs/outputs: ${err}`,
});
}
}

@throttle(200)
private static async _throttledBind(el: HTMLElement): Promise<void> {
await this.#doBind(el);
}

#highlightAndCodeCopy(): void {
const el = this.querySelector("pre code");
if (!el) return;
Expand Down Expand Up @@ -244,7 +299,9 @@ if (!customElements.get("shiny-markdown-stream")) {
customElements.define("shiny-markdown-stream", MarkdownElement);
}

function handleMessage(message: ContentMessage | IsStreamingMessage): void {
async function handleMessage(
message: ContentMessage | IsStreamingMessage
): Promise<void> {
const el = document.getElementById(message.id) as MarkdownElement;

if (!el) {
Expand All @@ -262,6 +319,10 @@ function handleMessage(message: ContentMessage | IsStreamingMessage): void {
return;
}

if (message.html_deps) {
await renderDependencies(message.html_deps);
}

if (message.operation === "replace") {
el.setAttribute("content", message.content);
} else if (message.operation === "append") {
Expand Down
95 changes: 93 additions & 2 deletions js/utils/_utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import DOMPurify from "dompurify";
import { LitElement } from "lit";

import type { HtmlDep } from "rstudio-shiny/srcts/types/src/shiny/render";

////////////////////////////////////////////////
// Lit helpers
////////////////////////////////////////////////
Expand All @@ -10,7 +13,9 @@ function createElement(
): HTMLElement {
const el = document.createElement(tag_name);
for (const [key, value] of Object.entries(attrs)) {
if (value !== null) el.setAttribute(key, value);
// Replace _ with - in attribute names
const attrName = key.replace(/_/g, "-");
if (value !== null) el.setAttribute(attrName, value);
}
return el;
}
Expand Down Expand Up @@ -49,4 +54,90 @@ function showShinyClientMessage({
);
}

export { LightElement, createElement, createSVGIcon, showShinyClientMessage, };
async function renderDependencies(deps: HtmlDep[]): Promise<void> {
if (!window.Shiny) return;
if (!deps) return;

try {
await window.Shiny.renderDependenciesAsync(deps);
} catch (renderError) {
showShinyClientMessage({
status: "error",
message: `Failed to render HTML dependencies: ${renderError}`,
});
}
}

////////////////////////////////////////////////
// General helpers
////////////////////////////////////////////////

function sanitizeHTML(html: string): string {
return sanitizer.sanitize(html, {
// Sanitize scripts manually (see below)
ADD_TAGS: ["script"],
// Allow any (defined) custom element
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: (tagName) => {
return window.customElements.get(tagName) !== undefined;
},
attributeNameCheck: (attr) => true,
allowCustomizedBuiltInElements: true,
},
});
}

// Allow htmlwidgets' script tags through the sanitizer
// by allowing `<script type="application/json" data-for="*"`,
// which every widget should follow, and seems generally safe.
const sanitizer = DOMPurify();
sanitizer.addHook("uponSanitizeElement", (node, data) => {
if (node.nodeName && node.nodeName === "SCRIPT") {
const isOK =
node.getAttribute("type") === "application/json" &&
node.getAttribute("data-for") !== null;

data.allowedTags["script"] = isOK;
}
});

/**
* Creates a throttle decorator that ensures the decorated method isn't called more
* frequently than the specified delay
* @param delay The minimum time (in ms) that must pass between calls
*/
export function throttle(delay: number) {
/* eslint-disable @typescript-eslint/no-explicit-any */
return function (
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
let timeout: number | undefined;

descriptor.value = function (...args: any[]) {
if (timeout) {
window.clearTimeout(timeout);
}

timeout = window.setTimeout(() => {
originalMethod.apply(this, args);
timeout = undefined;
}, delay);
};

return descriptor;
};
}

export {
LightElement,
createElement,
createSVGIcon,
renderDependencies,
sanitizeHTML,
showShinyClientMessage,
};

export type { HtmlDep };
6 changes: 3 additions & 3 deletions shiny/express/ui/_hold.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from types import TracebackType
from typing import Callable, Optional, Type, TypeVar

from htmltools import wrap_displayhook_handler
from htmltools import TagList, wrap_displayhook_handler

from ..._docstring import no_example
from ..._typing_extensions import ParamSpec
Expand Down Expand Up @@ -46,9 +46,9 @@ def hold() -> HoldContextManager:

class HoldContextManager:
def __init__(self):
self.content: list[object] = list()
self.content = TagList()

def __enter__(self) -> list[object]:
def __enter__(self) -> TagList:
self.prev_displayhook = sys.displayhook
sys.displayhook = wrap_displayhook_handler(
self.content.append # pyright: ignore[reportArgumentType]
Expand Down
Loading
Loading