From 890d87bd11ccb257b9bb3d02a8d6702daa8efaa3 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 13 Nov 2025 19:02:15 -0600 Subject: [PATCH 1/5] feat(pkg-py): Add .app() method, enable bookmarking by default --- pkg-py/CHANGELOG.md | 4 + pkg-py/src/querychat/_icons.py | 19 ++++ pkg-py/src/querychat/_querychat.py | 113 +++++++++++++++++++++- pkg-py/src/querychat/_querychat_module.py | 30 ++++++ pkg-py/src/querychat/tools.py | 14 +-- pyproject.toml | 2 +- 6 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 pkg-py/src/querychat/_icons.py diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 08b25af7..2c33cdf5 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* New `QueryChat.app()` method enables quicker/easier chatting with a dataset. (#104) + +* Enabled bookmarking by default in both `.app()` and `.server()` methods. In latter case, you'll need to also specify the `bookmark_store` (either in `shiny.App()` or `shiny.express.app_opts()`) for it to take effect. (#104) + * The current SQL query and title can now be programmatically set through the `.sql()` and `.title()` methods of `QueryChat()`. (#98, #101) * Added a `.generate_greeting()` method to help you create a greeting message for your querychat bot. (#87) 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 7cc27462..2e072cb9 100644 --- a/pkg-py/src/querychat/_querychat.py +++ b/pkg-py/src/querychat/_querychat.py @@ -10,9 +10,11 @@ import chatlas import chevron import sqlalchemy -from shiny import ui +from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui from shiny.session import get_current_session +from shinychat import output_markdown_stream +from ._icons import bs_icon from ._querychat_module import ModServerResult, mod_server, mod_ui from .datasource import DataFrameSource, DataSource, SQLAlchemySource @@ -133,6 +135,99 @@ def __init__( # Populated when ._server() gets called (in an active session) self._server_values: ModServerResult | None = None + def app( + self, *, bookmark_store: Literal["url", "server", "disable"] = "url" + ) -> 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 + ---------- + 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. + + Returns + ------- + : + A Shiny App object that can be run with `app.run()` or served with `shiny run`. + + """ + enable_bookmarking = bookmark_store != "disable" + table_name = self.data_source.table_name + + def app_ui(request): + return ui.page_sidebar( + self.sidebar(), + 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(table_name)), + class_="bslib-page-dashboard", + fillable=True, + ) + + def app_server(input: Inputs, output: Outputs, session: Session): + self._server(enable_bookmarking=enable_bookmarking) + + @render.text + def query_title(): + return self.title() or "SQL Query" + + @render.ui + def ui_reset(): + req(self.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 _(): + self.sql("") + self.title(None) + + @render.data_frame + def dt(): + return self.df() + + @render.ui + def sql_output(): + sql = self.sql() or f"SELECT * FROM {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) + def sidebar( self, *, @@ -183,7 +278,7 @@ def ui(self, **kwargs): """ return mod_ui(self.id, **kwargs) - def _server(self): + def _server(self, *, enable_bookmarking: bool = True) -> None: """ Initialize the server module. @@ -211,6 +306,7 @@ def _server(self): system_prompt=self.system_prompt, greeting=self.greeting, client=self.client, + enable_bookmarking=enable_bookmarking, ) return @@ -434,7 +530,7 @@ def set_client(self, client: str | chatlas.Chat) -> None: class QueryChat(QueryChatBase): - def server(self): + def server(self, *, enable_bookmarking: bool = True) -> None: """ Initialize Shiny server logic. @@ -443,6 +539,12 @@ def server(self): Express mode, you can use `querychat.express.QueryChat` instead of `querychat.QueryChat`, which calls `.server()` automatically. + Parameters + ---------- + enable_bookmarking + Whether to enable bookmarking for the querychat module. Default is + `True`. + Examples -------- ```python @@ -474,7 +576,7 @@ def data_table(): None """ - return self._server() + return self._server(enable_bookmarking=enable_bookmarking) class QueryChatExpress(QueryChatBase): @@ -514,6 +616,7 @@ def __init__( data_description: Optional[str | Path] = None, extra_instructions: Optional[str | Path] = None, prompt_template: Optional[str | Path] = None, + enable_bookmarking: bool = True, ): super().__init__( data_source, @@ -525,7 +628,7 @@ def __init__( extra_instructions=extra_instructions, prompt_template=prompt_template, ) - self._server() + self._server(enable_bookmarking=enable_bookmarking) def normalize_data_source( diff --git a/pkg-py/src/querychat/_querychat_module.py b/pkg-py/src/querychat/_querychat_module.py index 2fe0f3cd..fa259745 100644 --- a/pkg-py/src/querychat/_querychat_module.py +++ b/pkg-py/src/querychat/_querychat_module.py @@ -14,6 +14,7 @@ import chatlas import pandas as pd from shiny import Inputs, Outputs, Session + from shiny.bookmark import BookmarkState, RestoreState from .datasource import DataSource @@ -60,10 +61,12 @@ def mod_server( system_prompt: str, greeting: str | None, client: chatlas.Chat, + enable_bookmarking: bool, ): # Reactive values to store state sql = ReactiveString("") title = ReactiveStringOrNone(None) + has_greeted = reactive.value[bool](False) # noqa: FBT003 # Set up the chat object for this session chat = copy.deepcopy(client) @@ -99,6 +102,9 @@ async def _(user_input: str): @reactive.effect async def greet_on_startup(): + if has_greeted(): + return + if greeting: await chat_ui.append_message(greeting) elif greeting is None: @@ -108,6 +114,8 @@ async def greet_on_startup(): ) await chat_ui.append_message_stream(stream) + has_greeted.set(True) + # Handle update button clicks @reactive.effect @reactive.event(input.chat_update) @@ -125,4 +133,26 @@ def _(): if new_title is not None: title.set(new_title) + if enable_bookmarking: + chat_ui.enable_bookmarking(client) + + def _on_bookmark(x: BookmarkState) -> None: + vals = x.values # noqa: PD011 + vals["querychat_sql"] = sql.get() + vals["querychat_title"] = 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_sql" in vals: + sql.set(vals["querychat_sql"]) + if "querychat_title" in vals: + title.set(vals["querychat_title"]) + if "querychat_has_greeted" in vals: + has_greeted.set(vals["querychat_has_greeted"]) + + session.bookmark.on_restore(_on_restore) + return ModServerResult(df=filtered_df, sql=sql, title=title, client=chat) diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 8e88bc0d..c4235001 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 69fcd83e..c17da757 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", "shinywidgets", "htmltools", "chatlas>=0.13.2", From 74a171d39745bdca76bd1f95c86c946a7903d0d1 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 18 Nov 2025 16:56:36 -0600 Subject: [PATCH 2/5] fix: icon bug --- pkg-py/src/querychat/_icons.py | 3 ++- pkg-py/src/querychat/tools.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/_icons.py b/pkg-py/src/querychat/_icons.py index f207cff9..2b7683da 100644 --- a/pkg-py/src/querychat/_icons.py +++ b/pkg-py/src/querychat/_icons.py @@ -2,7 +2,7 @@ from shiny import ui -ICON_NAMES = Literal["funnel-fill", "terminal-fill", "table"] +ICON_NAMES = Literal["arrow-counterclockwise", "funnel-fill", "terminal-fill", "table"] def bs_icon(name: ICON_NAMES) -> ui.HTML: @@ -13,6 +13,7 @@ def bs_icon(name: ICON_NAMES) -> ui.HTML: BS_ICONS = { + "arrow-counterclockwise": '', "funnel-fill": '', "terminal-fill": '', "table": '', diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index c4235001..d908364a 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -140,7 +140,7 @@ def reset_dashboard() -> ContentToolResult: title=None, show_request=False, open=False, - icon=bs_icon("terminal-fill"), + icon=bs_icon("arrow-counterclockwise"), ), }, ) From 78e23bd8151d19502cee917c8e19c82fbf11e7fa Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 20 Nov 2025 09:23:55 -0600 Subject: [PATCH 3/5] Address feedback --- pkg-py/src/querychat/_querychat_module.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_module.py b/pkg-py/src/querychat/_querychat_module.py index fa259745..6153a5b8 100644 --- a/pkg-py/src/querychat/_querychat_module.py +++ b/pkg-py/src/querychat/_querychat_module.py @@ -136,14 +136,14 @@ def _(): if enable_bookmarking: chat_ui.enable_bookmarking(client) + @session.bookmark.on_bookmark def _on_bookmark(x: BookmarkState) -> None: vals = x.values # noqa: PD011 vals["querychat_sql"] = sql.get() vals["querychat_title"] = title.get() vals["querychat_has_greeted"] = has_greeted.get() - session.bookmark.on_bookmark(_on_bookmark) - + @session.bookmark.on_restore def _on_restore(x: RestoreState) -> None: vals = x.values # noqa: PD011 if "querychat_sql" in vals: @@ -153,6 +153,4 @@ def _on_restore(x: RestoreState) -> None: if "querychat_has_greeted" in vals: has_greeted.set(vals["querychat_has_greeted"]) - session.bookmark.on_restore(_on_restore) - return ModServerResult(df=filtered_df, sql=sql, title=title, client=chat) From 19515d99137c0f3cea6e62b241d3d661e468eb27 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 20 Nov 2025 12:29:20 -0600 Subject: [PATCH 4/5] Default enable_bookmarking to False; auto-enable in Express if a store is set --- pkg-py/src/querychat/_querychat.py | 80 +++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/pkg-py/src/querychat/_querychat.py b/pkg-py/src/querychat/_querychat.py index 2e072cb9..3e4f59f5 100644 --- a/pkg-py/src/querychat/_querychat.py +++ b/pkg-py/src/querychat/_querychat.py @@ -11,6 +11,7 @@ import chevron import sqlalchemy from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui +from shiny.express._stub_session import ExpressStubSession from shiny.session import get_current_session from shinychat import output_markdown_stream @@ -278,7 +279,7 @@ def ui(self, **kwargs): """ return mod_ui(self.id, **kwargs) - def _server(self, *, enable_bookmarking: bool = True) -> None: + def _server(self, *, enable_bookmarking: bool = False) -> None: """ Initialize the server module. @@ -530,7 +531,7 @@ def set_client(self, client: str | chatlas.Chat) -> None: class QueryChat(QueryChatBase): - def server(self, *, enable_bookmarking: bool = True) -> None: + def server(self, *, enable_bookmarking: bool = False) -> None: """ Initialize Shiny server logic. @@ -542,32 +543,45 @@ def server(self, *, enable_bookmarking: bool = True) -> None: Parameters ---------- enable_bookmarking - Whether to enable bookmarking for the querychat module. Default is - `True`. + Whether to enable bookmarking for the querychat module. Examples -------- ```python from shiny import App, render, ui + from seaborn import load_dataset from querychat import QueryChat - qc = QueryChat(my_dataframe, "my_data") + titanic = load_dataset("titanic") - app_ui = ui.page_fluid( - qc.sidebar(), - ui.output_data_frame("data_table"), - ) + qc = QueryChat(titanic, "titanic") + + + def app_ui(request): + return ui.page_sidebar( + qc.sidebar(), + ui.card( + ui.card_header(ui.output_text("title")), + ui.output_data_frame("data_table"), + ), + title="Titanic QueryChat App", + fillable=True, + ) def server(input, output, session): - qc.server() + qc.server(enable_bookmarking=True) @render.data_frame def data_table(): return qc.df() + @render.text + def title(): + return qc.title() or "My Data" - app = App(app_ui, server) + + app = App(app_ui, server, bookmark_store="url") ``` Returns @@ -590,17 +604,33 @@ class QueryChatExpress(QueryChatBase): Examples -------- ```python - from shiny.express import render, ui from querychat.express import QueryChat + from seaborn import load_dataset + from shiny.express import app_opts, render, ui - qc = QueryChat(my_dataframe, "my_data") + titanic = load_dataset("titanic") + qc = QueryChat(titanic, "titanic") qc.sidebar() + with ui.card(fill=True): + with ui.card_header(): + + @render.text + def title(): + return qc.title() or "Titanic Dataset" + + @render.data_frame + def data_table(): + return qc.df() + - @render.data_frame - def data_table(): - return qc.df() + ui.page_opts( + title="Titanic QueryChat App", + fillable=True, + ) + + app_opts(bookmark_store="url") ``` """ @@ -616,7 +646,7 @@ def __init__( data_description: Optional[str | Path] = None, extra_instructions: Optional[str | Path] = None, prompt_template: Optional[str | Path] = None, - enable_bookmarking: bool = True, + enable_bookmarking: Literal["auto", True, False] = "auto", ): super().__init__( data_source, @@ -628,7 +658,21 @@ def __init__( extra_instructions=extra_instructions, prompt_template=prompt_template, ) - self._server(enable_bookmarking=enable_bookmarking) + + # If the Express session has a bookmark store set, automatically enable + # querychat's bookmarking + enable: bool + if enable_bookmarking == "auto": + session = get_current_session() + if session and isinstance(session, ExpressStubSession): + store = session.app_opts.get("bookmark_store", "disable") + enable = store != "disable" + else: + enable = False + else: + enable = enable_bookmarking + + self._server(enable_bookmarking=enable) def normalize_data_source( From 17df2c7a40a74d4a8a324cf8c1f48ab64d4248a3 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 20 Nov 2025 12:32:27 -0600 Subject: [PATCH 5/5] Revert shiny version requirement --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c17da757..69fcd83e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ maintainers = [ dependencies = [ "duckdb", "pandas", - "shiny @ git+https://github.com/posit-dev/py-shiny.git", + "shiny", "shinywidgets", "htmltools", "chatlas>=0.13.2",