Skip to content

Commit da73dff

Browse files
cpsievertgadenbuie
andauthored
Support Shiny UI inside of chat_append() and markdown_stream() (#29)
* Support Shiny UI inside of chat_append() and markdown_stream() * Bugfix * Update CSS/JS assets * Update snapshot * Make it more explicit what process_ui() is doing * Add documentation * elmer -> ellmer * Update output_markdown_stream() docs * Add some snapshot test for static rendering * Apply suggestions from code review Co-authored-by: Garrick Aden-Buie <[email protected]> * usethis::use_import_from() * Update news --------- Co-authored-by: Garrick Aden-Buie <[email protected]>
1 parent e76f15a commit da73dff

31 files changed

+518
-232
lines changed

DESCRIPTION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ Authors@R: c(
88
)
99
Description: Provides a scrolling chat interface with multiline input, suitable
1010
for creating chatbot apps based on Large Language Models (LLMs). Designed to
11-
work particularly well with the 'elmer' R package for calling LLMs.
11+
work particularly well with the 'ellmer' R package for calling LLMs.
1212
License: MIT + file LICENSE
1313
URL: https://github.com/jcheng5/shinychat, https://jcheng5.github.io/shinychat/
1414
BugReports: https://github.com/jcheng5/shinychat/issues
1515
Imports:
1616
bslib,
1717
coro,
1818
htmltools,
19+
jsonlite,
1920
promises (>= 1.3.2),
2021
rlang,
2122
shiny (>= 1.10.0)

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export(chat_ui)
77
export(markdown_stream)
88
export(output_markdown_stream)
99
importFrom(coro,async)
10+
importFrom(htmltools,HTML)
1011
importFrom(htmltools,css)
1112
importFrom(htmltools,tag)
13+
importFrom(rlang,"%||%")
1214
importFrom(shiny,getDefaultReactiveDomain)

NEWS.md

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

3-
* Added a new `chat_clear()` function to clear the chat of all messages. (#25)
43
* 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+
* Both `chat_ui()` and `output_markdown_stream()` now support arbirary Shiny UI elements inside of messages. This allows for gathering input from the user (e.g., `selectInput()`), displaying of rich output (e.g., `{htmlwidgets}` like `{plotly}`), and more. (#1868)
5+
* Added a new `chat_clear()` function to clear the chat of all messages. (#25)
56

67
# shinychat 0.1.1
78

R/chat.R

Lines changed: 92 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
# trimming of the message history to fit within the context window; these
55
# are left for the caller to handle in the R version.
66

7-
#' @importFrom htmltools tag css
8-
#' @importFrom coro async
9-
NULL
10-
117
chat_deps <- function() {
128
htmltools::htmlDependency(
139
"shinychat",
@@ -20,36 +16,48 @@ chat_deps <- function() {
2016
list(src = "text-area/textarea-autoresize.js", type = "module")
2117
),
2218
stylesheet = c(
23-
"chat/chat.css",
19+
"chat/chat.css",
2420
"markdown-stream/markdown-stream.css",
2521
"text-area/textarea-autoresize.css"
2622
)
2723
)
2824
}
2925

3026
#' Create a chat UI element
31-
#'
27+
#'
3228
#' @description
3329
#' Inserts a chat UI element into a Shiny UI, which includes a scrollable
3430
#' section for displaying chat messages, and an input field for the user to
3531
#' enter new messages.
36-
#'
32+
#'
3733
#' To respond to user input, listen for `input$ID_user_input` (for example, if
3834
#' `id="my_chat"`, user input will be at `input$my_chat_user_input`), and use
3935
#' [chat_append()] to append messages to the chat.
4036
#'
4137
#' @param id The ID of the chat element
4238
#' @param ... Extra HTML attributes to include on the chat element
4339
#' @param messages A list of messages to prepopulate the chat with. Each
44-
#' message can be a string or a named list with `content` and `role` fields.
40+
#' message can be one of the following:
41+
#'
42+
#' * A string, which is interpreted as markdown and rendered to HTML on
43+
#' the client.
44+
#' * To prevent interpreting as markdown, mark the string as
45+
#' [htmltools::HTML()].
46+
#' * A UI element.
47+
#' * This includes [htmltools::tagList()], which take UI elements
48+
#' (including strings) as children. In this case, strings are still
49+
#' interpreted as markdown as long as they're not inside HTML.
50+
#' * A named list of `content` and `role`. The `content` can contain content
51+
#' as described above, and the `role` can be "assistant" or "user".
52+
#'
4553
#' @param placeholder The placeholder text for the chat's user input field
4654
#' @param width The CSS width of the chat element
4755
#' @param height The CSS height of the chat element
4856
#' @param fill Whether the chat element should try to vertically fill its
4957
#' container, if the container is
5058
#' [fillable](https://rstudio.github.io/bslib/articles/filling/index.html)
5159
#' @returns A Shiny tag object, suitable for inclusion in a Shiny UI
52-
#'
60+
#'
5361
#' @examplesIf interactive()
5462
#' library(shiny)
5563
#' library(bslib)
@@ -62,7 +70,7 @@ chat_deps <- function() {
6270
#' server <- function(input, output, session) {
6371
#' observeEvent(input$chat_user_input, {
6472
#' # In a real app, this would call out to a chat model or API,
65-
#' # perhaps using the 'elmer' package.
73+
#' # perhaps using the 'ellmer' package.
6674
#' response <- paste0(
6775
#' "You said:\n\n",
6876
#' "<blockquote>",
@@ -91,26 +99,30 @@ chat_ui <- function(
9199
}
92100

93101
message_tags <- lapply(messages, function(x) {
94-
if (is.character(x)) {
95-
x <- list(content = x, role = "assistant")
96-
} else if (is.list(x)) {
97-
if (!("content" %in% names(x))) {
98-
rlang::abort("Each message must have a 'content' key.")
99-
}
100-
if (!("role" %in% names(x))) {
101-
rlang::abort("Each message must have a 'role' key.")
102-
}
103-
} else {
104-
rlang::abort("Each message must be a string or a named list.")
102+
role <- "assistant"
103+
content <- x
104+
if (is.list(x) && ("content" %in% names(x))) {
105+
content <- x[["content"]]
106+
role <- x[["role"]] %||% role
105107
}
106108

107-
if (isTRUE(x[["role"]] == "user")) {
109+
if (isTRUE(role == "user")) {
108110
tag_name <- "shiny-user-message"
109111
} else {
110112
tag_name <- "shiny-chat-message"
111113
}
112114

113-
tag(tag_name, list(content = x[["content"]]))
115+
ui <- with_current_theme({
116+
htmltools::renderTags(content)
117+
})
118+
119+
tag(
120+
tag_name,
121+
rlang::list2(
122+
content = ui[["html"]],
123+
ui[["dependencies"]],
124+
)
125+
)
114126
})
115127

116128
res <- tag("shiny-chat-container", rlang::list2(
@@ -131,36 +143,47 @@ chat_ui <- function(
131143
res <- bslib::as_fill_carrier(res)
132144
}
133145

134-
res
146+
tag_require(res, version = 5, caller = "chat_ui")
135147
}
136148

137149
#' Append an assistant response (or user message) to a chat control
138150
#'
139151
#' @description
140-
#' The `chat_append` function appends a message to an existing chat control. The
152+
#' The `chat_append` function appends a message to an existing [chat_ui()]. The
141153
#' `response` can be a string, string generator, string promise, or string
142-
#' promise generator (as returned by the 'elmer' package's `chat`, `stream`,
154+
#' promise generator (as returned by the 'ellmer' package's `chat`, `stream`,
143155
#' `chat_async`, and `stream_async` methods, respectively).
144-
#'
156+
#'
145157
#' This function should be called from a Shiny app's server. It is generally
146158
#' used to append the model's response to the chat, while user messages are
147159
#' added to the chat UI automatically by the front-end. You'd only need to use
148160
#' `chat_append(role="user")` if you are programmatically generating queries
149161
#' from the server and sending them on behalf of the user, and want them to be
150162
#' reflected in the UI.
151-
#'
163+
#'
152164
#' # Error handling
153-
#'
165+
#'
154166
#' If the `response` argument is a generator, promise, or promise generator, and
155167
#' an error occurs while producing the message (e.g., an iteration in
156168
#' `stream_async` fails), the promise returned by `chat_append` will reject with
157169
#' the error. If the `chat_append` call is the last expression in a Shiny
158170
#' observer, Shiny will see that the observer failed, and end the user session.
159171
#' If you prefer to handle the error gracefully, use [promises::catch()] on the
160172
#' promise returned by `chat_append`.
161-
#'
173+
#'
162174
#' @param id The ID of the chat element
163-
#' @param response The message or message stream to append to the chat element
175+
#' @param response The message or message stream to append to the chat element.
176+
#' The actual message content can one of the following:
177+
#'
178+
#' * A string, which is interpreted as markdown and rendered to HTML on
179+
#' the client.
180+
#' * To prevent interpreting as markdown, mark the string as
181+
#' [htmltools::HTML()].
182+
#' * A UI element.
183+
#' * This includes [htmltools::tagList()], which take UI elements
184+
#' (including strings) as children. In this case, strings are still
185+
#' interpreted as markdown as long as they're not inside HTML.
186+
#'
164187
#' @param role The role of the message (either "assistant" or "user"). Defaults
165188
#' to "assistant".
166189
#' @param session The Shiny session object
@@ -175,7 +198,7 @@ chat_ui <- function(
175198
#' library(coro)
176199
#' library(bslib)
177200
#' library(shinychat)
178-
#'
201+
#'
179202
#' # Dumbest chatbot in the world: ignores user input and chooses
180203
#' # a random, vague response.
181204
#' fake_chatbot <- async_generator(function(input) {
@@ -187,42 +210,31 @@ chat_ui <- function(
187210
#' "Can you elaborate on that?",
188211
#' "Interesting question! Let's examine thi... **See more**"
189212
#' )
190-
#'
213+
#'
191214
#' await(async_sleep(1))
192215
#' for (chunk in strsplit(sample(responses, 1), "")[[1]]) {
193216
#' yield(chunk)
194217
#' await(async_sleep(0.02))
195218
#' }
196219
#' })
197-
#'
220+
#'
198221
#' ui <- page_fillable(
199222
#' chat_ui("chat", fill = TRUE)
200223
#' )
201-
#'
224+
#'
202225
#' server <- function(input, output, session) {
203226
#' observeEvent(input$chat_user_input, {
204227
#' response <- fake_chatbot(input$chat_user_input)
205228
#' chat_append("chat", response)
206229
#' })
207230
#' }
208-
#'
231+
#'
209232
#' shinyApp(ui, server)
210-
#'
233+
#'
211234
#' @export
212235
chat_append <- function(id, response, role = c("assistant", "user"), session = getDefaultReactiveDomain()) {
213236
role <- match.arg(role)
214-
if (is.character(response)) {
215-
# string => generator
216-
stream <- coro::gen(yield(response))
217-
} else if (promises::is.promising(response)) {
218-
# promise => async generator
219-
stream <- coro::gen(yield(response))
220-
} else if (inherits(response, "coro_generator_instance")) {
221-
# Already a generator (sync or async)
222-
stream <- response
223-
} else {
224-
rlang::abort("Unexpected message type; chat_append() expects a string, a string generator, a string promise, or a string promise generator")
225-
}
237+
stream <- as_generator(response)
226238
chat_append_stream(id, stream, role = role, session = session)
227239
}
228240

@@ -250,13 +262,13 @@ chat_append <- function(id, response, role = c("assistant", "user"), session = g
250262
#' @returns Returns nothing (\code{invisible(NULL)}).
251263
#'
252264
#' @importFrom shiny getDefaultReactiveDomain
253-
#'
265+
#'
254266
#' @examplesIf interactive()
255267
#' library(shiny)
256268
#' library(coro)
257269
#' library(bslib)
258270
#' library(shinychat)
259-
#'
271+
#'
260272
#' # Dumbest chatbot in the world: ignores user input and chooses
261273
#' # a random, vague response.
262274
#' fake_chatbot <- async_generator(function(id, input) {
@@ -268,7 +280,7 @@ chat_append <- function(id, response, role = c("assistant", "user"), session = g
268280
#' "Can you elaborate on that?",
269281
#' "Interesting question! Let's examine thi... **See more**"
270282
#' )
271-
#'
283+
#'
272284
#' # Use low-level chat_append_message() to temporarily set a progress message
273285
#' chat_append_message(id, list(role = "assistant", content = "_Thinking..._ "))
274286
#' await(async_sleep(1))
@@ -280,20 +292,20 @@ chat_append <- function(id, response, role = c("assistant", "user"), session = g
280292
#' await(async_sleep(0.02))
281293
#' }
282294
#' })
283-
#'
295+
#'
284296
#' ui <- page_fillable(
285297
#' chat_ui("chat", fill = TRUE)
286298
#' )
287-
#'
299+
#'
288300
#' server <- function(input, output, session) {
289301
#' observeEvent(input$chat_user_input, {
290302
#' response <- fake_chatbot("chat", input$chat_user_input)
291303
#' chat_append("chat", response)
292304
#' })
293305
#' }
294-
#'
306+
#'
295307
#' shinyApp(ui, server)
296-
#'
308+
#'
297309
#' @export
298310
chat_append_message <- function(id, msg, chunk = TRUE, operation = c("append", "replace"), session = getDefaultReactiveDomain()) {
299311
if (!is.list(msg)) {
@@ -320,21 +332,31 @@ chat_append_message <- function(id, msg, chunk = TRUE, operation = c("append", "
320332
chunk_type <- NULL
321333
}
322334

323-
if (identical(class(msg[["content"]]), "character")) {
324-
content_type <- "markdown"
325-
} else {
326-
content_type <- "html"
327-
}
335+
content <- msg[["content"]]
336+
is_html <- inherits(content, c("shiny.tag", "shiny.tag.list", "html", "htmlwidget"))
337+
content_type <- if (is_html) "html" else "markdown"
328338

329339
operation <- match.arg(operation)
330340
if (identical(operation, "replace")) {
331341
operation <- NULL
332342
}
333343

344+
if (is.character(content)) {
345+
# content is most likely a string, so avoid overhead in that case
346+
ui <- list(html = content, deps = "[]")
347+
} else {
348+
# process_ui() does *not* render markdown->HTML, but it does:
349+
# 1. Extract and register HTMLdependency()s with the session.
350+
# 2. Returns a HTML string representation of the TagChild
351+
# (i.e., `div()` -> `"<div>"`).
352+
ui <- process_ui(content, session)
353+
}
354+
334355
msg <- list(
335-
content = msg[["content"]],
356+
content = ui[["html"]],
336357
role = msg[["role"]],
337358
content_type = content_type,
359+
html_deps = ui[["deps"]],
338360
chunk_type = chunk_type,
339361
operation = operation
340362
)
@@ -390,26 +412,26 @@ rlang::on_load(chat_append_stream_impl <- coro::async(function(id, stream, role
390412

391413

392414
#' Clear all messages from a chat control
393-
#'
415+
#'
394416
#' @param id The ID of the chat element
395417
#' @param session The Shiny session object
396-
#'
397-
#' @export
418+
#'
419+
#' @export
398420
#' @examplesIf interactive()
399-
#'
421+
#'
400422
#' library(shiny)
401423
#' library(bslib)
402-
#'
424+
#'
403425
#' ui <- page_fillable(
404426
#' chat_ui("chat", fill = TRUE),
405427
#' actionButton("clear", "Clear chat")
406428
#' )
407-
#'
429+
#'
408430
#' server <- function(input, output, session) {
409431
#' observeEvent(input$clear, {
410432
#' chat_clear("chat")
411433
#' })
412-
#'
434+
#'
413435
#' observeEvent(input$chat_user_input, {
414436
#' response <- paste0("You said: ", input$chat_user_input)
415437
#' chat_append("chat", response)
@@ -426,4 +448,4 @@ chat_clear <- function(id, session = getDefaultReactiveDomain()) {
426448
obj = NULL
427449
)
428450
)
429-
}
451+
}

0 commit comments

Comments
 (0)