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",