diff --git a/DESCRIPTION b/DESCRIPTION index 509ca73..5f334e5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -15,14 +15,20 @@ Imports: ellmer (>= 0.3.0), shiny, shinychat, + shinyjs, shinyrealtime (>= 0.1.0.9000), withr Suggests: dplyr, - maps + htmlwidgets, + kaleido, + maps, + plotly, + webshot, + webshot2 Remotes: posit-dev/shinyrealtime/pkg-r License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 diff --git a/NAMESPACE b/NAMESPACE index 9d19fda..a3c55e3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -5,6 +5,8 @@ import(bslib) import(ellmer) import(ggplot2) import(shiny) +importFrom(grDevices,dev.off) +importFrom(grDevices,png) importFrom(shinychat,markdown_stream) importFrom(shinychat,output_markdown_stream) importFrom(shinyrealtime,realtime_server) diff --git a/R/ggbot2.R b/R/ggbot2.R index 6b7cd74..87626ee 100644 --- a/R/ggbot2.R +++ b/R/ggbot2.R @@ -4,6 +4,7 @@ #' @import ggplot2 #' @importFrom shinychat markdown_stream output_markdown_stream #' @importFrom shinyrealtime realtime_ui realtime_server +#' @importFrom grDevices png dev.off NULL globalVariables("yield") @@ -42,14 +43,32 @@ ggbot <- function(df, debug = FALSE) { title = "ggbot2", fillable = TRUE, style = "--bslib-spacer: 1rem; padding-bottom: 0;", + shinyjs::useShinyjs(), sidebar = sidebar( - helpText("Session cost:", textOutput("session_cost", inline = TRUE)), + div( + style = "display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;", + helpText("Session cost:", textOutput("session_cost", inline = TRUE)), + input_dark_mode(id = "dark_mode", mode = "light") + ), output_markdown_stream("response_text") ), card( full_screen = TRUE, - card_header("Plot"), - card_body(padding = 0, plotOutput("plot", fill = TRUE)), + card_header( + "Plot", + class = "d-flex justify-content-between align-items-center", + div( + style = "display: flex; gap: 0.5rem;", + actionButton("history_prev", icon("chevron-left"), class = "btn-sm"), + textOutput("history_info", inline = TRUE), + actionButton("history_next", icon("chevron-right"), class = "btn-sm"), + downloadButton("download_plot", "Download", class = "btn-sm") + ) + ), + card_body( + padding = 0, + uiOutput("plot_container") + ), height = "66%" ), layout_columns( @@ -65,14 +84,30 @@ ggbot <- function(df, debug = FALSE) { style = "z-index: 100000; margin-left: auto; margin-right: auto;", right = NULL ), - hidden_audio_el("shutter", system.file("shutter.mp3", package = "ggbot2")) + hidden_audio_el("shutter", system.file("shutter.mp3", package = "ggbot2")), + tags$script(HTML(" + // Fix initial mic button state to match muted status + setTimeout(function() { + var micBtn = document.querySelector('.mic-toggle-btn'); + if (micBtn) { + micBtn.classList.remove('active', 'btn-danger'); + micBtn.classList.add('btn-secondary'); + } + }, 100); + ")) ) server <- function(input, output, session) { last_code <- reactiveVal() + last_plotly_code <- reactiveVal() + plot_type <- reactiveVal("static") # "static" or "plotly" running_cost <- reactiveVal(0) # Cost of tokens used in the session, in dollars - greeting <- "Welcome to Shiny Realtime!\n\nYou're currently muted; click the mic button to unmute, click-and-hold the mic for push-to-talk, or hold the spacebar key for push-to-talk." + # History system + plot_history <- reactiveVal(list()) # List of history items: list(code, plot_type, timestamp) + history_position <- reactiveVal(0) # 0 = latest/live, 1 = one back, etc. + + greeting <- "Welcome to ggbot2!\n\nClick the mic button to start talking. You can also click-and-hold the mic button (or hold spacebar) for push-to-talk mode." append_transcript <- function(text, clear = FALSE) { markdown_stream( @@ -87,7 +122,15 @@ ggbot <- function(df, debug = FALSE) { run_r_plot_code <- function(code) { attr(code, "rnd") <- stats::runif(1) # Force re-evaluation even if code is the same + plot_type("static") last_code(code) + last_plotly_code(NULL) + + # Add to history + history <- plot_history() + history <- c(list(list(code = code, plot_type = "static", timestamp = Sys.time())), history) + plot_history(history) + history_position(0) # Reset to latest # Ideally we'd run the code here to check for errors and let the model # know about success/failure in a tool response. But we only want to run @@ -96,6 +139,21 @@ ggbot <- function(df, debug = FALSE) { NULL } + run_r_plotly_code <- function(code) { + attr(code, "rnd") <- stats::runif(1) # Force re-evaluation even if code is the same + plot_type("plotly") + last_plotly_code(code) + last_code(NULL) + + # Add to history + history <- plot_history() + history <- c(list(list(code = code, plot_type = "plotly", timestamp = Sys.time())), history) + plot_history(history) + history_position(0) # Reset to latest + + NULL + } + run_r_plot_code_tool <- ellmer::tool( run_r_plot_code, "Run R code that generates a static plot", @@ -106,11 +164,21 @@ ggbot <- function(df, debug = FALSE) { ) ) + run_r_plotly_code_tool <- ellmer::tool( + run_r_plotly_code, + "Run R code that generates an interactive plotly plot", + arguments = list( + code = type_string( + "The R code to run that generates an interactive plotly plot. This should use plotly::ggplotly() to convert a ggplot object to plotly, or use plotly functions directly. The last expression should be the plotly object." + ) + ) + ) + realtime_controls <- realtime_server( "realtime1", voice = "cedar", instructions = prompt, - tools = list(run_r_plot_code_tool), + tools = list(run_r_plot_code_tool, run_r_plotly_code_tool), speed = 1.1, debug = debug ) @@ -199,8 +267,99 @@ ggbot <- function(df, debug = FALSE) { output_audio = 20 / 1e6 ) + # History navigation + observeEvent(input$history_prev, { + pos <- history_position() + history <- plot_history() + if (pos < length(history) - 1) { + new_pos <- pos + 1 + history_position(new_pos) + # Load plot from history + item <- history[[new_pos + 1]] # +1 because R is 1-indexed + if (item$plot_type == "static") { + plot_type("static") + last_code(item$code) + last_plotly_code(NULL) + } else { + plot_type("plotly") + last_plotly_code(item$code) + last_code(NULL) + } + } + }) + + observeEvent(input$history_next, { + pos <- history_position() + if (pos > 0) { + new_pos <- pos - 1 + history_position(new_pos) + # Load plot from history + history <- plot_history() + item <- history[[new_pos + 1]] # +1 because R is 1-indexed + if (item$plot_type == "static") { + plot_type("static") + last_code(item$code) + last_plotly_code(NULL) + } else { + plot_type("plotly") + last_plotly_code(item$code) + last_code(NULL) + } + } + }) + + # Update button states + observe({ + pos <- history_position() + history <- plot_history() + + # Disable prev if at oldest plot + if (pos >= length(history) - 1 || length(history) <= 1) { + shinyjs::disable("history_prev") + } else { + shinyjs::enable("history_prev") + } + + # Disable next if at newest plot + if (pos <= 0) { + shinyjs::disable("history_next") + } else { + shinyjs::enable("history_next") + } + }) + + output$history_info <- renderText({ + pos <- history_position() + history <- plot_history() + if (length(history) == 0) { + return("") + } + paste0((pos + 1), "/", length(history)) + }) + + output$plot_container <- renderUI({ + if (plot_type() == "plotly") { + req(last_plotly_code()) + if (!requireNamespace("plotly", quietly = TRUE)) { + stop("plotly package is required for interactive plots. Install it with install.packages('plotly')") + } + tagList( + plotly::plotlyOutput("interactive_plot", width = "100%", height = "100%"), + tags$script(HTML(" + setTimeout(function() { + var audio = document.getElementById('shutter'); + if (audio) audio.play(); + }, 500); + ")) + ) + } else { + plotOutput("plot", fill = TRUE, width = "100%", height = "100%") + } + }) + output$plot <- renderPlot(res = 96, { req(last_code()) + req(plot_type() == "static") on.exit(session$sendCustomMessage( "play_audio", list(selector = "#shutter") @@ -222,14 +381,87 @@ ggbot <- function(df, debug = FALSE) { ) }) + output$interactive_plot <- plotly::renderPlotly({ + req(last_plotly_code()) + req(plot_type() == "plotly") + if (!requireNamespace("plotly", quietly = TRUE)) { + stop("plotly package is required for interactive plots") + } + eval(parse(text = last_plotly_code()), envir = new.env(parent = globalenv())) + }) + output$code_text <- renderText({ - req(last_code()) - last_code() + if (plot_type() == "static") { + req(last_code()) + last_code() + } else { + req(last_plotly_code()) + last_plotly_code() + } }) output$session_cost <- renderText({ paste0(sprintf("$%.4f", running_cost())) }) + + output$download_plot <- downloadHandler( + filename = function() { + current_plot_type <- isolate(plot_type()) + extension <- if (current_plot_type == "plotly") ".html" else ".png" + paste0("ggbot_plot_", format(Sys.time(), "%Y%m%d_%H%M%S"), extension) + }, + content = function(file) { + current_plot_type <- isolate(plot_type()) + + if (current_plot_type == "static") { + code <- isolate(last_code()) + + if (is.null(code) || code == "") { + stop("No plot available to download. Please generate a plot first.") + } + + # 300 DPI with typical plot size (10" x 8") + grDevices::png(file, width = 3000, height = 2400, res = 300) + tryCatch({ + result <- eval(parse(text = code), envir = new.env(parent = globalenv())) + # Explicitly print if it's a ggplot object + if (inherits(result, "ggplot")) { + print(result) + } + }, error = function(e) { + if (length(grDevices::dev.list()) > 0) { + grDevices::dev.off() + } + stop("Error generating plot: ", e$message) + }, finally = { + if (length(grDevices::dev.list()) > 0) { + grDevices::dev.off() + } + }) + + } else if (current_plot_type == "plotly") { + code <- isolate(last_plotly_code()) + + if (is.null(code) || code == "") { + stop("No plot available to download. Please generate a plot first.") + } + + if (!requireNamespace("plotly", quietly = TRUE)) { + stop("plotly package is required") + } + + if (!requireNamespace("htmlwidgets", quietly = TRUE)) { + stop("htmlwidgets package is required to save interactive plots") + } + + # Save interactive plotly as HTML to preserve interactivity + plotly_obj <- eval(parse(text = code), envir = new.env(parent = globalenv())) + htmlwidgets::saveWidget(plotly_obj, file, selfcontained = TRUE) + } else { + stop("No plot available to download. Please generate a plot first.") + } + } + ) } shinyApp(ui, server) diff --git a/inst/prompts/prompt.md b/inst/prompts/prompt.md index 421c7e8..c5a14e6 100644 --- a/inst/prompts/prompt.md +++ b/inst/prompts/prompt.md @@ -12,22 +12,47 @@ Respond using the same language as the user, or if in doubt, respond using Engli You're a helpful, casual, friendly AI that helps generate plotting code using ggplot2 or other R plotting libraries. The user will ask you -various plotting tasks, which you should fulfill by calling the -`run_r_plot_code` function. This code should either plot as a side effect, or -have its last expression be a ggplot or similar object that plots when printed. +various plotting tasks, which you should fulfill by calling either the +`run_r_plot_code` function for static plots or the `run_r_plotly_code` function +for interactive plots. -When you call this function, the user will see the generated plot in real-time. +For static plots, the code should either plot as a side effect, or have its last +expression be a ggplot or similar object that plots when printed. + +For interactive plots, use the `run_r_plotly_code` function. The code should use +plotly::ggplotly() to convert a ggplot object to an interactive plotly plot, or +use plotly functions directly. The last expression should be the plotly object. + +When you call these functions, the user will see the generated plot in real-time. Each generated plot will replace the previous one, so you don't need to worry about keeping track of old plots. -Each time you call function, think of it as a new R session. No variables -from previous calls will be available. You MUST ALWAYS include any necessary +Each time you call these functions, think of it as a new R session. No variables +from previous calls will be available. You should always include any necessary library imports, dataset loading, and intermediate calculations in your code, -every time you call `run_r_plot_code`. +every time you call `run_r_plot_code` or `run_r_plotly_code`. + +Choose `run_r_plotly_code` when the user explicitly asks for an interactive plot, +or when interactivity would be particularly useful (e.g., for exploring data with +many points, zooming, tooltips, etc.). Otherwise, use `run_r_plot_code` for +standard static plots. If the user asks for a plot that you cannot generate, you should respond saying why you can't fulfill the request. Stay on task, and refuse to engage in any other conversation that is not related to generating plots. +In your R code, you can assume the following packages have already been loaded: + +```r +library(ggplot2) +library(dplyr) +``` + +For interactive plots, the plotly package is available. You will need to load it explicitly: + +```r +library(plotly) +``` + Don't change the theme or set any plot colours unless the user explicitly asks for it.