|
| 1 | +#' Open a live chat application in the browser |
| 2 | +#' |
| 3 | +#' @description |
| 4 | +#' Create a simple Shiny app for live chatting using an [ellmer::Chat] object. |
| 5 | +#' Note that these functions will mutate the input `client` object as |
| 6 | +#' you chat because your turns will be appended to the history. |
| 7 | +#' |
| 8 | +#' @examples |
| 9 | +#' \dontrun{ |
| 10 | +#' # Interactive in the console ---- |
| 11 | +#' client <- ellmer::chat_claude() |
| 12 | +#' chat_app(client) |
| 13 | +#' |
| 14 | +#' # Inside a Shiny app ---- |
| 15 | +#' library(shiny) |
| 16 | +#' library(bslib) |
| 17 | +#' library(shinychat) |
| 18 | +#' |
| 19 | +#' ui <- page_fillable( |
| 20 | +#' titlePanel("shinychat example"), |
| 21 | +#' |
| 22 | +#' layout_columns( |
| 23 | +#' card( |
| 24 | +#' card_header("Chat with Claude"), |
| 25 | +#' chat_mod_ui( |
| 26 | +#' "claude", |
| 27 | +#' messages = list( |
| 28 | +#' "Hi! Use this chat interface to chat with Anthropic's `claude-3-5-sonnet`." |
| 29 | +#' ) |
| 30 | +#' ) |
| 31 | +#' ), |
| 32 | +#' card( |
| 33 | +#' card_header("Chat with ChatGPT"), |
| 34 | +#' chat_mod_ui( |
| 35 | +#' "openai", |
| 36 | +#' messages = list( |
| 37 | +#' "Hi! Use this chat interface to chat with OpenAI's `gpt-4o`." |
| 38 | +#' ) |
| 39 | +#' ) |
| 40 | +#' ) |
| 41 | +#' ) |
| 42 | +#' ) |
| 43 | +#' |
| 44 | +#' server <- function(input, output, session) { |
| 45 | +#' claude <- ellmer::chat_claude(model = "claude-3-5-sonnet-latest") # Requires ANTHROPIC_API_KEY |
| 46 | +#' openai <- ellmer::chat_openai(model = "gpt-4o") # Requires OPENAI_API_KEY |
| 47 | +#' |
| 48 | +#' chat_mod_server("claude", claude) |
| 49 | +#' chat_mod_server("openai", openai) |
| 50 | +#' } |
| 51 | +#' |
| 52 | +#' shinyApp(ui, server) |
| 53 | +#' } |
| 54 | +#' |
| 55 | +#' @param client A chat object created by \pkg{ellmer}, e.g. |
| 56 | +#' [ellmer::chat_openai()] and friends. |
| 57 | +#' @param ... In `chat_app()`, additional arguments are passed to |
| 58 | +#' [shiny::shinyApp()]. In `chat_mod_ui()`, additional arguments are passed to |
| 59 | +#' [chat_ui()]. |
| 60 | +#' |
| 61 | +#' @returns |
| 62 | +#' * `chat_app()` returns a [shiny::shinyApp()] object. |
| 63 | +#' * `chat_mod_ui()` returns the UI for a shinychat module. |
| 64 | +#' * `chat_mod_server()` includes the shinychat module server logic, and |
| 65 | +#' and returns the last turn upon successful chat completion. |
| 66 | +#' |
| 67 | +#' @describeIn chat_app A simple Shiny app for live chatting. |
| 68 | +#' @export |
| 69 | +chat_app <- function(client, ...) { |
| 70 | + check_ellmer_chat(client) |
| 71 | + |
| 72 | + ui <- bslib::page_fillable( |
| 73 | + chat_mod_ui("chat", client = client, height = "100%"), |
| 74 | + shiny::actionButton( |
| 75 | + "close_btn", |
| 76 | + label = "", |
| 77 | + class = "btn-close", |
| 78 | + style = "position: fixed; top: 6px; right: 6px;" |
| 79 | + ) |
| 80 | + ) |
| 81 | + |
| 82 | + server <- function(input, output, session) { |
| 83 | + chat_mod_server("chat", client) |
| 84 | + |
| 85 | + shiny::observeEvent(input$close_btn, { |
| 86 | + shiny::stopApp() |
| 87 | + }) |
| 88 | + } |
| 89 | + |
| 90 | + shiny::shinyApp(ui, server, ...) |
| 91 | +} |
| 92 | + |
| 93 | +check_ellmer_chat <- function(client) { |
| 94 | + if (!inherits(client, "Chat")) { |
| 95 | + abort("`client` must be an `ellmer::Chat` object.") |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +#' @describeIn chat_app A simple chat app module UI. |
| 100 | +#' @param id The chat module ID. |
| 101 | +#' @param messages Initial messages shown in the chat, used when `client` is not |
| 102 | +#' provided or when the chat `client` doesn't already contain turns. Passed to |
| 103 | +#' `messages` in [chat_ui()]. |
| 104 | +#' @export |
| 105 | +chat_mod_ui <- function(id, ..., client = NULL, messages = NULL) { |
| 106 | + if (!is.null(client)) { |
| 107 | + check_ellmer_chat(client) |
| 108 | + |
| 109 | + client_msgs <- map(client$get_turns(), function(turn) { |
| 110 | + content <- ellmer::contents_markdown(turn) |
| 111 | + if (is.null(content) || identical(content, "")) { |
| 112 | + return(NULL) |
| 113 | + } |
| 114 | + list(role = turn@role, content = content) |
| 115 | + }) |
| 116 | + client_msgs <- compact(client_msgs) |
| 117 | + |
| 118 | + if (length(client_msgs)) { |
| 119 | + if (!is.null(messages)) { |
| 120 | + warn( |
| 121 | + "`client` was provided and has initial messages, `messages` will be ignored." |
| 122 | + ) |
| 123 | + } |
| 124 | + messages <- client_msgs |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + shinychat::chat_ui( |
| 129 | + shiny::NS(id, "chat"), |
| 130 | + messages = messages, |
| 131 | + ... |
| 132 | + ) |
| 133 | +} |
| 134 | + |
| 135 | +#' @describeIn chat_app A simple chat app module server. |
| 136 | +#' @export |
| 137 | +chat_mod_server <- function(id, client) { |
| 138 | + check_ellmer_chat(client) |
| 139 | + |
| 140 | + append_stream_task <- shiny::ExtendedTask$new( |
| 141 | + function(client, ui_id, user_input) { |
| 142 | + promises::then( |
| 143 | + promises::promise_resolve(client$stream_async(user_input)), |
| 144 | + function(stream) { |
| 145 | + chat_append(ui_id, stream) |
| 146 | + } |
| 147 | + ) |
| 148 | + } |
| 149 | + ) |
| 150 | + |
| 151 | + shiny::moduleServer(id, function(input, output, session) { |
| 152 | + shiny::observeEvent(input$chat_user_input, { |
| 153 | + append_stream_task$invoke( |
| 154 | + client, |
| 155 | + "chat", |
| 156 | + input$chat_user_input |
| 157 | + ) |
| 158 | + }) |
| 159 | + |
| 160 | + shiny::observe({ |
| 161 | + if (append_stream_task$status() == "error") { |
| 162 | + tryCatch( |
| 163 | + append_stream_task$result(), |
| 164 | + error = notify_error("chat", session = session, module_id = id) |
| 165 | + ) |
| 166 | + } |
| 167 | + }) |
| 168 | + |
| 169 | + shiny::reactive({ |
| 170 | + if (append_stream_task$status() == "success") { |
| 171 | + client$last_turn() |
| 172 | + } |
| 173 | + }) |
| 174 | + }) |
| 175 | +} |
| 176 | + |
| 177 | +notify_error <- function( |
| 178 | + id, |
| 179 | + module_id, |
| 180 | + session = shiny::getDefaultReactiveDomain() |
| 181 | +) { |
| 182 | + function(err) { |
| 183 | + rlang::warn( |
| 184 | + sprintf( |
| 185 | + "ERROR: An error occurred in `chat_mod_server(id=\"%s\")`", |
| 186 | + module_id |
| 187 | + ), |
| 188 | + parent = err |
| 189 | + ) |
| 190 | + |
| 191 | + needs_sanitized <- |
| 192 | + isTRUE(getOption("shiny.sanitize.errors")) && |
| 193 | + !inherits(err, "shiny.custom.error") |
| 194 | + if (needs_sanitized) { |
| 195 | + msg <- "**An error occurred.** Please try again or contact the app author." |
| 196 | + } else { |
| 197 | + msg <- sprintf( |
| 198 | + "**An error occurred:**\n\n```\n%s\n```", |
| 199 | + strip_ansi(conditionMessage(err)) |
| 200 | + ) |
| 201 | + } |
| 202 | + |
| 203 | + chat_append_message( |
| 204 | + id, |
| 205 | + msg = list(role = "assistant", content = msg), |
| 206 | + chunk = TRUE, |
| 207 | + operation = "append", |
| 208 | + session = session |
| 209 | + ) |
| 210 | + chat_append_message( |
| 211 | + id, |
| 212 | + list(role = "assistant", content = ""), |
| 213 | + chunk = "end", |
| 214 | + operation = "append", |
| 215 | + session = session |
| 216 | + ) |
| 217 | + } |
| 218 | +} |
| 219 | + |
| 220 | +strip_ansi <- function(text) { |
| 221 | + # Matches codes like "\x1B[31;43m", "\x1B[1;3;4m" |
| 222 | + ansi_pattern <- "(\x1B|\x033)\\[[0-9;?=<>]*[@-~]" |
| 223 | + gsub(ansi_pattern, "", text) |
| 224 | +} |
0 commit comments