Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions pkg-r/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Imports:
fastmap,
htmltools,
jsonlite,
lifecycle,
promises (>= 1.3.2),
rlang,
S7,
Expand Down
3 changes: 2 additions & 1 deletion pkg-r/NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ export(chat_app)
export(chat_append)
export(chat_append_message)
export(chat_clear)
export(chat_enable_bookmarking)
export(chat_mod_server)
export(chat_mod_ui)
export(chat_restore)
export(chat_ui)
export(markdown_stream)
export(output_markdown_stream)
Expand All @@ -18,4 +18,5 @@ importFrom(coro,async)
importFrom(htmltools,HTML)
importFrom(htmltools,css)
importFrom(htmltools,tag)
importFrom(lifecycle,deprecated)
importFrom(shiny,getDefaultReactiveDomain)
3 changes: 2 additions & 1 deletion pkg-r/NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## New features

* Added `chat_enable_bookmarking()` which adds Shiny bookmarking hooks to save and restore the `{ellmer}` chat client. (#28)
* Added `chat_restore()` which adds Shiny bookmarking hooks to save and restore the `{ellmer}` chat client. (#28, #82)

* Added `update_chat_user_input()` for programmatically updating the user input of a chat UI element. (#78)

## Improvements
Expand Down
72 changes: 42 additions & 30 deletions pkg-r/R/chat_app.R
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@
#' }
#'
#' @param client A chat object created by \pkg{ellmer}, e.g.
#' [ellmer::chat_openai()] and friends.
#' [ellmer::chat_openai()] and friends. This argument is deprecated in
#' `chat_mod_ui()` because the client state is now managed by
#' `chat_mod_server()`.
#' @param ... In `chat_app()`, additional arguments are passed to
#' [shiny::shinyApp()]. In `chat_mod_ui()`, additional arguments are passed to
#' [chat_ui()].
#' @param bookmark_store The bookmarking store to use for the app. Passed to
#' `enable_bookmarking` in [shiny::shinyApp()]. Defaults to `"url"`, which
#' uses the URL to store the chat state. URL-based bookmarking is limited in
#' size; use `"server"` to store the state on the server side without size
#' limitations; or disable bookmarking by setting this to `"disable"`.
#'
#' @returns
#' * `chat_app()` returns a [shiny::shinyApp()] object.
Expand All @@ -73,12 +80,12 @@
#' app is suitable for interactive use by a single user; do not use
#' `chat_app()` in a multi-user Shiny app context.
#' @export
chat_app <- function(client, ...) {
chat_app <- function(client, ..., bookmark_store = "url") {
check_ellmer_chat(client)

ui <- function(req) {
bslib::page_fillable(
chat_mod_ui("chat", client = client, height = "100%"),
chat_mod_ui("chat", height = "100%"),
shiny::actionButton(
"close_btn",
label = "",
Expand All @@ -96,7 +103,7 @@ chat_app <- function(client, ...) {
})
}

shiny::shinyApp(ui, server, ...)
shiny::shinyApp(ui, server, ..., enableBookmarking = bookmark_store)
}

check_ellmer_chat <- function(client) {
Expand All @@ -107,43 +114,40 @@ check_ellmer_chat <- function(client) {

#' @describeIn chat_app A simple chat app module UI.
#' @param id The chat module ID.
#' @param messages Initial messages shown in the chat, used when `client` is not
#' provided or when the chat `client` doesn't already contain turns. Passed to
#' `messages` in [chat_ui()].
#' @param messages Initial messages shown in the chat, used only when `client`
#' (in `chat_mod_ui()`) doesn't already contain turns. Passed to `messages`
#' in [chat_ui()].
#' @export
chat_mod_ui <- function(id, ..., client = NULL, messages = NULL) {
if (!is.null(client)) {
check_ellmer_chat(client)

client_msgs <- map(client$get_turns(), function(turn) {
content <- ellmer::contents_markdown(turn)
if (is.null(content) || identical(content, "")) {
return(NULL)
}
list(role = turn@role, content = content)
})
client_msgs <- compact(client_msgs)

if (length(client_msgs)) {
if (!is.null(messages)) {
warn(
"`client` was provided and has initial messages, `messages` will be ignored."
)
}
messages <- client_msgs
}
chat_mod_ui <- function(
id,
...,
client = deprecated(),
messages = NULL
) {
if (lifecycle::is_present(client)) {
lifecycle::deprecate_warn(
"0.3.0",
"chat_mod_ui(client = )",
"chat_mod_server(client = )"
)
}

shinychat::chat_ui(
chat_ui(
shiny::NS(id, "chat"),
messages = messages,
...
)
}

#' @describeIn chat_app A simple chat app module server.
#' @inheritParams chat_restore
#' @export
chat_mod_server <- function(id, client) {
chat_mod_server <- function(
id,
client,
bookmark_on_input = TRUE,
bookmark_on_response = TRUE
) {
check_ellmer_chat(client)

append_stream_task <- shiny::ExtendedTask$new(
Expand All @@ -158,6 +162,14 @@ chat_mod_server <- function(id, client) {
)

shiny::moduleServer(id, function(input, output, session) {
chat_restore(
"chat",
client,
session = session,
bookmark_on_input = bookmark_on_input,
bookmark_on_response = bookmark_on_response
)

shiny::observeEvent(input$chat_user_input, {
append_stream_task$invoke(
client,
Expand Down
50 changes: 27 additions & 23 deletions pkg-r/R/chat_enable_bookmarking.R → pkg-r/R/chat_restore.R
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#' Add Shiny bookmarking for shinychat
#'
#' @description
#' Adds Shiny bookmarking hooks to save and restore the \pkg{ellmer} chat `client`.
#' Adds Shiny bookmarking hooks to save and restore the \pkg{ellmer} chat
#' `client`. Also restores chat messages from the history in the `client`.
#'
#' If either `bookmark_on_input` or `bookmark_on_response` is `TRUE`, the Shiny
#' App's bookmark will be automatically updated without showing a modal to the
Expand All @@ -13,6 +14,10 @@
#' then you may need to implement your own `session$onRestore()` (and possibly
#' `session$onBookmark`) handler to restore any additional state.
#'
#' To avoid restoring chat history from the `client`, you can ensure that the
#' history is empty by calling `client$set_turns(list())` before passing the
#' client to `chat_restore()`.
#'
#' @param id The ID of the chat element
#' @param client The \pkg{ellmer} LLM chat client.
#' @param ... Used for future parameter expansion.
Expand All @@ -39,7 +44,7 @@
#' echo = TRUE
#' )
#' # Update bookmark to chat on user submission and completed response
#' chat_enable_bookmarking("chat", chat_client)
#' chat_restore("chat", chat_client)
#'
#' observeEvent(input$chat_user_input, {
#' stream <- chat_client$stream_async(input$chat_user_input)
Expand All @@ -50,7 +55,7 @@
#' # Enable bookmarking!
#' shinyApp(ui, server, enableBookmarking = "server")
#' @export
chat_enable_bookmarking <- function(
chat_restore <- function(
id,
client,
...,
Expand All @@ -72,23 +77,12 @@ chat_enable_bookmarking <- function(

if (is.null(session)) {
rlang::abort(
"A `session` must be provided. Be sure to call `chat_enable_bookmarking()` where a session context is available."
"A `session` must be provided. Be sure to call `chat_restore()` where a session context is available."
)
}

# Verify bookmark store is not disabled. Bookmark options: "disable", "url", "server"
bookmark_store <- shiny::getShinyOption("bookmarkStore", "disable")
# TODO: Q - I feel this should be removed. Since we are only adding hooks, it doesn't matter if it's enabled or not. If the user diables chat, it would be very annoying to receive error messages for code they may not own.
if (bookmark_store == "disable") {
rlang::abort(
paste0(
"Error: Shiny bookmarking is not enabled. ",
"Please enable bookmarking in your Shiny app either by calling ",
"`shiny::enableBookmarking(\"server\")` or by setting the parameter in ",
"`shiny::shinyApp(enableBookmarking = \"server\")`"
)
)
}

# Exclude works with bookmark names
excluded_names <- session$getBookmarkExclude()
Expand All @@ -115,6 +109,11 @@ chat_enable_bookmarking <- function(
state$values[[id]] <- client_state
})

cancel_set_ui <- shiny::observe({
client_set_ui(client, id = id)
cancel_set_ui$destroy()
})

# Restore
cancel_on_restore_client <-
session$onRestore(function(state) {
Expand All @@ -123,11 +122,14 @@ chat_enable_bookmarking <- function(
return()
}

cancel_set_ui$destroy()
client_set_state(client, client_state)

# Set the UI
chat_clear(id)
client_set_ui(client, id = id)
shiny::withReactiveDomain(session, {
chat_clear(id)
Copy link
Collaborator

@cpsievert cpsievert Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this also clear starting messages (i.e., chat_ui(messages = list(...)))?

Copy link
Collaborator Author

@gadenbuie gadenbuie Jul 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point and yeah, that's true. I've updated the docs of chat_mod_ui() to clarify that messages only applies when the client doesn't have any history. And left a similar note in the chat_restore() docs.

client_set_ui(client, id = id)
})
})

# Update URL
Expand All @@ -151,13 +153,15 @@ chat_enable_bookmarking <- function(
cancel_update_bookmark <- NULL
if (bookmark_on_input || bookmark_on_response) {
cancel_update_bookmark <-
# Update the query string when bookmarked
shiny::onBookmarked(function(url) {
shiny::updateQueryString(url)
shiny::withReactiveDomain(session$rootScope(), {
# Update the query string when bookmarked
shiny::onBookmarked(function(url) {
shiny::updateQueryString(url)
})
})
}

# Set callbacks to cancel if `chat_enable_bookmarking(id, client)` is called again with the same id
# Set callbacks to cancel if `chat_restore(id, client)` is called again with the same id
# Only allow for bookmarks for each chat once. Last bookmark method would win if all values were to be computed.
# Remove previous `on*()` methods under same hash (.. odd author behavior)
previous_info <- get_session_chat_bookmark_info(session, id)
Expand All @@ -169,7 +173,7 @@ chat_enable_bookmarking <- function(
}
}

# Store callbacks to cancel in case a new call to `chat_enable_bookmarking(id, client)` is called with the same id
# Store callbacks to cancel in case a new call to `chat_restore(id, client)` is called with the same id
set_session_chat_bookmark_info(
session,
id,
Expand Down Expand Up @@ -205,7 +209,7 @@ chat_update_bookmark <- function(
prom <-
promises::then(stream_promise, function(stream) {
# Force a bookmark update when the stream ends!
session$doBookmark()
shiny::isolate(session$doBookmark())
})

return(prom)
Expand Down
16 changes: 6 additions & 10 deletions pkg-r/R/client_state.R
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ method(client_set_state, S7::new_S3_class(c("Chat", "R6"))) <-

replayed_turns <- lapply(
recorded_turns,
ellmer::contents_replay
ellmer::contents_replay,
tools = client$get_tools()
)

client$set_turns(replayed_turns)
Expand All @@ -84,15 +85,10 @@ method(client_set_ui, S7::new_S3_class(c("Chat", "R6"))) <-
function(client, ..., id) {
# TODO-future: Disable bookmarking when restoring. Leverage `tryCatch(finally={})`
# TODO-barret-future; In shinychat, make this a single/internal custom message call to send all the messages at once (and then scroll)
lapply(client$get_turns(), function(turn) {
chat_append(
id,
# Use `contents_markdown()` as it handles image serialization
# TODO: Use `contents_shinychat()` from posit-dev/shinychat#52
ellmer::contents_markdown(turn),
# turn_info$contents,
role = turn@role
)

msgs <- contents_shinychat(client)
lapply(msgs, function(x) {
chat_append(id, x$content, role = x$role)
})
}

Expand Down
14 changes: 14 additions & 0 deletions pkg-r/R/contents_shinychat.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
contents_shinychat <- function(client) {
# TODO(garrick): placeholder, will be replaces with S7 generic
check_ellmer_chat(client)

client_msgs <- map(client$get_turns(), function(turn) {
content <- ellmer::contents_markdown(turn)
if (is.null(content) || identical(content, "")) {
return(NULL)
}
list(role = turn@role, content = content)
})

compact(client_msgs)
}
5 changes: 3 additions & 2 deletions pkg-r/R/shinychat-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
"_PACKAGE"

## usethis namespace: start
#' @importFrom coro async
#' @importFrom htmltools tag css HTML
#' @import rlang
#' @import S7
#' @importFrom coro async
#' @importFrom htmltools tag css HTML
#' @importFrom lifecycle deprecated
## usethis namespace: end
NULL

Expand Down
31 changes: 24 additions & 7 deletions pkg-r/man/chat_app.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading