diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index c1f84d38f..c0228fc0f 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -15,6 +15,7 @@ export(chat_greeting) export(chat_mod_server) export(chat_mod_ui) export(chat_restore) +export(chat_server) export(chat_set_greeting) export(chat_ui) export(contents_shinychat) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 30901c7a9..93369a816 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -2,23 +2,25 @@ ## New features and improvements -* Added file attachment support: users can upload images, PDFs, and text files alongside chat messages via a file picker button, drag-and-drop, or clipboard paste. `chat_mod_ui()`/`chat_mod_server()` enable attachments by default and automatically convert uploads into ellmer `Content` objects for the model. For `chat_ui()`, enable with `allow_attachments = TRUE` (or a MIME allow-list) and splice `input$_user_input` into chat methods with `!!!`. The maximum combined attachment size defaults to approximately 30 MB and can be configured via the `SHINYCHAT_MAX_ATTACHMENT_SIZE` environment variable. +* Added `chat_server()` as the new primary way to wire up server-side chat logic. It does the same job as `chat_mod_server()` but runs directly in the caller's session scope rather than creating its own module scope. If you're already inside a `moduleServer()`, pass that session in — no extra nesting, no doubled namespaces. `chat_mod_server()` and `chat_mod_ui()` are now soft-deprecated in favor of `chat_server()` and `chat_ui()`. (#264) + +* Added file attachment support: users can upload images, PDFs, and text files alongside chat messages via a file picker button, drag-and-drop, or clipboard paste. `chat_server()` enables attachments by default and automatically convert uploads into ellmer `Content` objects for the model. For non-`chat_server()` usage, enable with `allow_attachments = TRUE` (or a MIME allow-list) and splice `input$_user_input` into chat methods with `!!!`. The maximum combined attachment size defaults to approximately 30 MB and can be configured via the `SHINYCHAT_MAX_ATTACHMENT_SIZE` environment variable. * Added slash commands: a typeahead command palette that lets users trigger named shortcuts directly from the chat input. Type `/` to open the palette, filter by typing, and pick a command with arrow keys or click. Commands can expand into LLM prompts, trigger server-side side effects (clear chat, open a modal, export transcript), or be handled entirely client-side via the cancelable `shiny:chat-slash-command` DOM event. Register commands with `chat$slash_command()`, which accepts 0- or 1-argument handlers; 1-argument handlers receive a `ContentSlashCommand` object (a `ContentText` subclass with `command` and `user_text` slots) so handlers can mutate `content@text` before passing it to `client$stream()`. The `echo` parameter controls whether an invocation is recorded as a user message and triggers a loading state. Echoed commands are faithfully restored on bookmark/restore. (#239) -* Added `submit_key` parameter to `chat_ui()` and `chat_mod_ui()`: `"enter"` (default, Enter submits) or `"enter+modifier"` (Ctrl/Cmd+Enter submits, plain Enter inserts a line break). The input remains editable while a response is streaming — only submission is blocked, not typing. (#251) +* Added `submit_key` parameter to `chat_ui()`: `"enter"` (default, Enter submits) or `"enter+modifier"` (Ctrl/Cmd+Enter submits, plain Enter inserts a line break). The input remains editable while a response is streaming — only submission is blocked, not typing. (#251) ## Breaking changes * `input$_user_input` now depends on `allow_attachments`. With `allow_attachments = FALSE`, it remains the historical typed string. With attachments enabled (`TRUE` or a MIME allow-list), it is always a list of ellmer `Content` objects (typed text, if present, followed by one object per attachment), and the separate `input$_user_attachments` input has been removed. Forward either form to a chat method by splicing with `!!!`, e.g. `chat$stream_async(!!!input$_user_input)`. -* The `last_input` reactive returned by `chat_mod_server()` now mirrors the shape of `input$_user_input`: a string when attachments are disabled, and a list of ellmer `Content` objects when enabled. +* The `last_input` reactive returned by `chat_server()` now mirrors the shape of `input$_user_input`: a string when attachments are disabled, and a list of ellmer `Content` objects when enabled. ## Bug fixes * `chat_app()` no longer renders a close button or registers a `stopApp()` observer when deployed to a server. Both are now gated on `rlang::is_interactive()`, preventing session crashes in multi-user deployments. (#265) -* The `dismissible` parameter of `chat_greeting()` has been renamed to `persistent` with an inverted value. `dismissible = FALSE` (greeting stays visible) is now `persistent = TRUE`. The old `dismissible` argument still works but warns. (#260) +* The `dismissible` parameter of `chat_greeting()` has been renamed to `persistent` with an inverted value. `dismissible = FALSE` (greeting stays visible) is now `persistent = TRUE`. The old `dismissible` argument still works but warns. When both `persistent` and `dismissible` are provided, `persistent` now takes precedence silently rather than erroring. (#260) * Fixed suggestion cards and the greeting overflowing the chat container in narrow spaces such as sidebars. (#255) diff --git a/pkg-r/R/attachments.R b/pkg-r/R/attachments.R index b97276967..f55541772 100644 --- a/pkg-r/R/attachments.R +++ b/pkg-r/R/attachments.R @@ -97,13 +97,16 @@ validate_attachment_payload_size <- function( } # Resolve `allow_attachments` into the allow/accept attribute pair. -# `allow` is NA (bare attribute) or NULL (omit); `accept` is a CSV or NULL. +# `allow` is NA (bare), "false" (explicit disable), or NULL (omit/defer); `accept` is a CSV or NULL. resolve_attachment_attrs <- function(allow_attachments) { + if (is.null(allow_attachments)) { + return(list(allow = NULL, accept = NULL)) + } if (isTRUE(allow_attachments)) { return(list(allow = NA, accept = NULL)) } if (isFALSE(allow_attachments)) { - return(list(allow = NULL, accept = NULL)) + return(list(allow = "false", accept = NULL)) } if (is.character(allow_attachments)) { invalid <- setdiff(allow_attachments, attachment_types()$supported) @@ -115,9 +118,10 @@ resolve_attachment_attrs <- function(allow_attachments) { ) ) } - # An empty vector means "no types accepted" -> treat as disabled. + # An empty vector means "no types accepted" -> explicitly disabled so the + # server's update_upload doesn't re-enable it. if (length(allow_attachments) == 0) { - return(list(allow = NULL, accept = NULL)) + return(list(allow = "false", accept = NULL)) } return(list(allow = NA, accept = paste(allow_attachments, collapse = ","))) } diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index b587cc062..fa7f0a884 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -156,8 +156,8 @@ chat_greeting <- function( #' **`greeting_dismissed` input.** When the user dismisses the greeting, #' `input$_greeting_dismissed` fires with a `Date.now()` timestamp. If the #' greeting is later cleared after being dismissed, the input resets to `NULL`. -#' If you use [chat_mod_server()], you can access the `greeting_dismissed` -#' reactive from the returned module value instead of the raw namespaced input +#' If you use [chat_server()], you can access the `greeting_dismissed` +#' reactive from the returned value instead of the raw namespaced input #' string. #' #' @param id The ID of the chat element @@ -190,22 +190,25 @@ chat_greeting <- function( #' @param icon_assistant The icon to use for the assistant chat messages. #' Can be HTML or a tag in the form of [htmltools::HTML()] or #' [htmltools::tags()]. If `None`, a default robot icon is used. -#' @param enable_cancel If `TRUE`, show a stop button during streaming that +#' @param enable_cancel Whether to show a stop button during streaming that #' allows the user to cancel the in-progress response. When using -#' [chat_mod_server()], cancellation is wired up automatically. For manual -#' usage with `chat_ui()`, observe `input$_cancel` to handle cancellation -#' (e.g., by calling `ctrl$cancel()` on an ellmer `stream_controller()`). -#' Defaults to `FALSE`. +#' [chat_server()], cancellation is wired up automatically and this defaults +#' to `NULL` (let the server decide). For manual usage without +#' [chat_server()], set `TRUE` or `FALSE` explicitly and observe +#' `input$_cancel` to handle cancellation (e.g., by calling +#' `ctrl$cancel()` on an ellmer `stream_controller()`). #' @param submit_key Controls which key combination submits the chat message. #' `"enter"` (the default): Enter submits, Shift+Enter adds a newline. #' `"enter+modifier"`: Ctrl+Enter (Cmd+Enter on Mac) submits, plain Enter #' adds a newline. #' @param allow_attachments Controls the file-attachment affordance (an attach -#' button, plus clipboard paste and drag-and-drop) in the chat input. Pass -#' `TRUE` to accept all supported types (PNG, JPEG, GIF, WebP, PDF, and common -#' text/code files such as Markdown, plain text, CSV, JSON, and source files), -#' `FALSE` to disable, or a character vector of MIME types to -#' restrict what is accepted (each must be one of the supported types). +#' button, plus clipboard paste and drag-and-drop) in the chat input. +#' `NULL` (default) defers to [chat_server()], which enables attachments +#' automatically. Pass `TRUE` to accept all supported types (PNG, JPEG, GIF, +#' WebP, PDF, and common text/code files such as Markdown, plain text, CSV, +#' JSON, and source files), `FALSE` to disable, or a character vector of MIME +#' types to restrict what is accepted (each must be one of the supported +#' types). #' #' The shape of `input$_user_input` is determined by this argument, so it #' is predictable for a given app. When attachments are disabled (the @@ -305,9 +308,9 @@ chat_ui <- function( height = "auto", fill = TRUE, icon_assistant = NULL, - enable_cancel = FALSE, + enable_cancel = NULL, submit_key = c("enter", "enter+modifier"), - allow_attachments = FALSE, + allow_attachments = NULL, footer = NULL ) { submit_key <- rlang::arg_match(submit_key) @@ -425,7 +428,13 @@ chat_ui <- function( ), placeholder = placeholder, fill = if (isTRUE(fill)) NA else NULL, - `enable-cancel` = if (isTRUE(enable_cancel)) NA else NULL, + `enable-cancel` = if (isTRUE(enable_cancel)) { + NA + } else if (isFALSE(enable_cancel)) { + "false" + } else { + NULL + }, `submit-key` = if (submit_key != "enter") submit_key, `allow-attachments` = attachment_attrs$allow, `attachment-accept` = attachment_attrs$accept, @@ -1095,7 +1104,7 @@ chat_set_greeting <- function( cli::cli_abort(c( "{.fn chat_set_greeting} does not accept a function as greeting content.", "i" = "Pass the {.emph result} of calling your function, not the function itself.", - "i" = "To use a greeting function with automatic lifecycle management, pass it to the {.arg greeting} argument of {.fn chat_mod_server}." + "i" = "To use a greeting function with automatic lifecycle management, pass it to the {.arg greeting} argument of {.fn chat_server}." )) } diff --git a/pkg-r/R/chat_app.R b/pkg-r/R/chat_app.R index ef8b4ddec..3cc250639 100644 --- a/pkg-r/R/chat_app.R +++ b/pkg-r/R/chat_app.R @@ -6,9 +6,8 @@ #' you chat because your turns will be appended to the history. #' #' The app created by `chat_app()` is suitable for interactive use by a single -#' user. For multi-user Shiny apps, use the Shiny module chat functions -- -#' `chat_mod_ui()` and `chat_mod_server()` -- and be sure to create a new chat -#' client for each user session. +#' user. For multi-user Shiny apps, use [chat_ui()] and `chat_server()` and be +#' sure to create a new chat client for each user session. #' #' @examples #' \dontrun{ @@ -27,7 +26,7 @@ #' layout_columns( #' card( #' card_header("Chat with Claude"), -#' chat_mod_ui( +#' chat_ui( #' "claude", #' messages = list( #' "Hi! Use this chat interface to chat with Anthropic's `claude-3-5-sonnet`." @@ -36,7 +35,7 @@ #' ), #' card( #' card_header("Chat with ChatGPT"), -#' chat_mod_ui( +#' chat_ui( #' "openai", #' messages = list( #' "Hi! Use this chat interface to chat with OpenAI's `gpt-4o`." @@ -50,20 +49,16 @@ #' claude <- ellmer::chat_anthropic(model = "claude-3-5-sonnet-latest") # Requires ANTHROPIC_API_KEY #' openai <- ellmer::chat_openai(model = "gpt-4o") # Requires OPENAI_API_KEY #' -#' chat_mod_server("claude", claude) -#' chat_mod_server("openai", openai) +#' chat_server("claude", claude) +#' chat_server("openai", openai) #' } #' #' shinyApp(ui, server) #' } #' #' @param client A chat object created by \pkg{ellmer}, e.g. -#' [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()]. +#' [ellmer::chat_openai()] and friends. +#' @param ... Additional arguments passed to [shiny::shinyApp()]. #' @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 @@ -72,8 +67,7 @@ #' #' @returns #' * `chat_app()` returns a [shiny::shinyApp()] object. -#' * `chat_mod_ui()` returns the UI for a shinychat module. -#' * `chat_mod_server()` includes the shinychat module server logic, and +#' * `chat_server()` includes the shinychat server logic, and #' returns an environment containing: #' #' * `last_input`: A reactive value containing the last user input (a string @@ -144,9 +138,10 @@ chat_app <- function( ui <- function(req) { bslib::page_fillable( - chat_mod_ui( + chat_ui( "chat", height = "100%", + enable_cancel = TRUE, allow_attachments = allow_attachments ), if (rlang::is_interactive()) { @@ -167,7 +162,7 @@ chat_app <- function( shiny::stopApp() }) } - chat_mod_server("chat", client) + chat_server("chat", client) } shiny::shinyApp(ui, server, ..., enableBookmarking = bookmark_store) @@ -179,12 +174,25 @@ check_ellmer_chat <- function(client) { } } -#' @describeIn chat_app A simple chat app module UI. +#' Deprecated chat module functions +#' +#' @description +#' `r lifecycle::badge("deprecated")` +#' +#' These functions are deprecated as of shinychat 0.5.0. +#' Use [chat_ui()] with `NS(id, "chat")` and [chat_server()] instead. +#' #' @param id The chat module ID. +#' @param client Deprecated. The client state is now managed by [chat_server()]. #' @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()]. +#' doesn't already contain turns. Passed to `messages` in [chat_ui()]. +#' @param greeting See [chat_server()]. +#' @param bookmark_on_input See [chat_server()]. +#' @param bookmark_on_response See [chat_server()]. #' @inheritParams chat_ui +#' @returns +#' * `chat_mod_ui()` returns the UI for a shinychat module. +#' * `chat_mod_server()` returns the value of [chat_server()]. #' @export chat_mod_ui <- function( id, @@ -193,11 +201,17 @@ chat_mod_ui <- function( messages = NULL, allow_attachments = TRUE ) { + lifecycle::deprecate_soft( + "0.5.0", + "chat_mod_ui()", + details = "Use `chat_ui(NS(id, \"chat\"), ...)` in your module UI instead." + ) + if (lifecycle::is_present(client)) { lifecycle::deprecate_warn( "0.3.0", "chat_mod_ui(client = )", - "chat_mod_server(client = )" + "chat_server(client = )" ) } @@ -211,8 +225,9 @@ chat_mod_ui <- function( ) } -#' @describeIn chat_app A simple chat app module server. +#' @describeIn chat_app Wire up batteries-included chat server logic in a Shiny session. #' @inheritParams chat_restore +#' @param session The Shiny session. Defaults to the current reactive domain. #' @param greeting Optional greeting to set when the module initializes. #' Accepts a static value (string, [htmltools::HTML()], [htmltools::tagList()], #' or [chat_greeting()]) or a **function** that generates the greeting @@ -220,22 +235,21 @@ chat_mod_ui <- function( #' #' @section Greeting: #' -#' When `greeting` is a **function**, the module calls it each time the +#' When `greeting` is a **function**, it is called each time the #' `greeting_requested` event fires — on first view when the chat is empty, #' and again after `clear(greeting = TRUE)`. The function should return a #' [chat_greeting()] (typically wrapping a stream). Static values (strings, #' [chat_greeting()] objects) are set once at init and do not regenerate. #' -#' The module detects **named arguments** in the greeting function to decide -#' what to pass. Currently the only recognized argument is `client`. +#' The function signature determines what is passed. Currently the only +#' recognized argument is `client`. #' -#' **`function(client)`** (recommended). The module clones the `client` -#' passed to `chat_mod_server()`, wipes its turn history, and passes the -#' fresh clone as `client`. This avoids manually creating and configuring -#' a separate client: +#' **`function(client)`** (recommended). A clone of the `client` with its turn +#' history wiped is passed as `client`. This avoids manually creating and +#' configuring a separate client: #' #' ```r -#' chat_mod_server("chat", client, greeting = function(client) { +#' chat_server("chat", client, greeting = function(client) { #' stream <- client$stream_async("Generate a short welcome message.") #' chat_greeting(stream) #' }) @@ -244,7 +258,7 @@ chat_mod_ui <- function( #' **`function()`** (zero arguments). You create and manage your own client: #' #' ```r -#' chat_mod_server("chat", client, greeting = function() { +#' chat_server("chat", client, greeting = function() { #' greeter <- ellmer::chat_openai(model = "gpt-4o") #' stream <- greeter$stream_async("Generate a short welcome message.") #' chat_greeting(stream) @@ -254,7 +268,7 @@ chat_mod_ui <- function( #' **Static value.** Set once; does not regenerate after `clear()`: #' #' ```r -#' chat_mod_server("chat", client, greeting = "## Welcome!\n\nHow can I help?") +#' chat_server("chat", client, greeting = "## Welcome!\n\nHow can I help?") #' ``` #' #' The returned `set_greeting()` helper is available for cases where you need @@ -262,12 +276,13 @@ chat_mod_ui <- function( #' #' @importFrom shiny isolate #' @export -chat_mod_server <- function( +chat_server <- function( id, client, greeting = NULL, bookmark_on_input = TRUE, - bookmark_on_response = TRUE + bookmark_on_response = TRUE, + session = shiny::getDefaultReactiveDomain() ) { check_ellmer_chat(client) @@ -297,323 +312,364 @@ chat_mod_server <- function( } ) - shiny::moduleServer(id, function(input, output, session) { - cancel_bookmarks <- chat_restore( - "chat", + cancel_bookmarks <- chat_restore( + id, + client, + session = session, + bookmark_on_input = bookmark_on_input, + bookmark_on_response = bookmark_on_response + ) + + last_turn <- shiny::reactiveVal(NULL, label = "last_turn") + last_input <- shiny::reactiveVal(NULL, label = "last_input") + pending_swap <- shiny::reactiveVal(NULL, label = "pending_swap") + ctrl <- ellmer::stream_controller() + + swap_client <- function(new_client, sync) { + if (sync) { + new_client$set_turns(client$get_turns()) + new_client$set_system_prompt(client$get_system_prompt()) + new_client$set_tools(client$get_tools()) + } + client <<- new_client + cancel_bookmarks() + cancel_bookmarks <<- chat_restore( + id, client, session = session, bookmark_on_input = bookmark_on_input, - bookmark_on_response = bookmark_on_response + bookmark_on_response = bookmark_on_response, + restore_ui = FALSE ) + invisible() + } - last_turn <- shiny::reactiveVal(NULL, label = "last_turn") - last_input <- shiny::reactiveVal(NULL, label = "last_input") - pending_swap <- shiny::reactiveVal(NULL, label = "pending_swap") - ctrl <- ellmer::stream_controller() + set_client <- function(new_client, sync = TRUE) { + check_ellmer_chat(new_client) - swap_client <- function(new_client, sync) { - if (sync) { - new_client$set_turns(client$get_turns()) - new_client$set_system_prompt(client$get_system_prompt()) - new_client$set_tools(client$get_tools()) - } - client <<- new_client - cancel_bookmarks() - cancel_bookmarks <<- chat_restore( - "chat", - client, - session = session, - bookmark_on_input = bookmark_on_input, - bookmark_on_response = bookmark_on_response, - restore_ui = FALSE - ) - invisible() + if (append_stream_task$status() == "running") { + pending_swap(list(client = new_client, sync = sync)) + return(invisible()) } - set_client <- function(new_client, sync = TRUE) { - check_ellmer_chat(new_client) - - if (append_stream_task$status() == "running") { - pending_swap(list(client = new_client, sync = sync)) - return(invisible()) - } - - swap_client(new_client, sync) - } + swap_client(new_client, sync) + } - shiny::observeEvent(input$chat_user_input, label = "on_chat_user_input", { - last_input(input$chat_user_input) + shiny::observeEvent( + session$input[[paste0(id, "_user_input")]], + label = "on_chat_user_input", + { + last_input(session$input[[paste0(id, "_user_input")]]) append_stream_task$invoke( client, - "chat", - input$chat_user_input, + id, + session$input[[paste0(id, "_user_input")]], controller = ctrl ) - }) + } + ) - shiny::observeEvent(input$chat_cancel, label = "on_chat_cancel", { + shiny::observeEvent( + session$input[[paste0(id, "_cancel")]], + label = "on_chat_cancel", + { ctrl$cancel() - }) - - shiny::observe(label = "on_stream_complete", { - status <- append_stream_task$status() - swap <- pending_swap() - - if (status == "success") { - last_turn(client$last_turn()) - } + } + ) - if (!is.null(swap) && status != "running") { - pending_swap(NULL) - swap_client(swap$client, swap$sync) - } - }) + shiny::observe(label = "on_stream_complete", { + status <- append_stream_task$status() + swap <- pending_swap() - chat_update_user_input <- function( - value = NULL, - ..., - placeholder = NULL, - submit = FALSE, - focus = FALSE, - attachments = NULL, - attachment_mode = c("append", "set") - ) { - update_chat_user_input( - "chat", - value = value, - placeholder = placeholder, - submit = submit, - focus = focus, - attachments = attachments, - attachment_mode = attachment_mode, - ..., - session = session - ) + if (status == "success") { + last_turn(client$last_turn()) } - chat_append_mod <- function(response, role = "assistant", icon = NULL) { - chat_append("chat", response, role = role, icon = icon, session = session) + if (!is.null(swap) && status != "running") { + pending_swap(NULL) + swap_client(swap$client, swap$sync) } + }) - set_greeting_mod <- function(greeting) { - greeting_stream_task$invoke("chat", greeting, session) - } + chat_update_user_input <- function( + value = NULL, + ..., + placeholder = NULL, + submit = FALSE, + focus = FALSE, + attachments = NULL, + attachment_mode = c("append", "set") + ) { + update_chat_user_input( + id, + value = value, + placeholder = placeholder, + submit = submit, + focus = focus, + attachments = attachments, + attachment_mode = attachment_mode, + ..., + session = session + ) + } - if (is.function(greeting)) { - greeting_fmls <- names(formals(greeting)) - - shiny::observeEvent( - input$chat_greeting_requested, - label = "on_greeting_requested", - { - args <- list() - if ("client" %in% greeting_fmls) { - greeter <- client$clone() - greeter$set_turns(list()) - args$client <- greeter - } - greeting_stream_task$invoke( - "chat", - do.call(greeting, args), - session - ) - } - ) - } else if (!is.null(greeting)) { - set_greeting_mod(greeting) - } + chat_append_mod <- function(response, role = "assistant", icon = NULL) { + chat_append(id, response, role = role, icon = icon, session = session) + } + + set_greeting_mod <- function(greeting) { + greeting_stream_task$invoke(id, greeting, session) + } - # Registered slash commands. Each entry: list(handler, takes_args, definition). - # Using a reactiveVal lets multiple registrations during app startup coalesce - # into a single client sync on the next flush. Starts as NULL so the sync - # observer skips the redundant initial send (the client already initializes - # to []); an empty list is sent when the last command is removed. - slash_commands <- shiny::reactiveVal(NULL, label = "slash_commands") + if (is.function(greeting)) { + greeting_fmls <- names(formals(greeting)) shiny::observeEvent( - input$chat_slash_command, - label = "on_chat_slash_command", + session$input[[paste0(id, "_greeting_requested")]], + label = "on_greeting_requested", { - data <- input$chat_slash_command - reg <- isolate(slash_commands())[[data$command]] - if (!is.null(reg) && is.function(reg$handler)) { - tryCatch( - { - if (isTRUE(reg$takes_args)) { - user_text <- data$userText %||% "" - content <- ContentSlashCommand( - command = data$command, - user_text = user_text, - text = paste0( - sprintf( - "The user entered the /%s slash command", - data$command - ), - if (nzchar(user_text)) { - paste0(" with arguments: ", user_text) - } else { - "." - } - ) - ) - reg$handler(content) - } else { - reg$handler() - } - }, - error = function(e) { - shiny::showNotification( - sanitized_error_message(e), - type = "error", - duration = NULL - ) - rlang::warn( - sprintf("Error in slash command '/%s'", data$command), - parent = e - ) - } - ) + args <- list() + if ("client" %in% greeting_fmls) { + greeter <- client$clone() + greeter$set_turns(list()) + args$client <- greeter } - send_chat_action( - "chat", - list(type = "remove_loading"), - session = session + greeting_stream_task$invoke( + id, + do.call(greeting, args), + session ) } ) + } else if (!is.null(greeting)) { + set_greeting_mod(greeting) + } - shiny::observe(label = "sync_slash_commands", { - cmds <- slash_commands() - if (!is.null(cmds)) { - defs <- lapply(cmds, `[[`, "definition") - send_chat_action( - "chat", - list(type = "update_slash_commands", commands = unname(defs)), - session = session - ) - } - }) + send_chat_action( + id, + list(type = "update_cancel", enable_cancel = TRUE), + session = session + ) + send_chat_action( + id, + list(type = "update_upload", enable_upload = TRUE), + session = session + ) - # TODO: Support a non-module version (e.g., standalone register_slash_command()) - slash_command_method <- function( - name, - description, - handler, - ..., - echo = NULL, - force = FALSE - ) { - rlang::check_dots_empty() - if (!is.character(name) || length(name) != 1) { - cli::cli_abort("{.arg name} must be a single string.") - } - if (!grepl("^[a-zA-Z0-9_-]+$", name)) { - cli::cli_abort( - "{.arg name} must contain only alphanumeric characters, underscores, or hyphens, got {.val {name}}." + # Registered slash commands. Each entry: list(handler, takes_args, definition). + # Using a reactiveVal lets multiple registrations during app startup coalesce + # into a single client sync on the next flush. Starts as NULL so the sync + # observer skips the redundant initial send (the client already initializes + # to []); an empty list is sent when the last command is removed. + slash_commands <- shiny::reactiveVal(NULL, label = "slash_commands") + + shiny::observeEvent( + session$input[[paste0(id, "_slash_command")]], + label = "on_chat_slash_command", + { + data <- session$input[[paste0(id, "_slash_command")]] + reg <- isolate(slash_commands())[[data$command]] + if (!is.null(reg) && is.function(reg$handler)) { + tryCatch( + { + if (isTRUE(reg$takes_args)) { + user_text <- data$userText %||% "" + content <- ContentSlashCommand( + command = data$command, + user_text = user_text, + text = paste0( + sprintf( + "The user entered the /%s slash command", + data$command + ), + if (nzchar(user_text)) { + paste0(" with arguments: ", user_text) + } else { + "." + } + ) + ) + reg$handler(content) + } else { + reg$handler() + } + }, + error = function(e) { + shiny::showNotification( + sanitized_error_message(e), + type = "error", + duration = NULL + ) + rlang::warn( + sprintf("Error in slash command '/%s'", data$command), + parent = e + ) + } ) } - if (!is.character(description) || length(description) != 1) { - cli::cli_abort("{.arg description} must be a single string.") - } - if (!is.null(handler) && !is.function(handler)) { - cli::cli_abort("{.arg handler} must be a function or {.code NULL}.") - } + send_chat_action( + id, + list(type = "remove_loading"), + session = session + ) + } + ) - takes_args <- FALSE - if (is.function(handler)) { - handler_args <- names(formals(handler)) - if (length(handler_args) > 1 || identical(handler_args, "...")) { - cli::cli_abort("{.arg handler} must take 0 or 1 argument.") - } - takes_args <- length(handler_args) > 0 - } + shiny::observe(label = "sync_slash_commands", { + cmds <- slash_commands() + if (!is.null(cmds)) { + defs <- lapply(cmds, `[[`, "definition") + send_chat_action( + id, + list(type = "update_slash_commands", commands = unname(defs)), + session = session + ) + } + }) - cmds <- isolate(slash_commands()) %||% list() + # TODO: Support a standalone register_slash_command() that works outside the + # returned environment (e.g., so callers don't have to thread the return value) + slash_command_method <- function( + name, + description, + handler, + ..., + echo = NULL, + force = FALSE + ) { + rlang::check_dots_empty() + if (!is.character(name) || length(name) != 1) { + cli::cli_abort("{.arg name} must be a single string.") + } + if (!grepl("^[a-zA-Z0-9_-]+$", name)) { + cli::cli_abort( + "{.arg name} must contain only alphanumeric characters, underscores, or hyphens, got {.val {name}}." + ) + } + if (!is.character(description) || length(description) != 1) { + cli::cli_abort("{.arg description} must be a single string.") + } + if (!is.null(handler) && !is.function(handler)) { + cli::cli_abort("{.arg handler} must be a function or {.code NULL}.") + } - if (!force && name %in% names(cmds)) { - cli::cli_abort( - "Slash command {.val {name}} is already registered. Use {.code force = TRUE} to overwrite it." - ) + takes_args <- FALSE + if (is.function(handler)) { + handler_args <- names(formals(handler)) + if (length(handler_args) > 1 || identical(handler_args, "...")) { + cli::cli_abort("{.arg handler} must take 0 or 1 argument.") } + takes_args <- length(handler_args) > 0 + } - resolved_echo <- if (is.null(echo)) !is.null(handler) else isTRUE(echo) + cmds <- isolate(slash_commands()) %||% list() - cmds[[name]] <- list( - handler = handler, - takes_args = takes_args, - definition = list( - name = name, - description = description, - echo = resolved_echo - ) + if (!force && name %in% names(cmds)) { + cli::cli_abort( + "Slash command {.val {name}} is already registered. Use {.code force = TRUE} to overwrite it." ) - slash_commands(cmds) + } - function() { - cmds <- isolate(slash_commands()) - cmds[[name]] <- NULL - slash_commands(cmds) - } + resolved_echo <- if (is.null(echo)) !is.null(handler) else isTRUE(echo) + + cmds[[name]] <- list( + handler = handler, + takes_args = takes_args, + definition = list( + name = name, + description = description, + echo = resolved_echo + ) + ) + slash_commands(cmds) + + function() { + cmds <- isolate(slash_commands()) + cmds[[name]] <- NULL + slash_commands(cmds) } + } - client_clear <- function( - messages = NULL, - greeting = FALSE, - client_history = c("clear", "set", "append", "keep") - ) { - client_history <- arg_match(client_history) - - if (!is.null(messages)) { - if (rlang::is_string(messages)) { - # Promote strings to single assistant message - messages <- list(list(role = "assistant", content = messages)) - } - if (!rlang::is_list(messages)) { - cli::cli_abort( - "{.var messages} must be a list of messages, and each message must be a list with {.field role} and {.field content}." - ) - } - if (length(intersect(c("role", "content"), names(messages))) == 2) { - # Catch the single-message case and promote it to a list of messages - messages <- list(messages) - } + client_clear <- function( + messages = NULL, + greeting = FALSE, + client_history = c("clear", "set", "append", "keep") + ) { + client_history <- arg_match(client_history) + + if (!is.null(messages)) { + if (rlang::is_string(messages)) { + # Promote strings to single assistant message + messages <- list(list(role = "assistant", content = messages)) } - - chat_clear("chat", greeting = greeting, session = session) - if (!is.null(messages)) { - for (msg in messages) { - chat_append("chat", msg$content, role = msg$role, session = session) - } + if (!rlang::is_list(messages)) { + cli::cli_abort( + "{.var messages} must be a list of messages, and each message must be a list with {.field role} and {.field content}." + ) + } + if (length(intersect(c("role", "content"), names(messages))) == 2) { + # Catch the single-message case and promote it to a list of messages + messages <- list(messages) } + } - if (client_history == "clear") { - client$set_turns(list()) - } else if (client_history == "set") { - client$set_turns(as_ellmer_turns(messages)) - } else if (client_history == "append") { - turns <- client$get_turns() - turns <- c(turns, as_ellmer_turns(messages)) - client$set_turns(turns) + chat_clear(id, greeting = greeting, session = session) + if (!is.null(messages)) { + for (msg in messages) { + chat_append(id, msg$content, role = msg$role, session = session) } + } - last_turn(NULL) - last_input(NULL) + if (client_history == "clear") { + client$set_turns(list()) + } else if (client_history == "set") { + client$set_turns(as_ellmer_turns(messages)) + } else if (client_history == "append") { + turns <- client$get_turns() + turns <- c(turns, as_ellmer_turns(messages)) + client$set_turns(turns) } - ret <- new.env(parent = emptyenv()) - ret$last_turn <- shiny::reactive(last_turn(), label = "mod_last_turn") - ret$last_input <- shiny::reactive(last_input(), label = "mod_last_input") - ret$status <- shiny::reactive(label = "mod_status", { - if (append_stream_task$status() == "running") "streaming" else "idle" - }) - makeActiveBinding("client", function() client, ret) - ret$append <- chat_append_mod - ret$update_user_input <- chat_update_user_input - ret$clear <- client_clear - ret$set_greeting <- set_greeting_mod - ret$set_client <- set_client - ret$slash_command <- slash_command_method - lockEnvironment(ret) - ret + last_turn(NULL) + last_input(NULL) + } + + ret <- new.env(parent = emptyenv()) + ret$last_turn <- shiny::reactive(last_turn(), label = "mod_last_turn") + ret$last_input <- shiny::reactive(last_input(), label = "mod_last_input") + ret$status <- shiny::reactive(label = "mod_status", { + if (append_stream_task$status() == "running") "streaming" else "idle" + }) + makeActiveBinding("client", function() client, ret) + ret$append <- chat_append_mod + ret$update_user_input <- chat_update_user_input + ret$clear <- client_clear + ret$set_greeting <- set_greeting_mod + ret$set_client <- set_client + ret$slash_command <- slash_command_method + lockEnvironment(ret) + ret +} + +#' @describeIn chat_mod_ui A Shiny module server for chat (deprecated). Use [chat_server()] instead. +#' @export +chat_mod_server <- function( + id, + client, + greeting = NULL, + bookmark_on_input = TRUE, + bookmark_on_response = TRUE +) { + lifecycle::deprecate_soft("0.5.0", "chat_mod_server()", "chat_server()") + check_ellmer_chat(client) + shiny::moduleServer(id, function(input, output, session) { + chat_server( + "chat", + client, + greeting = greeting, + bookmark_on_input = bookmark_on_input, + bookmark_on_response = bookmark_on_response, + session = session + ) }) } diff --git a/pkg-r/R/content-slash-command.R b/pkg-r/R/content-slash-command.R index 3ba6482c2..fc570225c 100644 --- a/pkg-r/R/content-slash-command.R +++ b/pkg-r/R/content-slash-command.R @@ -52,8 +52,8 @@ #' #' @return A `ContentSlashCommand` object. #' -#' @seealso [chat_mod_server()] for registering slash commands via the -#' `slash_command()` method on the returned module object. +#' @seealso [chat_server()] for registering slash commands via the +#' `slash_command()` method on the returned object. #' #' @export ContentSlashCommand <- S7::new_class( diff --git a/pkg-r/man/ContentSlashCommand.Rd b/pkg-r/man/ContentSlashCommand.Rd index 1b52ddb88..0701f8a57 100644 --- a/pkg-r/man/ContentSlashCommand.Rd +++ b/pkg-r/man/ContentSlashCommand.Rd @@ -66,6 +66,6 @@ The LLM sees \code{"Say hello to world"}, but on restore the chat UI shows } \seealso{ -\code{\link[=chat_mod_server]{chat_mod_server()}} for registering slash commands via the -\code{slash_command()} method on the returned module object. +\code{\link[=chat_server]{chat_server()}} for registering slash commands via the +\code{slash_command()} method on the returned object. } diff --git a/pkg-r/man/chat_app.Rd b/pkg-r/man/chat_app.Rd index e14bd5268..530785c55 100644 --- a/pkg-r/man/chat_app.Rd +++ b/pkg-r/man/chat_app.Rd @@ -2,37 +2,25 @@ % Please edit documentation in R/chat_app.R \name{chat_app} \alias{chat_app} -\alias{chat_mod_ui} -\alias{chat_mod_server} +\alias{chat_server} \title{Open a live chat application in the browser} \usage{ chat_app(client, ..., bookmark_store = "url", allow_attachments = TRUE) -chat_mod_ui( - id, - ..., - client = deprecated(), - messages = NULL, - allow_attachments = TRUE -) - -chat_mod_server( +chat_server( id, client, greeting = NULL, bookmark_on_input = TRUE, - bookmark_on_response = TRUE + bookmark_on_response = TRUE, + session = shiny::getDefaultReactiveDomain() ) } \arguments{ \item{client}{A chat object created by \pkg{ellmer}, e.g. -\code{\link[ellmer:chat_openai]{ellmer::chat_openai()}} and friends. This argument is deprecated in -\code{chat_mod_ui()} because the client state is now managed by -\code{chat_mod_server()}.} +\code{\link[ellmer:chat_openai]{ellmer::chat_openai()}} and friends.} -\item{...}{In \code{chat_app()}, additional arguments are passed to -\code{\link[shiny:shinyApp]{shiny::shinyApp()}}. In \code{chat_mod_ui()}, additional arguments are passed to -\code{\link[=chat_ui]{chat_ui()}}.} +\item{...}{Additional arguments passed to \code{\link[shiny:shinyApp]{shiny::shinyApp()}}.} \item{bookmark_store}{The bookmarking store to use for the app. Passed to \code{enable_bookmarking} in \code{\link[shiny:shinyApp]{shiny::shinyApp()}}. Defaults to \code{"url"}, which @@ -41,11 +29,13 @@ size; use \code{"server"} to store the state on the server side without size limitations; or disable bookmarking by setting this to \code{"disable"}.} \item{allow_attachments}{Controls the file-attachment affordance (an attach -button, plus clipboard paste and drag-and-drop) in the chat input. Pass -\code{TRUE} to accept all supported types (PNG, JPEG, GIF, WebP, PDF, and common -text/code files such as Markdown, plain text, CSV, JSON, and source files), -\code{FALSE} to disable, or a character vector of MIME types to -restrict what is accepted (each must be one of the supported types). +button, plus clipboard paste and drag-and-drop) in the chat input. +\code{NULL} (default) defers to \code{\link[=chat_server]{chat_server()}}, which enables attachments +automatically. Pass \code{TRUE} to accept all supported types (PNG, JPEG, GIF, +WebP, PDF, and common text/code files such as Markdown, plain text, CSV, +JSON, and source files), \code{FALSE} to disable, or a character vector of MIME +types to restrict what is accepted (each must be one of the supported +types). The shape of \verb{input$_user_input} is determined by this argument, so it is predictable for a given app. When attachments are disabled (the @@ -62,11 +52,7 @@ controlled globally by the \code{SHINYCHAT_MAX_ATTACHMENT_SIZE} environment variable (a raw byte count; defaults to approximately 30 MB). Files that would push the total over this cap are rejected in the browser with a notice.} -\item{id}{The chat module ID.} - -\item{messages}{Initial messages shown in the chat, used only when \code{client} -(in \code{chat_mod_ui()}) doesn't already contain turns. Passed to \code{messages} -in \code{\link[=chat_ui]{chat_ui()}}.} +\item{id}{The ID of the chat element} \item{greeting}{Optional greeting to set when the module initializes. Accepts a static value (string, \code{\link[htmltools:HTML]{htmltools::HTML()}}, \code{\link[htmltools:tagList]{htmltools::tagList()}}, @@ -76,12 +62,13 @@ dynamically. See the \strong{Greeting} section below for details.} \item{bookmark_on_input}{A logical value determines if the bookmark should be updated when the user submits a message. Default is \code{TRUE}.} \item{bookmark_on_response}{A logical value determines if the bookmark should be updated when the response stream completes. Default is \code{TRUE}.} + +\item{session}{The Shiny session. Defaults to the current reactive domain.} } \value{ \itemize{ \item \code{chat_app()} returns a \code{\link[shiny:shinyApp]{shiny::shinyApp()}} object. -\item \code{chat_mod_ui()} returns the UI for a shinychat module. -\item \code{chat_mod_server()} includes the shinychat module server logic, and +\item \code{chat_server()} includes the shinychat server logic, and returns an environment containing: \itemize{ \item \code{last_input}: A reactive value containing the last user input (a string @@ -145,9 +132,8 @@ Note that these functions will mutate the input \code{client} object as you chat because your turns will be appended to the history. The app created by \code{chat_app()} is suitable for interactive use by a single -user. For multi-user Shiny apps, use the Shiny module chat functions -- -\code{chat_mod_ui()} and \code{chat_mod_server()} -- and be sure to create a new chat -client for each user session. +user. For multi-user Shiny apps, use \code{\link[=chat_ui]{chat_ui()}} and \code{chat_server()} and be +sure to create a new chat client for each user session. } \section{Functions}{ \itemize{ @@ -155,29 +141,26 @@ client for each user session. app is suitable for interactive use by a single user; do not use \code{chat_app()} in a multi-user Shiny app context. -\item \code{chat_mod_ui()}: A simple chat app module UI. - -\item \code{chat_mod_server()}: A simple chat app module server. +\item \code{chat_server()}: Wire up batteries-included chat server logic in a Shiny session. }} \section{Greeting}{ -When \code{greeting} is a \strong{function}, the module calls it each time the +When \code{greeting} is a \strong{function}, it is called each time the \code{greeting_requested} event fires — on first view when the chat is empty, and again after \code{clear(greeting = TRUE)}. The function should return a \code{\link[=chat_greeting]{chat_greeting()}} (typically wrapping a stream). Static values (strings, \code{\link[=chat_greeting]{chat_greeting()}} objects) are set once at init and do not regenerate. -The module detects \strong{named arguments} in the greeting function to decide -what to pass. Currently the only recognized argument is \code{client}. +The function signature determines what is passed. Currently the only +recognized argument is \code{client}. -\strong{\verb{function(client)}} (recommended). The module clones the \code{client} -passed to \code{chat_mod_server()}, wipes its turn history, and passes the -fresh clone as \code{client}. This avoids manually creating and configuring -a separate client: +\strong{\verb{function(client)}} (recommended). A clone of the \code{client} with its turn +history wiped is passed as \code{client}. This avoids manually creating and +configuring a separate client: -\if{html}{\out{
}}\preformatted{chat_mod_server("chat", client, greeting = function(client) \{ +\if{html}{\out{
}}\preformatted{chat_server("chat", client, greeting = function(client) \{ stream <- client$stream_async("Generate a short welcome message.") chat_greeting(stream) \}) @@ -185,7 +168,7 @@ a separate client: \strong{\verb{function()}} (zero arguments). You create and manage your own client: -\if{html}{\out{
}}\preformatted{chat_mod_server("chat", client, greeting = function() \{ +\if{html}{\out{
}}\preformatted{chat_server("chat", client, greeting = function() \{ greeter <- ellmer::chat_openai(model = "gpt-4o") stream <- greeter$stream_async("Generate a short welcome message.") chat_greeting(stream) @@ -194,7 +177,7 @@ a separate client: \strong{Static value.} Set once; does not regenerate after \code{clear()}: -\if{html}{\out{
}}\preformatted{chat_mod_server("chat", client, greeting = "## Welcome!\\n\\nHow can I help?") +\if{html}{\out{
}}\preformatted{chat_server("chat", client, greeting = "## Welcome!\\n\\nHow can I help?") }\if{html}{\out{
}} The returned \code{set_greeting()} helper is available for cases where you need @@ -218,7 +201,7 @@ ui <- page_fillable( layout_columns( card( card_header("Chat with Claude"), - chat_mod_ui( + chat_ui( "claude", messages = list( "Hi! Use this chat interface to chat with Anthropic's `claude-3-5-sonnet`." @@ -227,7 +210,7 @@ ui <- page_fillable( ), card( card_header("Chat with ChatGPT"), - chat_mod_ui( + chat_ui( "openai", messages = list( "Hi! Use this chat interface to chat with OpenAI's `gpt-4o`." @@ -241,8 +224,8 @@ server <- function(input, output, session) { claude <- ellmer::chat_anthropic(model = "claude-3-5-sonnet-latest") # Requires ANTHROPIC_API_KEY openai <- ellmer::chat_openai(model = "gpt-4o") # Requires OPENAI_API_KEY - chat_mod_server("claude", claude) - chat_mod_server("openai", openai) + chat_server("claude", claude) + chat_server("openai", openai) } shinyApp(ui, server) diff --git a/pkg-r/man/chat_mod_ui.Rd b/pkg-r/man/chat_mod_ui.Rd new file mode 100644 index 000000000..15a876bd4 --- /dev/null +++ b/pkg-r/man/chat_mod_ui.Rd @@ -0,0 +1,80 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/chat_app.R +\name{chat_mod_ui} +\alias{chat_mod_ui} +\alias{chat_mod_server} +\title{Deprecated chat module functions} +\usage{ +chat_mod_ui( + id, + ..., + client = deprecated(), + messages = NULL, + allow_attachments = TRUE +) + +chat_mod_server( + id, + client, + greeting = NULL, + bookmark_on_input = TRUE, + bookmark_on_response = TRUE +) +} +\arguments{ +\item{id}{The chat module ID.} + +\item{...}{Extra HTML attributes to include on the chat element} + +\item{client}{Deprecated. The client state is now managed by \code{\link[=chat_server]{chat_server()}}.} + +\item{messages}{Initial messages shown in the chat, used only when \code{client} +doesn't already contain turns. Passed to \code{messages} in \code{\link[=chat_ui]{chat_ui()}}.} + +\item{allow_attachments}{Controls the file-attachment affordance (an attach +button, plus clipboard paste and drag-and-drop) in the chat input. +\code{NULL} (default) defers to \code{\link[=chat_server]{chat_server()}}, which enables attachments +automatically. Pass \code{TRUE} to accept all supported types (PNG, JPEG, GIF, +WebP, PDF, and common text/code files such as Markdown, plain text, CSV, +JSON, and source files), \code{FALSE} to disable, or a character vector of MIME +types to restrict what is accepted (each must be one of the supported +types). + +The shape of \verb{input$_user_input} is determined by this argument, so it +is predictable for a given app. When attachments are disabled (the +default), it is the typed text as a character string, exactly as before. +When attachments are enabled, it is always a list of ellmer +\link[ellmer:Content]{ellmer::Content} objects (the typed text, if any, followed by one content +object per attachment) - a list even when no files were attached. Splice +the list into a chat method's \code{...} with \verb{!!!}, e.g. +\verb{client$stream_async(!!!input$_user_input)}. (No \code{\link[rlang:inject]{rlang::inject()}} is +needed: ellmer's chat methods collect \code{...} with dynamic dots.) + +The maximum combined size of all attachments in a single message is +controlled globally by the \code{SHINYCHAT_MAX_ATTACHMENT_SIZE} environment +variable (a raw byte count; defaults to approximately 30 MB). Files that +would push the total over this cap are rejected in the browser with a notice.} + +\item{greeting}{See \code{\link[=chat_server]{chat_server()}}.} + +\item{bookmark_on_input}{See \code{\link[=chat_server]{chat_server()}}.} + +\item{bookmark_on_response}{See \code{\link[=chat_server]{chat_server()}}.} +} +\value{ +\itemize{ +\item \code{chat_mod_ui()} returns the UI for a shinychat module. +\item \code{chat_mod_server()} returns the value of \code{\link[=chat_server]{chat_server()}}. +} +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} + +These functions are deprecated as of shinychat 0.5.0. +Use \code{\link[=chat_ui]{chat_ui()}} with \code{NS(id, "chat")} and \code{\link[=chat_server]{chat_server()}} instead. +} +\section{Functions}{ +\itemize{ +\item \code{chat_mod_server()}: A Shiny module server for chat (deprecated). Use \code{\link[=chat_server]{chat_server()}} instead. + +}} diff --git a/pkg-r/man/chat_ui.Rd b/pkg-r/man/chat_ui.Rd index b2fef683b..538b7593c 100644 --- a/pkg-r/man/chat_ui.Rd +++ b/pkg-r/man/chat_ui.Rd @@ -14,9 +14,9 @@ chat_ui( height = "auto", fill = TRUE, icon_assistant = NULL, - enable_cancel = FALSE, + enable_cancel = NULL, submit_key = c("enter", "enter+modifier"), - allow_attachments = FALSE, + allow_attachments = NULL, footer = NULL ) } @@ -64,12 +64,13 @@ container, if the container is Can be HTML or a tag in the form of \code{\link[htmltools:HTML]{htmltools::HTML()}} or \code{\link[htmltools:tags]{htmltools::tags()}}. If \code{None}, a default robot icon is used.} -\item{enable_cancel}{If \code{TRUE}, show a stop button during streaming that +\item{enable_cancel}{Whether to show a stop button during streaming that allows the user to cancel the in-progress response. When using -\code{\link[=chat_mod_server]{chat_mod_server()}}, cancellation is wired up automatically. For manual -usage with \code{chat_ui()}, observe \verb{input$_cancel} to handle cancellation -(e.g., by calling \code{ctrl$cancel()} on an ellmer \code{stream_controller()}). -Defaults to \code{FALSE}.} +\code{\link[=chat_server]{chat_server()}}, cancellation is wired up automatically and this defaults +to \code{NULL} (let the server decide). For manual usage without +\code{\link[=chat_server]{chat_server()}}, set \code{TRUE} or \code{FALSE} explicitly and observe +\verb{input$_cancel} to handle cancellation (e.g., by calling +\code{ctrl$cancel()} on an ellmer \code{stream_controller()}).} \item{submit_key}{Controls which key combination submits the chat message. \code{"enter"} (the default): Enter submits, Shift+Enter adds a newline. @@ -77,11 +78,13 @@ Defaults to \code{FALSE}.} adds a newline.} \item{allow_attachments}{Controls the file-attachment affordance (an attach -button, plus clipboard paste and drag-and-drop) in the chat input. Pass -\code{TRUE} to accept all supported types (PNG, JPEG, GIF, WebP, PDF, and common -text/code files such as Markdown, plain text, CSV, JSON, and source files), -\code{FALSE} to disable, or a character vector of MIME types to -restrict what is accepted (each must be one of the supported types). +button, plus clipboard paste and drag-and-drop) in the chat input. +\code{NULL} (default) defers to \code{\link[=chat_server]{chat_server()}}, which enables attachments +automatically. Pass \code{TRUE} to accept all supported types (PNG, JPEG, GIF, +WebP, PDF, and common text/code files such as Markdown, plain text, CSV, +JSON, and source files), \code{FALSE} to disable, or a character vector of MIME +types to restrict what is accepted (each must be one of the supported +types). The shape of \verb{input$_user_input} is determined by this argument, so it is predictable for a given app. When attachments are disabled (the @@ -154,8 +157,8 @@ fresh one. \strong{\code{greeting_dismissed} input.} When the user dismisses the greeting, \verb{input$_greeting_dismissed} fires with a \code{Date.now()} timestamp. If the greeting is later cleared after being dismissed, the input resets to \code{NULL}. -If you use \code{\link[=chat_mod_server]{chat_mod_server()}}, you can access the \code{greeting_dismissed} -reactive from the returned module value instead of the raw namespaced input +If you use \code{\link[=chat_server]{chat_server()}}, you can access the \code{greeting_dismissed} +reactive from the returned value instead of the raw namespaced input string. } diff --git a/pkg-r/tests/testthat/apps/tool-basic/app.R b/pkg-r/tests/testthat/apps/tool-basic/app.R index 61e0741c5..e32c20051 100644 --- a/pkg-r/tests/testthat/apps/tool-basic/app.R +++ b/pkg-r/tests/testthat/apps/tool-basic/app.R @@ -14,7 +14,7 @@ TOOL_OPTS <- list( ) ui <- bslib::page_fillable( - shinychat::chat_mod_ui( + shinychat::chat_ui( "chat", messages = list( list( @@ -101,7 +101,7 @@ server <- function(input, output, session) { client <- chat(PROVIDER_MODEL) client$register_tool(packaged_list_files_tool) - chat_mod_server("chat", client) + chat_server("chat", client) observeEvent(input$click, { updateActionButton( diff --git a/pkg-r/tests/testthat/apps/tool-map/app.R b/pkg-r/tests/testthat/apps/tool-map/app.R index 58e15932f..5d65bb17b 100644 --- a/pkg-r/tests/testthat/apps/tool-map/app.R +++ b/pkg-r/tests/testthat/apps/tool-map/app.R @@ -53,7 +53,7 @@ Use this tool whenever you're talking about a location with the user. ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -66,7 +66,7 @@ You're a helpful guide who can tell users about places and show them maps. Anytime you mention a location, use the `tool_show_map` tool to show a map with a marker at the location. Don't make the user ask to see the map, just show it automatically when it'd be relevant to have a visual.)" ) client$register_tool(tool_show_map) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-01-simple.R b/pkg-r/tests/testthat/apps/tool-weather/app-01-simple.R index 1e19b2e7b..3f3266189 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-01-simple.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-01-simple.R @@ -18,7 +18,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -26,7 +26,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral:v0.3") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-02-annotations.R b/pkg-r/tests/testthat/apps/tool-weather/app-02-annotations.R index db1c43f7c..404e0b570 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-02-annotations.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-02-annotations.R @@ -22,7 +22,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -30,7 +30,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral:v0.3") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-03-tool-result-simple.R b/pkg-r/tests/testthat/apps/tool-weather/app-03-tool-result-simple.R index 1137fc8c0..b58a148ed 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-03-tool-result-simple.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-03-tool-result-simple.R @@ -41,7 +41,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -49,7 +49,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral-nemo") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-04-tool-result-table.R b/pkg-r/tests/testthat/apps/tool-weather/app-04-tool-result-table.R index b0060697a..dc8797657 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-04-tool-result-table.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-04-tool-result-table.R @@ -37,7 +37,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -45,7 +45,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral-nemo") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-05-tool-custom-result-class.R b/pkg-r/tests/testthat/apps/tool-weather/app-05-tool-custom-result-class.R index 0c7775e71..7d3f33d54 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-05-tool-custom-result-class.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-05-tool-custom-result-class.R @@ -59,7 +59,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -67,7 +67,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral-nemo") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/apps/tool-weather/app-06-tool-custom-output.R b/pkg-r/tests/testthat/apps/tool-weather/app-06-tool-custom-output.R index b6c124e0c..85376dd8c 100644 --- a/pkg-r/tests/testthat/apps/tool-weather/app-06-tool-custom-output.R +++ b/pkg-r/tests/testthat/apps/tool-weather/app-06-tool-custom-output.R @@ -63,7 +63,7 @@ get_weather_forecast <- tool( ui <- function(req) { page_fillable( - chat_mod_ui("chat") + chat_ui("chat") ) } @@ -71,7 +71,7 @@ server <- function(input, output, session) { client <- ellmer::chat("openai/gpt-4.1-nano") # client <- ellmer::chat_ollama(model = "mistral-nemo") client$register_tool(get_weather_forecast) - chat_mod_server("chat", client) + chat_server("chat", client) } shinyApp(ui, server, enableBookmarking = "url") diff --git a/pkg-r/tests/testthat/test-attachments.R b/pkg-r/tests/testthat/test-attachments.R index bb60187d5..7d4888e51 100644 --- a/pkg-r/tests/testthat/test-attachments.R +++ b/pkg-r/tests/testthat/test-attachments.R @@ -37,7 +37,7 @@ test_that("resolve_attachment_attrs handles bool, subset, and errors", { expect_equal(resolve_attachment_attrs(TRUE), list(allow = NA, accept = NULL)) expect_equal( resolve_attachment_attrs(FALSE), - list(allow = NULL, accept = NULL) + list(allow = "false", accept = NULL) ) expect_equal( resolve_attachment_attrs("application/pdf"), @@ -49,7 +49,7 @@ test_that("resolve_attachment_attrs handles bool, subset, and errors", { ) expect_equal( resolve_attachment_attrs(character(0)), - list(allow = NULL, accept = NULL) + list(allow = "false", accept = NULL) ) expect_error( resolve_attachment_attrs("application/msword"), @@ -334,7 +334,7 @@ test_that("chat_ui emits attachment attributes", { ) html_off <- as.character(chat_ui("chat", allow_attachments = FALSE)) - expect_false(grepl("allow-attachments", html_off)) + expect_match(html_off, "allow-attachments=\"false\"", fixed = TRUE) # max-attachment-size is emitted unconditionally (mirrors Python). expect_match(html_off, "max-attachment-size") }) diff --git a/pkg-r/tests/testthat/test-chat.R b/pkg-r/tests/testthat/test-chat.R index 3c7f7d5be..d90ddf620 100644 --- a/pkg-r/tests/testthat/test-chat.R +++ b/pkg-r/tests/testthat/test-chat.R @@ -122,7 +122,7 @@ test_that("chat_append_stream() handles errors in the stream", { }) }) -test_that("chat_mod_server handles string user_input values", { +test_that("chat_server handles string user_input values", { local_mocked_bindings( chat_restore = function(...) function() invisible(NULL), chat_append = function(...) invisible(NULL), @@ -142,12 +142,15 @@ test_that("chat_mod_server handles string user_input values", { ) shiny::testServer( - chat_mod_server, - args = list( - client = client, - bookmark_on_input = FALSE, - bookmark_on_response = FALSE - ), + function(input, output, session) { + chat_server( + "chat", + client, + bookmark_on_input = FALSE, + bookmark_on_response = FALSE, + session = session + ) + }, { expect_no_warning(session$setInputs(chat_user_input = "hello")) expect_identical(args_seen[[1]], "hello") diff --git a/pkg-r/tests/testthat/test-greeting.R b/pkg-r/tests/testthat/test-greeting.R index d11aeb8c1..476e66784 100644 --- a/pkg-r/tests/testthat/test-greeting.R +++ b/pkg-r/tests/testthat/test-greeting.R @@ -240,7 +240,7 @@ test_that("chat_clear(greeting = TRUE) includes greeting in action", { }) -# ── chat_mod_server() greeting function ────────────────────────────────────── +# ── chat_server() greeting function ────────────────────────────────────────── # Helper: minimal R6 mock that satisfies check_ellmer_chat() and chat_restore(). # Requires get_tools() so that the chat_restore set_ui observer does not crash @@ -288,18 +288,18 @@ test_that("named-arg detection: 'client' in formals identifies one-arg greeting" expect_false("client" %in% names(formals(function(x) {}))) }) -test_that("chat_mod_server() calls zero-arg greeting on chat_greeting_requested", { +test_that("chat_server() calls zero-arg greeting on chat_greeting_requested", { called <- FALSE + client <- mock_chat_client() + greeting <- function() { + called <<- TRUE + "## Hello" + } suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list( - client = mock_chat_client(), - greeting = function() { - called <<- TRUE - "## Hello" - } - ), + function(input, output, session) { + chat_server("chat", client, greeting = greeting, session = session) + }, { expect_false(called) session$setInputs(chat_greeting_requested = 1L) @@ -309,18 +309,18 @@ test_that("chat_mod_server() calls zero-arg greeting on chat_greeting_requested" ) }) -test_that("chat_mod_server() zero-arg greeting is not called without input trigger", { +test_that("chat_server() zero-arg greeting is not called without input trigger", { called <- FALSE + client <- mock_chat_client() + greeting <- function() { + called <<- TRUE + "## Hello" + } suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list( - client = mock_chat_client(), - greeting = function() { - called <<- TRUE - "## Hello" - } - ), + function(input, output, session) { + chat_server("chat", client, greeting = greeting, session = session) + }, { # No setInputs — observer must not fire expect_false(called) @@ -329,18 +329,18 @@ test_that("chat_mod_server() zero-arg greeting is not called without input trigg ) }) -test_that("chat_mod_server() calls one-arg greeting with a cloned client on chat_greeting_requested", { +test_that("chat_server() calls one-arg greeting with a cloned client on chat_greeting_requested", { received_greeter <- NULL + client <- mock_chat_client() + greeting <- function(client) { + received_greeter <<- client + "## Hello" + } suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list( - client = mock_chat_client(), - greeting = function(client) { - received_greeter <<- client - "## Hello" - } - ), + function(input, output, session) { + chat_server("chat", client, greeting = greeting, session = session) + }, { session$setInputs(chat_greeting_requested = 1L) expect_true(inherits(received_greeter, "Chat")) @@ -349,23 +349,27 @@ test_that("chat_mod_server() calls one-arg greeting with a cloned client on chat ) }) -test_that("chat_mod_server() one-arg greeting receives a client with empty turns", { +test_that("chat_server() one-arg greeting receives a client with empty turns", { received_turns <- NULL client_with_turns <- mock_chat_client() client_with_turns$set_turns(list(list( role = "user", content = "prior message" ))) + greeting <- function(client) { + received_turns <<- client$get_turns() + "## Hello" + } suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list( - client = client_with_turns, - greeting = function(client) { - received_turns <<- client$get_turns() - "## Hello" - } - ), + function(input, output, session) { + chat_server( + "chat", + client_with_turns, + greeting = greeting, + session = session + ) + }, { session$setInputs(chat_greeting_requested = 1L) expect_equal(length(received_turns), 0L) @@ -374,7 +378,7 @@ test_that("chat_mod_server() one-arg greeting receives a client with empty turns ) }) -test_that("chat_mod_server() one-arg greeting does not clear original client turns", { +test_that("chat_server() one-arg greeting does not clear original client turns", { client_with_turns <- mock_chat_client() client_with_turns$set_turns(list(list( role = "user", @@ -382,11 +386,14 @@ test_that("chat_mod_server() one-arg greeting does not clear original client tur ))) suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list( - client = client_with_turns, - greeting = function(client) "## Hello" - ), + function(input, output, session) { + chat_server( + "chat", + client_with_turns, + greeting = function(client) "## Hello", + session = session + ) + }, { session$setInputs(chat_greeting_requested = 1L) expect_equal(length(client_with_turns$get_turns()), 1L) @@ -395,12 +402,14 @@ test_that("chat_mod_server() one-arg greeting does not clear original client tur ) }) -test_that("chat_mod_server() does not error with static string greeting", { +test_that("chat_server() does not error with static string greeting", { + client <- mock_chat_client() expect_no_error( suppress_restore_warnings( shiny::testServer( - chat_mod_server, - args = list(client = mock_chat_client(), greeting = "## Static"), + function(input, output, session) { + chat_server("chat", client, greeting = "## Static", session = session) + }, {} ) ) diff --git a/pkg-r/tests/testthat/test-slash-commands.R b/pkg-r/tests/testthat/test-slash-commands.R index 48a573607..3a69e4613 100644 --- a/pkg-r/tests/testthat/test-slash-commands.R +++ b/pkg-r/tests/testthat/test-slash-commands.R @@ -1,10 +1,18 @@ +# chat_server isn't a module function, so session$returned requires a module +# wrapper to work with shiny::testServer. +chat_server_module <- function(id, client, ...) { + shiny::moduleServer(id, function(input, output, session) { + chat_server("chat", client, ..., session = session) + }) +} + test_that("chat_ui does not emit data-slash-commands attribute by default", { ui <- chat_ui("chat") html <- as.character(ui) expect_false(grepl("data-slash-commands", html)) }) -test_that("chat_mod_server slash_command supports zero-argument handlers", { +test_that("chat_server slash_command supports zero-argument handlers", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) @@ -13,7 +21,7 @@ test_that("chat_mod_server slash_command supports zero-argument handlers", { calls <- 0 shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -37,14 +45,14 @@ test_that("chat_mod_server slash_command supports zero-argument handlers", { ) }) -test_that("chat_mod_server slash_command rejects handlers with more than one parameter", { +test_that("chat_server slash_command rejects handlers with more than one parameter", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) ) shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -63,14 +71,14 @@ test_that("chat_mod_server slash_command rejects handlers with more than one par ) }) -test_that("chat_mod_server slash_command errors on duplicate name by default", { +test_that("chat_server slash_command errors on duplicate name by default", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) ) shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -86,7 +94,7 @@ test_that("chat_mod_server slash_command errors on duplicate name by default", { ) }) -test_that("chat_mod_server slash_command removal unregisters the command", { +test_that("chat_server slash_command removal unregisters the command", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) @@ -95,7 +103,7 @@ test_that("chat_mod_server slash_command removal unregisters the command", { calls <- 0 shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -135,7 +143,7 @@ test_that("chat_mod_server slash_command removal unregisters the command", { ) }) -test_that("chat_mod_server slash_command allows overwrite with force = TRUE", { +test_that("chat_server slash_command allows overwrite with force = TRUE", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) @@ -144,7 +152,7 @@ test_that("chat_mod_server slash_command allows overwrite with force = TRUE", { calls <- character() shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -172,14 +180,14 @@ test_that("chat_mod_server slash_command allows overwrite with force = TRUE", { ) }) -test_that("chat_mod_server slash_command echo defaults to handler presence", { +test_that("chat_server slash_command echo defaults to handler presence", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) ) shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -205,14 +213,14 @@ test_that("chat_mod_server slash_command echo defaults to handler presence", { ) }) -test_that("chat_mod_server slash_command echo can be set explicitly", { +test_that("chat_server slash_command echo can be set explicitly", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) ) shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -234,14 +242,14 @@ test_that("chat_mod_server slash_command echo can be set explicitly", { ) }) -test_that("chat_mod_server slash_command rejects a non-function, non-NULL handler", { +test_that("chat_server slash_command rejects a non-function, non-NULL handler", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) ) shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, @@ -256,7 +264,7 @@ test_that("chat_mod_server slash_command rejects a non-function, non-NULL handle ) }) -test_that("chat_mod_server slash_command with NULL handler does not run server-side", { +test_that("chat_server slash_command with NULL handler does not run server-side", { local_mocked_bindings( chat_restore = function(...) invisible(NULL), send_chat_action = function(...) invisible(NULL) @@ -265,7 +273,7 @@ test_that("chat_mod_server slash_command with NULL handler does not run server-s calls <- 0 shiny::testServer( - chat_mod_server, + chat_server_module, args = list( client = structure(list(), class = "Chat"), bookmark_on_input = FALSE, diff --git a/pkg-r/vignettes/articles/tool-ui.Rmd b/pkg-r/vignettes/articles/tool-ui.Rmd index 5d5380047..222edfe34 100644 --- a/pkg-r/vignettes/articles/tool-ui.Rmd +++ b/pkg-r/vignettes/articles/tool-ui.Rmd @@ -149,7 +149,7 @@ error_result <- ContentToolResult( contents_shinychat(error_result) ``` -When you use `chat_app()` or the chat UI module via `chat_mod_ui()` and `chat_mod_server()`, shinychat automatically handles tool requests and results, displaying them in the chat interface. +When you use `chat_app()` or `chat_server()`, shinychat automatically handles tool requests and results, displaying them in the chat interface. On the other hand, if you're using `chat_ui()` and calling `chat_append()` to stream the chat output, you'll need to make sure that ellmer streams tool requests and results to shinychat by setting `stream = "content"` in the `$stream_async()` call. diff --git a/pkg-r/vignettes/get-started.Rmd b/pkg-r/vignettes/get-started.Rmd index 233858810..a1bbe4387 100644 --- a/pkg-r/vignettes/get-started.Rmd +++ b/pkg-r/vignettes/get-started.Rmd @@ -342,7 +342,7 @@ knitr::include_graphics("images/chat-card.png") ## Slash commands -Slash commands give users discoverable shortcuts — like `/search`, `/clear`, or `/help` — that run a handler you define on the server. Register commands on the object returned by `chat_mod_server()`, using its `slash_command()` method. When a user runs a command, its handler fires instead of the text being sent to the model, and what happens next is entirely up to the handler. +Slash commands give users discoverable shortcuts — like `/search`, `/clear`, or `/help` — that run a handler you define on the server. Register commands on the object returned by `chat_server()`, using its `slash_command()` method. When a user runs a command, its handler fires instead of the text being sent to the model, and what happens next is entirely up to the handler. The two most common patterns are **prompt expansion** — where the command transforms the user's input before sending it to the LLM — and **side effects** — where the command performs an action without involving the LLM at all. @@ -362,12 +362,12 @@ library(bslib) library(shinychat) ui <- page_fillable( - chat_mod_ui("chat", placeholder = "Type / for commands, or chat away...") + chat_ui("chat", placeholder = "Type / for commands, or chat away...") ) server <- function(input, output, session) { client <- ellmer::chat_openai(system_prompt = "You are a helpful assistant.") - chat <- chat_mod_server("chat", client = client) + chat <- chat_server("chat", client = client) chat$slash_command("search", "Search the docs", function(content) { # In practice, retrieve relevant documents here (e.g., via a vector DB) @@ -425,15 +425,15 @@ The event is cancelable and bubbles. Use `e.detail.id` to target a specific chat - `slash_command()` returns a function that removes the command when called. Re-registering an existing name raises an error unless you pass `force = TRUE`. - Slash command messages are restored faithfully when a bookmarked app is reopened. -Slash commands are currently available only through the chat module (`chat_mod_server()`), not when building a custom UI with `chat_ui()` directly. +Slash commands are currently available only through `chat_server()`, not when building a fully custom chat loop with `chat_ui()` and `chat_append()` directly. ## Stream cancellation shinychat supports cancelling an in-progress AI response. When cancellation is enabled, a stop button appears in the chat input area during streaming. Users can also press Escape while the chat has focus to cancel the current response. Any partial response already received is preserved in the chat history. -### Using the chat module (recommended) +### Using `chat_server()` (recommended) -The easiest way to add cancellation support is to use the `chat_mod_ui()` and `chat_mod_server()` functions. The module handles everything automatically — the stop button is shown during streaming and wired up internally, with no extra code required. +Pass `enable_cancel = TRUE` to `chat_ui()` and `chat_server()` handles everything automatically — the stop button is shown during streaming and the cancel input is wired up internally. ```r library(shiny) @@ -442,12 +442,12 @@ library(shinychat) library(ellmer) ui <- page_fillable( - chat_mod_ui("chat") + chat_ui("chat", enable_cancel = TRUE) ) server <- function(input, output, session) { chat <- chat_anthropic(system_prompt = "You are a helpful assistant.") - chat_mod_server("chat", client = chat) + chat_server("chat", client = chat) } shinyApp(ui, server) @@ -455,7 +455,7 @@ shinyApp(ui, server) ### Manual approach -If you are building a custom chat UI with `chat_ui()` directly, you can enable cancellation by setting `enable_cancel = TRUE` and wiring up the cancel input in your server function. +If you are building a fully custom chat loop with `chat_ui()` and `chat_append()` directly, you can wire up cancellation yourself. The key steps are: @@ -500,9 +500,9 @@ shinyApp(ui, server) shinychat supports file attachments, allowing users to upload images, PDFs, and text files alongside their messages. When attachments are enabled, the chat input shows a file picker button and also accepts drag-and-drop or clipboard paste. -### Using the chat module (recommended) +### Using `chat_server()` (recommended) -The `chat_mod_ui()` and `chat_mod_server()` functions enable file attachments by default. Uploaded files are automatically converted to ellmer content objects and sent to the model — no extra code needed. +Pass `allow_attachments = TRUE` to `chat_ui()` and `chat_server()` handles the rest — uploaded files are automatically converted to ellmer content objects and sent to the model. ```r library(shiny) @@ -511,12 +511,12 @@ library(shinychat) library(ellmer) ui <- page_fillable( - chat_mod_ui("chat") + chat_ui("chat", allow_attachments = TRUE) ) server <- function(input, output, session) { chat <- chat_anthropic(system_prompt = "You are a helpful assistant.") - chat_mod_server("chat", client = chat) + chat_server("chat", client = chat) } shinyApp(ui, server)