Skip to content

Commit 0d0fba6

Browse files
committed
Add streaming context
1 parent 3acbd4c commit 0d0fba6

File tree

7 files changed

+126
-80
lines changed

7 files changed

+126
-80
lines changed

js/chat/chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ class ChatMessage extends LightElement {
7777

7878
return html`
7979
<div class="message-icon">${unsafeHTML(icon)}</div>
80-
<shiny-markdown
80+
<shiny-markdown-stream
8181
content=${this.content}
8282
content_type=${this.content_type}
8383
streaming=${this.streaming}
84-
></shiny-markdown>
84+
></shiny-markdown-stream>
8585
`;
8686
}
8787
}

js/markdown-stream/markdown-stream.ts

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
1-
import { LitElement,html } from "lit";
1+
import { LitElement, html } from "lit";
22
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
33
import { property } from "lit/decorators.js";
44

55
import ClipboardJS from "clipboard";
66
import { sanitize } from "dompurify";
77
import hljs from "highlight.js/lib/common";
8-
import { Renderer,parse } from "marked";
8+
import { Renderer, parse } from "marked";
99

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

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

14-
type Message = {
14+
type ContentMessage = {
1515
id: string;
1616
content: string;
1717
operation: "append" | "replace";
1818
};
1919

20+
type IsStreamingMessage = {
21+
id: string;
22+
isStreaming: boolean;
23+
};
24+
25+
// Type guard
26+
function isStreamingMessage(
27+
message: ContentMessage | IsStreamingMessage
28+
): message is IsStreamingMessage {
29+
return "isStreaming" in message;
30+
}
31+
32+
// SVG dot to indicate content is currently streaming
2033
const SVG_DOT = createSVGIcon(
2134
'<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>'
2235
);
@@ -66,7 +79,7 @@ class MarkdownElement extends LightElement {
6679
if (changedProperties.has("content")) {
6780
this.#highlightAndCodeCopy();
6881
if (this.streaming) this.#appendStreamingDot();
69-
// TODO: throw an event here that we're done and catch it in SHINY_CHAT_MESSAGE
82+
// TODO: throw an event here that content has rendered and catch it in SHINY_CHAT_MESSAGE
7083
// requestScroll(this, this.streaming);
7184
}
7285
if (changedProperties.has("streaming")) {
@@ -110,28 +123,37 @@ class MarkdownElement extends LightElement {
110123
}
111124
}
112125

113-
// TODO: is it a problem if this gets imported multiple times?
114-
customElements.define("shiny-markdown-stream", MarkdownElement);
126+
// TODO: it is a problem if this runs twice in the browser?
127+
if (!customElements.get("shiny-markdown-stream")) {
128+
customElements.define("shiny-markdown-stream", MarkdownElement);
129+
}
130+
131+
function handleMessage(message: ContentMessage | IsStreamingMessage): void {
132+
const el = document.getElementById(message.id) as MarkdownElement;
133+
134+
if (!el) {
135+
console.error(`Element with id ${message.id} not found`);
136+
return;
137+
}
138+
139+
if (isStreamingMessage(message)) {
140+
el.streaming = message.isStreaming;
141+
return;
142+
}
143+
144+
if (message.operation === "replace") {
145+
el.setAttribute("content", message.content);
146+
} else if (message.operation === "append") {
147+
const content = el.getAttribute("content");
148+
el.setAttribute("content", content + message.content);
149+
} else {
150+
console.error(`Unknown operation: ${message.operation}`);
151+
}
152+
}
115153

154+
// TODO: it is a problem if this runs twice in the browser?
116155
$(function () {
117-
Shiny.addCustomMessageHandler(
118-
"shinyMarkdownStreamMessage",
119-
function (message: Message) {
120-
const el = document.getElementById(message.id);
121-
if (!el) {
122-
console.error(`Element with id ${message.id} not found`);
123-
return;
124-
}
125-
if (message.operation === "replace") {
126-
el.setAttribute("content", message.content);
127-
} else if (message.operation === "append") {
128-
const content = el.getAttribute("content");
129-
el.setAttribute("content", content + message.content);
130-
} else {
131-
console.error(`Unknown operation: ${message.operation}`);
132-
}
133-
}
134-
);
156+
Shiny.addCustomMessageHandler("shinyMarkdownStreamMessage", handleMessage);
135157
});
136158

137-
export { MarkdownElement,contentToHTML };
159+
export { MarkdownElement, contentToHTML };

shiny/ui/_markdown_stream.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from contextlib import contextmanager
12
from typing import Iterable, Literal
23

34
from .. import reactive
@@ -15,12 +16,17 @@
1516
StreamingContentType = Literal["markdown", "semi-markdown", "html", "text"]
1617

1718

18-
class Message(TypedDict):
19+
class ContentMessage(TypedDict):
1920
id: str
2021
content: str
2122
operation: Literal["append", "replace"]
2223

2324

25+
class isStreamingMessage(TypedDict):
26+
id: str
27+
isStreaming: bool
28+
29+
2430
@add_example()
2531
class MarkdownStream:
2632
"""
@@ -85,16 +91,17 @@ def stream(self, content: Iterable[str], clear: bool = True):
8591
async def _task():
8692
if clear:
8793
self._replace("")
88-
for c in content:
89-
self._append(c)
94+
with self._streaming_dot():
95+
for c in content:
96+
self._append(c)
9097

9198
_task()
9299

93100
def update(self, content: str):
94101
self._replace(content)
95102

96103
def _append(self, content: str):
97-
msg: Message = {
104+
msg: ContentMessage = {
98105
"id": self.id,
99106
"content": content,
100107
"operation": "append",
@@ -103,15 +110,32 @@ def _append(self, content: str):
103110
self._send_custom_message(msg)
104111

105112
def _replace(self, content: str):
106-
msg: Message = {
113+
msg: ContentMessage = {
107114
"id": self.id,
108115
"content": content,
109116
"operation": "replace",
110117
}
111118

112119
self._send_custom_message(msg)
113120

114-
def _send_custom_message(self, msg: Message):
121+
@contextmanager
122+
def _streaming_dot(self):
123+
start: isStreamingMessage = {
124+
"id": self.id,
125+
"isStreaming": True,
126+
}
127+
self._send_custom_message(start)
128+
129+
try:
130+
yield
131+
finally:
132+
end: isStreamingMessage = {
133+
"id": self.id,
134+
"isStreaming": False,
135+
}
136+
self._send_custom_message(end)
137+
138+
def _send_custom_message(self, msg: ContentMessage | isStreamingMessage):
115139
if self._session.is_stub_session():
116140
return
117141
self._session._send_message_sync(

shiny/www/py-shiny/chat/chat.js

Lines changed: 18 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/chat/chat.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/markdown-stream/markdown-stream.js

Lines changed: 22 additions & 22 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shiny/www/py-shiny/markdown-stream/markdown-stream.js.map

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)