diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md
index 418e24f5..847f48a1 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
+* New `querychat.app()` function lets you quickly launch an app to chat with your data. (#99)
+
* The `.sql` query and `.title` returned from `querychat.server()` are now reactive values, meaning you can now `.set()` their value, and `.df()` will update accordingly. (#98)
* Added `querychat.greeting()` to help you create a greeting message for your querychat bot. (#87)
@@ -19,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added rich tool UI support using shinychat development version and chatlas >= 0.11.1. (#67)
+* Enabled bookmarking by default in both `querychat.app()` and `querychat.server()`. In latter case, you'll need to also specify the `bookmark_store` (either in `shiny.App()` or `shiny.express.app_opts()`) for it to work. (#99)
+
* querychat's system prompt and tool descriptions were rewritten for clarity and future extensibility. (#90)
## [0.2.2] - 2025-09-04
diff --git a/pkg-py/src/querychat/__init__.py b/pkg-py/src/querychat/__init__.py
index b5e4279d..ed6042a9 100644
--- a/pkg-py/src/querychat/__init__.py
+++ b/pkg-py/src/querychat/__init__.py
@@ -1,4 +1,5 @@
from querychat._greeting import greeting
+from querychat._app import app
from querychat.querychat import (
init,
sidebar,
@@ -11,4 +12,4 @@
mod_ui as ui,
)
-__all__ = ["greeting", "init", "server", "sidebar", "system_prompt", "ui"]
+__all__ = ["app", "greeting", "init", "server", "sidebar", "system_prompt", "ui"]
diff --git a/pkg-py/src/querychat/_app.py b/pkg-py/src/querychat/_app.py
new file mode 100644
index 00000000..191b55a1
--- /dev/null
+++ b/pkg-py/src/querychat/_app.py
@@ -0,0 +1,144 @@
+"""Convenience function for creating a simple querychat app."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal, Optional
+
+import chatlas
+from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
+from shinychat import output_markdown_stream
+
+from ._icons import bs_icon
+from .datasource import DataSource
+from .querychat import QueryChatConfig, _resolve_querychat_client, init
+from .querychat import mod_server as server
+from .querychat import sidebar
+
+if TYPE_CHECKING:
+ import sqlalchemy
+ from narwhals.stable.v1.typing import IntoFrame
+
+
+def app(
+ x: IntoFrame | sqlalchemy.Engine | QueryChatConfig | DataSource,
+ *,
+ name: Optional[str] = None,
+ client: Optional[str | chatlas.Chat] = None,
+ bookmark_store: Literal["url", "server", "disable"] = "url",
+ **kwargs,
+) -> App:
+ """
+ Quickly chat with a dataset.
+
+ Creates a Shiny app with a chat sidebar and data table view -- providing a
+ quick-and-easy way to start chatting with your data.
+
+ Parameters
+ ----------
+ x
+ The dataset to chat with. Can be any one of the following:
+ - A Narwhals-compatible data frame (e.g., Polars or Pandas)
+ - A SQLAlchemy engine containing the table to query against.
+ - The result of `querychat.init()` (for more advanced configuration).
+ - A `DataSource` object.
+ name
+ The name of the dataset.
+ client
+ A `chatlas.Chat` object or a string to be passed to `chatlas.ChatAuto()`
+ describing the model to use (e.g. `"openai/gpt-4.1"`). If no client is
+ provided, querychat will look for the `QUERYCHAT_CLIENT` environment
+ variable. If that variable is not set, it will default to
+ `chatlas.ChatOpenAI()`.
+ bookmark_store
+ The bookmarking store to use for the Shiny app. Options are:
+ - `"url"`: Store bookmarks in the URL (default).
+ - `"server"`: Store bookmarks on the server.
+ - `"disable"`: Disable bookmarking.
+ **kwargs
+ Additional keyword arguments to pass to `querychat.init()` if `x`
+ is not already a QueryChatConfig object.
+
+ Returns
+ -------
+ App
+ A Shiny App object that can be run with `app.run()` or served with `shiny run`.
+
+ """
+ if not isinstance(x, QueryChatConfig):
+ data_source = x.get_data() if isinstance(x, DataSource) else x
+ x = init(data_source, table_name=name or "data", **kwargs)
+
+ if client is not None:
+ x.client = _resolve_querychat_client(client)
+
+ enable_bookmarking = bookmark_store != "disable"
+
+ def app_ui(request):
+ return ui.page_sidebar(
+ sidebar("chat"),
+ ui.card(
+ ui.card_header(
+ ui.div(
+ ui.div(
+ bs_icon("terminal-fill"),
+ ui.output_text("query_title", inline=True),
+ class_="d-flex align-items-center gap-2",
+ ),
+ ui.output_ui("ui_reset", inline=True),
+ class_="hstack gap-3",
+ ),
+ ),
+ ui.output_ui("sql_output"),
+ fill=False,
+ style="max-height: 33%;",
+ ),
+ ui.card(
+ ui.card_header(bs_icon("table"), " Data"),
+ ui.output_data_frame("dt"),
+ ),
+ title=ui.span(
+ "querychat with ",
+ ui.code(x.data_source.table_name),
+ ),
+ class_="bslib-page-dashboard",
+ fillable=True,
+ )
+
+ def app_server(input: Inputs, output: Outputs, session: Session):
+ qc = server("chat", x, enable_bookmarking=enable_bookmarking)
+
+ @render.text
+ def query_title():
+ return qc.title() or "SQL Query"
+
+ @render.ui
+ def ui_reset():
+ req(qc.sql())
+ return ui.input_action_button(
+ "reset_query",
+ "Reset Query",
+ class_="btn btn-outline-danger btn-sm lh-1 ms-auto",
+ )
+
+ @reactive.effect
+ @reactive.event(input.reset_query)
+ def _():
+ qc.sql("")
+ qc.title(None)
+
+ @render.data_frame
+ def dt():
+ return qc.df()
+
+ @render.ui
+ def sql_output():
+ sql = qc.sql() or f"SELECT * FROM {x.data_source.table_name}"
+ sql_code = f"```sql\n{sql}\n```"
+ return output_markdown_stream(
+ "sql_code",
+ content=sql_code,
+ auto_scroll=False,
+ width="100%",
+ )
+
+ return App(app_ui, app_server, bookmark_store=bookmark_store)
\ No newline at end of file
diff --git a/pkg-py/src/querychat/_icons.py b/pkg-py/src/querychat/_icons.py
new file mode 100644
index 00000000..f207cff9
--- /dev/null
+++ b/pkg-py/src/querychat/_icons.py
@@ -0,0 +1,19 @@
+from typing import Literal
+
+from shiny import ui
+
+ICON_NAMES = Literal["funnel-fill", "terminal-fill", "table"]
+
+
+def bs_icon(name: ICON_NAMES) -> ui.HTML:
+ """Get Bootstrap icon SVG by name."""
+ if name not in BS_ICONS:
+ raise ValueError(f"Unknown Bootstrap icon: {name}")
+ return ui.HTML(BS_ICONS[name])
+
+
+BS_ICONS = {
+ "funnel-fill": '',
+ "terminal-fill": '',
+ "table": '',
+}
diff --git a/pkg-py/src/querychat/querychat.py b/pkg-py/src/querychat/querychat.py
index 95219f8a..3b3b7d29 100644
--- a/pkg-py/src/querychat/querychat.py
+++ b/pkg-py/src/querychat/querychat.py
@@ -21,6 +21,7 @@
if TYPE_CHECKING:
import pandas as pd
from narwhals.stable.v1.typing import IntoFrame
+ from shiny.bookmark import BookmarkState, RestoreState
from .datasource import DataFrameSource, DataSource, SQLAlchemySource
@@ -521,6 +522,8 @@ def mod_server( # noqa: D417
output: Outputs,
session: Session,
querychat_config: QueryChatConfig,
+ *,
+ enable_bookmarking: bool = True,
) -> QueryChat:
"""
Initialize the querychat server.
@@ -529,6 +532,8 @@ def mod_server( # noqa: D417
----------
querychat_config
Configuration object from init().
+ enable_bookmarking
+ Whether to enable bookmarking support for the chat session.
Returns
-------
@@ -546,6 +551,7 @@ def mod_server( # noqa: D417
# Reactive values to store state
current_title = ReactiveStringOrNone(None)
current_query = ReactiveString("")
+ has_greeted = reactive.value[bool](False) # noqa: FBT003
@reactive.calc
def filtered_df():
@@ -603,6 +609,9 @@ def _():
@reactive.effect
async def greet_on_startup():
+ if has_greeted():
+ return
+
if querychat_config.greeting:
await chat_ui.append_message(greeting)
elif querychat_config.greeting is None:
@@ -612,5 +621,29 @@ async def greet_on_startup():
)
await chat_ui.append_message_stream(stream)
+ has_greeted.set(True)
+
+ if enable_bookmarking:
+ chat_ui.enable_bookmarking(client)
+
+ def _on_bookmark(x: BookmarkState) -> None:
+ vals = x.values # noqa: PD011
+ vals["querychat_current_query"] = current_query.get()
+ vals["querychat_current_title"] = current_title.get()
+ vals["querychat_has_greeted"] = has_greeted.get()
+
+ session.bookmark.on_bookmark(_on_bookmark)
+
+ def _on_restore(x: RestoreState) -> None:
+ vals = x.values # noqa: PD011
+ if "querychat_current_query" in vals:
+ current_query.set(vals["querychat_current_query"])
+ if "querychat_current_title" in vals:
+ current_title.set(vals["querychat_current_title"])
+ if "querychat_has_greeted" in vals:
+ has_greeted.set(vals["querychat_has_greeted"])
+
+ session.bookmark.on_restore(_on_restore)
+
# Return the interface for other components to use
return QueryChat(chat, current_query, current_title, filtered_df)
diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py
index 0e5fd964..4e27aed7 100644
--- a/pkg-py/src/querychat/tools.py
+++ b/pkg-py/src/querychat/tools.py
@@ -5,9 +5,9 @@
import chevron
from chatlas import ContentToolResult, Tool
-from htmltools import HTML
from shinychat.types import ToolResultDisplay
+from ._icons import bs_icon
from ._utils import df_to_html
if TYPE_CHECKING:
@@ -66,9 +66,7 @@ def update_dashboard(query: str, title: str) -> ContentToolResult:
title=title,
show_request=False,
open=True,
- icon=HTML(
- '',
- ),
+ icon=bs_icon("funnel-fill"),
),
},
)
@@ -142,9 +140,7 @@ def reset_dashboard() -> ContentToolResult:
title=None,
show_request=False,
open=False,
- icon=HTML(
- '',
- ),
+ icon=bs_icon("terminal-fill"),
),
},
)
@@ -213,9 +209,7 @@ def query(query: str, _intent: str = "") -> ContentToolResult:
markdown=markdown,
show_request=False,
open=True,
- icon=HTML(
- '',
- ),
+ icon=bs_icon("table"),
),
},
)
diff --git a/pyproject.toml b/pyproject.toml
index c90b99d6..90406847 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,7 +21,7 @@ maintainers = [
dependencies = [
"duckdb",
"pandas",
- "shiny",
+ "shiny @ git+https://github.com/posit-dev/py-shiny.git@fix/bookmark-missing-input-error",
"shinywidgets",
"htmltools",
"chatlas>=0.12.0",