diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 9b600116..4d6e38f4 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* `QueryChat.sidebar()`, `QueryChat.ui()`, and `QueryChat.server()` now support an optional `id` parameter to create multiple chat instances from a single `QueryChat` object. (#172) + * `QueryChat.client()` can now create standalone querychat-enabled chat clients with configurable tools and callbacks, enabling use outside of Shiny applications. (#168) * `QueryChat.console()` was added to launch interactive console-based chat sessions with your data source, with persistent conversation state across invocations. (#168) diff --git a/pkg-py/src/querychat/_querychat.py b/pkg-py/src/querychat/_querychat.py index 8a7469e4..3a83b090 100644 --- a/pkg-py/src/querychat/_querychat.py +++ b/pkg-py/src/querychat/_querychat.py @@ -58,7 +58,7 @@ def __init__( "Table name must begin with a letter and contain only letters, numbers, and underscores", ) - self.id = id or table_name + self.id = id or f"querychat_{table_name}" self.tools = normalize_tools(tools, default=("update", "query")) self.greeting = greeting.read_text() if isinstance(greeting, Path) else greeting @@ -188,6 +188,7 @@ def sidebar( width: int = 400, height: str = "100%", fillable: bool = True, + id: Optional[str] = None, **kwargs, ) -> ui.Sidebar: """ @@ -201,6 +202,9 @@ def sidebar( Height of the sidebar. fillable Whether the sidebar should be fillable. Default is `True`. + id + Optional ID for the QueryChat instance. If not provided, + will use the ID provided at initialization. **kwargs Additional arguments passed to `shiny.ui.sidebar()`. @@ -211,7 +215,7 @@ def sidebar( """ return ui.sidebar( - self.ui(), + self.ui(id=id), width=width, height=height, fillable=fillable, @@ -219,12 +223,15 @@ def sidebar( **kwargs, ) - def ui(self, **kwargs): + def ui(self, *, id: Optional[str] = None, **kwargs): """ Create the UI for the querychat component. Parameters ---------- + id + Optional ID for the QueryChat instance. If not provided, + will use the ID provided at initialization. **kwargs Additional arguments to pass to `shinychat.chat_ui()`. @@ -234,7 +241,7 @@ def ui(self, **kwargs): A UI component. """ - return mod_ui(self.id, **kwargs) + return mod_ui(id or self.id, **kwargs) def generate_greeting(self, *, echo: Literal["none", "output"] = "none"): """ @@ -561,7 +568,9 @@ class QueryChat(QueryChatBase): """ - def server(self, *, enable_bookmarking: bool = False) -> ServerValues: + def server( + self, *, enable_bookmarking: bool = False, id: Optional[str] = None + ) -> ServerValues: """ Initialize Shiny server logic. @@ -574,6 +583,10 @@ def server(self, *, enable_bookmarking: bool = False) -> ServerValues: ---------- enable_bookmarking Whether to enable bookmarking for the querychat module. + id + Optional module ID for the QueryChat instance. If not provided, + will use the ID provided at initialization. This must match the ID + used in the `.ui()` or `.sidebar()` methods. Examples -------- @@ -629,7 +642,7 @@ def title(): ) return mod_server( - self.id, + id or self.id, data_source=self._data_source, greeting=self.greeting, client=self.client, diff --git a/pkg-py/tests/test_client_console.py b/pkg-py/tests/test_client_console.py index 06eaa895..a2948366 100644 --- a/pkg-py/tests/test_client_console.py +++ b/pkg-py/tests/test_client_console.py @@ -291,5 +291,5 @@ def test_existing_initialization_still_works(self, sample_df): ) assert qc is not None - assert qc.id == "test_table" + assert qc.id == "querychat_test_table" assert qc.tools == ("update", "query") diff --git a/pkg-py/tests/test_querychat.py b/pkg-py/tests/test_querychat.py index ebf5ec57..22dab6d7 100644 --- a/pkg-py/tests/test_querychat.py +++ b/pkg-py/tests/test_querychat.py @@ -39,7 +39,7 @@ def test_querychat_init(sample_df): # Verify basic attributes are set assert qc is not None - assert qc.id == "test_table" + assert qc.id == "querychat_test_table" # Even without server initialization, we should be able to query the data source result = qc.data_source.execute_query( diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 64a8f6a3..144a3f29 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,11 +1,13 @@ # querychat (development version) +* `QueryChat$sidebar()`, `QueryChat$ui()`, and `QueryChat$server()` now support an optional `id` parameter to enable use within Shiny modules. When used in a module UI function, pass `id = ns("your_id")` where `ns` is the namespacing function from `shiny::NS()`. In the corresponding module server function, pass the unwrapped ID to `QueryChat$server(id = "your_id")`. This enables multiple independent QueryChat instances from the same QueryChat object. (#172) + * `QueryChat$client()` can now create standalone querychat-enabled chat clients with configurable tools and callbacks, enabling use outside of Shiny applications. (#168) * `QueryChat$console()` was added to launch interactive console-based chat sessions with your data source, with persistent conversation state across invocations. (#168) * The tools used in a `QueryChat` chatbot are now configurable. Use the new `tools` parameter of `querychat()` or `QueryChat$new()` to select either or both `"query"` or `"update"` tools. Choose `tools = "update"` if you only want QueryChat to be able to update the dashboard (useful when you want to be 100% certain that the LLM will not see _any_ raw data). (#168) - + * `querychat_app()` will now only automatically clean up the data source if QueryChat creates the data source internally from a data frame. (#164) * **Breaking change:** The `$sql()` method now returns `NULL` instead of `""` (empty string) when no query has been set, aligning with the behavior of `$title()` for consistency. Most code using `isTruthy()` or similar falsy checks will continue working without changes. Code that explicitly checks `sql() == ""` should be updated to use falsy checks (e.g., `!isTruthy(sql())`) or explicit null checks (`is.null(sql())`). (#146) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 09199c95..cf8c7483 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -74,7 +74,7 @@ QueryChat <- R6::R6Class( public = list( #' @field greeting The greeting message displayed to users. greeting = NULL, - #' @field id The module ID for namespacing. + #' @field id ID for the QueryChat instance. id = NULL, #' @field tools The allowed tools for the chat client. tools = c("update", "query"), @@ -178,7 +178,7 @@ QueryChat <- R6::R6Class( private$.data_source <- normalize_data_source(data_source, table_name) - self$id <- id %||% table_name + self$id <- id %||% sprintf("querychat_%s", table_name) self$tools <- tools if (!is.null(greeting) && file.exists(greeting)) { @@ -470,10 +470,14 @@ QueryChat <- R6::R6Class( #' This method generates a [bslib::sidebar()] component containing the chat #' interface, suitable for use with [bslib::page_sidebar()] or similar layouts. #' + #' @param ... Additional arguments passed to [bslib::sidebar()]. #' @param width Width of the sidebar in pixels. Default is 400. #' @param height Height of the sidebar. Default is "100%". #' @param fillable Whether the sidebar should be fillable. Default is `TRUE`. - #' @param ... Additional arguments passed to [bslib::sidebar()]. + #' @param id Optional ID for the QueryChat instance. If not provided, + #' will use the ID provided at initialization. If using `$sidebar()` in + #' a Shiny module, you'll need to provide `id = ns("your_id")` where `ns` + #' is the namespacing function from [shiny::NS()]. #' #' @return A [bslib::sidebar()] UI component. #' @@ -486,14 +490,20 @@ QueryChat <- R6::R6Class( #' # Main content here #' ) #' } - sidebar = function(width = 400, height = "100%", fillable = TRUE, ...) { + sidebar = function( + ..., + width = 400, + height = "100%", + fillable = TRUE, + id = NULL + ) { bslib::sidebar( width = width, height = height, fillable = fillable, class = "querychat-sidebar", ..., - self$ui() + self$ui(id = id) ) }, @@ -504,6 +514,10 @@ QueryChat <- R6::R6Class( #' `$sidebar()` instead, which wraps this in a sidebar layout. #' #' @param ... Additional arguments passed to [shinychat::chat_ui()]. + #' @param id Optional ID for the QueryChat instance. If not provided, + #' will use the ID provided at initialization. If using `$ui()` in a Shiny + #' module, you'll need to provide `id = ns("your_id")` where `ns` is the + #' namespacing function from [shiny::NS()]. #' #' @return A UI component containing the chat interface. #' @@ -515,8 +529,18 @@ QueryChat <- R6::R6Class( #' qc$ui() #' ) #' } - ui = function(...) { - mod_ui(self$id, ...) + ui = function(..., id = NULL) { + check_string(id, allow_null = TRUE, allow_empty = FALSE) + + # If called within another module, the UI id needs to be namespaced + # by that "parent" module. If called in a module *server* context, we + # can infer the namespace from the session, but if not, the user + # will need to provide it. + # NOTE: this isn't a problem for Python since id namespacing is handled + # implicitly by UI functions like shinychat.chat_ui(). + id <- id %||% namespaced_id(self$id) + + mod_ui(id, ...) }, #' @description @@ -532,6 +556,12 @@ QueryChat <- R6::R6Class( #' with Shiny bookmarks. This requires that the Shiny app has bookmarking #' enabled via `shiny::enableBookmarking()` or the `enableBookmarking` #' parameter of `shiny::shinyApp()`. + #' @param ... Ignored. + #' @param id Optional module ID for the QueryChat instance. If not provided, + #' will use the ID provided at initialization. When used in Shiny modules, + #' this `id` should match the `id` used in the corresponding UI function + #' (i.e., `qc$ui(id = ns("your_id"))` pairs with `qc$server(id = + #' "your_id")`). #' @param session The Shiny session object. #' #' @return A list containing session-specific reactive values and the chat @@ -555,8 +585,13 @@ QueryChat <- R6::R6Class( #' } server = function( enable_bookmarking = FALSE, + ..., + id = NULL, session = shiny::getDefaultReactiveDomain() ) { + check_string(id, allow_null = TRUE, allow_empty = FALSE) + check_dots_empty() + if (is.null(session)) { cli::cli_abort( "{.fn $server} must be called within a Shiny server function" @@ -564,7 +599,7 @@ QueryChat <- R6::R6Class( } mod_server( - self$id, + id %||% self$id, data_source = private$.data_source, greeting = self$greeting, client = self$client, @@ -826,3 +861,12 @@ normalize_data_source <- function(data_source, table_name) { "{.arg data_source} must be a {.cls DataSource}, {.cls data.frame}, or {.cls DBIConnection}, not {.obj_type_friendly {data_source}}." ) } + + +namespaced_id <- function(id, session = shiny::getDefaultReactiveDomain()) { + if (is.null(session)) { + id + } else { + session$ns(id) + } +} diff --git a/pkg-r/inst/examples-shiny/03-module-app/README.md b/pkg-r/inst/examples-shiny/03-module-app/README.md new file mode 100644 index 00000000..a30243b5 --- /dev/null +++ b/pkg-r/inst/examples-shiny/03-module-app/README.md @@ -0,0 +1,60 @@ +# QueryChat Modules Example + +This example demonstrates how to use QueryChat within Shiny modules, following standard Shiny module patterns. + +## Key Concepts + +### Module UI Function + +In a Shiny module UI function, you wrap the QueryChat ID with the namespace function `ns()`: + +```r +module_ui <- function(id) { + ns <- NS(id) + card( + qc$sidebar(id = ns("qc-ui")) # Wrap ID with ns() + ) +} +``` + +### Module Server Function + +In the corresponding server function, you pass the **unwrapped** ID to `qc$server()`: + +```r +module_server <- function(id) { + moduleServer(id, function(input, output, session) { + qc_vals <- qc$server(id = "qc-ui") # Use unwrapped ID + # ... rest of server logic + }) +} +``` + +## Why This Pattern? + +This follows the established Shiny module pattern where: + +1. **UI functions** namespace all IDs using `ns()` to avoid conflicts when multiple instances exist +2. **Server functions** receive the unwrapped ID and use it to connect to the corresponding UI + +This is the same pattern used for any Shiny component in a module, and QueryChat now supports it seamlessly. + +## Benefits + +- **Multiple instances**: You can have multiple QueryChat explorers in the same app +- **Familiar pattern**: Uses standard Shiny module conventions +- **Clean isolation**: Each module instance has its own reactive state + +## Running the Example + +From the R console: + +```r +shiny::runApp(system.file("examples-shiny/03-module-app", package = "querychat")) +``` + +Or navigate to this directory and run: + +```bash +Rscript app.R +``` diff --git a/pkg-r/inst/examples-shiny/03-module-app/app.R b/pkg-r/inst/examples-shiny/03-module-app/app.R new file mode 100644 index 00000000..45019335 --- /dev/null +++ b/pkg-r/inst/examples-shiny/03-module-app/app.R @@ -0,0 +1,110 @@ +library(shiny) +library(bslib) +library(querychat) +library(palmerpenguins) + +# Define a custom greeting for the penguins app +greeting <- r"( +# Welcome to the Palmer Penguins Explorer! 🐧 + +I can help you explore and analyze the Palmer Penguins dataset. Ask me questions +about the penguins, and I'll generate SQL queries to get the answers. + +Try asking: +- Show me the first 10 rows of the penguins dataset +- What's the average bill length by species? +- Which species has the largest body mass? +)" + +# Create QueryChat object with custom options +qc <- QueryChat$new( + penguins, + greeting = greeting, + data_description = paste( + "The Palmer Penguins dataset contains measurements of bill", + "dimensions, flipper length, body mass, sex, and species", + "(Adelie, Chinstrap, and Gentoo) collected from three islands in", + "the Palmer Archipelago, Antarctica." + ) +) + +# Module UI function +# This demonstrates the standard Shiny module pattern where: +# - The module ID is wrapped with ns() in the UI function +# - The same ID (unwrapped) is used in the corresponding server function +module_ui <- function(id) { + ns <- NS(id) + layout_sidebar( + sidebar = qc$sidebar(id = ns("qc-ui")), # Pass namespaced ID to QueryChat + padding = 0, + navset_card_tab( + title = "Data Explorer", + nav_panel( + "Data View", + DT::DTOutput(ns("data_table")) + ), + nav_panel( + "SQL Query", + verbatimTextOutput(ns("sql_query")) + ) + ) + ) +} + +# Module server function +module_server <- function(id) { + moduleServer(id, function(input, output, session) { + # Initialize QueryChat server with the same ID (unwrapped) + # This connects to the UI initialized with id = ns("qc-ui") + qc_vals <- qc$server(id = "qc-ui") + + # Render the data table + output$data_table <- DT::renderDT( + { + qc_vals$df() + }, + fillContainer = TRUE, + options = list(pageLength = 25, scrollX = TRUE) + ) + + # Render the SQL query + output$sql_query <- renderText({ + query <- qc_vals$sql() + if (is.null(query) || !nzchar(query)) { + "No filter applied - showing all data" + } else { + query + } + }) + }) +} + +# Define UI with multiple module instances +ui <- page_sidebar( + title = "QueryChat Modules Example", + sidebar = sidebar( + "This example demonstrates using QueryChat within Shiny modules.", + markdown( + "Each module instance has its own QueryChat sidebar and data explorer. + + **UI:** `qc$sidebar(id = ns(\"qc-ui\"))` wraps the ID with the namespace function + + **Server:** `qc$server(id = \"qc-ui\")` uses the unwrapped ID" + ) + ), + class = "p-0", + navset_card_underline( + wrapper = \(...) card_body(..., padding = 0, border_radius = 0), + nav_panel("Explorer 1", module_ui("module1")), + nav_panel("Explorer 2", module_ui("module2")) + ) +) + +# Define server logic +server <- function(input, output, session) { + # Initialize both module instances + module_server("module1") + module_server("module2") +} + +shinyApp(ui = ui, server = server) diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd index 58236f15..64f15b0a 100644 --- a/pkg-r/man/QueryChat.Rd +++ b/pkg-r/man/QueryChat.Rd @@ -178,7 +178,7 @@ qc2 <- QueryChat$new(mtcars, greeting = "mtcars_greeting.md") \describe{ \item{\code{greeting}}{The greeting message displayed to users.} -\item{\code{id}}{The module ID for namespacing.} +\item{\code{id}}{ID for the QueryChat instance.} \item{\code{tools}}{The allowed tools for the chat client.} } @@ -484,19 +484,30 @@ Create a sidebar containing the querychat UI. This method generates a \code{\link[bslib:sidebar]{bslib::sidebar()}} component containing the chat interface, suitable for use with \code{\link[bslib:page_sidebar]{bslib::page_sidebar()}} or similar layouts. \subsection{Usage}{ -\if{html}{\out{