diff --git a/DESCRIPTION b/DESCRIPTION index fc7d3aca..9ed1eaef 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -52,7 +52,7 @@ Imports: withr, xml2 Suggests: - bslib (>= 0.7.0), + bslib (>= 0.11.0), callr, chromote, covr, @@ -73,7 +73,7 @@ Suggests: renv, roxygen2, shiny, - shinychat (>= 0.3.0), + shinychat (>= 0.3.0.9000), testthat (>= 3.0.0), tibble, usethis @@ -137,3 +137,5 @@ Collate: 'utils-r.R' 'utils.R' 'zzz.R' +Remotes: + posit-dev/shinychat diff --git a/R/btw_client_app.R b/R/btw_client_app.R index 0b147a03..9f946616 100644 --- a/R/btw_client_app.R +++ b/R/btw_client_app.R @@ -4,27 +4,37 @@ #' chat #' @param messages A list of initial messages to show in the chat, passed to #' [shinychat::chat_mod_ui()]. +#' @param model_choices Can be one of `"btw_md"` (model choices from your +#' `path_btw` configuration), `"provider"` (models from the provider API), +#' `"auto"` (uses `path_btw` if `client` comes from `path_btw`, otherwise +#' falling back to provider), or `"none"` (don't show model choices). #' @export btw_app <- function( ..., client = NULL, tools = NULL, path_btw = NULL, - messages = list() + messages = list(), + model_choices = c("auto", "btw_md", "provider", "none") ) { rlang::check_installed("shiny") - rlang::check_installed("bslib") + rlang::check_installed("bslib", version = "0.11.0") rlang::check_installed("htmltools") - rlang::check_installed("shinychat", version = "0.3.0") + rlang::check_installed("shinychat", version = "0.3.0.9000") + + model_choices <- rlang::arg_match(model_choices) if (getOption("btw.app.close_on_session_end", FALSE)) { cli::cli_alert("Starting up {.fn btw::btw_app} ...") } + client_name <- if (is_string(client)) client + # Get reference tools for the app if (inherits(client, "AsIs")) { # When client is AsIs (pre-configured), use btw_tools() as reference reference_tools <- btw_tools() + app_models <- app_resolve_model_choices(model_choices, path_btw = FALSE) } else { client <- btw_client( client = client, @@ -44,12 +54,24 @@ btw_app <- function( ) reference_tools <- reference_client$get_tools() }) + + app_models <- app_resolve_model_choices( + model_choices, + path_btw, + client_name = client_name + ) + } + + selected_client <- if (is.list(app_models) && !is.null(client_name)) { + resolve_model_choice_name(client_name, names(app_models)) } btw_app_from_client( client, messages = messages, allowed_tools = reference_tools, + app_models = app_models, + selected_client = selected_client, ... ) } @@ -72,18 +94,14 @@ btw_app_from_client <- function( client, messages = list(), allowed_tools = btw_tools(), + app_models = "provider", + selected_client = NULL, ... ) { path_figures_installed <- system.file("help", "figures", package = "btw") path_figures_dev <- system.file("man", "figures", package = "btw") path_logo <- "btw_figures/logo.png" - provider_model <- sprintf( - "%s/%s", - client$get_provider()@name, - client$get_model() - ) - # Store original client tools (preserves configuration like closures) # $get_tools() returns a named list where names are tool names original_client_tools <- client$get_tools() @@ -179,11 +197,16 @@ btw_app_from_client <- function( shinychat::chat_mod_ui( "chat", messages = messages, - width = "min(750px, 100%)" + width = "min(750px, 100%)", + footer = if (utils::packageVersion("shinychat") >= "0.3.0.9000") { + btw_status_bar_ui( + "status_bar", + client = client, + models = app_models, + selected = selected_client + ) + } ), - if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_ui("status_bar", provider_model) - }, btw_app_html_dep(), ) } @@ -191,8 +214,12 @@ btw_app_from_client <- function( server <- function(input, output, session) { chat <- shinychat::chat_mod_server("chat", client = client) - if (utils::packageVersion("shinychat") >= "0.2.0.9000") { - btw_status_bar_server("status_bar", chat) + if (utils::packageVersion("shinychat") >= "0.3.0.9000") { + res <- btw_status_bar_server("status_bar", chat, app_models) + + shiny::observeEvent(res$clear_chat(), { + chat$clear(client_history = "clear") + }) } shiny::observeEvent(input$show_sidebar, { @@ -470,60 +497,176 @@ notifier <- function(icon, action, error = NULL, ...) { bslib_show_toast(toast) } -btw_status_bar_ui <- function(id, provider_model) { +btw_status_bar_ui <- function( + id, + client, + models = "provider", + selected = NULL +) { ns <- shiny::NS(id) + + if (identical(models, "provider")) { + selected <- client$get_model() + choices <- selected # full list populated asynchronously in server + } else if (length(models) > 0) { + selected <- selected %||% names(models)[[1]] + choices <- names(models) + } + shiny::tagList( shiny::tags$footer( - class = "status-footer d-flex align-items-center gap-3 small text-muted", + class = "status-footer small text-muted", style = "width: min(725px, 100%); margin-inline: auto;", - bslib::tooltip( - shiny::actionLink(ns("show_sys_prompt"), tool_icon("quick-reference")), - "Show system prompt" - ), - shiny::div( - class = "status-provider-model", - shiny::span(class = "font-monospace", provider_model), - ), - shiny::div( - class = "ms-auto status-tokens font-monospace", - bslib::tooltip( - id = ns("status_tokens_input_tooltip"), - shiny::span( - id = ns("status_tokens_input"), - class = "status-countup", - "data-type" = "tokens_input" - ), - "Input tokens" + bslib::toolbar( + gap = "0.25em", + shiny::uiOutput(ns("provider")), + if (is.null(models)) { + shiny::div( + class = "status-model badge text-body-secondary fw-normal", + client$get_model() + ) + } else { + bslib::toolbar_input_select( + id = ns("model"), + label = "Model", + selected = selected, + choices = choices, + style = bslib::css(min_width = "12rem") + ) + }, + bslib::toolbar_spacer(), + bslib::toolbar_input_button( + id = ns("show_sys_prompt"), + label = "Show system prompt", + icon = tool_icon("quick-reference") ), - bslib::tooltip( - shiny::span( - id = ns("status_tokens_output"), - class = "status-countup", - "data-type" = "tokens_output" - ), - "Output tokens" - ) - ), - shiny::div( - class = "status-cost font-monospace", - bslib::tooltip( - id = ns("status_cost_tooltip"), - shiny::span( - id = ns("status_cost"), - class = "status-countup", - "data-type" = "cost" + bslib::toolbar_input_button( + id = ns("clear_chat"), + label = "Clear chat", + icon = tool_icon("ink-eraser"), + ), + bslib::toolbar_divider(), + shiny::div( + class = "status-tokens font-monospace", + bslib::tooltip( + id = ns("status_tokens_input_tooltip"), + shiny::span( + id = ns("status_tokens_input"), + class = "status-countup", + "data-type" = "tokens_input" + ), + "Input tokens" ), - "Estimated cost" - ) + bslib::tooltip( + shiny::span( + id = ns("status_tokens_output"), + class = "status-countup", + "data-type" = "tokens_output" + ), + "Output tokens" + ) + ), + shiny::div( + class = "status-cost font-monospace", + bslib::tooltip( + id = ns("status_cost_tooltip"), + shiny::span( + id = ns("status_cost"), + class = "status-countup", + "data-type" = "cost" + ), + "Estimated cost" + ) + ), + width = "100%" ) ) ) } -btw_status_bar_server <- function(id, chat) { +btw_status_bar_server <- function(id, chat, models = "provider") { shiny::moduleServer( id, function(input, output, session) { + provider_name <- shiny::reactiveVal({ + # chat$client is not reactive, will be updated manually on model change + chat$client$get_provider()@name + }) + + model_name <- shiny::reactiveVal({ + chat$client$get_model() + }) + + if (identical(models, "provider")) { + shiny::observe({ + provider_df <- client_get_models(chat$client) + if (!is.null(provider_df)) { + current <- shiny::isolate(model_name()) + all_choices <- union(current, sort(provider_df$id)) + shiny::updateSelectInput(session, "model", choices = all_choices, selected = current) + } + }) + } + + output$provider <- shiny::renderUI({ + badge <- shiny::div( + class = "status-provider badge", + provider_name() + ) + if (identical(models, "provider")) { + badge + } else { + bslib::tooltip(badge, model_name(), placement = "top") + } + }) + + shiny::observeEvent(input$model, ignoreInit = TRUE, { + tryCatch( + { + old_provider <- chat$client$get_provider()@name + + if (identical(models, "provider")) { + new_client <- chat$client$clone() + new_client$set_model(input$model) + } else { + new_config <- models[[input$model]] + new_client <- btw_client(client = new_config, tools = FALSE) + new_client$set_system_prompt(chat$client$get_system_prompt()) + turns <- chat$client$get_turns() + new_provider <- new_client$get_provider()@name + if (!identical(old_provider, new_provider)) { + turns <- turns_replace_thinking(turns) + } + new_client$set_turns(turns) + new_client$set_tools(chat$client$get_tools()) + } + + chat$set_client(new_client, sync = FALSE) + new_provider <- chat$client$get_provider()@name + provider_name(new_provider) + model_name(chat$client$get_model()) + + notifier( + shiny::icon("check"), + shiny::HTML( + sprintf( + "Switched model to %s from %s.", + new_client$get_model(), + new_provider + ) + ) + ) + }, + error = function(err) { + notifier( + shiny::icon("warning"), + sprintf("Failed to switch model to %s", input$model), + error = err + ) + } + ) + }) + chat_tokens <- shiny::reactiveVal( chat_get_tokens(chat$client), label = "btw_app_tokens" @@ -559,6 +702,10 @@ btw_status_bar_server <- function(id, chat) { } }) + shiny::observeEvent(input$clear_chat, { + session$sendCustomMessage("btw_reset_status", list(ns = session$ns(""))) + }) + shiny::observeEvent(chat_tokens(), { tokens <- chat_tokens() @@ -683,6 +830,12 @@ btw_status_bar_server <- function(id, chat) { ) } ) + + return( + list( + clear_chat = reactive(input$clear_chat) + ) + ) } ) } diff --git a/R/utils-ellmer.R b/R/utils-ellmer.R index e8ea6cde..b395cd92 100644 --- a/R/utils-ellmer.R +++ b/R/utils-ellmer.R @@ -1,3 +1,168 @@ +client_get_models <- function(client) { + provider <- client$get_provider() + + models_fns <- list( + ProviderAnthropic = function(p) { + ellmer::models_anthropic( + base_url = p@base_url, + credentials = p@credentials + ) + }, + ProviderGoogleGemini = function(p) { + ellmer::models_google_gemini( + base_url = p@base_url, + credentials = p@credentials + ) + }, + ProviderAWSBedrock = function(p) { + base_url <- sub("bedrock-runtime", "bedrock", p@base_url) + ellmer::models_aws_bedrock(profile = p@profile, base_url = base_url) + }, + ProviderOpenAI = function(p) { + ellmer::models_openai(base_url = p@base_url, credentials = p@credentials) + }, + ProviderMistral = function(p) { + ellmer::models_mistral() + }, + ProviderLMStudio = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_lmstudio(base_url = base_url, credentials = p@credentials) + }, + ProviderVllm = function(p) { + ellmer::models_vllm(base_url = p@base_url, credentials = p@credentials) + }, + ProviderOllama = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_ollama(base_url = base_url, credentials = p@credentials) + }, + ProviderPortkeyAI = function(p) { + ellmer::models_portkey(base_url = p@base_url) + }, + ProviderOpenAICompatible = function(p) { + base_url <- sub("/v1$", "", p@base_url) + ellmer::models_openai(base_url = p@base_url, credentials = p@credentials) + } + ) + + try_get_models <- function(fn, provider) { + tryCatch(fn(provider), error = function(e) { + cli::cli_warn( + "Failed to fetch models for provider {provider@name}", + parent = e + ) + NULL + }) + } + + if (provider@name == "LM Studio") { + return(try_get_models(models_fns$ProviderLMStudio, provider)) + } + + for (cls in names(models_fns)) { + if (inherits(provider, sprintf("ellmer::%s", cls))) { + return( + tryCatch(models_fns[[cls]](provider), error = function(e) { + cli::cli_warn( + "Failed to fetch models for provider {provider@name}", + parent = e + ) + NULL + }) + ) + } + } + + NULL +} + +# Returns NULL (no selector), "provider" (lazy fetch), or list of btw.md client configs +app_resolve_model_choices <- function( + model_choices, + path_btw, + client_name = NULL +) { + if (model_choices == "none") { + return(NULL) + } + if (model_choices == "provider") { + return("provider") + } + + config <- read_btw_file(path_btw) + btw_models <- config$client + + if (is.null(btw_models)) { + return("provider") + } + + if (is_list(btw_models)) { + if (all(nzchar(names2(btw_models)))) { + if ( + model_choices == "auto" && + !is.null(client_name) && + is.null(resolve_model_choice_name(client_name, names(btw_models))) + ) { + return("provider") + } + return(btw_models) + } + cli::cli_inform( + "Model choices in `client` in {.path {btw_md}} must be named for model selection to work." + ) + } + + "provider" +} + +resolve_model_choice_name <- function(name, choices) { + idx <- match(tolower(name), tolower(choices)) + if (is.na(idx)) NULL else choices[[idx]] +} + +client_models_from_config <- function(client_config) { + aliases <- client_aliases(client_config) + if (is.null(aliases)) { + return(NULL) + } + + model_ids <- vapply( + client_config, + function(cfg) { + if (is_string(cfg)) { + parts <- strsplit(cfg, "/", fixed = TRUE)[[1]] + if (length(parts) > 1) paste(parts[-1], collapse = "/") else "" + } else if (is.list(cfg) && !inherits(cfg, "Chat")) { + cfg$model %||% "" + } else if (inherits(cfg, "Chat")) { + cfg$get_model() + } else { + "" + } + }, + character(1) + ) + + valid <- nzchar(model_ids) + if (!any(valid)) { + return(NULL) + } + + model_ids[valid] +} + +turns_replace_thinking <- function(turns) { + lapply(turns, function(turn) { + turn@contents <- lapply(turn@contents, function(content) { + if (S7::S7_inherits(content, ellmer::ContentThinking)) { + ellmer::ContentText(format(content)) + } else { + content + } + }) + turn + }) +} + btw_prompt <- function(path, ..., .envir = parent.frame()) { path <- system.file("prompts", path, package = "btw") ellmer::interpolate_file(path, ..., .envir = .envir) @@ -78,7 +243,8 @@ BtwToolBuiltIn <- tryCatch( ) built_in_tool_info <- function(name) { - switch(name, + switch( + name, web_search = list( title = "Web Search", description = "Search the web for up-to-date information.", diff --git a/R/zzz.R b/R/zzz.R index 5d09c061..6ad5346c 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -7,6 +7,18 @@ assign(tool_def@name, tool_def, envir = pkg_env) } + # Patch ellmer:::Chat to add set_model() if it doesn't exist + ellmer_chat <- getFromNamespace("Chat", "ellmer") + if (!is.null(ellmer_chat)) { + if (!"set_model" %in% names(ellmer_chat$public_methods)) { + ellmer_chat$set("public", "set_model", function(model) { + old <- private$provider@model + private$provider@model <- model + invisible(old) + }) + } + } + rlang::run_on_load() } diff --git a/btw.md b/btw.md deleted file mode 100644 index 6439220c..00000000 --- a/btw.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -client: - sonnet: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-6 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - haiku: - provider: aws_bedrock - model: us.anthropic.claude-haiku-4-5-20251001-v1:0 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - opus: - provider: aws_bedrock - model: us.anthropic.claude-opus-4-5-20251101-v1:0 - api_args: - additionalModelRequestFields: - thinking: - type: enabled - budget_tokens: 4000 - - sonnet-4.5: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-5-20250929-v1:0 - sonnet-4: - provider: aws_bedrock - model: us.anthropic.claude-sonnet-4-20250514-v1:0 - opus-4.5: - provider: aws_bedrock - model: us.anthropic.claude-opus-4-5-20251101-v1:0 - - gemma4: - provider: openai_compatible - model: google/gemma-4-26b-a4b - # base_url: http://127.0.0.1:1234/v1 - base_url: http://remy.local:1234/v1 - name: "LM Studio" - - qwen3.6: - provider: openai_compatible - model: qwen/qwen3.6-35b-a3b - base_url: http://remy.local:1234/v1 - name: "LM Studio" - preserve_thinking: true - - qwen-3-5-35b: - provider: openai_compatible - model: qwen/qwen3.5-35b-a3b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - qwen-3-5-9b: - provider: openai_compatible - model: qwen/qwen3.5-9b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - params: - reasoning_effort: medium - - glm-4-7-flash: - provider: openai_compatible - model: zai-org/glm-4.7-flash - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - nemotron-3-nano-4b: - provider: openai_compatible - model: nvidia/nemotron-3-nano-4b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - - lm-glm4v: - provider: openai_compatible - model: zai-org/glm-4.6v-flash - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - qwen3: - provider: openai_compatible - model: qwen/qwen3-vl-30b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - gpt-oss-20b: - provider: openai_compatible - model: openai/gpt-oss-20b - base_url: http://127.0.0.1:1234/v1 - name: "LM Studio" - -tools: - # - agent - - skills - - files_list - - files_read - - files_write - # - files_edit - - files_replace - - docs_help_page - - docs_package_help_topics - -options: - skills: - paths: ["~/.agents/skills"] - subagent: - # client: openai/gpt-5.4-mini - tools_allowed: [docs, files_search, files_list] ---- - -## Overview - -btw is an R package that helps humans and LLMs work together with R by providing utilities to describe R objects, package documentation, and workspace state in LLM-friendly plain text. The package offers a flexible collection of tools that can be used interactively (copy-paste workflows), programmatically (direct function calls), or as enhanced chat clients (via ellmer or MCP servers). - -The primary goal is creating a collection of tools useful to both LLMs and humans when working together with R, with an emphasis on flexibility of usage across different workflows and platforms. - -## Quick Reference - -- **Project type:** R Package -- **Language:** R (≥ 4.1.0) -- **Key frameworks:** ellmer (LLM chat integration), mcptools (Model Context Protocol), shiny and shinychat (chat app) - -## Purpose and Design Philosophy - -btw prioritizes flexibility of usage through multiple entry points: - -- **`btw()`** - Interactive copy-paste workflow: gather context from R and paste into any chat interface -- **`btw_tools()`** - Register tools with ellmer chat clients for custom applications -- **`btw_client()` / `btw_app()`** - Batteries-included chat clients with your preferred LLM provider, model, and project context -- **MCP server** - Expose tools to third-party coding agents like Claude Desktop or Continue via `btw_mcp_server()` - -Project configuration via `btw.md` files provides conversation stability across sessions by defining default provider, model, tools, and project-specific instructions. These files are treated as instructions for coding assistants and help avoid repeating context. - -btw also serves as a laboratory for discovering best practices in LLM tool design - output formats and approaches evolve based on experimentation with what works best across different models. \ No newline at end of file diff --git a/inst/icons/ink-eraser.svg b/inst/icons/ink-eraser.svg new file mode 100644 index 00000000..8969a7fe --- /dev/null +++ b/inst/icons/ink-eraser.svg @@ -0,0 +1 @@ + diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index 538bb6c9..1a25352d 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -66,15 +66,23 @@ shiny-chat-message .message-icon { height: 1em; width: 1em; } -.status-provider-model { - text-overflow: ellipsis; - text-wrap-mode: nowrap; - overflow: hidden; +.status-footer .status-tokens, +.status-footer .status-cost { + display: inline-flex; + gap: 0.75rem; +} +.status-footer .status-cost { + margin-left: 0.5rem; } .modal { --bs-modal-margin: min(2rem, 10%); } +.status-provider.badge { + color: var(--bs-body-color); + background-color: rgba(var(--bs-body-color-rgb), 0.1); +} + /* ---------------- Tool Results ---------------- */ .btw-tool-result-write-file > .card > .card-body { padding: 0; diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index fc06c84a..2e2a9412 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -20,11 +20,30 @@ function initializeCountUp(element, initialValue, options) { return counter } -document.addEventListener("DOMContentLoaded", function () { - document.querySelectorAll(".status-countup").forEach((element) => { +function initializeStatusCountups() { + const elements = document.querySelectorAll(".status-countup") + elements.forEach((element) => { + if (statusCounters.has(element)) return const counter = initializeCountUp(element, 0) statusCounters.set(element, counter) }) + return elements.length > 0 +} + +document.addEventListener("DOMContentLoaded", function () { + if (initializeStatusCountups()) return + + let attempt = 0 + const maxAttempts = 5 + const baseDelay = 100 + + function retry() { + attempt++ + if (initializeStatusCountups() || attempt >= maxAttempts) return + setTimeout(retry, baseDelay * Math.pow(2, attempt)) + } + + setTimeout(retry, baseDelay) }) if (typeof Shiny !== "undefined") { @@ -32,11 +51,12 @@ if (typeof Shiny !== "undefined") { const element = document.getElementById(message.id) if (element) { const counter = statusCounters.get(element) - const lastValue = parseFloat(element.dataset.value | "0") + const lastValue = parseFloat(element.dataset.value || "0") - if (counter && message.value) { + if (counter && Object.hasOwn(message, "value")) { if ( element.dataset.type === "cost" && + message.value > 0 && (lastValue < 0.1 || message.value < 0.1) ) { const newCounter = initializeCountUp(element, lastValue, { @@ -61,6 +81,19 @@ if (typeof Shiny !== "undefined") { } } }) + + Shiny.addCustomMessageHandler("btw_reset_status", function (message) { + const elements = document.querySelectorAll(".status-countup") + elements.forEach((element) => { + if (!element.id.startsWith(message.ns)) return + element.classList.remove("btw-status-recalculating", "btw-status-unknown") + element.dataset.value = 0 + const counter = statusCounters.get(element) + if (counter) { + counter.update(0) + } + }) + }) } // Open File Buttons ---------------------------------------------------------- diff --git a/man/btw_client.Rd b/man/btw_client.Rd index 9391d9d3..dfa06260 100644 --- a/man/btw_client.Rd +++ b/man/btw_client.Rd @@ -13,7 +13,14 @@ btw_client( path_llms_txt = NULL ) -btw_app(..., client = NULL, tools = NULL, path_btw = NULL, messages = list()) +btw_app( + ..., + client = NULL, + tools = NULL, + path_btw = NULL, + messages = list(), + model_choices = c("auto", "btw_md", "provider", "none") +) } \arguments{ \item{...}{In \code{btw_app()}, additional arguments are passed to @@ -54,6 +61,11 @@ the your current working directory or its parents. Set \code{path_llms_txt = FAL \item{messages}{A list of initial messages to show in the chat, passed to \code{\link[shinychat:chat_mod_ui]{shinychat::chat_mod_ui()}}.} + +\item{model_choices}{Can be one of \code{"btw_md"} (model choices from your +\code{path_btw} configuration), \code{"provider"} (models from the provider API), +\code{"auto"} (uses \code{path_btw} if \code{client} comes from \code{path_btw}, otherwise +falling back to provider), or \code{"none"} (don't show model choices).} } \value{ Returns an \link[ellmer:Chat]{ellmer::Chat} object with additional tools registered