diff --git a/DESCRIPTION b/DESCRIPTION index 9f777581..22995b41 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Type: Package Package: gptstudio Title: Use Large Language Models Directly in your Development Environment -Version: 0.4.0.9003 +Version: 0.4.0.9004 Authors@R: c( person("Michel", "Nivard", , "m.g.nivard@vu.nl", role = c("aut", "cph")), person("James", "Wade", , "github@jameshwade.com", role = c("aut", "cre", "cph"), @@ -21,14 +21,11 @@ BugReports: https://github.com/MichelNivard/gptstudio/issues Depends: R (>= 4.0) Imports: - assertthat, - bslib (>= 0.6.0), + bsicons, + bslib (>= 0.8.0), cli, colorspace, - curl, - fontawesome, glue, - grDevices, htmltools, htmlwidgets, httr2, @@ -36,22 +33,25 @@ Imports: jsonlite, magrittr, purrr, - R6, + R6 (>= 2.0), rlang, rstudioapi (>= 0.12), - rvest, - shiny, + shiny (>= 1.9.0), shiny.i18n, SSEparser, stringr (>= 1.5.0), utils, - waiter, yaml Suggests: AzureRMR, + future, + grDevices, knitr, + miniUI, mockr, + promises, rmarkdown, + rvest, shinytest2, spelling, testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 40398b6a..add55d17 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -48,11 +48,13 @@ export(gptstudio_comment_code) export(gptstudio_create_skeleton) export(gptstudio_request_perform) export(gptstudio_response_process) +export(gptstudio_run_chat_app) export(gptstudio_sitrep) export(gptstudio_skeleton_build) export(gptstudio_spelling_grammar) +export(input_audio_clip) export(openai_create_chat_completion) -export(run_chatgpt_app) +export(transcribe_audio) import(cli) import(htmltools) import(htmlwidgets) @@ -60,10 +62,11 @@ import(httr2) import(rlang) import(shiny) importFrom(R6,R6Class) -importFrom(assertthat,assert_that) -importFrom(assertthat,is.count) -importFrom(assertthat,is.number) -importFrom(assertthat,is.string) importFrom(glue,glue) +importFrom(htmltools,div) +importFrom(htmltools,tag) +importFrom(htmltools,tagList) +importFrom(htmltools,tags) importFrom(jsonlite,fromJSON) importFrom(magrittr,"%>%") +importFrom(shiny,icon) diff --git a/NEWS.md b/NEWS.md index 95e29abc..8b0aaf39 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,13 @@ - Added claude-3.5-sonnet model from Anthropic. - Set gpt-4o-mini as default model for OpenAI. #219 - Fixed bugs with Azure OpenAI service. #223 +- Add audio input option for chat app. #224 +- Fix bug with chat app not loading on linux. #224 +- Allow chat app to run in Positron (not yet as background job) #224 +- API calls now run async with ExtendedTask. #224 +- New styling of chat app. #224 +- Add code syntax highlighting to chat app. #224 +- Replace curl calls with httr2. #224 ## gptstudio 0.4.0 diff --git a/R/addin_chatgpt.R b/R/addin_chatgpt.R index f98bdfd6..8d32abe4 100644 --- a/R/addin_chatgpt.R +++ b/R/addin_chatgpt.R @@ -1,147 +1,96 @@ -#' Run Chat GPT -#' Run the Chat GPT Shiny App as a background job and show it in the viewer pane +#' Run GPTStudio Chat App #' -#' @export +#' This function initializes and runs the Chat GPT Shiny App as a background job +#' in RStudio and opens it in the viewer pane or browser window. +#' +#' @param host A character string specifying the host on which to run the app. +#' Defaults to the value of `getOption("shiny.host", "127.0.0.1")`. +#' +#' @return This function does not return a value. It runs the Shiny app as a side effect. +#' +#' @details +#' The function performs the following steps: +#' 1. Verifies that RStudio API is available. +#' 2. Finds an available port for the Shiny app. +#' 3. Creates a temporary directory for the app files. +#' 4. Runs the app as a background job in RStudio. +#' 5. Opens the app in the RStudio viewer pane or browser window. #' -#' @return This function has no return value. +#' @note This function is designed to work within the RStudio IDE and requires +#' the rstudioapi package. #' -#' @inheritParams shiny::runApp #' @export +#' #' @examples -#' # Call the function as an RStudio addin #' \dontrun{ #' gptstudio_chat() #' } gptstudio_chat <- function(host = getOption("shiny.host", "127.0.0.1")) { + check_installed(c("miniUI", "future")) rstudioapi::verifyAvailable() - stopifnot(rstudioapi::hasFun("viewer")) - - port <- random_port() - app_dir <- create_tmp_app_dir() - run_app_as_bg_job(appDir = app_dir, job_name = "gptstudio", host, port) + port <- find_available_port() + app_dir <- create_temp_app_dir() - open_bg_shinyapp(host, port) -} + run_app_background(app_dir, "gptstudio", host, port) + if (rstudioapi::versionInfo()$mode == "server") { + Sys.sleep(3) + } -#' Generate a random safe port number -#' -#' This function generates a random port allowed by shiny::runApp. -#' -#' @return A single integer representing the randomly selected safe port number. -random_port <- function() { - all_ports <- 3000:8000 - unsafe_ports <- c(3659, 4045, 5060, 5061, 6000, 6566, 6665:6669, 6697) - safe_ports <- setdiff(all_ports, unsafe_ports) - sample(safe_ports, size = 1) + open_app_in_viewer(host, port) } +# Helper functions -#' Run an R Shiny app in the background -#' -#' This function runs an R Shiny app as a background job using the specified -#' directory, name, host, and port. -#' -#' @param job_name The name of the background job to be created -#' @inheritParams shiny::runApp -#' @return This function returns nothing because is meant to run an app as a -#' side effect. -run_app_as_bg_job <- function(appDir = ".", job_name, host, port) { # nolint - job_script <- create_tmp_job_script( - appDir = appDir, - port = port, - host = host - ) - rstudioapi::jobRunScript(job_script, name = job_name) - cli_alert_success( - glue("{job_name} initialized as background job in RStudio") - ) +find_available_port <- function() { + safe_ports <- setdiff(3000:8000, c(3659, 4045, 5060, 5061, 6000, 6566, 6665:6669, 6697)) + sample(safe_ports, 1) } - -#' Create a temporary job script -#' -#' This function creates a temporary R script file that runs the Shiny -#' application from the specified directory with the specified port and host. -#' @inheritParams shiny::runApp -#' @return A string containing the path of a temporary job script -create_tmp_job_script <- function(appDir, port, host) { # nolint - script_file <- tempfile(fileext = ".R") - - line <- - glue::glue( - "shiny::runApp(appDir = '{appDir}', port = {port}, host = '{host}')" - ) - - file_con <- file(script_file) - writeLines(line, con = script_file) - close(file_con) - return(script_file) +create_temp_app_dir <- function() { + dir <- normalizePath(tempdir(), winslash = "/") + app_file <- create_temp_app_file() + file.copy(app_file, file.path(dir, "app.R"), overwrite = TRUE) + dir } -create_tmp_app_dir <- function() { - dir <- tempdir() - - if (.Platform$OS.type == "windows") { - dir <- gsub(pattern = "[\\]", replacement = "/", x = dir) - } +create_temp_app_file <- function() { + temp_file <- tempfile(fileext = ".R") + ide_colors <- dput(get_ide_theme_info()) + code_theme_url <- get_highlightjs_theme() - app_file <- create_tmp_app_file() - file.copy(from = app_file, to = file.path(dir, "app.R"), overwrite = TRUE) - return(dir) + writeLines( + # nolint start + glue::glue( + "ide_colors <- {{paste(deparse(ide_colors), collapse = '\n')}} + ui <- gptstudio:::mod_app_ui('app', ide_colors, '{{code_theme_url}}') + server <- function(input, output, session) { + gptstudio:::mod_app_server('app', ide_colors) + } + shiny::shinyApp(ui, server)", + .open = "{{", .close = "}}" + ), + temp_file) + # nolint end + temp_file } -create_tmp_app_file <- function() { - script_file <- tempfile(fileext = ".R") - ide_theme <- get_ide_theme_info() %>% - dput() %>% - utils::capture.output() - - line_theme <- glue::glue( - "ide_colors <- {ide_theme}" - ) - line_ui <- glue::glue( - "ui <- gptstudio:::mod_app_ui('app', ide_colors)" - ) - line_server <- glue::glue( - "server <- function(input, output, session) { - gptstudio:::mod_app_server('app', ide_colors) - }", - .open = "{{", - .close = "}}" - ) - line_run_app <- glue::glue("shiny::shinyApp(ui, server)") - - file_con <- file(script_file) - - writeLines( - text = c(line_theme, line_ui, line_server, line_run_app), - sep = "\n\n", - con = script_file - ) +run_app_background <- function(app_dir, job_name, host, port) { + job_script <- tempfile(fileext = ".R") + writeLines(glue::glue( + "shiny::runApp(appDir = '{app_dir}', port = {port}, host = '{host}')" + ), job_script) - close(file_con) - return(script_file) + rstudioapi::jobRunScript(job_script, name = job_name) + cli::cli_alert_success("{job_name} initialized as background job in RStudio") } - -#' Open browser to local Shiny app -#' -#' This function takes in the host and port of a local Shiny app and opens the -#' app in the default browser. -#' -#' @param host A character string representing the IP address or domain name of -#' the server where the Shiny app is hosted. -#' @param port An integer representing the port number on which the Shiny app is -#' hosted. -#' -#' @return None (opens the Shiny app in the viewer pane or browser window) -open_bg_shinyapp <- function(host, port) { +open_app_in_viewer <- function(host, port) { url <- glue::glue("http://{host}:{port}") translated_url <- rstudioapi::translateLocalUrl(url, absolute = TRUE) - if (host %in% c("127.0.0.1")) { + if (host == "127.0.0.1") { cli::cli_inform(c( "i" = "Showing app in 'Viewer' pane", "i" = "Run {.run rstudioapi::viewer(\"{url}\")} to see it" @@ -150,17 +99,17 @@ open_bg_shinyapp <- function(host, port) { cli::cli_alert_info("Showing app in browser window") } - if (.Platform$OS.type == "unix") { - wait_for_bg_shinyapp(translated_url) - } + wait_for_bg_app(translated_url) rstudioapi::viewer(translated_url) } -# This function makes a request for the app's url and fails -# if doesn't find anything after 10 seconds -wait_for_bg_shinyapp <- function(url) { +wait_for_bg_app <- function(url, max_seconds = 10) { request(url) %>% - req_retry(max_seconds = 10, backoff = function(n) 0.2) %>% + req_retry( + max_seconds = max_seconds, + is_transient = \(resp) resp_status(resp) >= 300, + backoff = function(n) 0.2 + ) %>% req_perform() } diff --git a/R/addin_comment-code.R b/R/addin_comment-code.R index 14c3102c..562809a4 100644 --- a/R/addin_comment-code.R +++ b/R/addin_comment-code.R @@ -12,7 +12,6 @@ #' gptstudio_comment_code() #' } gptstudio_comment_code <- function() { - file_ext <- get_file_extension() task <- glue::glue( diff --git a/R/api-transcribe-audio.R b/R/api-transcribe-audio.R new file mode 100644 index 00000000..29af8ffe --- /dev/null +++ b/R/api-transcribe-audio.R @@ -0,0 +1,86 @@ +#' Parse a Data URI +#' +#' This function parses a data URI and returns the MIME type and decoded data. +#' +#' @param data_uri A string. The data URI to parse. +#' +#' @return A list with two elements: 'mime_type' and 'data'. +#' +parse_data_uri <- function(data_uri) { + if (is.null(data_uri) || !is.character(data_uri) || length(data_uri) != 1) { + cli::cli_abort("Invalid input: data_uri must be a single character string") + } + + match <- regexec("^data:(.+);base64,(.*)$", data_uri) + if (match[[1]][1] == -1) { + cli::cli_abort("Invalid data URI format") + } + groups <- regmatches(data_uri, match)[[1]] + mime_type <- groups[2] + b64data <- groups[3] + # Add padding if necessary + padding <- nchar(b64data) %% 4 + if (padding > 0) { + b64data <- paste0(b64data, strrep("=", 4 - padding)) + } + list(mime_type = mime_type, data = jsonlite::base64_dec(b64data)) +} + +#' Transcribe Audio from Data URI Using OpenAI's Whisper Model +#' +#' This function takes an audio file in data URI format, converts it to WAV, and +#' sends it to OpenAI's transcription API to get the transcribed text. +#' +#' @param audio_input A string. The audio data in data URI format. +#' @param api_key A string. Your OpenAI API key. Defaults to the OPENAI_API_KEY +#' environment variable. +#' +#' @return A string containing the transcribed text. +#' +#' @export +#' +#' @examples +#' \dontrun{ +#' audio_uri <- "data:audio/webm;base64,SGVsbG8gV29ybGQ=" # Example data URI +#' transcription <- transcribe_audio(audio_uri) +#' print(transcription) +#' } +#' +transcribe_audio <- function(audio_input, api_key = Sys.getenv("OPENAI_API_KEY")) { + parsed <- parse_data_uri(audio_input) + + temp_webm <- tempfile(fileext = ".webm") + temp_wav <- tempfile(fileext = ".wav") + writeBin(parsed$data, temp_webm) + system_result <- # nolint + system2("ffmpeg", + args = c("-i", temp_webm, "-acodec", "pcm_s16le", "-ar", "44100", temp_wav), # nolint + stdout = TRUE, + stderr = TRUE + ) + + if (!file.exists(temp_wav)) { + cli::cli_abort("Failed to convert audio: {system_result}") + } + + req <- request("https://api.openai.com/v1/audio/transcriptions") %>% + req_auth_bearer_token(api_key) %>% + req_body_multipart( + file = structure(list(path = temp_wav, + type = NULL, + name = NULL), + class = "form_file"), + model = "whisper-1", + response_format = "text" + ) + + resp <- req_perform(req) + + if (resp_is_error(resp)) { + cli::cli_abort("API request failed: {resp_status_desc(resp)}") + } + + user_prompt <- resp_body_string(resp) + file.remove(temp_webm, temp_wav) + invisible(user_prompt) +} diff --git a/R/api_perform_request.R b/R/api_perform_request.R index 0f32669e..63e1efb5 100644 --- a/R/api_perform_request.R +++ b/R/api_perform_request.R @@ -150,20 +150,40 @@ gptstudio_request_perform.gptstudio_request_anthropic <- } #' @export -gptstudio_request_perform.gptstudio_request_azure_openai <- function(skeleton, ...) { - messages <- c( - skeleton$history, - list( - list(role = "user", content = skeleton$prompt) - ) +gptstudio_request_perform.gptstudio_request_azure_openai <- function(skeleton, + shiny_session = NULL, + ...) { + + skeleton$history <- chat_history_append( + history = skeleton$history, + role = "user", + name = "user_message", + content = skeleton$prompt ) - response <- query_api_azure_openai(request_body = messages) + if (isTRUE(skeleton$stream)) { + if (is.null(shiny_session)) stop("Stream requires a shiny session object") + + stream_handler <- OpenaiStreamParser$new( + session = shiny_session, + user_prompt = skeleton$prompt + ) + + stream_azure_openai( + messages = skeleton$history, + element_callback = stream_handler$parse_sse + ) + + response <- stream_handler$value + } else { + response <- query_api_azure_openai(request_body = skeleton$history) + response <- response$choices[[1]]$message$content + } structure( list( skeleton = skeleton, - response = response$choices[[1]]$message$content + response = response ), class = "gptstudio_response_azure_openai" ) diff --git a/R/api_process_response.R b/R/api_process_response.R index 8acf7c24..05409b9f 100644 --- a/R/api_process_response.R +++ b/R/api_process_response.R @@ -112,17 +112,14 @@ gptstudio_response_process.gptstudio_response_google <- #' @export gptstudio_response_process.gptstudio_response_azure_openai <- function(skeleton, ...) { - response <- skeleton$response + last_response <- skeleton$response skeleton <- skeleton$skeleton - last_response <- response - - new_history <- c( - skeleton$history, - list( - list(role = "user", content = skeleton$prompt), - list(role = "assistant", content = last_response) - ) + new_history <- chat_history_append( + history = skeleton$history, + role = "assistant", + name = "assistant", + content = last_response ) skeleton$history <- new_history diff --git a/R/api_skeletons.R b/R/api_skeletons.R index b070ac0f..791505d4 100644 --- a/R/api_skeletons.R +++ b/R/api_skeletons.R @@ -16,32 +16,35 @@ new_gpstudio_request_skeleton <- function(url, api_key, model, prompt, history, } validate_skeleton <- function(url, api_key, model, prompt, history, stream) { - assert_that( - rlang::is_scalar_character(url), - msg = "URL is not a valid character scalar" - ) - assert_that( - rlang::is_scalar_character(api_key) && api_key != "", - msg = "API key is not valid" - ) - assert_that( - rlang::is_scalar_character(model) && model != "", - msg = "Model name is not a valid character scalar" - ) - assert_that( - rlang::is_scalar_character(prompt), - msg = "Prompt is not a valid list" - ) + if (!is_scalar_character(url)) { + cli_abort("{.arg url} is not a valid character scalar. + It is a {.cls {class(url)}}.") + } - # is list or is NULL - assert_that( - rlang::is_list(history) || is.null(history), - msg = "History is not a valid list or NULL" - ) - assert_that( - rlang::is_bool(stream), - msg = "Stream is not a valid boolean" - ) + if (!is_scalar_character(api_key) || api_key == "") { + cli_abort("{.arg api_key} is not a valid character scalar. + It is a {.cls {class(api_key)}}.") + } + + if (!is_scalar_character(model) || model == "") { + cli_abort("{.arg model} is not a valid character scalar. + It is a {.cls {class(model)}}.") + } + + if (!is_scalar_character(prompt)) { + cli_abort("{.arg prompt} is not a valid character scalar. + It is a {.cls {class(prompt)}}.") + } + + if (!is_list(history) && !is.null(history)) { + cli_abort("{.arg history} is not a valid list or NULL. + It is a {.cls {class(history)}}.") + } + + if (!is_scalar_logical(stream)) { + cli_abort("{.arg stream} is not a valid boolean. + It is a {.cls {class(stream)}}.") + } } new_gptstudio_request_skeleton_openai <- function( @@ -160,7 +163,7 @@ new_gptstudio_request_skeleton_azure_openai <- function( new_gptstudio_request_skeleton_ollama <- function(model, prompt, history, stream) { new_gpstudio_request_skeleton( url = Sys.getenv("OLLAMA_HOST"), - api_key = "JUST A PLACESHOLDER", + api_key = "JUST A PLACEHOLDER", model = model, prompt = prompt, history = history, @@ -283,8 +286,7 @@ gptstudio_create_skeleton <- function(service = "openai", model = model, prompt = prompt, history = history, - # forcing false until streaming implemented for azure openai - stream = FALSE + stream = stream ), "ollama" = new_gptstudio_request_skeleton_ollama( model = model, diff --git a/R/app_chat_style.R b/R/app_chat_style.R index c868645d..cece5743 100644 --- a/R/app_chat_style.R +++ b/R/app_chat_style.R @@ -5,7 +5,7 @@ #' #' @param history A list of chat messages with elements containing 'role' and #' 'content'. -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' #' @return A list of formatted chat messages with styling applied, excluding #' system messages. @@ -30,43 +30,78 @@ style_chat_history <- function(history, ide_colors = get_ide_theme_info()) { #' Style a message based on the role of its author. #' #' @param message A chat message. -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' @return An HTML element. style_chat_message <- function(message, ide_colors = get_ide_theme_info()) { colors <- create_ide_matching_colors(message$role, ide_colors) - - icon_name <- switch(message$role, - "user" = "fas fa-user", - "assistant" = "fas fa-robot" - ) - - position_class <- switch(message$role, - "user" = "justify-content-end", - "assistant" = "justify-content-start" + icon_name <- switch( + message$role, + "user" = "person-fill", + "assistant" = "robot" ) - - if (!is.null(message$name) && message$name == "docs") { message_content <- render_docs_message_content(message$content) } else { message_content <- shiny::markdown(message$content) } - + bubble_style <- htmltools::css( + `color` = colors$fg_color, + `background-color` = colors$bg_color, + `border-radius` = if (message$role == "user") "20px 20px 0 20px" else "20px 20px 20px 0", + `box-shadow` = "0 2px 4px rgba(0, 0, 0, 0.2)", + `max-width` = "85%", # Increased from 80% to allow more space + `min-width` = "auto", # Allow the bubble to shrink to fit content + `width` = "fit-content", # Make the bubble fit its content + `word-break` = "break-word", # Changed from word-wrap to word-break + `white-space` = "normal" # Ensure normal wrapping behavior + ) + icon_style <- htmltools::css( + `width` = "30px", + `height` = "30px", + `background-color` = colors$bg_color, + `color` = colors$fg_color, + `border-radius` = "50%", + `display` = "flex", + `align-items` = "center", + `justify-content` = "center", + `flex-shrink` = "0" + ) htmltools::div( - class = glue("row m-0 p-0 {position_class}"), - htmltools::tags$div( - class = glue("p-2 mb-2 rounded d-inline-block w-auto mw-100"), - style = htmltools::css( - `color` = colors$fg_color, - `background-color` = colors$bg_color - ), - shiny::icon(icon_name, lib = "font-awesome"), - htmltools::tags$div( - class = glue("{message$role}-message-wrapper"), - htmltools::tagList( - message_content - ) + class = "row m-0 p-2", + style = "max-width: 100%; overflow-x: hidden;", + htmltools::div( + class = if (message$role == "user") { + "d-flex justify-content-end w-100" + } else { + "d-flex w-100" + }, + htmltools::div( + class = "d-flex align-items-end", + style = "max-width: 100%;", + if (message$role == "assistant") { + htmltools::div( + style = icon_style, + class = "m-1", + bsicons::bs_icon(icon_name) + ) + }, + htmltools::div( + class = glue("p-3 mb-2 rounded d-inline-block chat-bubble {message$role}-bubble"), + style = bubble_style, + htmltools::div( + class = glue("{message$role}-message-wrapper"), + style = "overflow-x: auto;", + htmltools::tagList(message_content) + ) + ), + if (message$role == "user") { + htmltools::div( + style = icon_style, + class = "m-1", + bsicons::bs_icon(icon_name) + ) + } ) ) ) @@ -77,11 +112,11 @@ style_chat_message <- function(message, #' This returns a list of color properties for a chat message #' #' @param role The role of the message author -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' @return list -create_ide_matching_colors <- function(role, +create_ide_matching_colors <- function(role = c("user", "assistant"), ide_colors = get_ide_theme_info()) { - assert_that(role %in% c("user", "assistant")) + arg_match(role) bg_colors <- if (ide_colors$is_dark) { list( @@ -189,3 +224,144 @@ chat_history_append <- function(history, role, content, name = NULL) { c(history, list(new_message)) } + +get_highlightjs_theme <- function() { + if (.Platform$GUI == "RStudio") { + rstudio_theme <- rstudioapi::getThemeInfo()$editor + clean_theme_name <- tolower(gsub(" \\{rsthemes\\}$", "", rstudio_theme)) + + theme_mapping <- list( + # Original mappings + "a11y-dark" = "a11y-dark", + "a11y-light" = "a11y-light", + "base16 3024" = "base16/3024", + "base16 apathy" = "base16/apathy", + "base16 ashes" = "base16/ashes", + "base16 atelier cave light" = "base16/atelier-cave-light", + "base16 atelier cave" = "base16/atelier-cave", + "base16 atelier dune light" = "base16/atelier-dune-light", + "base16 atelier dune" = "base16/atelier-dune", + "base16 atelier estuary light" = "base16/atelier-estuary-light", + "base16 atelier estuary" = "base16/atelier-estuary", + "base16 atelier forest light" = "base16/atelier-forest-light", + "base16 atelier forest" = "base16/atelier-forest", + "base16 atelier heath light" = "base16/atelier-heath-light", + "base16 atelier heath" = "base16/atelier-heath", + "base16 atelier lakeside light" = "base16/atelier-lakeside-light", + "base16 atelier lakeside" = "base16/atelier-lakeside", + "base16 atelier plateau light" = "base16/atelier-plateau-light", + "base16 atelier plateau" = "base16/atelier-plateau", + "base16 atelier savanna light" = "base16/atelier-savanna-light", + "base16 atelier savanna" = "base16/atelier-savanna", + "base16 atelier seaside light" = "base16/atelier-seaside-light", + "base16 atelier seaside" = "base16/atelier-seaside", + "base16 atelier sulphurpool light" = "base16/atelier-sulphurpool-light", + "base16 atelier sulphurpool" = "base16/atelier-sulphurpool", + "base16 bespin" = "base16/bespin", + "base16 brewer" = "base16/brewer", + "base16 bright" = "base16/bright", + "base16 chalk" = "base16/chalk", + "base16 codeschool" = "base16/codeschool", + "base16 cupcake" = "base16/cupcake", + "base16 darktooth" = "base16/darktooth", + "base16 default dark" = "base16/default-dark", + "base16 default light" = "base16/default-light", + "base16 dracula" = "dracula", + "base16 eighties" = "base16/eighties", + "base16 embers" = "base16/embers", + "base16 flat" = "base16/flat", + "base16 google dark" = "base16/google-dark", + "base16 google light" = "base16/google-light", + "base16 grayscale dark" = "base16/grayscale-dark", + "base16 grayscale light" = "base16/grayscale-light", + "base16 green screen" = "base16/greenscreen", + "base16 gruvbox dark, hard" = "base16/gruvbox-dark-hard", + "base16 gruvbox dark, medium" = "base16/gruvbox-dark-medium", + "base16 gruvbox dark, pale" = "base16/gruvbox-dark-pale", + "base16 gruvbox dark, soft" = "base16/gruvbox-dark-soft", + "base16 gruvbox light, hard" = "base16/gruvbox-light-hard", + "base16 gruvbox light, medium" = "base16/gruvbox-light-medium", + "base16 gruvbox light, soft" = "base16/gruvbox-light-soft", + "base16 harmonic16 dark" = "base16/harmonic16-dark", + "base16 harmonic16 light" = "base16/harmonic16-light", + "base16 hopscotch" = "base16/hopscotch", + "base16 ir black" = "base16/ir-black", + "base16 isotope" = "base16/isotope", + "base16 london tube" = "base16/london-tube", + "base16 macintosh" = "base16/macintosh", + "base16 marrakesh" = "base16/marrakesh", + "base16 materia" = "base16/materia", + "base16 mexico light" = "base16/mexico-light", + "base16 mocha" = "base16/mocha", + "base16 monokai" = "monokai", + "base16 nord" = "nord", + "base16 ocean" = "base16/ocean", + "base16 oceanicnext" = "base16/oceanicnext", + "base16 onedark" = "base16/onedark", + "base16 paraiso" = "base16/paraiso", + "base16 phd" = "base16/phd", + "base16 pico" = "base16/pico", + "base16 pop" = "base16/pop", + "base16 railscasts" = "base16/railscasts", + "base16 rebecca" = "base16/rebecca", + "base16 seti ui" = "base16/seti-ui", + "base16 shapeshifter" = "base16/shapeshifter", + "base16 solar flare" = "base16/solar-flare", + "base16 solarized dark" = "solarized-dark", + "base16 solarized light" = "solarized-light", + "base16 spacemacs" = "base16/spacemacs", + "base16 summerfruit dark" = "base16/summerfruit-dark", + "base16 summerfruit light" = "base16/summerfruit-light", + "base16 tomorrow night" = "tomorrow-night", + "base16 tomorrow" = "tomorrow", + "base16 twilight" = "twilight", + "base16 unikitty dark" = "base16/unikitty-dark", + "base16 unikitty light" = "base16/unikitty-light", + "base16 woodland" = "base16/woodland", + "elm dark" = "atom-one-dark", + "elm light" = "atom-one-light", + "embark" = "dracula", + "fairyfloss" = "rainbow", + "flat white" = "github", + "github" = "github", + "horizon dark" = "night-owl", + "material darker" = "atom-one-dark", + "material lighter" = "atom-one-light", + "material ocean" = "ocean", + "material palenight" = "atom-one-dark", + "material" = "atom-one-dark", + "night owl" = "night-owl", + "nord polar night aurora" = "nord", + "nord snow storm" = "nord", + "oceanic plus" = "ocean", + "one dark" = "atom-one-dark", + "one light" = "atom-one-light", + "serendipity dark" = "dracula", + "serendipity light" = "github", + "solarized dark" = "solarized-dark", + "solarized light" = "solarized-light", + "yule rstudio (reduced motion)" = "github", + "yule rstudio" = "github", + "textmate" = "github", + "cobalt" = "cobalt", + "eclipse" = "eclipse", + "vibrant ink" = "vibrant-ink", + "clouds" = "clouds", + "clouds midnight" = "tomorrow-night-blue", + "merbivore" = "merbivore", + "ambiance" = "ambiance", + "chaos" = "chaos", + "tomorrow night blue" = "tomorrow-night-blue", + "tomorrow night bright" = "tomorrow-night-bright", + "tomorrow night eighties" = "tomorrow-night-eighties" + ) + + theme <- theme_mapping[[clean_theme_name]] %||% "github-dark" + } else { + cli::cli_inform("Failed to get RStudio theme. Using default 'github-dark' theme.") + theme <- "github-dark" + } + base_url <- + "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/" + glue::glue("{base_url}{theme}.min.css") +} diff --git a/R/app_config.R b/R/app_config.R index 48d3d992..7f889a52 100644 --- a/R/app_config.R +++ b/R/app_config.R @@ -6,7 +6,8 @@ save_user_config <- function(code_style, model, custom_prompt, stream, - read_docs) { + read_docs, + audio_input) { if (is.null(custom_prompt)) custom_prompt <- "" config <- data.frame( @@ -18,7 +19,8 @@ save_user_config <- function(code_style, model, custom_prompt, stream, - read_docs + read_docs, + audio_input ) user_config_path <- tools::R_user_dir("gptstudio", which = "config") user_config <- file.path(user_config_path, "config.yml") @@ -40,7 +42,8 @@ set_user_options <- function(config) { gptstudio.custom_prompt = config$custom_prompt, gptstudio.stream = config$stream, # added in v.3.1+ dev version - gptstudio.read_docs = config$read_docs + gptstudio.read_docs = config$read_docs, + gptstudio.audio_input = config$audio_input ) options(op_gptstudio) invisible() diff --git a/R/chat.R b/R/chat.R index c556bf4f..ef6df5b3 100644 --- a/R/chat.R +++ b/R/chat.R @@ -40,6 +40,7 @@ #' response. If `TRUE`, the response will be passed to #' `gptstudio_response_process()` for further processing. Defaults to `FALSE`. #' Refer to `gptstudio_response_process()` for more details. +#' @param session An optional parameter for a shiny session object. #' @param ... Reserved for future use. #' #' @return Depending on the task and processing, the function returns the @@ -82,6 +83,7 @@ chat <- function(prompt, task = getOption("gptstudio.task", "coding"), custom_prompt = NULL, process_response = FALSE, + session = NULL, ...) { response <- gptstudio_create_skeleton( @@ -98,7 +100,7 @@ chat <- function(prompt, task = task, custom_prompt = custom_prompt ) %>% - gptstudio_request_perform() + gptstudio_request_perform(shiny_session = session) if (process_response) { response %>% gptstudio_response_process() diff --git a/R/gptstudio-package.R b/R/gptstudio-package.R index 08dfeaed..53cc8f07 100644 --- a/R/gptstudio-package.R +++ b/R/gptstudio-package.R @@ -5,7 +5,6 @@ #' @import cli #' @import rlang #' @import httr2 -#' @importFrom assertthat assert_that is.string is.number is.count #' @importFrom glue glue ## gptstudio namespace: end NULL diff --git a/R/gptstudio-sitrep.R b/R/gptstudio-sitrep.R index 80bb48f6..8da244cc 100644 --- a/R/gptstudio-sitrep.R +++ b/R/gptstudio-sitrep.R @@ -136,9 +136,10 @@ check_api_connection_cohere <- function(service, api_key) { #' print to the console. #' #' @examples +#' \dontrun{ #' gptstudio_sitrep(verbose = FALSE) # Print basic settings, no API checks #' gptstudio_sitrep() # Print settings and check API connections -#' +#' } #' @export gptstudio_sitrep <- function(verbose = TRUE) { cli::cli_h1("Configuration for gptstudio") diff --git a/R/mod_app.R b/R/mod_app.R index e6344b2e..6f2d3ef9 100644 --- a/R/mod_app.R +++ b/R/mod_app.R @@ -1,16 +1,18 @@ #' App UI #' #' @param id id of the module -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' #' @import htmltools #' @import shiny #' -mod_app_ui <- function(id, ide_colors = get_ide_theme_info()) { +mod_app_ui <- function(id, + ide_colors = get_ide_theme_info(), + code_theme_url = get_highlightjs_theme()) { ns <- NS(id) translator <- create_translator(language = getOption("gptstudio.language")) tagList( - waiter::use_waiter(), + useBusyIndicators(), bslib::page_fluid( theme = create_chat_app_theme(ide_colors), title = "ChatGPT from gptstudio", @@ -29,8 +31,7 @@ mod_app_ui <- function(id, ide_colors = get_ide_theme_info()) { class = "row justify-content-center h-100", div( class = "col h-100", - style = htmltools::css(`max-width` = "800px"), - mod_chat_ui(ns("chat"), translator) + mod_chat_ui(ns("chat"), translator, code_theme_url) ) ) ) @@ -41,7 +42,7 @@ mod_app_ui <- function(id, ide_colors = get_ide_theme_info()) { #' App Server #' #' @inheritParams mod_app_ui -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' mod_app_server <- function(id, ide_colors = get_ide_theme_info()) { moduleServer(id, function(input, output, session) { @@ -63,6 +64,7 @@ mod_app_server <- function(id, ide_colors = get_ide_theme_info()) { #' #' @return hex color rgb_str_to_hex <- function(rgb_string) { + check_installed("grDevices") rgb_vec <- unlist(strsplit(gsub("[rgba() ]", "", rgb_string), ",")) grDevices::rgb( red = as.numeric(rgb_vec[1]), @@ -78,12 +80,13 @@ rgb_str_to_hex <- function(rgb_string) { #' #' Create a bslib theme that matches the user's RStudio IDE theme. #' -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' #' @return A bslib theme create_chat_app_theme <- function(ide_colors = get_ide_theme_info()) { bslib::bs_theme( version = 5, + preset = "shiny", bg = ide_colors$bg, fg = ide_colors$fg, font_scale = 0.9, @@ -93,39 +96,57 @@ create_chat_app_theme <- function(ide_colors = get_ide_theme_info()) { } -#' Get IDE theme information. +#' Get IDE Theme Information #' -#' This function returns a list with the current IDE theme's information. +#' Retrieves the current RStudio IDE theme information including whether it is a dark theme, +#' and the background and foreground colors in hexadecimal format. #' -#' @return A list with three components: -#' \item{is_dark}{A boolean indicating whether the current IDE theme is dark.} -#' \item{bg}{The current IDE theme's background color.} -#' \item{fg}{The current IDE theme's foreground color.} +#' @return A list with the following components: +#' \item{is_dark}{A logical indicating whether the current IDE theme is dark.} +#' \item{bg}{A character string representing the background color of the IDE theme in hex format.} +#' \item{fg}{A character string representing the foreground color of the IDE theme in hex format.} #' -#' @export +#' If RStudio is unavailable, returns the fallback theme details. +#' +#' @examples +#' theme_info <- get_ide_theme_info() +#' print(theme_info) #' +#' @export get_ide_theme_info <- function() { if (rstudioapi::isAvailable()) { - rstudio_theme_info <- rstudioapi::getThemeInfo() + tryCatch( + { + rstudio_theme_info <- rstudioapi::getThemeInfo() - # create a list with three components - list( - is_dark = rstudio_theme_info$dark, - bg = rgb_str_to_hex(rstudio_theme_info$background), - fg = rgb_str_to_hex(rstudio_theme_info$foreground) + # Create a list with theme components + list( + is_dark = rstudio_theme_info$dark, + bg = rgb_str_to_hex(rstudio_theme_info$background), + fg = rgb_str_to_hex(rstudio_theme_info$foreground) + ) + }, + error = function(e) { + cli::cli_warn("Error fetching theme info from RStudio: {e$message}") + fallback_theme() + } ) } else { - if (interactive()) cli::cli_inform("Using fallback ide theme") - - # create a list with three components with fallback values - list( - is_dark = TRUE, - bg = "#002B36", - fg = "#93A1A1" - ) + if (interactive()) cli::cli_inform("RStudio is not available. Using fallback IDE theme.") + fallback_theme() } } +# Fallback function to provide default values +fallback_theme <- function() { + list( + is_dark = TRUE, + bg = "#181818", + fg = "#C1C1C1" + ) +} + + html_dependencies <- function() { htmltools::htmlDependency( name = "gptstudio-assets", version = "0.4.0", diff --git a/R/mod_chat.R b/R/mod_chat.R index 36765c06..c8839582 100644 --- a/R/mod_chat.R +++ b/R/mod_chat.R @@ -2,8 +2,11 @@ #' #' @param id id of the module #' @param translator A Translator from `shiny.i18n::Translator` +#' @param code_theme_url URL to the highlight.js theme #' -mod_chat_ui <- function(id, translator = create_translator()) { +mod_chat_ui <- function(id, + translator = create_translator(), + code_theme_url = get_highlightjs_theme()) { ns <- NS(id) bslib::card( @@ -21,35 +24,31 @@ mod_chat_ui <- function(id, translator = create_translator()) { div( class = "mt-auto", style = css( - "margin-left" = "40px", - "margin-right" = "40px" + "margin-left" = "20px", + "margin-right" = "20px" ), htmltools::div( class = "position-relative", style = css( "width" = "100%" ), - div( - text_area_input_wrapper( - inputId = ns("chat_input"), - label = NULL, - width = "100%", - placeholder = translator$t("Write your prompt here"), - value = "", - resize = "none", - textarea_class = "chat-prompt" - ) - ), - div( - class = "position-absolute top-50 end-0 translate-middle", - actionButton( - inputId = ns("chat"), - label = icon("fas fa-paper-plane"), - class = "w-100 btn-primary p-1 chat-send-btn" - ) %>% - bslib::tooltip("Send (click or Enter)") - ) + uiOutput(ns("chat_with_audio")) ) + ), + tags$head( + tags$script(src = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/highlight.js"), #nolint + tags$link( + rel = "stylesheet", + href = code_theme_url + ), + # Add JavaScript to initialize highlight.js + tags$script(HTML(" + document.addEventListener('DOMContentLoaded', (event) => { + document.querySelectorAll('pre code').forEach((el) => { + hljs.highlightElement(el); + }); + }); + ")) ) ) ) @@ -61,72 +60,104 @@ mod_chat_ui <- function(id, translator = create_translator()) { #' @param id id of the module #' @param translator Translator from `shiny.i18n::Translator` #' @param settings,history Reactive values from the settings and history module -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' -mod_chat_server <- function(id, - ide_colors = get_ide_theme_info(), - translator = create_translator(), - settings, - history) { - # This is where changes will focus +mod_chat_server <- function( + id, + ide_colors = get_ide_theme_info(), + translator = create_translator(), + settings, + history) { moduleServer(id, function(input, output, session) { - # Session data ---- + check_installed("promises") - rv <- reactiveValues() - rv$reset_welcome_message <- 0L - rv$reset_streaming_message <- 0L + # Session data ---- + rv <- reactiveValues( + reset_welcome_message = 0L, + reset_streaming_message = 0L, + audio_input = getOption("gptstudio.audio_input") + ) # UI outputs ---- - output$welcome <- renderWelcomeMessage({ welcomeMessage(ide_colors) - }) %>% - bindEvent(rv$reset_welcome_message) - + }) %>% bindEvent(rv$reset_welcome_message) output$history <- renderUI({ - history$chat_history %>% - style_chat_history(ide_colors = ide_colors) + rendered_history <- history$chat_history %>% style_chat_history(ide_colors = ide_colors) + tagList( + tags$div(rendered_history), + tags$script("hljs.highlightAll();") + ) }) - output$streaming <- renderStreamingMessage({ - # This has display: none by default. It is only shown when receiving a stream - # After the stream is completed, it will reset. streamingMessage(ide_colors) - }) %>% - bindEvent(rv$reset_streaming_message) - + }) %>% bindEvent(rv$reset_streaming_message) # Observers ---- - - observe({ + observeEvent(history$create_new_chat, { rv$reset_welcome_message <- rv$reset_welcome_message + 1L - }) %>% - bindEvent(history$create_new_chat) + }) + process_chat <- ExtendedTask$new(function(prompt, + service, + chat_history, + stream, + model, + skill, + style, + task, + custom_prompt) { + promises::future_promise({ + chat( + prompt = prompt, + service = service, + history = chat_history, + stream = stream, + model = model, + skill = skill, + style = style, + task = task, + custom_prompt = custom_prompt, + process_response = TRUE, + session = session + ) + }) + }) %>% bslib::bind_task_button("chat") - observe({ + observeEvent(input$chat, { + process_chat$invoke( + prompt = input$chat_input, + service = settings$service, + chat_history = history$chat_history, + stream = settings$stream, + model = settings$model, + skill = settings$skill, + style = settings$style, + task = settings$task, + custom_prompt = settings$custom_prompt + ) + }) - skeleton <- gptstudio_create_skeleton( + observeEvent(input$clip, { + req(input$clip) + new_prompt <- transcribe_audio(input$clip) + process_chat$invoke( + prompt = new_prompt, service = settings$service, + chat_history = history$chat_history, + stream = settings$stream, model = settings$model, - prompt = input$chat_input, - history = history$chat_history, - stream = settings$stream - ) %>% - gptstudio_skeleton_build( - skill = settings$skill, - style = settings$style, - task = settings$task, - custom_prompt = settings$custom_prompt - ) + skill = settings$skill, + style = settings$style, + task = settings$task, + custom_prompt = settings$custom_prompt + ) + }) - response <- gptstudio_request_perform( - skeleton = skeleton, - shiny_session = session - ) %>% - gptstudio_response_process() + observeEvent(process_chat$result(), { + response <- process_chat$result() history$chat_history <- response$history @@ -141,7 +172,48 @@ mod_chat_server <- function(id, } updateTextAreaInput(session, "chat_input", value = "") - }) %>% - bindEvent(input$chat) + }) + + output$chat_with_audio <- renderUI({ + ns <- session$ns + audio_recorder <- if (rv$audio_input) { + div( + style = "position: absolute; right: 20px; top: 25%; transform: translateY(-50%);", + input_audio_clip( + ns("clip"), + record_label = NULL, + stop_label = NULL, + show_mic_settings = FALSE, + ) + ) + } + + tagList( + div( + div( + style = "flex-grow: 1; height: 100%;", + text_area_input_wrapper( + inputId = ns("chat_input"), + label = NULL, + width = "100%", + height = "100%", + value = "", + resize = "none", + textarea_class = "chat-prompt" + ) + ), + div( + style = "position: absolute; right: 10px; top: 50%; transform: translateY(-50%);", + bslib::input_task_button( + id = ns("chat"), + label = bsicons::bs_icon("send"), + label_busy = NULL, + class = "btn-secondary p-2 chat-send-btn" + ) %>% bslib::tooltip("Send (click or Enter)") + ), + audio_recorder + ) + ) + }) }) } diff --git a/R/mod_history.R b/R/mod_history.R index 5cad2948..7942fe5b 100644 --- a/R/mod_history.R +++ b/R/mod_history.R @@ -4,20 +4,20 @@ mod_history_ui <- function(id) { btn_new_chat <- actionButton( inputId = ns("new_chat"), label = "New chat", - icon = shiny::icon("plus"), + icon = icon("plus"), class = "flex-grow-1 me-2" ) btn_delete_all <- actionButton( inputId = ns("delete_all"), - label = fontawesome::fa("trash"), + label = bsicons::bs_icon("trash"), class = "me-2" ) %>% bslib::tooltip("Delete all chats") btn_settings <- actionButton( inputId = ns("settings"), - label = fontawesome::fa("gear") + label = bsicons::bs_icon("gear") ) %>% bslib::tooltip("Settings") @@ -237,13 +237,13 @@ conversation <- function( class = "multi-click-input flex-grow-1 text-truncate", `shiny-input-id` = ns_safe("conversation_id", ns), value = id, - fontawesome::fa("message"), + bsicons::bs_icon("chat"), title ) %>% tooltip_on_hover(title, placement = "right") edit_btn <- tags$span( - fontawesome::fa("pen-to-square", margin_left = "0.4em"), + bsicons::bs_icon("pencil-square"), class = "multi-click-input", `shiny-input-id` = ns_safe("conversation_to_edit", ns), value = id @@ -251,7 +251,7 @@ conversation <- function( tooltip_on_hover("Edit title", placement = "left") delete_btn <- tags$span( - fontawesome::fa("trash-can", margin_left = "0.4em"), + bsicons::bs_icon("trash"), class = "multi-click-input", `shiny-input-id` = ns_safe("conversation_to_delete", ns), value = id @@ -272,7 +272,7 @@ tooltip_on_hover <- purrr::partial(bslib::tooltip, options = list(trigger = "hov # Finds the first user prompt and returns it truncated find_placeholder_title <- function(chat_history) { chat_history %>% - purrr::keep(~(!is.null(.x$name)) && .x$name == "user_message") %>% + purrr::keep(~ (!is.null(.x$name)) && .x$name == "user_message") %>% purrr::pluck(1L, "content") %>% stringr::str_trunc(40L) } diff --git a/R/mod_settings.R b/R/mod_settings.R index 8d27b276..73bc8ff8 100644 --- a/R/mod_settings.R +++ b/R/mod_settings.R @@ -8,7 +8,7 @@ mod_settings_ui <- function(id, translator = create_translator()) { read_docs_label <- tags$span( "Read R help pages", bslib::tooltip( - shiny::icon("info-circle"), + bsicons::bs_icon("info-circle"), "Add help pages of 'package::object' matches for context. Potentially expensive. Save as default to effectively change" @@ -20,7 +20,7 @@ mod_settings_ui <- function(id, translator = create_translator()) { multiple = FALSE, bslib::accordion_panel( title = "Assistant behavior", - icon = fontawesome::fa("robot"), + icon = bsicons::bs_icon("robot"), selectInput( inputId = ns("task"), label = translator$t("Task"), @@ -57,7 +57,7 @@ mod_settings_ui <- function(id, translator = create_translator()) { ), bslib::accordion_panel( title = "API service", - icon = fontawesome::fa("server"), + icon = bsicons::bs_icon("server"), selectInput( inputId = ns("service"), label = translator$t("Select API Service"), @@ -77,11 +77,17 @@ mod_settings_ui <- function(id, translator = create_translator()) { label = "Stream Response", value = as.logical(getOption("gptstudio.stream")), width = "100%" + ), + bslib::input_switch( + id = ns("audio_input"), + label = "Audio as Input", + value = as.logical(getOption("gptstudio.audio_input")), + width = "100%" ) ), bslib::accordion_panel( title = "UI options", - icon = fontawesome::fa("sliders"), + icon = bsicons::bs_icon("sliders"), selectInput( inputId = ns("language"), # label = translator$t("Language"), # TODO: update translator @@ -95,21 +101,21 @@ mod_settings_ui <- function(id, translator = create_translator()) { btn_to_history <- actionButton( inputId = ns("to_history"), - label = fontawesome::fa("arrow-left-long"), + label = bsicons::bs_icon("arrow-left"), class = "mb-3" ) %>% bslib::tooltip("Back to history") btn_save_as_default <- actionButton( inputId = ns("save_default"), - label = fontawesome::fa("floppy-disk"), + label = bsicons::bs_icon("floppy"), class = "mb-3" ) %>% bslib::tooltip("Save as default") btn_save_in_session <- actionButton( inputId = ns("save_session"), - label = fontawesome::fa("bookmark"), + label = bsicons::bs_icon("bookmark"), class = "mb-3" ) %>% bslib::tooltip("Save for this session") @@ -130,10 +136,11 @@ mod_settings_server <- function(id) { rv$selected_history <- 0L rv$modify_session_settings <- 0L rv$create_new_chat <- 0L + rv$record_input <- 0L observe({ msg <- glue::glue("Fetching models for {input$service} service...") - showNotification(ui = msg, type = "message", duration = 3, session = session) + showNotification(ui = msg, type = "message", duration = 1, session = session) cli::cli_alert_info(msg) models <- tryCatch( { @@ -153,7 +160,7 @@ mod_settings_server <- function(id) { ) if (length(models) > 0) { - showNotification(ui = "Got models!", duration = 3, type = "message", session = session) + showNotification(ui = "Got models!", duration = 1.5, type = "message", session = session) cli::cli_alert_success("Got models!") default_model <- getOption("gptstudio.model") @@ -217,7 +224,8 @@ mod_settings_server <- function(id) { model = input$model, custom_prompt = input$custom_prompt, stream = input$stream, - read_docs = input$read_docs + read_docs = input$read_docs, + audio_input = input$audio_input ) rv$modify_session_settings <- rv$modify_session_settings + 1L @@ -259,6 +267,7 @@ mod_settings_server <- function(id) { rv$service <- input$service %||% getOption("gptstudio.service") rv$stream <- as.logical(input$stream %||% getOption("gptstudio.stream")) rv$custom_prompt <- input$custom_prompt %||% getOption("gptstudio.custom_prompt") + rv$audio_input <- input$audio_input %||% getOption("gptstudio.audio_input") rv$create_new_chat <- rv$create_new_chat + 1L }) %>% diff --git a/R/read_docs.R b/R/read_docs.R index ee0bfb6c..6c8d921f 100644 --- a/R/read_docs.R +++ b/R/read_docs.R @@ -16,6 +16,7 @@ read_docs <- function(user_prompt) { read_html_docs <- function(pkg_ref, topic_name) { + check_installed("rvest") # This should output a scalar character file_location <- utils::help(topic = (topic_name), package = (pkg_ref), help_type = "html") %>% as.character() @@ -51,6 +52,7 @@ get_help_file_path <- function(file) { docs_get_inner_text <- function(x) { + check_installed("rvest") if (is.null(x)) { return(NULL) } @@ -79,6 +81,7 @@ docs_get_inner_text <- function(x) { } docs_get_sections <- function(children) { + check_installed("rvest") h3_locations <- children %>% purrr::map_lgl(~ rvest::html_name(.x) == "h3") %>% which() diff --git a/R/record-audio.R b/R/record-audio.R new file mode 100644 index 00000000..4ba428ad --- /dev/null +++ b/R/record-audio.R @@ -0,0 +1,131 @@ +#' An audio clip input control that records short audio clips from the +#' microphone +#' +#' @param id The input slot that will be used to access the value. +#' @param record_label Display label for the "record" control, or NULL for no +#' label. Default is 'Record'. +#' @param stop_label Display label for the "stop" control, or NULL for no label. +#' Default is 'Record'. +#' @param reset_on_record Whether to reset the audio clip input value when +#' recording starts. If TRUE, the audio clip input value will become NULL at +#' the moment the Record button is pressed; if FALSE, the value will not +#' change until the user stops recording. Default is TRUE. +#' @param mime_type The MIME type of the audio clip to record. By default, this +#' is NULL, which means the browser will choose a suitable MIME type for audio +#' recording. Common MIME types include 'audio/webm' and 'audio/mp4'. +#' @param audio_bits_per_second The target audio bitrate in bits per second. By +#' default, this is NULL, which means the browser will choose a suitable +#' bitrate for audio recording. This is only a suggestion; the browser may +#' choose a different bitrate. +#' @param show_mic_settings Whether to show the microphone settings in the +#' settings menu. Default is TRUE. +#' @param ... Additional parameters to pass to the underlying HTML tag. +#' +#' @return An audio clip input control that can be added to a UI definition. +#' @export +#' +#' @importFrom htmltools tag tags tagList div +#' @importFrom shiny icon +input_audio_clip <- function( + id, + record_label = "Record", + stop_label = "Stop", + reset_on_record = TRUE, + mime_type = NULL, + audio_bits_per_second = NULL, + show_mic_settings = TRUE, + ...) { + # Create the settings menu + settings_menu <- if (show_mic_settings) { + tag("av-settings-menu", list( + slot = "settings", + div( + class = "btn-group", + tags$button( + class = "btn btn-sm btn-secondary dropdown-toggle px-3 py-2", + type = "button", + `data-bs-toggle` = "dropdown", + bsicons::bs_icon("gear") + ), + tags$ul( + class = "dropdown-menu", + tags$li( + class = "mic-header", + tags$h6("Microphone", class = "dropdown-header") + ) + # Microphone items will go here + ) + ) + )) + } else { + tag("av-settings-menu", list( + slot = "settings", + div( + class = "btn-group", + tags$ul( + class = "dropdown-menu", + tags$li( + class = "mic-header", + tags$h6("Microphone", class = "dropdown-header") + ) + # Microphone items will go here + ) + ) + )) + } + + # Create the recording controls + recording_controls <- div( + class = "d-flex flex-column align-items-center", + slot = "recording-controls", + `aria-label` = "Recording controls", + div( + class = "btn-group m-3", + tags$button( + class = "record-button btn-sm btn-secondary rounded-circle p-0 mx-2", + style = "width: 2.5rem; height: 2.5rem; display: flex; justify-content: center; align-items: center;", # nolint + div( + style = "background-color: red; width: 1.5rem; height: 1.5rem; border-radius: 50%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" # nolint + ) + ), + tags$button( + class = "stop-button btn-sm btn-secondary rounded-circle p-0 mx-2", + style = "width: 3rem; height: 3rem; display: flex; justify-content: center; align-items: center;", # nolint + div( + style = "background-color: currentColor; width: 1.5rem; height: 1.5rem; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" # nolint + ) + ) + ), + div( + class = "d-flex justify-content-between w-100", + div(class = "text-center mx-2", record_label), + div(class = "text-center mx-2", stop_label) + ) + ) + + # Create the main audio-clipper tag + tag("audio-clipper", list( + id = id, + class = "shiny-audio-clip", + `data-reset-on-record` = if (reset_on_record) "true" else "false", + `data-mime-type` = mime_type, + `data-audio-bits-per-second` = audio_bits_per_second, + multimodal_dep(), + settings_menu, + recording_controls, + ... + )) +} + +#' Create HTML dependency for multimodal component +#' +multimodal_dep <- function() { + htmltools::htmlDependency( + name = "gptstudio", + version = "0.4.0", + package = "gptstudio", + src = "assets", + script = "js/audio-recorder.js", + stylesheet = "css/audio-recorder.css" + ) +} diff --git a/R/run_chatgpt_app.R b/R/run_chatgpt_app.R index 5ec3f641..290de888 100644 --- a/R/run_chatgpt_app.R +++ b/R/run_chatgpt_app.R @@ -4,14 +4,17 @@ #' script. #' #' @param ide_colors List containing the colors of the IDE theme. +#' @param code_theme_url URL to the highlight.js theme #' @inheritParams shiny::runApp #' #' @return Nothing. #' @export -run_chatgpt_app <- function(ide_colors = get_ide_theme_info(), - host = getOption("shiny.host", "127.0.0.1"), - port = getOption("shiny.port")) { - ui <- mod_app_ui("app", ide_colors) +gptstudio_run_chat_app <- function(ide_colors = get_ide_theme_info(), + code_theme_url = get_highlightjs_theme(), + host = getOption("shiny.host", "127.0.0.1"), + port = getOption("shiny.port")) { + check_installed("future") + ui <- mod_app_ui("app", ide_colors, code_theme_url) server <- function(input, output, session) { mod_app_server("app", ide_colors) diff --git a/R/service-azure_openai.R b/R/service-azure_openai.R index 4b5c3917..f18655e0 100644 --- a/R/service-azure_openai.R +++ b/R/service-azure_openai.R @@ -69,7 +69,6 @@ request_base_azure_openai <- "Content-Type" = "application/json" ) } - } query_api_azure_openai <- @@ -111,13 +110,16 @@ query_api_azure_openai <- retrieve_azure_token <- function() { rlang::check_installed("AzureRMR") - token <- tryCatch({ - AzureRMR::get_azure_login( - tenant = Sys.getenv("AZURE_OPENAI_TENANT_ID"), - app = Sys.getenv("AZURE_OPENAI_CLIENT_ID"), - scopes = ".default" - ) - }, error = function(e) NULL) + token <- tryCatch( + { + AzureRMR::get_azure_login( + tenant = Sys.getenv("AZURE_OPENAI_TENANT_ID"), + app = Sys.getenv("AZURE_OPENAI_CLIENT_ID"), + scopes = ".default" + ) + }, + error = function(e) NULL + ) if (is.null(token)) { token <- AzureRMR::create_azure_login( @@ -131,3 +133,28 @@ retrieve_azure_token <- function() { invisible(token$token$credentials$access_token) } + + +stream_azure_openai <- function(messages = list(list(role = "user", content = "hi there")), + element_callback = cat) { + body <- list( + messages = messages, + stream = TRUE + ) + + response <- + request_base_azure_openai() %>% + req_body_json(data = body) %>% + req_retry(max_tries = 3) %>% + req_error(is_error = function(resp) FALSE) %>% + req_perform_stream( + callback = \(x) { + element <- rawToChar(x) + element_callback(element) + TRUE + }, + round = "line" + ) + + invisible(response) +} diff --git a/R/service-ollama.R b/R/service-ollama.R index c4e3f885..d8acd532 100644 --- a/R/service-ollama.R +++ b/R/service-ollama.R @@ -49,25 +49,14 @@ body_to_json_str <- function(x) { ollama_perform_stream <- function(request, parser) { - request_body <- request %>% - purrr::pluck("body") - - request_url <- request %>% - purrr::pluck("url") - - request_handle <- curl::new_handle() %>% - curl::handle_setopt(postfields = body_to_json_str(request_body)) - - curl_response <- curl::curl_fetch_stream( - url = request_url, - handle = request_handle, - fun = function(x) parser$parse_ndjson(rawToChar(x)) - ) - - response_json( - url = curl_response$url, - method = "POST", - body = list(response = parser$lines) + req_perform_stream( + request, + callback = function(x) { + parser$parse_ndjson(rawToChar(x)) + TRUE + }, + buffer_kb = 0.01, + round = "line" ) } diff --git a/R/service-openai_api_calls.R b/R/service-openai_api_calls.R index 4ecea827..b4dfe79a 100644 --- a/R/service-openai_api_calls.R +++ b/R/service-openai_api_calls.R @@ -44,12 +44,7 @@ openai_create_chat_completion <- model = getOption("gptstudio.model"), openai_api_key = Sys.getenv("OPENAI_API_KEY"), task = "chat/completions") { - assert_that( - is.string(model), - is.string(openai_api_key) - ) - - if (is.string(prompt)) { + if (is_string(prompt)) { prompt <- list( list( role = "user", @@ -63,7 +58,7 @@ openai_create_chat_completion <- messages = prompt ) - query_openai_api(task = task, request_body = body, openai_api_key = openai_api_key) + query_api_openai(task = task, request_body = body, openai_api_key = openai_api_key) } @@ -76,7 +71,7 @@ openai_create_chat_completion <- #' #' @return The response from the API. #' -query_openai_api <- function(task, request_body, openai_api_key = Sys.getenv("OPENAI_API_KEY")) { +query_api_openai <- function(task, request_body, openai_api_key = Sys.getenv("OPENAI_API_KEY")) { response <- request_base(task, token = openai_api_key) %>% req_body_json(data = request_body) %>% req_retry(max_tries = 3) %>% @@ -113,3 +108,47 @@ query_openai_api <- function(task, request_body, openai_api_key = Sys.getenv("OP get_available_endpoints <- function() { c("completions", "chat/completions", "edits", "embeddings", "models") } + +#' Encode an image file to base64 +#' +#' @param image_path String containing the path to the image file +#' @return A base64 encoded string of the image +encode_image <- function(image_path) { + image_file <- file(image_path, "rb") + image_data <- readBin(image_file, "raw", file.info(image_path)$size) + close(image_file) + base64_image <- jsonlite::base64_enc(image_data) + paste0("data:image/jpeg;base64,", base64_image) +} + +create_image_chat_openai <- function(image_path, + prompt = "What is this image?", + model = getOption("gptstudio.model"), + openai_api_key = Sys.getenv("OPENAI_API_KEY"), + task = "chat/completions") { + image_data <- encode_image(image_path) + body <- list( + model = model, + messages = + list( + list( + role = "user", + content = list( + list( + type = "text", + text = prompt + ), + list( + type = "image_url", + image_url = list(url = image_data) + ) + ) + ) + ) + ) + query_api_openai( + task = task, + request_body = body, + openai_api_key = openai_api_key + ) +} diff --git a/R/service-openai_streaming.R b/R/service-openai_streaming.R index f96b3c08..03cf5449 100644 --- a/R/service-openai_streaming.R +++ b/R/service-openai_streaming.R @@ -13,59 +13,67 @@ #' By default, it is fetched from the "OPENAI_API_KEY" environment variable. #' Please note that the OpenAI API key is sensitive information and should be #' treated accordingly. -#' @return The same as `curl::curl_fetch_stream` +#' @return The same as `httr2::req_perform_stream` stream_chat_completion <- - function(messages = NULL, - element_callback = cat, + function(messages = list(list(role = "user", content = "Hi there!")), + element_callback = openai_handler, model = "gpt-4o-mini", openai_api_key = Sys.getenv("OPENAI_API_KEY")) { - # Set the API endpoint URL url <- paste0(getOption("gptstudio.openai_url"), "/chat/completions") - # Set the request headers - headers <- list( - "Content-Type" = "application/json", - "Authorization" = paste0("Bearer ", openai_api_key) - ) - - # Set the request body body <- list( "model" = model, "stream" = TRUE, "messages" = messages ) - # Create a new curl handle object - handle <- curl::new_handle() %>% - curl::handle_setheaders(.list = headers) %>% - curl::handle_setopt(postfields = jsonlite::toJSON(body, auto_unbox = TRUE)) # request body - - # Make the streaming request using curl_fetch_stream() - curl::curl_fetch_stream( - url = url, - fun = function(x) { - element <- rawToChar(x) - element_callback(element) # Do whatever element_callback does - }, - handle = handle - ) + request(url) %>% + req_headers( + "Content-Type" = "application/json", + "Authorization" = paste0("Bearer ", openai_api_key) + ) %>% + req_body_json(body) %>% + req_perform_stream( + callback = function(x) { + element <- rawToChar(x) + element_callback(element) + TRUE + }, + round = "line", + buffer_kb = 0.01 + ) } - +openai_handler <- function(x) { + lines <- stringr::str_split(x, "\n")[[1]] + lines <- lines[lines != ""] + lines <- stringr::str_replace_all(lines, "^data: ", "") + lines <- lines[lines != "[DONE]"] + if (length(lines) == 0) { + return() + } + json <- jsonlite::parse_json(lines) + if (!is.null(json$choices[[1]]$finish_reason)) { + return() + } else { + cat(json$choices[[1]]$delta$content) + } +} #' Stream handler for chat completions #' -#' R6 class that allows to handle chat completions chunk by chunk. -#' It also adds methods to retrieve relevant data. This class DOES NOT make the request. +#' R6 class that allows to handle chat completions chunk by chunk. It also adds +#' methods to retrieve relevant data. This class DOES NOT make the request. #' -#' Because `curl::curl_fetch_stream` blocks the R console until the stream finishes, -#' this class can take a shiny session object to handle communication with JS -#' without recurring to a `shiny::observe` inside a module server. +#' Because `httr2::req_perform_stream` blocks the R console until the stream +#' finishes, this class can take a shiny session object to handle communication +#' with JS without recurring to a `shiny::observe` inside a module server. #' #' @param session The shiny session it will send the message to (optional). -#' @param user_prompt The prompt for the chat completion. -#' Only to be displayed in an HTML tag containing the prompt. (Optional). -#' @param parsed_event An already parsed server-sent event to append to the events field. +#' @param user_prompt The prompt for the chat completion. Only to be displayed +#' in an HTML tag containing the prompt. (Optional). +#' @param parsed_event An already parsed server-sent event to append to the +#' events field. #' @importFrom R6 R6Class #' @importFrom jsonlite fromJSON OpenaiStreamParser <- R6::R6Class( # nolint @@ -87,14 +95,18 @@ OpenaiStreamParser <- R6::R6Class( # nolint super$initialize() }, - #' @description Overwrites `SSEparser$append_parsed_sse()` to be able to send a custom message - #' to a shiny session, escaping shiny's reactivity. + #' @description Overwrites `SSEparser$append_parsed_sse()` to be able to + #' send a custom message to a shiny session, escaping shiny's reactivity. append_parsed_sse = function(parsed_event) { # ----- here you can do whatever you want with the event data ----- if (is.null(parsed_event$data) || parsed_event$data == "[DONE]") { return() } - parsed_event$data <- jsonlite::fromJSON(parsed_event$data, simplifyDataFrame = FALSE) + + parsed_event$data <- jsonlite::fromJSON(parsed_event$data, + simplifyDataFrame = FALSE) + + if (length(parsed_event$data$choices) == 0) return() content <- parsed_event$data$choices[[1]]$delta$content self$value <- paste0(self$value, content) @@ -110,7 +122,6 @@ OpenaiStreamParser <- R6::R6Class( # nolint ) } - # ----- END ---- self$events <- c(self$events, list(parsed_event)) diff --git a/R/streamingMessage.R b/R/streamingMessage.R index b0c5724e..706dbb4b 100644 --- a/R/streamingMessage.R +++ b/R/streamingMessage.R @@ -4,7 +4,7 @@ #' It can be reset dynamically inside a shiny app #' #' @import htmlwidgets -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' @inheritParams streamingMessage-shiny #' @param element_id The element's id streamingMessage <- function(ide_colors = get_ide_theme_info(), # nolint diff --git a/R/welcomeMessage.R b/R/welcomeMessage.R index a9f72b04..040baea4 100644 --- a/R/welcomeMessage.R +++ b/R/welcomeMessage.R @@ -4,7 +4,7 @@ #' This has been created to be able to bind the message to a shiny event to trigger a new render. #' #' @import htmlwidgets -#' @inheritParams run_chatgpt_app +#' @inheritParams gptstudio_run_chat_app #' @inheritParams welcomeMessage-shiny #' @inheritParams chat_message_default #' @param element_id The element's id diff --git a/R/zzz.R b/R/zzz.R index 52797c8c..e2535db7 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -27,7 +27,8 @@ gptstudio.stream = config$stream, # options added after v3.0 will need a safe check because the user's # config file might not have values for new features - gptstudio.read_docs = config$read_docs %||% FALSE + gptstudio.read_docs = config$read_docs %||% FALSE, + gptstudio.audio_input = config$audio_input %||% FALSE ) toset <- !(names(op_gptstudio) %in% names(op)) diff --git a/inst/WORDLIST b/inst/WORDLIST index 4c38bccb..fd48d2ae 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -7,9 +7,12 @@ Anthropic's CMD ChatGPT Codecov +Cohere's Colley Config +ExtendedTask GPT +GPTStudio GitLab HuggingFace HuggingFace's @@ -31,11 +34,16 @@ Rmd Rstudio StreamHandler UI +URI VScode +WAV addin addins ang +anthropic api +async +bitrate bslib chatgpt chattr @@ -43,24 +51,31 @@ claude cloneable codellama config +customizable gitignore gpt gpttools grey httr +huggingface +instruc +js json +linux magrittr mixtral ollama +openai pplx pre px quickstart rstudio -runApp +rstudioapi runnable scrollable scrollbar +shiny's spanish str streamingMessage @@ -70,4 +85,5 @@ textarea textareas tidyverse tooltips +webm welcomeMessage diff --git a/inst/assets/css/audio-recorder.css b/inst/assets/css/audio-recorder.css new file mode 100644 index 00000000..a1381f05 --- /dev/null +++ b/inst/assets/css/audio-recorder.css @@ -0,0 +1,12 @@ +av-settings-menu { + display: block; + width: min-content; +} + +/* The normal treatment of .dropdown-item.active is a little too much */ +av-settings-menu .dropdown-item.active, +av-settings-menu .dropdown-menu > li > a.active { + color: inherit; + background-color: inherit; + font-weight: bold; +} diff --git a/inst/assets/css/audioRecorder.css b/inst/assets/css/audioRecorder.css new file mode 100644 index 00000000..a1381f05 --- /dev/null +++ b/inst/assets/css/audioRecorder.css @@ -0,0 +1,12 @@ +av-settings-menu { + display: block; + width: min-content; +} + +/* The normal treatment of .dropdown-item.active is a little too much */ +av-settings-menu .dropdown-item.active, +av-settings-menu .dropdown-menu > li > a.active { + color: inherit; + background-color: inherit; + font-weight: bold; +} diff --git a/inst/assets/css/mod_app.css b/inst/assets/css/mod_app.css index 54bf6124..1c9db103 100644 --- a/inst/assets/css/mod_app.css +++ b/inst/assets/css/mod_app.css @@ -20,3 +20,21 @@ pre code { display: block; } + +.chat-bubble { + position: relative; + max-width: 90%; +} + +.user-bubble { + margin-left: auto; +} + +.assistant-bubble { + margin-right: auto; +} + +.shiny-input-container textarea { + border-radius: 15px; + padding: 10px; +} diff --git a/inst/assets/js/audio-recorder.js b/inst/assets/js/audio-recorder.js new file mode 100644 index 00000000..cfd79d95 --- /dev/null +++ b/inst/assets/js/audio-recorder.js @@ -0,0 +1,874 @@ +"use strict"; +(() => { + // srcts/videoClipper.ts + var VideoClipperElement = class extends HTMLElement { + constructor() { + super(); + this.chunks = []; + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + + +
+ +
+
+ +
+ `; + this.video = this.shadowRoot.querySelector("video"); + } + connectedCallback() { + (async () => { + const slotSettings = this.shadowRoot.querySelector( + "slot[name=settings]" + ); + slotSettings.addEventListener("slotchange", async () => { + this.avSettingsMenu = slotSettings.assignedElements()[0]; + await this.#initializeMediaInput(); + if (this.buttonRecord) { + this.#setEnabledButton(this.buttonRecord); + } + }); + const slotControls = this.shadowRoot.querySelector( + "slot[name=recording-controls]" + ); + slotControls.addEventListener("slotchange", () => { + const findButton = (selector) => { + for (const el of slotControls.assignedElements()) { + if (el.matches(selector)) { + return el; + } + const sub = el.querySelector(selector); + if (sub) { + return sub; + } + } + return null; + }; + this.buttonRecord = findButton(".record-button"); + this.buttonStop = findButton(".stop-button"); + this.#setEnabledButton(); + this.buttonRecord.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("recordstart")); + this.#setEnabledButton(this.buttonStop); + this._beginRecord(); + }); + this.buttonStop.addEventListener("click", () => { + this._endRecord(); + this.#setEnabledButton(this.buttonRecord); + }); + }); + })().catch((err) => { + console.error(err); + }); + } + disconnectedCallback() { + } + #setEnabledButton(btn) { + this.buttonRecord.style.display = btn === this.buttonRecord ? "inline-block" : "none"; + this.buttonStop.style.display = btn === this.buttonStop ? "inline-block" : "none"; + } + async setMediaDevices(cameraId, micId) { + if (this.cameraStream) { + this.cameraStream.getTracks().forEach((track) => track.stop()); + } + this.cameraStream = await navigator.mediaDevices.getUserMedia({ + video: { + deviceId: cameraId || void 0, + // If cameraId is not specified, default to the selfie cam + facingMode: cameraId ? void 0 : "user" + }, + audio: { + deviceId: micId || void 0 + } + }); + const label = this.cameraStream.getVideoTracks()[0].label; + const isSelfieCam = hasConstraint( + this.cameraStream.getVideoTracks()[0].getCapabilities().facingMode, + "user" + ) || /facetime|isight|front/i.test(label); + this.video.classList.toggle("mirrored", isSelfieCam); + const aspectRatio = this.cameraStream.getVideoTracks()[0].getSettings().aspectRatio; + if (aspectRatio) { + this.video.style.aspectRatio = `${aspectRatio}`; + } else { + this.video.style.aspectRatio = ""; + } + this.video.srcObject = this.cameraStream; + try { + await this.video.play(); + this.video.style.aspectRatio = ""; + } catch (err) { + console.error("Error playing video: ", err); + } + return { + cameraId: this.cameraStream.getVideoTracks()[0].getSettings().deviceId, + micId: this.cameraStream.getAudioTracks()[0].getSettings().deviceId + }; + } + async #initializeMediaInput() { + const savedCamera = window.localStorage.getItem("multimodal-camera"); + const savedMic = window.localStorage.getItem("multimodal-mic"); + const { cameraId, micId } = await this.setMediaDevices( + savedCamera, + savedMic + ); + const devices = await navigator.mediaDevices.enumerateDevices(); + this.avSettingsMenu.setCameras( + devices.filter((dev) => dev.kind === "videoinput") + ); + this.avSettingsMenu.setMics( + devices.filter((dev) => dev.kind === "audioinput") + ); + this.avSettingsMenu.cameraId = cameraId; + this.avSettingsMenu.micId = micId; + const handleDeviceChange = async (deviceType, deviceId) => { + if (!deviceId) return; + window.localStorage.setItem(`multimodal-${deviceType}`, deviceId); + await this.setMediaDevices( + this.avSettingsMenu.cameraId, + this.avSettingsMenu.micId + ); + }; + this.avSettingsMenu.addEventListener("camera-change", (e) => { + handleDeviceChange("camera", this.avSettingsMenu.cameraId); + }); + this.avSettingsMenu.addEventListener("mic-change", (e) => { + handleDeviceChange("mic", this.avSettingsMenu.micId); + }); + } + _beginRecord() { + this.recorder = new MediaRecorder(this.cameraStream, { + mimeType: this.dataset.mimeType, + videoBitsPerSecond: safeFloat(this.dataset.videoBitsPerSecond), + audioBitsPerSecond: safeFloat(this.dataset.audioBitsPerSecond) + }); + this.recorder.addEventListener("error", (e) => { + console.error("MediaRecorder error:", e.error); + }); + this.recorder.addEventListener("dataavailable", (e) => { + this.chunks.push(e.data); + }); + this.recorder.addEventListener("start", () => { + }); + this.recorder.addEventListener("stop", () => { + if (this.chunks.length === 0) { + console.warn("No data recorded"); + return; + } + const blob = new Blob(this.chunks, { type: this.chunks[0].type }); + const event = new BlobEvent("data", { + data: blob + }); + try { + this.dispatchEvent(event); + } finally { + this.chunks = []; + } + }); + this.recorder.start(); + } + _endRecord(emit = true) { + this.recorder.stop(); + } + }; + customElements.define("video-clipper", VideoClipperElement); + function safeFloat(value) { + if (value === void 0) { + return void 0; + } + const floatVal = parseFloat(value); + if (isNaN(floatVal)) { + return void 0; + } + return floatVal; + } + function hasConstraint(constraint, value) { + if (constraint === void 0) { + return false; + } + if (Array.isArray(constraint)) { + return constraint.includes(value); + } + if (typeof constraint === "string") { + return constraint === value; + } + if (constraint instanceof Object) { + if (constraint.exact) { + if (hasConstraint(constraint.exact, value)) { + return true; + } + } + if (constraint.ideal) { + if (hasConstraint(constraint.ideal, value)) { + return true; + } + } + } + return false; + } + + // srcts/avSettingsMenu.ts + var DeviceChangeEvent = class extends CustomEvent { + constructor(type, detail) { + super(type, { detail }); + } + }; + var AVSettingsMenuElement = class extends HTMLElement { + constructor() { + super(); + this.addEventListener("click", (e) => { + if (e.target instanceof HTMLAnchorElement) { + const a = e.target; + if (a.classList.contains("camera-device-item")) { + this.cameraId = a.dataset.deviceId; + } else if (a.classList.contains("mic-device-item")) { + this.micId = a.dataset.deviceId; + } + } + }); + } + #setDevices(deviceType, devices) { + const deviceEls = devices.map( + (dev) => this.#createDeviceElement(dev, `${deviceType}-device-item`) + ); + const header = this.querySelector(`.${deviceType}-header`); + header.after(...deviceEls); + } + setCameras(cameras) { + this.#setDevices("camera", cameras); + } + setMics(mics) { + this.#setDevices("mic", mics); + } + setMicsOnly(mics) { + this.#setDevices("mic", mics); + } + get cameraId() { + return this.#getSelectedDevice("camera"); + } + set cameraId(id) { + this.#setSelectedDevice("camera", id); + } + get micId() { + return this.#getSelectedDevice("mic"); + } + set micId(id) { + this.#setSelectedDevice("mic", id); + } + #createDeviceElement(dev, className) { + const li = this.ownerDocument.createElement("li"); + const a = li.appendChild(this.ownerDocument.createElement("a")); + a.onclick = (e) => e.preventDefault(); + a.href = "#"; + a.textContent = dev.label; + a.dataset.deviceId = dev.deviceId; + a.className = className; + a.classList.add("dropdown-item"); + return li; + } + #getSelectedDevice(device) { + return this.querySelector( + `a.${device}-device-item.active` + )?.dataset.deviceId ?? null; + } + #setSelectedDevice(device, id) { + this.querySelectorAll(`a.${device}-device-item.active`).forEach( + (a) => a.classList.remove("active") + ); + if (id) { + this.querySelector( + `a.${device}-device-item[data-device-id="${id}"]` + ).classList.add("active"); + } + this.dispatchEvent( + new DeviceChangeEvent(`${device}-change`, { + deviceId: id + }) + ); + } + }; + customElements.define("av-settings-menu", AVSettingsMenuElement); + + // srcts/audioSpinner.ts + var AudioSpinnerElement = class extends HTMLElement { + #audio; + #canvas; + #ctx2d; + #analyzer; + #dataArray; + #smoother; + #secondsOffset = 0; + #tooltip; + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + + + + `; + } + connectedCallback() { + const audioSlot = this.shadowRoot.querySelector( + "slot[name=audio]" + ); + this.#audio = this.ownerDocument.createElement("audio"); + this.#audio.controls = false; + this.#audio.src = this.getAttribute("src"); + this.#audio.slot = "audio"; + audioSlot.assign(this.#audio); + this.#audio.addEventListener("play", () => { + this.#draw(); + }); + this.#audio.addEventListener("ended", () => { + if (typeof this.dataset.autodismiss !== "undefined") { + this.style.transition = "opacity 0.5s 1s"; + this.classList.add("fade"); + this.addEventListener("transitionend", () => { + this.remove(); + }); + } else { + this.#secondsOffset += this.#audio.currentTime; + this.#audio.pause(); + this.#audio.currentTime = 0; + } + }); + const canvasSlot = this.shadowRoot.querySelector( + "slot[name=canvas]" + ); + this.#canvas = this.ownerDocument.createElement("canvas"); + this.#canvas.slot = "canvas"; + this.#canvas.width = this.clientWidth * window.devicePixelRatio; + this.#canvas.height = this.clientHeight * window.devicePixelRatio; + this.#canvas.style.width = this.clientWidth + "px"; + this.#canvas.style.height = this.clientHeight + "px"; + this.#canvas.onclick = () => { + if (this.#audio.paused) { + this.#audio.play(); + } else { + this.#audio.pause(); + } + }; + this.appendChild(this.#canvas); + canvasSlot.assign(this.#canvas); + this.#ctx2d = this.#canvas.getContext("2d"); + new ResizeObserver(() => { + this.#canvas.width = this.clientWidth * 2; + this.#canvas.height = this.clientHeight * 2; + this.#canvas.style.width = this.clientWidth + "px"; + this.#canvas.style.height = this.clientHeight + "px"; + }).observe(this); + const audioCtx = new AudioContext(); + const source = audioCtx.createMediaElementSource(this.#audio); + this.#analyzer = new AnalyserNode(audioCtx, { + fftSize: 2048 + }); + this.#dataArray = new Float32Array(this.#analyzer.frequencyBinCount); + source.connect(this.#analyzer); + this.#analyzer.connect(audioCtx.destination); + const dataArray2 = new Float32Array(this.#analyzer.frequencyBinCount); + this.#smoother = new Smoother(5, (samples) => { + for (let i = 0; i < dataArray2.length; i++) { + dataArray2[i] = 0; + for (let j = 0; j < samples.length; j++) { + dataArray2[i] += samples[j][i]; + } + dataArray2[i] /= samples.length; + } + return dataArray2; + }); + this.#draw(); + if (typeof this.dataset.autoplay !== "undefined") { + this.#audio.play().catch((err) => { + this.#showTooltip(); + }); + } + } + disconnectedCallback() { + if (this.#tooltip) { + this.#tooltip.dispose(); + this.#tooltip = void 0; + } + if (!this.#audio.paused) { + this.#audio.pause(); + } + } + #showTooltip() { + const isMobile = /Mobi/.test(navigator.userAgent); + const gesture = isMobile ? "Tap" : "Click"; + this.#tooltip = new window.bootstrap.Tooltip(this, { + title: `${gesture} to play`, + trigger: "manual", + placement: "right" + }); + this.#audio.addEventListener( + "play", + () => { + if (this.#tooltip) { + this.#tooltip.dispose(); + this.#tooltip = void 0; + } + }, + { once: true } + ); + this.#tooltip.show(); + } + #draw() { + if (!this.isConnected) { + return; + } + requestAnimationFrame(() => this.#draw()); + const pixelRatio = window.devicePixelRatio; + const physicalWidth = this.#canvas.width; + const physicalHeight = this.#canvas.height; + const width = physicalWidth / pixelRatio; + const height = physicalHeight / pixelRatio; + this.#ctx2d.reset(); + this.#ctx2d.clearRect(0, 0, physicalWidth, physicalHeight); + this.#ctx2d.scale(pixelRatio, pixelRatio); + this.#ctx2d.translate(width / 2, height / 2); + this.#analyzer.getFloatTimeDomainData(this.#dataArray); + const smoothed = this.#smoother.add(new Float32Array(this.#dataArray)); + let { + rpm, + gap, + stroke, + minRadius, + radiusCompression, + radiusOverscan, + steps, + blades + } = this.#getSettings(width, height); + if (blades === 0) { + blades = 1; + gap = 0; + } + stroke = Math.max(0, stroke); + minRadius = Math.max(0, minRadius); + steps = Math.max(0, steps); + const scalarVal = Math.max(0, ...smoothed.map(Math.abs)); + const compressedScalarVal = Math.pow(scalarVal, radiusCompression); + const maxRadius = Math.min(width, height) / 2 * radiusOverscan; + const radius = minRadius + compressedScalarVal * (maxRadius - minRadius); + const sweep = Math.PI * 2 / blades - gap; + const staticAngle = Math.PI / -2 + // rotate -90 degrees to start at the top + sweep / -2; + for (let step = 0; step < steps + 1; step++) { + const this_radius = radius - step * (radius / (steps + 2)); + if (step === steps) { + this.#drawPie(0, Math.PI * 2, this_radius, stroke); + } else { + const seconds = (this.#audio.currentTime || 0) + this.#secondsOffset; + const spinVelocity = rpm / 60 * Math.PI * 2; + const startAngle = staticAngle + seconds * spinVelocity % (Math.PI * 2); + for (let blade = 0; blade < blades; blade++) { + const angleOffset = Math.PI * 2 / blades * blade; + this.#drawPie(startAngle + angleOffset, sweep, this_radius, stroke); + } + } + } + } + #drawPie(startAngle, sweep, radius, stroke) { + this.#ctx2d.beginPath(); + this.#ctx2d.fillStyle = window.getComputedStyle(this.#canvas).color; + if (!stroke) { + this.#ctx2d.moveTo(0, 0); + } + this.#ctx2d.arc(0, 0, radius, startAngle, startAngle + sweep); + if (!stroke) { + this.#ctx2d.lineTo(0, 0); + } else { + this.#ctx2d.arc( + 0, + 0, + radius - stroke, + startAngle + sweep, + startAngle, + true + ); + } + this.#ctx2d.fill(); + } + #getSettings(width, height) { + const settings = { + rpm: 10, + gap: Math.PI / 5, + stroke: 2.5, + minRadius: Math.min(width, height) / 6, + radiusCompression: 0.5, + radiusOverscan: 1, + steps: 2, + blades: 3 + }; + for (const key in settings) { + const value = tryParseFloat(this.dataset[key]); + if (typeof value !== "undefined") { + Object.assign(settings, { [key]: value }); + } + } + return settings; + } + }; + window.customElements.define("audio-spinner", AudioSpinnerElement); + var Smoother = class { + #samples = []; + #smooth; + #size; + #pos; + constructor(size, smooth) { + this.#size = size; + this.#pos = 0; + this.#smooth = smooth; + } + add(sample) { + this.#samples[this.#pos] = sample; + this.#pos = (this.#pos + 1) % this.#size; + return this.smoothed(); + } + smoothed() { + return this.#smooth(this.#samples); + } + }; + function tryParseFloat(str) { + if (typeof str === "undefined") { + return void 0; + } + const parsed = parseFloat(str); + return isNaN(parsed) ? void 0 : parsed; + } + + // New AudioClipperElement + class AudioClipperElement extends HTMLElement { + constructor() { + super(); + this.chunks = []; + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + +
+ +
+
+ +
+ `; + } + + connectedCallback() { + (async () => { + const slotSettings = this.shadowRoot.querySelector( + "slot[name=settings]" + ); + slotSettings.addEventListener("slotchange", async () => { + this.avSettingsMenu = slotSettings.assignedElements()[0]; + await this.#initializeMediaInput(); + if (this.buttonRecord) { + this.#setEnabledButton(this.buttonRecord); + } + }); + const slotControls = this.shadowRoot.querySelector( + "slot[name=recording-controls]" + ); + slotControls.addEventListener("slotchange", () => { + const findButton = (selector) => { + for (const el of slotControls.assignedElements()) { + if (el.matches(selector)) { + return el; + } + const sub = el.querySelector(selector); + if (sub) { + return sub; + } + } + return null; + }; + this.buttonRecord = findButton(".record-button"); + this.buttonStop = findButton(".stop-button"); + this.#setEnabledButton(); + this.buttonRecord.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("recordstart")); + this.#setEnabledButton(this.buttonStop); + this._beginRecord(); + }); + this.buttonStop.addEventListener("click", () => { + this._endRecord(); + this.#setEnabledButton(this.buttonRecord); + }); + }); + })().catch((err) => { + console.error(err); + }); + } + + #setEnabledButton(btn) { + this.buttonRecord.style.display = btn === this.buttonRecord ? "inline-block" : "none"; + this.buttonStop.style.display = btn === this.buttonStop ? "inline-block" : "none"; + } + + async setMediaDevices(micId) { + if (this.audioStream) { + this.audioStream.getTracks().forEach((track) => track.stop()); + } + this.audioStream = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: micId || undefined, + }, + }); + return { + micId: this.audioStream.getAudioTracks()[0].getSettings().deviceId, + }; + } + + async #initializeMediaInput() { + const savedMic = window.localStorage.getItem("multimodal-mic"); + const { micId } = await this.setMediaDevices(savedMic); + const devices = await navigator.mediaDevices.enumerateDevices(); + this.avSettingsMenu.setMicsOnly( + devices.filter((dev) => dev.kind === "audioinput") + ); + this.avSettingsMenu.micId = micId; + const handleDeviceChange = async (deviceType, deviceId) => { + if (!deviceId) return; + window.localStorage.setItem(`multimodal-${deviceType}`, deviceId); + await this.setMediaDevices(this.avSettingsMenu.micId); + }; + this.avSettingsMenu.addEventListener("mic-change", (e) => { + handleDeviceChange("mic", this.avSettingsMenu.micId); + }); + } + + _beginRecord() { + this.recorder = new MediaRecorder(this.audioStream, { + mimeType: this.dataset.mimeType, + audioBitsPerSecond: safeFloat(this.dataset.audioBitsPerSecond) + }); + this.recorder.addEventListener("error", (e) => { + console.error("MediaRecorder error:", e.error); + }); + this.recorder.addEventListener("dataavailable", (e) => { + this.chunks.push(e.data); + }); + this.recorder.addEventListener("start", () => { + }); + this.recorder.addEventListener("stop", () => { + if (this.chunks.length === 0) { + console.warn("No data recorded"); + return; + } + const blob = new Blob(this.chunks, { type: this.chunks[0].type }); + const event = new BlobEvent("data", { + data: blob + }); + try { + this.dispatchEvent(event); + } finally { + this.chunks = []; + } + }); + this.recorder.start(); + } + + _endRecord() { + this.recorder.stop(); + } + } + + customElements.define("audio-clipper", AudioClipperElement); + + // srcts/index.ts + if (window.Shiny) { + let bustAutoPlaySuppression = function() { + const audioContext = new AudioContext(); + const buffer = audioContext.createBuffer( + 1, + audioContext.sampleRate * 105, + audioContext.sampleRate + ); + const destination = audioContext.createMediaStreamDestination(); + const source = audioContext.createBufferSource(); + source.buffer = buffer; + source.connect(destination); + source.start(); + const audioElement = document.createElement("audio"); + audioElement.controls = true; + audioElement.autoplay = true; + audioElement.style.display = "none"; + audioElement.addEventListener("play", () => { + audioElement.remove(); + }); + audioElement.srcObject = destination.stream; + document.body.appendChild(audioElement); + document.body.addEventListener( + "click", + () => { + audioElement.play(); + }, + { capture: true, once: true } + ); + }; + bustAutoPlaySuppression2 = bustAutoPlaySuppression; + class VideoClipperBinding extends Shiny.InputBinding { + #lastKnownValue = /* @__PURE__ */ new WeakMap(); + #handlers = /* @__PURE__ */ new WeakMap(); + find(scope) { + return $(scope).find("video-clipper.shiny-video-clip"); + } + getValue(el) { + return this.#lastKnownValue.get(el); + } + subscribe(el, callback) { + const handler = async (ev) => { + const blob = ev.data; + console.log( + `Recorded video of type ${blob.type} and size ${blob.size} bytes` + ); + const encoded = `data:${blob.type};base64,${await base64(blob)}`; + this.#lastKnownValue.set(el, encoded); + callback(true); + }; + el.addEventListener("data", handler); + const handler2 = (ev) => { + if (typeof el.dataset.resetOnRecord !== "undefined") { + this.#lastKnownValue.set(el, null); + callback(true); + } + }; + el.addEventListener("recordstart", handler2); + this.#handlers.set(el, [handler, handler2]); + } + unsubscribe(el) { + const handlers = this.#handlers.get(el); + el.removeEventListener("data", handlers[0]); + el.removeEventListener("recordstart", handlers[1]); + this.#handlers.delete(el); + } + } + window.Shiny.inputBindings.register( + new VideoClipperBinding(), + "video-clipper" + ); + + // New AudioClipperBinding + class AudioClipperBinding extends Shiny.InputBinding { + #lastKnownValue = new WeakMap(); + #handlers = new WeakMap(); + + find(scope) { + return $(scope).find("audio-clipper.shiny-audio-clip"); + } + + getValue(el) { + return this.#lastKnownValue.get(el); + } + + subscribe(el, callback) { + const handler = async (ev) => { + const blob = ev.data; + console.log(`Recorded audio of type ${blob.type} and size ${blob.size} bytes`); + const encoded = `data:${blob.type};base64,${await base64(blob)}`; + this.#lastKnownValue.set(el, encoded); + callback(true); + }; + el.addEventListener("data", handler); + + const handler2 = (ev) => { + if (typeof el.dataset.resetOnRecord !== "undefined") { + this.#lastKnownValue.set(el, null); + callback(true); + } + }; + el.addEventListener("recordstart", handler2); + + this.#handlers.set(el, [handler, handler2]); + } + + unsubscribe(el) { + const handlers = this.#handlers.get(el); + el.removeEventListener("data", handlers[0]); + el.removeEventListener("recordstart", handlers[1]); + this.#handlers.delete(el); + } + } + + window.Shiny.inputBindings.register( + new AudioClipperBinding(), + "audio-clipper" + ); + + async function base64(blob) { + const buf = await blob.arrayBuffer(); + const results = []; + const CHUNKSIZE = 1024; + for (let i = 0; i < buf.byteLength; i += CHUNKSIZE) { + const chunk = buf.slice(i, i + CHUNKSIZE); + results.push(String.fromCharCode(...new Uint8Array(chunk))); + } + return btoa(results.join("")); + } + + document.addEventListener("DOMContentLoaded", bustAutoPlaySuppression); + } + var bustAutoPlaySuppression2; +})(); diff --git a/inst/assets/js/copyToClipboard.js b/inst/assets/js/copyToClipboard.js index fa6883da..ea231991 100644 --- a/inst/assets/js/copyToClipboard.js +++ b/inst/assets/js/copyToClipboard.js @@ -44,13 +44,13 @@ function addCopyBtn() { } // Create a div element with the copy button and language text - // The svg icon was generated using FontAwesome library via R + // The svg icon was generated using Bootrap library via R var div = $(`

${language}

diff --git a/inst/mod_app/app.R b/inst/mod_app/app.R index ba4f650e..00090d61 100644 --- a/inst/mod_app/app.R +++ b/inst/mod_app/app.R @@ -1 +1 @@ -gptstudio::run_chatgpt_app() +gptstudio::gptstudio_run_chat_app() diff --git a/inst/rstudio/config.yml b/inst/rstudio/config.yml index 529d7efe..2ee8f108 100644 --- a/inst/rstudio/config.yml +++ b/inst/rstudio/config.yml @@ -3,7 +3,8 @@ skill: "beginner" task: "coding" language: "en" service: "openai" -model: "gpt-4-turbo-preview" +model: "gpt-4o" custom_prompt: null stream: true read_docs: false +audio_input: false diff --git a/inst/shiny-recorder/app.R b/inst/shiny-recorder/app.R deleted file mode 100644 index 66a8bca7..00000000 --- a/inst/shiny-recorder/app.R +++ /dev/null @@ -1,167 +0,0 @@ -# nolint start -library(shiny) -library(shinyjs) -library(httr) -library(jsonlite) - -audio_recorder_ui <- function(id) { - ns <- NS(id) - - tagList( - useShinyjs(), - div( - id = ns("recording-area"), - actionButton(ns("startrec"), "Start Recording"), - actionButton(ns("stoprec"), "Stop Recording"), - tags$audio(id = ns("recorded_audio"), controls = TRUE) - ), - div( - id = ns("timer"), - style = "display: none;", - "Recording time: ", - tags$span(id = ns("time"), "0"), - " seconds" - ) - ) -} - -audio_recorder <- function(input, output, session) { - observeEvent(input$startrec, { - runjs(sprintf(" - navigator.mediaDevices.getUserMedia({ audio: true }) - .then(function(stream) { - mediaRecorder = new MediaRecorder(stream); - const chunks = []; - - const audioCtx = new AudioContext(); - const analyzer = audioCtx.createAnalyser(); - analyzer.fftSize = 256; - const bufferLength = analyzer.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - const startTime = Date.now(); - const timer = setInterval(function() { - const elapsedTime = Math.round((Date.now() - startTime) / 1000); - document.getElementById('%s').style.display = 'block'; - document.getElementById('%s').innerHTML = elapsedTime; - }, 1000); - - mediaRecorder.start(); - - mediaRecorder.addEventListener('dataavailable', event => { - chunks.push(event.data); - }); - - const vadInterval = 50; - let vadTimer; - let lastVoiceTime = 0; - const vadThreshold = 30; - const vadBuffer = 5; - let vadBufferCount = 0; - const vadBufferMax = Math.floor((1000 / vadInterval) * vadBuffer); - const stopRecording = () => { - clearInterval(vadTimer); - mediaRecorder.stop(); - }; - - const dest = audioCtx.createMediaStreamDestination(); - analyzer.connect(dest); - const source = audioCtx.createMediaStreamSource(stream); - source.connect(analyzer); - - vadTimer = setInterval(function() { - analyzer.getByteFrequencyData(dataArray); - let sum = 0; - for (let i = 0; i < bufferLength; i++) { - sum += dataArray[i]; - } - const average = sum / bufferLength; - const level = Math.max(0, Math.min(100, average - vadThreshold)); - if (level > 0) { - lastVoiceTime = Date.now(); - vadBufferCount = 0; - } else { - vadBufferCount++; - if (vadBufferCount >= - vadBufferMax && Date.now() - lastVoiceTime >= 500) { - stopRecording(); - } - } - }, vadInterval); - - mediaRecorder.addEventListener('stop', () => { - clearInterval(timer); - clearInterval(vadTimer); - const blob = new Blob(chunks, - { 'type' : 'audio/ogg; codecs=opus' }); - const url = URL.createObjectURL(blob); - document.getElementById('%s').src = url; - }); - }); - ", session$ns("timer"), session$ns("time"), session$ns("recorded_audio"))) - }) - - observeEvent(input$stoprec, { - cli::cli_inform("Stopping recording") - runjs(" - if (mediaRecorder) { - mediaRecorder.stop(); - } - ") - }) -} - - -# Main app UI -ui <- fluidPage( - audio_recorder_ui("recorder1"), - hr(), - h3("Transcription:"), - verbatimTextOutput("transcription") -) - -# Main app server function -server <- function(input, output, session) { - callModule(audio_recorder, "recorder1") - - observeEvent(input$recorder1_stoprec, { - # Ensure the recorded_audio input exists - req(input$recorder1_recorded_audio) - # Save the recorded audio as a file on the server - audio_content <- input$recorder1_recorded_audio - audio_filepath <- tempfile(fileext = ".ogg") - writeBin(base64enc::base64decode(audio_content), audio_filepath) - - # Send the audio file to the Whisper ASR API - openai_api_key <- "" - res <- httr::POST( - "https://api.openai.com/v1/engines/davinci-codex/completions", - httr::add_headers( - "Content-Type" = "application/json", - "Authorization" = glue::glue("Bearer {openai_api_key}") - ), - body = jsonlite::toJSON(list( - model = "whisper", - prompt = "Transcribe the following audio:", - audio = base64enc::base64encode(audio_filepath), - max_tokens = 1000 - ), auto_unbox = TRUE), - encode = "json" - ) - - # Check for errors and parse the transcription - if (httr::http_error(res)) { - stop("Error occurred during transcription:", - httr::http_status(res)$message) - } else { - transcription <- - jsonlite::fromJSON( - httr::content(res, "text", encoding = "UTF-8") - )$choices[[1]]$text - output$transcription <- renderText(transcription) - } - }) -} - -shinyApp(ui, server) -# nolint end diff --git a/man/OpenaiStreamParser.Rd b/man/OpenaiStreamParser.Rd index 0965ee84..603de9bb 100644 --- a/man/OpenaiStreamParser.Rd +++ b/man/OpenaiStreamParser.Rd @@ -9,12 +9,12 @@ Stream handler for chat completions Stream handler for chat completions } \details{ -R6 class that allows to handle chat completions chunk by chunk. -It also adds methods to retrieve relevant data. This class DOES NOT make the request. +R6 class that allows to handle chat completions chunk by chunk. It also adds +methods to retrieve relevant data. This class DOES NOT make the request. -Because \code{curl::curl_fetch_stream} blocks the R console until the stream finishes, -this class can take a shiny session object to handle communication with JS -without recurring to a \code{shiny::observe} inside a module server. +Because \code{httr2::req_perform_stream} blocks the R console until the stream +finishes, this class can take a shiny session object to handle communication +with JS without recurring to a \code{shiny::observe} inside a module server. } \section{Super class}{ \code{\link[SSEparser:SSEparser]{SSEparser::SSEparser}} -> \code{OpenaiStreamParser} @@ -60,8 +60,8 @@ Start a StreamHandler. Recommended to be assigned to the \code{stream_handler} n \describe{ \item{\code{session}}{The shiny session it will send the message to (optional).} -\item{\code{user_prompt}}{The prompt for the chat completion. -Only to be displayed in an HTML tag containing the prompt. (Optional).} +\item{\code{user_prompt}}{The prompt for the chat completion. Only to be displayed +in an HTML tag containing the prompt. (Optional).} } \if{html}{\out{}} } @@ -70,8 +70,8 @@ Only to be displayed in an HTML tag containing the prompt. (Optional).} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-OpenaiStreamParser-append_parsed_sse}{}}} \subsection{Method \code{append_parsed_sse()}}{ -Overwrites \code{SSEparser$append_parsed_sse()} to be able to send a custom message -to a shiny session, escaping shiny's reactivity. +Overwrites \code{SSEparser$append_parsed_sse()} to be able to +send a custom message to a shiny session, escaping shiny's reactivity. \subsection{Usage}{ \if{html}{\out{
}}\preformatted{OpenaiStreamParser$append_parsed_sse(parsed_event)}\if{html}{\out{
}} } @@ -79,7 +79,8 @@ to a shiny session, escaping shiny's reactivity. \subsection{Arguments}{ \if{html}{\out{
}} \describe{ -\item{\code{parsed_event}}{An already parsed server-sent event to append to the events field.} +\item{\code{parsed_event}}{An already parsed server-sent event to append to the +events field.} } \if{html}{\out{
}} } diff --git a/man/chat.Rd b/man/chat.Rd index 9121f404..24c86afc 100644 --- a/man/chat.Rd +++ b/man/chat.Rd @@ -15,6 +15,7 @@ chat( task = getOption("gptstudio.task", "coding"), custom_prompt = NULL, process_response = FALSE, + session = NULL, ... ) } @@ -62,6 +63,8 @@ response. If \code{TRUE}, the response will be passed to \code{gptstudio_response_process()} for further processing. Defaults to \code{FALSE}. Refer to \code{gptstudio_response_process()} for more details.} +\item{session}{An optional parameter for a shiny session object.} + \item{...}{Reserved for future use.} } \value{ diff --git a/man/create_ide_matching_colors.Rd b/man/create_ide_matching_colors.Rd index 33bd38e4..233ebfa9 100644 --- a/man/create_ide_matching_colors.Rd +++ b/man/create_ide_matching_colors.Rd @@ -4,7 +4,10 @@ \alias{create_ide_matching_colors} \title{Chat message colors in RStudio} \usage{ -create_ide_matching_colors(role, ide_colors = get_ide_theme_info()) +create_ide_matching_colors( + role = c("user", "assistant"), + ide_colors = get_ide_theme_info() +) } \arguments{ \item{role}{The role of the message author} diff --git a/man/create_tmp_job_script.Rd b/man/create_tmp_job_script.Rd deleted file mode 100644 index b681cfdf..00000000 --- a/man/create_tmp_job_script.Rd +++ /dev/null @@ -1,39 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/addin_chatgpt.R -\name{create_tmp_job_script} -\alias{create_tmp_job_script} -\title{Create a temporary job script} -\usage{ -create_tmp_job_script(appDir, port, host) -} -\arguments{ -\item{appDir}{The application to run. Should be one of the following: -\itemize{ -\item A directory containing \code{server.R}, plus, either \code{ui.R} or -a \code{www} directory that contains the file \code{index.html}. -\item A directory containing \code{app.R}. -\item An \code{.R} file containing a Shiny application, ending with an -expression that produces a Shiny app object. -\item A list with \code{ui} and \code{server} components. -\item A Shiny app object created by \code{\link[shiny:shinyApp]{shinyApp()}}. -}} - -\item{port}{The TCP port that the application should listen on. If the -\code{port} is not specified, and the \code{shiny.port} option is set (with -\code{options(shiny.port = XX)}), then that port will be used. Otherwise, -use a random port between 3000:8000, excluding ports that are blocked -by Google Chrome for being considered unsafe: 3659, 4045, 5060, -5061, 6000, 6566, 6665:6669 and 6697. Up to twenty random -ports will be tried.} - -\item{host}{The IPv4 address that the application should listen on. Defaults -to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See -Details.} -} -\value{ -A string containing the path of a temporary job script -} -\description{ -This function creates a temporary R script file that runs the Shiny -application from the specified directory with the specified port and host. -} diff --git a/man/encode_image.Rd b/man/encode_image.Rd new file mode 100644 index 00000000..bf55f48d --- /dev/null +++ b/man/encode_image.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/service-openai_api_calls.R +\name{encode_image} +\alias{encode_image} +\title{Encode an image file to base64} +\usage{ +encode_image(image_path) +} +\arguments{ +\item{image_path}{String containing the path to the image file} +} +\value{ +A base64 encoded string of the image +} +\description{ +Encode an image file to base64 +} diff --git a/man/get_ide_theme_info.Rd b/man/get_ide_theme_info.Rd index 74b3d251..1e07fa8b 100644 --- a/man/get_ide_theme_info.Rd +++ b/man/get_ide_theme_info.Rd @@ -2,16 +2,24 @@ % Please edit documentation in R/mod_app.R \name{get_ide_theme_info} \alias{get_ide_theme_info} -\title{Get IDE theme information.} +\title{Get IDE Theme Information} \usage{ get_ide_theme_info() } \value{ -A list with three components: -\item{is_dark}{A boolean indicating whether the current IDE theme is dark.} -\item{bg}{The current IDE theme's background color.} -\item{fg}{The current IDE theme's foreground color.} +A list with the following components: +\item{is_dark}{A logical indicating whether the current IDE theme is dark.} +\item{bg}{A character string representing the background color of the IDE theme in hex format.} +\item{fg}{A character string representing the foreground color of the IDE theme in hex format.} + +If RStudio is unavailable, returns the fallback theme details. } \description{ -This function returns a list with the current IDE theme's information. +Retrieves the current RStudio IDE theme information including whether it is a dark theme, +and the background and foreground colors in hexadecimal format. +} +\examples{ +theme_info <- get_ide_theme_info() +print(theme_info) + } diff --git a/man/gptstudio_chat.Rd b/man/gptstudio_chat.Rd index 4eb70ec5..c89ad472 100644 --- a/man/gptstudio_chat.Rd +++ b/man/gptstudio_chat.Rd @@ -2,25 +2,36 @@ % Please edit documentation in R/addin_chatgpt.R \name{gptstudio_chat} \alias{gptstudio_chat} -\title{Run Chat GPT -Run the Chat GPT Shiny App as a background job and show it in the viewer pane} +\title{Run GPTStudio Chat App} \usage{ gptstudio_chat(host = getOption("shiny.host", "127.0.0.1")) } \arguments{ -\item{host}{The IPv4 address that the application should listen on. Defaults -to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See -Details.} +\item{host}{A character string specifying the host on which to run the app. +Defaults to the value of \code{getOption("shiny.host", "127.0.0.1")}.} } \value{ -This function has no return value. +This function does not return a value. It runs the Shiny app as a side effect. } \description{ -Run Chat GPT -Run the Chat GPT Shiny App as a background job and show it in the viewer pane +This function initializes and runs the Chat GPT Shiny App as a background job +in RStudio and opens it in the viewer pane or browser window. +} +\details{ +The function performs the following steps: +\enumerate{ +\item Verifies that RStudio API is available. +\item Finds an available port for the Shiny app. +\item Creates a temporary directory for the app files. +\item Runs the app as a background job in RStudio. +\item Opens the app in the RStudio viewer pane or browser window. +} +} +\note{ +This function is designed to work within the RStudio IDE and requires +the rstudioapi package. } \examples{ -# Call the function as an RStudio addin \dontrun{ gptstudio_chat() } diff --git a/man/run_chatgpt_app.Rd b/man/gptstudio_run_chat_app.Rd similarity index 85% rename from man/run_chatgpt_app.Rd rename to man/gptstudio_run_chat_app.Rd index 557b92ec..91b824d6 100644 --- a/man/run_chatgpt_app.Rd +++ b/man/gptstudio_run_chat_app.Rd @@ -1,11 +1,12 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/run_chatgpt_app.R -\name{run_chatgpt_app} -\alias{run_chatgpt_app} +\name{gptstudio_run_chat_app} +\alias{gptstudio_run_chat_app} \title{Run the ChatGPT app} \usage{ -run_chatgpt_app( +gptstudio_run_chat_app( ide_colors = get_ide_theme_info(), + code_theme_url = get_highlightjs_theme(), host = getOption("shiny.host", "127.0.0.1"), port = getOption("shiny.port") ) @@ -13,6 +14,8 @@ run_chatgpt_app( \arguments{ \item{ide_colors}{List containing the colors of the IDE theme.} +\item{code_theme_url}{URL to the highlight.js theme} + \item{host}{The IPv4 address that the application should listen on. Defaults to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See Details.} diff --git a/man/gptstudio_sitrep.Rd b/man/gptstudio_sitrep.Rd index f9541066..ec88501c 100644 --- a/man/gptstudio_sitrep.Rd +++ b/man/gptstudio_sitrep.Rd @@ -19,7 +19,8 @@ This function prints out the current configuration settings for gptstudio and checks API connections if verbose is TRUE. } \examples{ +\dontrun{ gptstudio_sitrep(verbose = FALSE) # Print basic settings, no API checks gptstudio_sitrep() # Print settings and check API connections - +} } diff --git a/man/input_audio_clip.Rd b/man/input_audio_clip.Rd new file mode 100644 index 00000000..461a2c29 --- /dev/null +++ b/man/input_audio_clip.Rd @@ -0,0 +1,53 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record-audio.R +\name{input_audio_clip} +\alias{input_audio_clip} +\title{An audio clip input control that records short audio clips from the +microphone} +\usage{ +input_audio_clip( + id, + record_label = "Record", + stop_label = "Stop", + reset_on_record = TRUE, + mime_type = NULL, + audio_bits_per_second = NULL, + show_mic_settings = TRUE, + ... +) +} +\arguments{ +\item{id}{The input slot that will be used to access the value.} + +\item{record_label}{Display label for the "record" control, or NULL for no +label. Default is 'Record'.} + +\item{stop_label}{Display label for the "stop" control, or NULL for no label. +Default is 'Record'.} + +\item{reset_on_record}{Whether to reset the audio clip input value when +recording starts. If TRUE, the audio clip input value will become NULL at +the moment the Record button is pressed; if FALSE, the value will not +change until the user stops recording. Default is TRUE.} + +\item{mime_type}{The MIME type of the audio clip to record. By default, this +is NULL, which means the browser will choose a suitable MIME type for audio +recording. Common MIME types include 'audio/webm' and 'audio/mp4'.} + +\item{audio_bits_per_second}{The target audio bitrate in bits per second. By +default, this is NULL, which means the browser will choose a suitable +bitrate for audio recording. This is only a suggestion; the browser may +choose a different bitrate.} + +\item{show_mic_settings}{Whether to show the microphone settings in the +settings menu. Default is TRUE.} + +\item{...}{Additional parameters to pass to the underlying HTML tag.} +} +\value{ +An audio clip input control that can be added to a UI definition. +} +\description{ +An audio clip input control that records short audio clips from the +microphone +} diff --git a/man/mod_app_ui.Rd b/man/mod_app_ui.Rd index 7e7b8044..37c8ba3f 100644 --- a/man/mod_app_ui.Rd +++ b/man/mod_app_ui.Rd @@ -4,12 +4,18 @@ \alias{mod_app_ui} \title{App UI} \usage{ -mod_app_ui(id, ide_colors = get_ide_theme_info()) +mod_app_ui( + id, + ide_colors = get_ide_theme_info(), + code_theme_url = get_highlightjs_theme() +) } \arguments{ \item{id}{id of the module} \item{ide_colors}{List containing the colors of the IDE theme.} + +\item{code_theme_url}{URL to the highlight.js theme} } \description{ App UI diff --git a/man/mod_chat_ui.Rd b/man/mod_chat_ui.Rd index f9bc9ca5..5050d3ec 100644 --- a/man/mod_chat_ui.Rd +++ b/man/mod_chat_ui.Rd @@ -4,12 +4,18 @@ \alias{mod_chat_ui} \title{Chat UI} \usage{ -mod_chat_ui(id, translator = create_translator()) +mod_chat_ui( + id, + translator = create_translator(), + code_theme_url = get_highlightjs_theme() +) } \arguments{ \item{id}{id of the module} \item{translator}{A Translator from \code{shiny.i18n::Translator}} + +\item{code_theme_url}{URL to the highlight.js theme} } \description{ Chat UI diff --git a/man/multimodal_dep.Rd b/man/multimodal_dep.Rd new file mode 100644 index 00000000..974fb87b --- /dev/null +++ b/man/multimodal_dep.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record-audio.R +\name{multimodal_dep} +\alias{multimodal_dep} +\title{Create HTML dependency for multimodal component} +\usage{ +multimodal_dep() +} +\description{ +Create HTML dependency for multimodal component +} diff --git a/man/open_bg_shinyapp.Rd b/man/open_bg_shinyapp.Rd deleted file mode 100644 index 313b2994..00000000 --- a/man/open_bg_shinyapp.Rd +++ /dev/null @@ -1,22 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/addin_chatgpt.R -\name{open_bg_shinyapp} -\alias{open_bg_shinyapp} -\title{Open browser to local Shiny app} -\usage{ -open_bg_shinyapp(host, port) -} -\arguments{ -\item{host}{A character string representing the IP address or domain name of -the server where the Shiny app is hosted.} - -\item{port}{An integer representing the port number on which the Shiny app is -hosted.} -} -\value{ -None (opens the Shiny app in the viewer pane or browser window) -} -\description{ -This function takes in the host and port of a local Shiny app and opens the -app in the default browser. -} diff --git a/man/parse_data_uri.Rd b/man/parse_data_uri.Rd new file mode 100644 index 00000000..d93d273d --- /dev/null +++ b/man/parse_data_uri.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/api-transcribe-audio.R +\name{parse_data_uri} +\alias{parse_data_uri} +\title{Parse a Data URI} +\usage{ +parse_data_uri(data_uri) +} +\arguments{ +\item{data_uri}{A string. The data URI to parse.} +} +\value{ +A list with two elements: 'mime_type' and 'data'. +} +\description{ +This function parses a data URI and returns the MIME type and decoded data. +} diff --git a/man/query_openai_api.Rd b/man/query_api_openai.Rd similarity index 91% rename from man/query_openai_api.Rd rename to man/query_api_openai.Rd index d1e33175..a83e31df 100644 --- a/man/query_openai_api.Rd +++ b/man/query_api_openai.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/service-openai_api_calls.R -\name{query_openai_api} -\alias{query_openai_api} +\name{query_api_openai} +\alias{query_api_openai} \title{A function that sends a request to the OpenAI API and returns the response.} \usage{ -query_openai_api( +query_api_openai( task, request_body, openai_api_key = Sys.getenv("OPENAI_API_KEY") diff --git a/man/random_port.Rd b/man/random_port.Rd deleted file mode 100644 index 6e341606..00000000 --- a/man/random_port.Rd +++ /dev/null @@ -1,14 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/addin_chatgpt.R -\name{random_port} -\alias{random_port} -\title{Generate a random safe port number} -\usage{ -random_port() -} -\value{ -A single integer representing the randomly selected safe port number. -} -\description{ -This function generates a random port allowed by shiny::runApp. -} diff --git a/man/run_app_as_bg_job.Rd b/man/run_app_as_bg_job.Rd deleted file mode 100644 index 93dfb0e4..00000000 --- a/man/run_app_as_bg_job.Rd +++ /dev/null @@ -1,42 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/addin_chatgpt.R -\name{run_app_as_bg_job} -\alias{run_app_as_bg_job} -\title{Run an R Shiny app in the background} -\usage{ -run_app_as_bg_job(appDir = ".", job_name, host, port) -} -\arguments{ -\item{appDir}{The application to run. Should be one of the following: -\itemize{ -\item A directory containing \code{server.R}, plus, either \code{ui.R} or -a \code{www} directory that contains the file \code{index.html}. -\item A directory containing \code{app.R}. -\item An \code{.R} file containing a Shiny application, ending with an -expression that produces a Shiny app object. -\item A list with \code{ui} and \code{server} components. -\item A Shiny app object created by \code{\link[shiny:shinyApp]{shinyApp()}}. -}} - -\item{job_name}{The name of the background job to be created} - -\item{host}{The IPv4 address that the application should listen on. Defaults -to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See -Details.} - -\item{port}{The TCP port that the application should listen on. If the -\code{port} is not specified, and the \code{shiny.port} option is set (with -\code{options(shiny.port = XX)}), then that port will be used. Otherwise, -use a random port between 3000:8000, excluding ports that are blocked -by Google Chrome for being considered unsafe: 3659, 4045, 5060, -5061, 6000, 6566, 6665:6669 and 6697. Up to twenty random -ports will be tried.} -} -\value{ -This function returns nothing because is meant to run an app as a -side effect. -} -\description{ -This function runs an R Shiny app as a background job using the specified -directory, name, host, and port. -} diff --git a/man/stream_chat_completion.Rd b/man/stream_chat_completion.Rd index 152cf9dd..25ed5736 100644 --- a/man/stream_chat_completion.Rd +++ b/man/stream_chat_completion.Rd @@ -5,8 +5,8 @@ \title{Stream Chat Completion} \usage{ stream_chat_completion( - messages = NULL, - element_callback = cat, + messages = list(list(role = "user", content = "Hi there!")), + element_callback = openai_handler, model = "gpt-4o-mini", openai_api_key = Sys.getenv("OPENAI_API_KEY") ) @@ -27,7 +27,7 @@ Please note that the OpenAI API key is sensitive information and should be treated accordingly.} } \value{ -The same as \code{curl::curl_fetch_stream} +The same as \code{httr2::req_perform_stream} } \description{ \code{stream_chat_completion} sends the prepared chat completion request to the diff --git a/man/transcribe_audio.Rd b/man/transcribe_audio.Rd new file mode 100644 index 00000000..5de9b3db --- /dev/null +++ b/man/transcribe_audio.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/api-transcribe-audio.R +\name{transcribe_audio} +\alias{transcribe_audio} +\title{Transcribe Audio from Data URI Using OpenAI's Whisper Model} +\usage{ +transcribe_audio(audio_input, api_key = Sys.getenv("OPENAI_API_KEY")) +} +\arguments{ +\item{audio_input}{A string. The audio data in data URI format.} + +\item{api_key}{A string. Your OpenAI API key. Defaults to the OPENAI_API_KEY +environment variable.} +} +\value{ +A string containing the transcribed text. +} +\description{ +This function takes an audio file in data URI format, converts it to WAV, and +sends it to OpenAI's transcription API to get the transcribed text. +} +\examples{ +\dontrun{ +audio_uri <- "data:audio/webm;base64,SGVsbG8gV29ybGQ=" # Example data URI +transcription <- transcribe_audio(audio_uri) +print(transcription) +} + +} diff --git a/revdep/library.noindex/gptstudio/new/gptstudio/mod_app/app.R b/revdep/library.noindex/gptstudio/new/gptstudio/mod_app/app.R index ba4f650e..00090d61 100644 --- a/revdep/library.noindex/gptstudio/new/gptstudio/mod_app/app.R +++ b/revdep/library.noindex/gptstudio/new/gptstudio/mod_app/app.R @@ -1 +1 @@ -gptstudio::run_chatgpt_app() +gptstudio::gptstudio_run_chat_app() diff --git a/revdep/library.noindex/gptstudio/old/gptstudio/mod_app/app.R b/revdep/library.noindex/gptstudio/old/gptstudio/mod_app/app.R index ba4f650e..00090d61 100644 --- a/revdep/library.noindex/gptstudio/old/gptstudio/mod_app/app.R +++ b/revdep/library.noindex/gptstudio/old/gptstudio/mod_app/app.R @@ -1 +1 @@ -gptstudio::run_chatgpt_app() +gptstudio::gptstudio_run_chat_app() diff --git a/revdep/library.noindex/gptstudio/old/gptstudio/shiny/app.R b/revdep/library.noindex/gptstudio/old/gptstudio/shiny/app.R index ec72302d..0a434a9e 100644 --- a/revdep/library.noindex/gptstudio/old/gptstudio/shiny/app.R +++ b/revdep/library.noindex/gptstudio/old/gptstudio/shiny/app.R @@ -16,7 +16,8 @@ chat_card <- bslib::card( shiny::actionButton( width = "100%", inputId = "chat", label = "Chat", - icon = shiny::icon("robot"), class = "btn-primary" + icon = bsicons::bs_icon("robot"), + class = "btn-primary" ), shiny::br(), shiny::br(), shiny::fluidRow( @@ -34,7 +35,7 @@ chat_card <- bslib::card( shiny::actionButton( width = "100%", inputId = "clear_history", label = "Clear History", - icon = shiny::icon("eraser") + icon = bsicons::bs_icon("eraser") ), ) ) diff --git a/tests/testthat/_snaps/addin-chatgpt.md b/tests/testthat/_snaps/addin-chatgpt.md new file mode 100644 index 00000000..0e60c583 --- /dev/null +++ b/tests/testthat/_snaps/addin-chatgpt.md @@ -0,0 +1,12 @@ +# create_temp_app_file creates a valid R script + + Code + content + Output + [1] "ide_colors <- list(editor_theme = \"textmate\", editor_theme_is_dark = FALSE)" + [2] " ui <- gptstudio:::mod_app_ui('app', ide_colors, 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/github-dark.min.css')" + [3] " server <- function(input, output, session) {" + [4] " gptstudio:::mod_app_server('app', ide_colors)" + [5] " }" + [6] " shiny::shinyApp(ui, server)" + diff --git a/tests/testthat/_snaps/api_skeletons.md b/tests/testthat/_snaps/api_skeletons.md new file mode 100644 index 00000000..0b2fa2d4 --- /dev/null +++ b/tests/testthat/_snaps/api_skeletons.md @@ -0,0 +1,391 @@ +# multiplication works + + Code + gptstudio_create_skeleton() + Output + $url + https://api.openai.com/v1/chat/completions + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] TRUE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_openai" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "anthropic") + Output + $url + [1] "https://api.anthropic.com/v1/complete" + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] FALSE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_anthropic" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "cohere") + Output + $url + [1] "https://api.cohere.ai/v1/chat" + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] FALSE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_cohere" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "google") + Output + $url + [1] "https://generativelanguage.googleapis.com/v1beta2/models/" + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] FALSE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_google" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "huggingface") + Output + $url + [1] "https://api-inference.huggingface.co/models" + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] FALSE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_huggingface" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "ollama") + Output + $url + [1] "JUST A PLACEHOLDER" + + $api_key + [1] "JUST A PLACEHOLDER" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] TRUE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_ollama" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "openai") + Output + $url + https://api.openai.com/v1/chat/completions + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] TRUE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_openai" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "perplexity") + Output + $url + [1] "https://api.perplexity.ai/chat/completions" + + $api_key + [1] "a-fake-key" + + $model + [1] "gpt-4o-mini" + + $prompt + [1] "Name the top 5 packages in R." + + $history + $history[[1]] + $history[[1]]$role + [1] "system" + + $history[[1]]$content + [1] "You are an R chat assistant" + + + + $stream + [1] FALSE + + $extras + list() + + attr(,"class") + [1] "gptstudio_request_perplexity" "gptstudio_request_skeleton" + +--- + + Code + gptstudio_create_skeleton(service = "azure-openai") + +# new_gptstudio_request_skeleton_openai creates correct structure + + Code + skeleton <- new_gptstudio_request_skeleton_openai(url = "https://api.openai.com/v1/chat/completions", + api_key = "test_key", model = "gpt-4-turbo-preview", prompt = "What is R?", + history = list(list(role = "system", content = "You are an R assistant")), + stream = TRUE, n = 1) + str(skeleton) + Output + List of 7 + $ url : chr "https://api.openai.com/v1/chat/completions" + $ api_key: chr "test_key" + $ model : chr "gpt-4-turbo-preview" + $ prompt : chr "What is R?" + $ history:List of 1 + ..$ :List of 2 + .. ..$ role : chr "system" + .. ..$ content: chr "You are an R assistant" + $ stream : logi TRUE + $ extras : list() + - attr(*, "class")= chr [1:2] "gptstudio_request_openai" "gptstudio_request_skeleton" + +# new_gptstudio_request_skeleton_huggingface creates correct structure + + Code + skeleton <- new_gptstudio_request_skeleton_huggingface(url = "https://api-inference.huggingface.co/models", + api_key = "test_key", model = "gpt2", prompt = "What is R?", history = list( + list(role = "system", content = "You are an R assistant")), stream = FALSE) + str(skeleton) + Output + List of 7 + $ url : chr "https://api-inference.huggingface.co/models" + $ api_key: chr "test_key" + $ model : chr "gpt2" + $ prompt : chr "What is R?" + $ history:List of 1 + ..$ :List of 2 + .. ..$ role : chr "system" + .. ..$ content: chr "You are an R assistant" + $ stream : logi FALSE + $ extras : list() + - attr(*, "class")= chr [1:2] "gptstudio_request_huggingface" "gptstudio_request_skeleton" + +# validate_skeleton throws error for invalid URL + + Code + validate_skeleton(url = 123, api_key = "valid_key", model = "test_model", + prompt = "What is R?", history = list(), stream = TRUE) + Condition + Error in `validate_skeleton()`: + ! `url` is not a valid character scalar. It is a . + +# validate_skeleton throws error for empty API key + + Code + validate_skeleton(url = "https://api.example.com", api_key = "", model = "test_model", + prompt = "What is R?", history = list(), stream = TRUE) + Condition + Error in `validate_skeleton()`: + ! `api_key` is not a valid character scalar. It is a . + +# validate_skeleton throws error for empty model + + Code + validate_skeleton(url = "https://api.example.com", api_key = "valid_key", + model = "", prompt = "What is R?", history = list(), stream = TRUE) + Condition + Error in `validate_skeleton()`: + ! `model` is not a valid character scalar. It is a . + +# validate_skeleton throws error for non-character prompt + + Code + validate_skeleton(url = "https://api.example.com", api_key = "valid_key", + model = "test_model", prompt = list("not a string"), history = list(), + stream = TRUE) + Condition + Error in `validate_skeleton()`: + ! `prompt` is not a valid character scalar. It is a . + +# validate_skeleton throws error for invalid history + + Code + validate_skeleton(url = "https://api.example.com", api_key = "valid_key", + model = "test_model", prompt = "What is R?", history = "not a list", stream = TRUE) + Condition + Error in `validate_skeleton()`: + ! `history` is not a valid list or NULL. It is a . + +# validate_skeleton throws error for non-boolean stream + + Code + validate_skeleton(url = "https://api.example.com", api_key = "valid_key", + model = "test_model", prompt = "What is R?", history = list(), stream = "not a boolean") + Condition + Error in `validate_skeleton()`: + ! `stream` is not a valid boolean. It is a . + diff --git a/tests/testthat/_snaps/models.md b/tests/testthat/_snaps/models.md index c44bfd9a..65c42258 100644 --- a/tests/testthat/_snaps/models.md +++ b/tests/testthat/_snaps/models.md @@ -15,9 +15,10 @@ Code models Output - [1] "command-r" "command-nightly" "command-r-plus" - [4] "c4ai-aya-23-35b" "command-light-nightly" "c4ai-aya-23-8b" - [7] "command" "command-light" + [1] "c4ai-aya-23-35b" "command-r" "command-r-plus" + [4] "command-nightly" "command-light-nightly" "command" + [7] "command-r-08-2024" "command-r-plus-08-2024" "command-light" + [10] "c4ai-aya-23-8b" # get_available_models works for google @@ -30,8 +31,10 @@ [7] "gemini-1.0-pro-001" "gemini-1.0-pro-vision-latest" [9] "gemini-pro-vision" "gemini-1.5-pro-latest" [11] "gemini-1.5-pro-001" "gemini-1.5-pro" - [13] "gemini-1.5-pro-exp-0801" "gemini-1.5-flash-latest" - [15] "gemini-1.5-flash-001" "gemini-1.5-flash" - [17] "gemini-1.5-flash-001-tuning" "embedding-001" - [19] "text-embedding-004" "aqa" + [13] "gemini-1.5-pro-exp-0801" "gemini-1.5-pro-exp-0827" + [15] "gemini-1.5-flash-latest" "gemini-1.5-flash-001" + [17] "gemini-1.5-flash-001-tuning" "gemini-1.5-flash" + [19] "gemini-1.5-flash-exp-0827" "gemini-1.5-flash-8b-exp-0827" + [21] "embedding-001" "text-embedding-004" + [23] "aqa" diff --git a/tests/testthat/test-addin-chatgpt.R b/tests/testthat/test-addin-chatgpt.R index 785389d1..91bf8b04 100644 --- a/tests/testthat/test-addin-chatgpt.R +++ b/tests/testthat/test-addin-chatgpt.R @@ -1,15 +1,106 @@ -test_that("random_port() works", { - set.seed(123) +test_that("find_available_port returns a valid port", { + port <- find_available_port() + expect_true(port >= 3000 && port <= 8000) + expect_false(port %in% c(3659, 4045, 5060, 5061, 6000, 6566, 6665:6669, 6697)) +}) + +test_that("create_temp_app_file creates a valid R script", { + mock_get_ide_theme_info <- function() { + list( + editor_theme = "textmate", + editor_theme_is_dark = FALSE + ) + } + + local_mocked_bindings( + get_ide_theme_info = mock_get_ide_theme_info, + .package = "gptstudio" # Specify the package explicitly + ) + + temp_file <- create_temp_app_file() + + expect_true(file.exists(temp_file)) + expect_true(grepl("\\.R$", temp_file)) + + content <- readLines(temp_file) + expect_snapshot(content) +}) + +test_that("run_app_background creates a job", { + mock_job_run_script <- function(...) NULL + mock_cli_alert_success <- function(...) NULL - random_port() %>% - expect_equal(5466) + with_mocked_bindings( + jobRunScript = mock_job_run_script, + .package = "rstudioapi", + { + with_mocked_bindings( + cli_alert_success = mock_cli_alert_success, + .package = "cli", + { + expect_no_error(run_app_background("test_dir", "test_job", "127.0.0.1", 3000)) + } + ) + } + ) }) -test_that("create_tmp_job_script() returns string", { - create_tmp_job_script( - appDir = system.file("shiny", package = "gptstudio"), - host = "127.0.0.1", - port = 3838 - ) %>% - expect_type("character") +test_that("open_app_in_viewer opens the app correctly", { + mock_translate_local_url <- function(...) "http://translated.url" + mock_viewer <- function(...) NULL + mock_cli_inform <- function(...) NULL + mock_cli_alert_info <- function(...) NULL + mock_wait_for_bg_app <- function(...) NULL + + with_mocked_bindings( + translateLocalUrl = mock_translate_local_url, + viewer = mock_viewer, + .package = "rstudioapi", + { + with_mocked_bindings( + cli_inform = mock_cli_inform, + cli_alert_info = mock_cli_alert_info, + .package = "cli", + { + local_mocked_bindings( + wait_for_bg_app = mock_wait_for_bg_app + ) + + # Test for localhost + expect_no_error(open_app_in_viewer("127.0.0.1", 3000)) + + # Test for non-localhost + expect_no_error(open_app_in_viewer("192.168.1.100", 3000)) + } + ) + } + ) +}) + +test_that("gptstudio_chat runs the app correctly", { + mock_verify_available <- function() NULL + mock_find_available_port <- function() 3000 + mock_create_temp_app_dir <- function() "test_dir" + mock_run_app_background <- function(...) NULL + mock_open_app_in_viewer <- function(...) NULL + mock_version_availalbe <- function() list(mode = "desktop") + + with_mocked_bindings( + verifyAvailable = mock_verify_available, + versionInfo = mock_version_availalbe, + .package = "rstudioapi", + { + local_mocked_bindings( + find_available_port = mock_find_available_port, + create_temp_app_dir = mock_create_temp_app_dir, + run_app_background = mock_run_app_background, + open_app_in_viewer = mock_open_app_in_viewer + ) + + expect_no_error(gptstudio_chat()) + + # Test with custom host + expect_no_error(gptstudio_chat(host = "192.168.1.100")) + } + ) }) diff --git a/tests/testthat/test-api-transcribe-audio.R b/tests/testthat/test-api-transcribe-audio.R new file mode 100644 index 00000000..c6ac7ccb --- /dev/null +++ b/tests/testthat/test-api-transcribe-audio.R @@ -0,0 +1,56 @@ +test_that("parse_data_uri correctly parses valid data URIs", { + # Test case 1: Simple data URI + uri1 <- "data:text/plain;base64,SGVsbG8gV29ybGQ=" + result1 <- parse_data_uri(uri1) + expect_equal(result1$mime_type, "text/plain") + expect_equal(result1$data, charToRaw("Hello World")) + + # Test case 2: Data URI with padding + uri2 <- "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" # nolint + result2 <- parse_data_uri(uri2) + expect_equal(result2$mime_type, "image/png") + expect_true(length(result2$data) > 0) + + # Test case 3: Data URI without padding + uri3 <- "data:audio/mp3;base64,AAAAHGZ0eXBNNEEgAAAAAE00QSBtcDQyaXNvbQ" + result3 <- parse_data_uri(uri3) + expect_equal(result3$mime_type, "audio/mp3") + expect_true(length(result3$data) > 0) +}) + +test_that("parse_data_uri handles invalid inputs correctly", { + # Test case 4: Invalid data URI format + expect_error(parse_data_uri("not a data uri"), "Invalid data URI format") + + # Test case 5: Empty string + expect_error(parse_data_uri(""), "Invalid data URI format") + + # Test case 6: NULL input + expect_error(parse_data_uri(NULL), "Invalid input: data_uri must be a single character string") + + # Test case 7: Non-character input + expect_error(parse_data_uri(123), "Invalid input: data_uri must be a single character string") + + # Test case 8: Character vector with length > 1 + expect_error( + parse_data_uri(c( + "data:text/plain;base64,SGVsbG8=", + "data:text/plain;base64,V29ybGQ=" + )), + "Invalid input: data_uri must be a single character string" + ) +}) + +test_that("parse_data_uri handles edge cases", { + # Test case 9: Data URI with empty data + uri9 <- "data:text/plain;base64," + result9 <- parse_data_uri(uri9) + expect_equal(result9$mime_type, "text/plain") + expect_equal(result9$data, raw(0)) + + # Test case 10: Data URI with special characters in MIME type + uri10 <- "data:application/x-custom+xml;base64,PGhlbGxvPndvcmxkPC9oZWxsbz4=" + result10 <- parse_data_uri(uri10) + expect_equal(result10$mime_type, "application/x-custom+xml") + expect_equal(result10$data, charToRaw("world")) +}) diff --git a/tests/testthat/test-api_skeletons.R b/tests/testthat/test-api_skeletons.R new file mode 100644 index 00000000..7f5f0b06 --- /dev/null +++ b/tests/testthat/test-api_skeletons.R @@ -0,0 +1,288 @@ +withr::local_envvar( + list( + "OPENAI_API_KEY" = "a-fake-key", + "ANTHROPIC_API_KEY" = "a-fake-key", + "HF_API_KEY" = "a-fake-key", + "GOOGLE_API_KEY" = "a-fake-key", + "AZURE_OPENAI_API_KEY" = "a-fake-key", + "PERPLEXITY_API_KEY" = "a-fake-key", + "COHERE_API_KEY" = "a-fake-key" + ) +) + +test_that("multiplication works", { + config <- yaml::read_yaml(system.file("rstudio/config.yml", + package = "gptstudio" + )) + set_user_options(config) + + withr::with_envvar( + new = c( + "OPENAI_API_KEY" = "a-fake-key", + "ANTHROPIC_API_KEY" = "a-fake-key", + "HF_API_KEY" = "a-fake-key", + "GOOGLE_API_KEY" = "a-fake-key", + "AZURE_OPENAI_API_KEY" = "a-fake-key", + "PERPLEXITY_API_KEY" = "a-fake-key", + "COHERE_API_KEY" = "a-fake-key", + "OLLAMA_HOST" = "JUST A PLACEHOLDER" + ), + { + expect_snapshot(gptstudio_create_skeleton()) + expect_snapshot(gptstudio_create_skeleton(service = "anthropic")) + expect_snapshot(gptstudio_create_skeleton(service = "cohere")) + expect_snapshot(gptstudio_create_skeleton(service = "google")) + expect_snapshot(gptstudio_create_skeleton(service = "huggingface")) + expect_snapshot(gptstudio_create_skeleton(service = "ollama")) + expect_snapshot(gptstudio_create_skeleton(service = "openai")) + expect_snapshot(gptstudio_create_skeleton(service = "perplexity")) + expect_snapshot(gptstudio_create_skeleton(service = "azure-openai")) + } + ) +}) + + +test_that("gptstudio_create_skeleton creates correct skeleton for OpenAI", { + skeleton <- gptstudio_create_skeleton( + service = "openai", + prompt = "What is R?", + model = "gpt-4-turbo-preview" + ) + + expect_s3_class(skeleton, "gptstudio_request_openai") + expect_equal(skeleton$model, "gpt-4-turbo-preview") + expect_equal(skeleton$prompt, "What is R?") + expect_true(skeleton$stream) +}) + +test_that("gptstudio_create_skeleton creates correct skeleton for Hugging Face", { + skeleton <- gptstudio_create_skeleton( + service = "huggingface", + prompt = "What is R?", + model = "gpt2" + ) + + expect_s3_class(skeleton, "gptstudio_request_huggingface") + expect_equal(skeleton$model, "gpt2") + expect_equal(skeleton$prompt, "What is R?") + expect_false(skeleton$stream) +}) + +test_that("gptstudio_create_skeleton creates correct skeleton for Anthropic", { + skeleton <- gptstudio_create_skeleton( + service = "anthropic", + prompt = "What is R?", + model = "claude-3-5-sonnet-20240620" + ) + + expect_s3_class(skeleton, "gptstudio_request_anthropic") + expect_equal(skeleton$model, "claude-3-5-sonnet-20240620") + expect_equal(skeleton$prompt, "What is R?") + expect_false(skeleton$stream) +}) + +test_that("gptstudio_create_skeleton creates correct skeleton for Cohere", { + skeleton <- gptstudio_create_skeleton( + service = "cohere", + prompt = "What is R?", + model = "command" + ) + + expect_s3_class(skeleton, "gptstudio_request_cohere") + expect_equal(skeleton$model, "command") + expect_equal(skeleton$prompt, "What is R?") + expect_false(skeleton$stream) +}) + +test_that("new_gptstudio_request_skeleton_openai creates correct structure", { + expect_snapshot({ + skeleton <- new_gptstudio_request_skeleton_openai( + url = "https://api.openai.com/v1/chat/completions", + api_key = "test_key", + model = "gpt-4-turbo-preview", + prompt = "What is R?", + history = list(list(role = "system", content = "You are an R assistant")), + stream = TRUE, + n = 1 + ) + str(skeleton) + }) +}) + +test_that("new_gptstudio_request_skeleton_huggingface creates correct structure", { + expect_snapshot({ + skeleton <- new_gptstudio_request_skeleton_huggingface( + url = "https://api-inference.huggingface.co/models", + api_key = "test_key", + model = "gpt2", + prompt = "What is R?", + history = list(list(role = "system", content = "You are an R assistant")), + stream = FALSE + ) + str(skeleton) + }) +}) + + +library(testthat) +library(gptstudio) + +# Tests for new_gpstudio_request_skeleton +test_that("new_gpstudio_request_skeleton creates correct structure with valid inputs", { + result <- new_gpstudio_request_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = list(list(role = "system", content = "You are an R assistant")), + stream = TRUE, + extra_param = "value" + ) + + expect_s3_class(result, "gptstudio_request_skeleton") + expect_equal(result$url, "https://api.example.com") + expect_equal(result$api_key, "valid_key") + expect_equal(result$model, "test_model") + expect_equal(result$prompt, "What is R?") + expect_equal(result$history, list(list(role = "system", content = "You are an R assistant"))) + expect_true(result$stream) + expect_equal(result$extras, list(extra_param = "value")) +}) + +test_that("new_gpstudio_request_skeleton handles NULL history", { + result <- new_gpstudio_request_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = NULL, + stream = FALSE + ) + + expect_null(result$history) +}) + +test_that("new_gpstudio_request_skeleton adds custom class", { + result <- new_gpstudio_request_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = list(), + stream = TRUE, + class = "custom_class" + ) + + expect_s3_class(result, c("custom_class", "gptstudio_request_skeleton")) +}) + +# Tests for validate_skeleton +test_that("validate_skeleton passes with valid inputs", { + expect_silent( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = list(list(role = "system", content = "You are an R assistant")), + stream = TRUE + ) + ) +}) + +test_that("validate_skeleton handles NULL history", { + expect_silent( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = NULL, + stream = TRUE + ) + ) +}) + +test_that("validate_skeleton throws error for invalid URL", { + expect_snapshot( + validate_skeleton( + url = 123, + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = list(), + stream = TRUE + ), + error = TRUE + ) +}) + +test_that("validate_skeleton throws error for empty API key", { + expect_snapshot( + validate_skeleton( + url = "https://api.example.com", + api_key = "", + model = "test_model", + prompt = "What is R?", + history = list(), + stream = TRUE + ), + error = TRUE + ) +}) + +test_that("validate_skeleton throws error for empty model", { + expect_snapshot( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "", + prompt = "What is R?", + history = list(), + stream = TRUE + ), + error = TRUE + ) +}) + +test_that("validate_skeleton throws error for non-character prompt", { + expect_snapshot( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = list("not a string"), + history = list(), + stream = TRUE + ), + error = TRUE + ) +}) + +test_that("validate_skeleton throws error for invalid history", { + expect_snapshot( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = "not a list", + stream = TRUE + ), + error = TRUE + ) +}) + +test_that("validate_skeleton throws error for non-boolean stream", { + expect_snapshot( + validate_skeleton( + url = "https://api.example.com", + api_key = "valid_key", + model = "test_model", + prompt = "What is R?", + history = list(), + stream = "not a boolean" + ), + error = TRUE + ) +}) diff --git a/tests/testthat/test-models.R b/tests/testthat/test-models.R index 868cabb1..bb815e4f 100644 --- a/tests/testthat/test-models.R +++ b/tests/testthat/test-models.R @@ -55,7 +55,8 @@ test_that("get_available_models works for perplexity", { test_that("get_available_models works for ollama", { with_mocked_bindings( `ollama_is_available` = mock_ollama_is_available, - `ollama_list` = mock_ollama_list, { + `ollama_list` = mock_ollama_list, + { service <- "ollama" models <- get_available_models(service) expect_equal(models, c("ollama-3.5", "ollama-3", "ollama-2")) diff --git a/tests/testthat/test-service-azure_openai.R b/tests/testthat/test-service-azure_openai.R index e64d4c0f..c78192f9 100644 --- a/tests/testthat/test-service-azure_openai.R +++ b/tests/testthat/test-service-azure_openai.R @@ -41,8 +41,10 @@ test_that("request_base_azure_openai constructs correct request", { } mock_req_headers <- function(req, ...) { - req$headers <- list("api-key" = "test_token", - "Content-Type" = "application/json") + req$headers <- list( + "api-key" = "test_token", + "Content-Type" = "application/json" + ) req } @@ -64,9 +66,11 @@ test_that("request_base_azure_openai constructs correct request", { api_version = "test_version" ) - expect_equal(result$url, "https://test.openai.azure.com/openai/deployments/test_deployment/test_task?api-version=test_version") #nolint - expect_equal(result$headers, list("api-key" = "test_token", - "Content-Type" = "application/json")) + expect_equal(result$url, "https://test.openai.azure.com/openai/deployments/test_deployment/test_task?api-version=test_version") # nolint + expect_equal(result$headers, list( + "api-key" = "test_token", + "Content-Type" = "application/json" + )) } ) }) @@ -74,12 +78,14 @@ test_that("request_base_azure_openai constructs correct request", { test_that("query_api_azure_openai handles successful response", { mock_request_base <- function(...) { structure(list(url = "https://test.openai.azure.com", headers = list()), - class = "httr2_request") + class = "httr2_request" + ) } mock_req_perform <- function(req) { structure(list(status_code = 200, body = '{"result": "success"}'), - class = "httr2_response") + class = "httr2_response" + ) } mock_resp_body_json <- function(resp) list(result = "success") @@ -110,12 +116,14 @@ test_that("query_api_azure_openai handles successful response", { test_that("query_api_azure_openai handles error response", { mock_request_base <- function(...) { structure(list(url = "https://test.openai.azure.com", headers = list()), - class = "httr2_request") + class = "httr2_request" + ) } mock_req_perform <- function(req) { structure(list(status_code = 400, body = '{"error": "Bad Request"}'), - class = "httr2_response") + class = "httr2_response" + ) } local_mocked_bindings( diff --git a/tests/testthat/test-service-openai_streaming.R b/tests/testthat/test-service-openai_streaming.R index bdfc58db..f81f3f8f 100644 --- a/tests/testthat/test-service-openai_streaming.R +++ b/tests/testthat/test-service-openai_streaming.R @@ -1,5 +1,4 @@ test_that("OpenaiStreamParser works with different kinds of data values", { - openai_parser <- function(sse) { parser <- OpenaiStreamParser$new() parser$parse_sse(sse) @@ -16,5 +15,4 @@ test_that("OpenaiStreamParser works with different kinds of data values", { expect_type(openai_parser(event2), "list") expect_type(openai_parser(event3), "list") expect_type(openai_parser(event4), "list") - })