Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 19 additions & 6 deletions pkg-py/src/querychat/_querychat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -188,6 +188,7 @@ def sidebar(
width: int = 400,
height: str = "100%",
fillable: bool = True,
id: Optional[str] = None,
**kwargs,
) -> ui.Sidebar:
"""
Expand All @@ -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()`.

Expand All @@ -211,20 +215,23 @@ def sidebar(

"""
return ui.sidebar(
self.ui(),
self.ui(id=id),
width=width,
height=height,
fillable=fillable,
class_="querychat-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()`.

Expand All @@ -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"):
"""
Expand Down Expand Up @@ -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.

Expand All @@ -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
--------
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pkg-py/tests/test_client_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion pkg-py/tests/test_querychat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion pkg-r/NEWS.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
60 changes: 52 additions & 8 deletions pkg-r/R/QueryChat.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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.
#'
Expand All @@ -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)
)
},

Expand All @@ -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.
#'
Expand All @@ -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
Expand All @@ -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
Expand All @@ -555,16 +585,21 @@ 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"
)
}

mod_server(
self$id,
id %||% self$id,
data_source = private$.data_source,
greeting = self$greeting,
client = self$client,
Expand Down Expand Up @@ -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)
}
}
60 changes: 60 additions & 0 deletions pkg-r/inst/examples-shiny/03-module-app/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Loading
Loading