Skip to content

Commit 8d41729

Browse files
authored
Add markdown_stream() (#23)
* Update chat_ui() assets to accomodate incoming markdown-stream web component * Add markdown_stream() and output_markdown_stream() * py-shiny component is merged now * Get latest JS assets * Add additional UI parameters that were added recently * Update news
1 parent b94d19e commit 8d41729

File tree

16 files changed

+555
-80
lines changed

16 files changed

+555
-80
lines changed

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
export(chat_append)
44
export(chat_append_message)
55
export(chat_ui)
6+
export(markdown_stream)
7+
export(output_markdown_stream)
68
importFrom(coro,async)
79
importFrom(htmltools,css)
810
importFrom(htmltools,tag)

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# shinychat (development version)
22

3+
* Added new `output_markdown_stream()` and `markdown_stream()` functions to allow for streaming markdown content to the client. This is useful for showing Generative AI responses in real-time in a Shiny app, outside of a chat interface. (#23)
4+
35
# shinychat 0.1.1
46

57
* Initial CRAN submission.

R/chat.R

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ chat_deps <- function() {
1616
src = "lib/shiny",
1717
script = list(
1818
list(src = "chat/chat.js", type = "module"),
19+
list(src = "markdown-stream/markdown-stream.js", type = "module"),
1920
list(src = "text-area/textarea-autoresize.js", type = "module")
2021
),
21-
stylesheet = c("chat/chat.css", "text-area/textarea-autoresize.css")
22+
stylesheet = c(
23+
"chat/chat.css",
24+
"markdown-stream/markdown-stream.css",
25+
"text-area/textarea-autoresize.css"
26+
)
2227
)
2328
}
2429

R/markdown-stream.R

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
2+
markdown_stream_deps <- function() {
3+
htmltools::htmlDependency(
4+
"shinychat",
5+
utils::packageVersion("shinychat"),
6+
package = "shinychat",
7+
src = "lib/shiny",
8+
script = list(src = "markdown-stream/markdown-stream.js", type = "module"),
9+
stylesheet = "markdown-stream/markdown-stream.css",
10+
)
11+
}
12+
13+
#' Create a UI element for a markdown stream.
14+
#'
15+
#' @description
16+
#' Creates a UI element for a [markdown_stream()]. A markdown stream can be
17+
#' useful for displaying generative AI responses (outside of a chat interface),
18+
#' streaming logs, or other use cases where chunks of content are generated
19+
#' over time.
20+
#'
21+
#' @param id A unique identifier for this markdown stream.
22+
#' @param ... Extra HTML attributes to include on the chat element
23+
#' @param content Some content to display before any streaming occurs.
24+
#' @param content_type The content type. Default is `"markdown"` (specifically,
25+
#' CommonMark). Other supported options are:
26+
#' * `"html"`: for rendering HTML content.
27+
#' * `"text"`: for plain text.
28+
#' * `"semi-markdown"`: for rendering markdown, but with HTML tags escaped.
29+
#' @param auto_scroll Whether to automatically scroll to the bottom of a
30+
#' scrollable container when new content is added. Default is True.
31+
#' @param width The width of the UI element.
32+
#' @param height The height of the UI element.
33+
#'
34+
#' @return A shiny tag object.
35+
#'
36+
#' @export
37+
#' @seealso [markdown_stream()]
38+
#'
39+
output_markdown_stream <- function(
40+
id,
41+
...,
42+
content = "",
43+
content_type = "markdown",
44+
auto_scroll = TRUE,
45+
width = "100%",
46+
height = "auto"
47+
) {
48+
htmltools::tag(
49+
"shiny-markdown-stream",
50+
rlang::list2(
51+
id = id,
52+
style = css(
53+
width = width,
54+
height = height
55+
),
56+
content = content,
57+
"content-type" = content_type,
58+
"auto-scroll" = auto_scroll,
59+
...,
60+
markdown_stream_deps()
61+
)
62+
)
63+
}
64+
65+
#' Stream markdown content
66+
#'
67+
#' @description
68+
#' Streams markdown content into a [output_markdown_stream()] UI element. A
69+
#' markdown stream can be useful for displaying generative AI responses (outside
70+
#' of a chat interface), streaming logs, or other use cases where chunks of
71+
#' content are generated over time.
72+
#'
73+
#' @param id The ID of the markdown stream to stream content to.
74+
#' @param content_stream A string generator (e.g., [coro::generator()] or
75+
#' [coro::async_generator()]), a string promise (e.g., [promises::promise()]),
76+
#' or a string promise generator.
77+
#' @param operation The operation to perform on the markdown stream. The default,
78+
#' `"replace"`, will replace the current content with the new content stream.
79+
#' The other option, `"append"`, will append the new content stream to the
80+
#' existing content.
81+
#'
82+
#' @param session The Shiny session object.
83+
#'
84+
#' @return NULL
85+
#'
86+
#' @export
87+
#' @examplesIf interactive()
88+
#'
89+
#' library(shiny)
90+
#' library(coro)
91+
#' library(bslib)
92+
#' library(shinychat)
93+
#'
94+
#' # Define a generator that yields a random response
95+
#' # (imagine this is a more sophisticated AI generator)
96+
#' random_response_generator <- async_generator(function() {
97+
#' responses <- c(
98+
#' "What does that suggest to you?",
99+
#' "I see.",
100+
#' "I'm not sure I understand you fully.",
101+
#' "What do you think?",
102+
#' "Can you elaborate on that?",
103+
#' "Interesting question! Let's examine thi... **See more**"
104+
#' )
105+
#'
106+
#' await(async_sleep(1))
107+
#' for (chunk in strsplit(sample(responses, 1), "")[[1]]) {
108+
#' yield(chunk)
109+
#' await(async_sleep(0.02))
110+
#' }
111+
#' })
112+
#'
113+
#' ui <- page_fillable(
114+
#' actionButton("generate", "Generate response"),
115+
#' output_markdown_stream("stream")
116+
#' )
117+
#'
118+
#' server <- function(input, output, session) {
119+
#' observeEvent(input$generate, {
120+
#' markdown_stream("stream", random_response_generator())
121+
#' })
122+
#' }
123+
#'
124+
#' shinyApp(ui, server)
125+
markdown_stream <- function(id, content_stream, operation = c("replace", "append"), session = getDefaultReactiveDomain()) {
126+
if (promises::is.promising(content_stream)) {
127+
# promise => async generator
128+
stream <- coro::gen(yield(content_stream))
129+
} else if (inherits(content_stream, "coro_generator_instance")) {
130+
# Already a generator (sync or async)
131+
stream <- content_stream
132+
} else {
133+
rlang::abort("Unexpected message type; markdown_stream() expects a string generator, a string promise, or a string promise generator")
134+
}
135+
136+
operation <- match.arg(operation)
137+
138+
result <- markdown_stream_impl(id, stream, operation, session)
139+
# Handle erroneous result...
140+
promises::catch(result, function(reason) {
141+
shiny::showNotification(
142+
sprintf("Error in markdown_stream('%s'): %s", id, conditionMessage(reason)),
143+
type = "error",
144+
duration = NULL,
145+
closeButton = TRUE
146+
)
147+
})
148+
# ...but also return it, so the caller can also handle it if they want. Note
149+
# that we're not returning the result of `promises::catch`; we want to return
150+
# a rejected promise (so the caller can see the error) that was already
151+
# handled (so there's no "unhandled promise error" warning if the caller
152+
# chooses not to do anything with it).
153+
result
154+
}
155+
156+
157+
markdown_stream_impl <- NULL
158+
rlang::on_load(markdown_stream_impl <- coro::async(function(id, stream, operation, session) {
159+
160+
send_stream_message <- function(...) {
161+
session$sendCustomMessage(
162+
"shinyMarkdownStreamMessage",
163+
list(id = id, ...)
164+
)
165+
}
166+
167+
if (operation == "replace") {
168+
send_stream_message(content = "", operation = "replace")
169+
}
170+
171+
send_stream_message(isStreaming = TRUE)
172+
173+
on.exit({
174+
send_stream_message(isStreaming = FALSE)
175+
})
176+
177+
for (msg in stream) {
178+
if (promises::is.promising(msg)) {
179+
msg <- await(msg)
180+
}
181+
if (coro::is_exhausted(msg)) {
182+
break
183+
}
184+
send_stream_message(content = msg, operation = "append")
185+
}
186+
187+
invisible(NULL)
188+
}))

inst/lib/shiny/GIT_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a06b3fec221e41a4a1ff119b72cc04e74b06a949
1+
a7813054f267e4c80b9ea5780788c56296a7e1d8

inst/lib/shiny/chat/chat.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)