Skip to content

Commit 9b61375

Browse files
authored
feat(Chat, MarkdownStream): Support Shiny UI inside of message content (#1868)
1 parent 5f0bcbf commit 9b61375

File tree

21 files changed

+833
-240
lines changed

21 files changed

+833
-240
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [UNRELEASED]
99

10+
## New features
11+
12+
* Both `ui.Chat()` and `ui.MarkdownStream()` now support arbirary Shiny UI elements inside of messages. This allows for gathering input from the user (e.g., `ui.input_select()`), displaying of rich output (e.g., `render.DataGrid()`), and more. (#1868)
13+
1014
### Changes
1115

1216
* Express mode's `app_opts()` requires all arguments to be keyword-only. If you are using positional arguments, you will need to update your code. (#1895)

js/chat/chat.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { property } from "lit/decorators.js";
55
import {
66
LightElement,
77
createElement,
8+
renderDependencies,
89
showShinyClientMessage,
910
} from "../utils/_utils";
1011

12+
import type { HtmlDep } from "../utils/_utils";
13+
1114
type ContentType = "markdown" | "html" | "text";
1215

1316
type Message = {
@@ -18,10 +21,13 @@ type Message = {
1821
icon?: string;
1922
operation: "append" | null;
2023
};
24+
2125
type ShinyChatMessage = {
2226
id: string;
2327
handler: string;
24-
obj: Message;
28+
// Message keys will create custom element attributes, but html_deps are handled
29+
// separately
30+
obj: (Message & { html_deps?: HtmlDep[] }) | null;
2531
};
2632

2733
type UpdateUserInput = {
@@ -59,7 +65,8 @@ const ICONS = {
5965

6066
class ChatMessage extends LightElement {
6167
@property() content = "...";
62-
@property() content_type: ContentType = "markdown";
68+
@property({ attribute: "content-type" }) contentType: ContentType =
69+
"markdown";
6370
@property({ type: Boolean, reflect: true }) streaming = false;
6471
@property() icon = "";
6572

@@ -72,7 +79,7 @@ class ChatMessage extends LightElement {
7279
<div class="message-icon">${unsafeHTML(icon)}</div>
7380
<shiny-markdown-stream
7481
content=${this.content}
75-
content-type=${this.content_type}
82+
content-type=${this.contentType}
7683
?streaming=${this.streaming}
7784
auto-scroll
7885
.onContentChange=${this.#onContentChange.bind(this)}
@@ -529,7 +536,11 @@ customElements.define(CHAT_CONTAINER_TAG, ChatContainer);
529536

530537
window.Shiny.addCustomMessageHandler(
531538
"shinyChatMessage",
532-
function (message: ShinyChatMessage) {
539+
async function (message: ShinyChatMessage) {
540+
if (message.obj?.html_deps) {
541+
await renderDependencies(message.obj.html_deps);
542+
}
543+
533544
const evt = new CustomEvent(message.handler, {
534545
detail: message.obj,
535546
});

js/markdown-stream/markdown-stream.ts

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,28 @@ import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
33
import { property } from "lit/decorators.js";
44

55
import ClipboardJS from "clipboard";
6-
import { sanitize } from "dompurify";
76
import hljs from "highlight.js/lib/common";
87
import { Renderer, parse } from "marked";
98

109
import {
1110
LightElement,
1211
createElement,
1312
createSVGIcon,
13+
renderDependencies,
14+
sanitizeHTML,
1415
showShinyClientMessage,
16+
throttle,
1517
} from "../utils/_utils";
1618

19+
import type { HtmlDep } from "../utils/_utils";
20+
1721
type ContentType = "markdown" | "semi-markdown" | "html" | "text";
1822

1923
type ContentMessage = {
2024
id: string;
2125
content: string;
2226
operation: "append" | "replace";
27+
html_deps?: HtmlDep[];
2328
};
2429

2530
type IsStreamingMessage = {
@@ -59,11 +64,11 @@ const markedEscapeOpts = { renderer: rendererEscapeHTML };
5964

6065
function contentToHTML(content: string, content_type: ContentType) {
6166
if (content_type === "markdown") {
62-
return unsafeHTML(sanitize(parse(content) as string));
67+
return unsafeHTML(sanitizeHTML(parse(content) as string));
6368
} else if (content_type === "semi-markdown") {
64-
return unsafeHTML(sanitize(parse(content, markedEscapeOpts) as string));
69+
return unsafeHTML(sanitizeHTML(parse(content, markedEscapeOpts) as string));
6570
} else if (content_type === "html") {
66-
return unsafeHTML(sanitize(content));
71+
return unsafeHTML(sanitizeHTML(content));
6772
} else if (content_type === "text") {
6873
return content;
6974
} else {
@@ -94,6 +99,8 @@ class MarkdownElement extends LightElement {
9499
protected willUpdate(changedProperties: PropertyValues): void {
95100
if (changedProperties.has("content")) {
96101
this.#isContentBeingAdded = true;
102+
103+
MarkdownElement.#doUnBind(this);
97104
}
98105
super.willUpdate(changedProperties);
99106
}
@@ -106,7 +113,14 @@ class MarkdownElement extends LightElement {
106113
} catch (error) {
107114
console.warn("Failed to highlight code:", error);
108115
}
109-
if (this.streaming) this.#appendStreamingDot();
116+
117+
// Render Shiny HTML dependencies and bind inputs/outputs
118+
if (this.streaming) {
119+
this.#appendStreamingDot();
120+
MarkdownElement._throttledBind(this);
121+
} else {
122+
MarkdownElement.#doBind(this);
123+
}
110124

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

165+
static async #doUnBind(el: HTMLElement): Promise<void> {
166+
if (!window?.Shiny?.unbindAll) return;
167+
168+
try {
169+
window.Shiny.unbindAll(el);
170+
} catch (err) {
171+
showShinyClientMessage({
172+
status: "error",
173+
message: `Failed to unbind Shiny inputs/outputs: ${err}`,
174+
});
175+
}
176+
}
177+
178+
static async #doBind(el: HTMLElement): Promise<void> {
179+
if (!window?.Shiny?.initializeInputs) return;
180+
if (!window?.Shiny?.bindAll) return;
181+
182+
try {
183+
window.Shiny.initializeInputs(el);
184+
} catch (err) {
185+
showShinyClientMessage({
186+
status: "error",
187+
message: `Failed to initialize Shiny inputs: ${err}`,
188+
});
189+
}
190+
191+
try {
192+
await window.Shiny.bindAll(el);
193+
} catch (err) {
194+
showShinyClientMessage({
195+
status: "error",
196+
message: `Failed to bind Shiny inputs/outputs: ${err}`,
197+
});
198+
}
199+
}
200+
201+
@throttle(200)
202+
private static async _throttledBind(el: HTMLElement): Promise<void> {
203+
await this.#doBind(el);
204+
}
205+
151206
#highlightAndCodeCopy(): void {
152207
const el = this.querySelector("pre code");
153208
if (!el) return;
@@ -244,7 +299,9 @@ if (!customElements.get("shiny-markdown-stream")) {
244299
customElements.define("shiny-markdown-stream", MarkdownElement);
245300
}
246301

247-
function handleMessage(message: ContentMessage | IsStreamingMessage): void {
302+
async function handleMessage(
303+
message: ContentMessage | IsStreamingMessage
304+
): Promise<void> {
248305
const el = document.getElementById(message.id) as MarkdownElement;
249306

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

322+
if (message.html_deps) {
323+
await renderDependencies(message.html_deps);
324+
}
325+
265326
if (message.operation === "replace") {
266327
el.setAttribute("content", message.content);
267328
} else if (message.operation === "append") {

js/utils/_utils.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import DOMPurify from "dompurify";
12
import { LitElement } from "lit";
23

4+
import type { HtmlDep } from "rstudio-shiny/srcts/types/src/shiny/render";
5+
36
////////////////////////////////////////////////
47
// Lit helpers
58
////////////////////////////////////////////////
@@ -10,7 +13,9 @@ function createElement(
1013
): HTMLElement {
1114
const el = document.createElement(tag_name);
1215
for (const [key, value] of Object.entries(attrs)) {
13-
if (value !== null) el.setAttribute(key, value);
16+
// Replace _ with - in attribute names
17+
const attrName = key.replace(/_/g, "-");
18+
if (value !== null) el.setAttribute(attrName, value);
1419
}
1520
return el;
1621
}
@@ -49,4 +54,90 @@ function showShinyClientMessage({
4954
);
5055
}
5156

52-
export { LightElement, createElement, createSVGIcon, showShinyClientMessage, };
57+
async function renderDependencies(deps: HtmlDep[]): Promise<void> {
58+
if (!window.Shiny) return;
59+
if (!deps) return;
60+
61+
try {
62+
await window.Shiny.renderDependenciesAsync(deps);
63+
} catch (renderError) {
64+
showShinyClientMessage({
65+
status: "error",
66+
message: `Failed to render HTML dependencies: ${renderError}`,
67+
});
68+
}
69+
}
70+
71+
////////////////////////////////////////////////
72+
// General helpers
73+
////////////////////////////////////////////////
74+
75+
function sanitizeHTML(html: string): string {
76+
return sanitizer.sanitize(html, {
77+
// Sanitize scripts manually (see below)
78+
ADD_TAGS: ["script"],
79+
// Allow any (defined) custom element
80+
CUSTOM_ELEMENT_HANDLING: {
81+
tagNameCheck: (tagName) => {
82+
return window.customElements.get(tagName) !== undefined;
83+
},
84+
attributeNameCheck: (attr) => true,
85+
allowCustomizedBuiltInElements: true,
86+
},
87+
});
88+
}
89+
90+
// Allow htmlwidgets' script tags through the sanitizer
91+
// by allowing `<script type="application/json" data-for="*"`,
92+
// which every widget should follow, and seems generally safe.
93+
const sanitizer = DOMPurify();
94+
sanitizer.addHook("uponSanitizeElement", (node, data) => {
95+
if (node.nodeName && node.nodeName === "SCRIPT") {
96+
const isOK =
97+
node.getAttribute("type") === "application/json" &&
98+
node.getAttribute("data-for") !== null;
99+
100+
data.allowedTags["script"] = isOK;
101+
}
102+
});
103+
104+
/**
105+
* Creates a throttle decorator that ensures the decorated method isn't called more
106+
* frequently than the specified delay
107+
* @param delay The minimum time (in ms) that must pass between calls
108+
*/
109+
export function throttle(delay: number) {
110+
/* eslint-disable @typescript-eslint/no-explicit-any */
111+
return function (
112+
_target: any,
113+
_propertyKey: string,
114+
descriptor: PropertyDescriptor
115+
) {
116+
const originalMethod = descriptor.value;
117+
let timeout: number | undefined;
118+
119+
descriptor.value = function (...args: any[]) {
120+
if (timeout) {
121+
window.clearTimeout(timeout);
122+
}
123+
124+
timeout = window.setTimeout(() => {
125+
originalMethod.apply(this, args);
126+
timeout = undefined;
127+
}, delay);
128+
};
129+
130+
return descriptor;
131+
};
132+
}
133+
134+
export {
135+
LightElement,
136+
createElement,
137+
createSVGIcon,
138+
renderDependencies,
139+
sanitizeHTML,
140+
showShinyClientMessage,
141+
};
142+
143+
export type { HtmlDep };

shiny/express/ui/_hold.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from types import TracebackType
55
from typing import Callable, Optional, Type, TypeVar
66

7-
from htmltools import wrap_displayhook_handler
7+
from htmltools import TagList, wrap_displayhook_handler
88

99
from ..._docstring import no_example
1010
from ..._typing_extensions import ParamSpec
@@ -46,9 +46,9 @@ def hold() -> HoldContextManager:
4646

4747
class HoldContextManager:
4848
def __init__(self):
49-
self.content: list[object] = list()
49+
self.content = TagList()
5050

51-
def __enter__(self) -> list[object]:
51+
def __enter__(self) -> TagList:
5252
self.prev_displayhook = sys.displayhook
5353
sys.displayhook = wrap_displayhook_handler(
5454
self.content.append # pyright: ignore[reportArgumentType]

0 commit comments

Comments
 (0)