diff --git a/R/chat.r b/R/chat.r
index 155466d..6a7cd0e 100644
--- a/R/chat.r
+++ b/R/chat.r
@@ -9,8 +9,8 @@
#'
#' @param question A character string with the user's question (optional). If
#' not provided, app opens with a blank chat.
-#' @param client An `ellmer::Chat` object. Defaults to openai 'gpt-4.1'. Note
-#' that if a different chat provider is used for chat, an `OPENAI_API_KEY`
+#' @param client A closure that returns an `ellmer::Chat` object. Defaults to openai
+#' 'gpt-4.1'. Note that if a different chat provider is used for chat, an `OPENAI_API_KEY`
#' must still be set for embedding vector search.
#' @param interactive Logical; whether to launch the interactive Shiny app
#' (default `TRUE`). If `FALSE`, returns chat response directly if `question`
@@ -28,7 +28,7 @@
#' }
ask <- function(
question = NULL,
- client = ellmer::chat_openai(model = "gpt-4.1"),
+ client = \() ellmer::chat_openai(model = "gpt-4.1"),
interactive = TRUE
) {
# Early check for OpenAI API Key
@@ -47,13 +47,13 @@ ask <- function(
}
store <- quarto_ragnar_store()
- client <- quartohelp_setup_client(client, store)
if (!interactive) {
- if (is.null(client)) {
+ client <- quartohelp_setup_client(client, store = store)
+ if (is.null(question)) {
return(client)
} else {
- return(quartohelp_complete(client, store, question, async = FALSE))
+ return(client$chat(!!!question))
}
}
@@ -72,11 +72,19 @@ quartohelp_chat_ui <- function(question) {
question <- list(list(role = "user", content = question))
}
- bslib::page_fillable(
- style = "display: flex; flex-direction: column; height: 100vh; padding: 0.5rem;",
- shiny::h1(
- "Quarto Help",
- style = "margin-bottom: 0.5rem; text-align: center;"
+ bslib::page_sidebar(
+ title = "Quarto Help",
+ sidebar = bslib::sidebar(
+ open = TRUE,
+ shiny::div(
+ class = "btn-toolbar",
+ shiny::div(
+ class = "btn-group btn-group-sm",
+ shiny::actionButton("new_chat", label = "New", icon = shiny::icon("plus")),
+ shiny::actionButton("delete_chat", label = "Delete", icon = shiny::icon("trash"))
+ )
+ ),
+ chatListUI("chat_list")
),
shinychat::chat_ui(
"chat",
@@ -88,48 +96,170 @@ quartohelp_chat_ui <- function(question) {
label = "",
class = "btn-close",
style = "position: fixed; top: 6px; right: 6px;"
- )
+ ),
+ open_chat_links_in_new_window()
)
}
#' Shiny Server for QuartoHelp Chat (with Initial Stream)
+#'
+#' By default, history is stored in the package cache directory.
+#' You can customize the location and the functionality of saving and loading
+#' chats with the options:
+#'
+#' - `quartohelp.history_dir`: A function that takes a Shiny session and returns
+#' the directory where chat history should be stored.
+#' - `quartohelp.load_state`: A function that takes a Shiny session and returns
+#' the initial state of the chat history. Called when the app starts to load
+#' the current state.
+#' - `quartohelp.save_state`: A function that takes a Shiny session and the
+#' current chat history, and saves it to the specified directory. Called at the
+#' end of the session to save the current state.
+#'
+#' For example use `option(``quartohelp.history_dir = function(session) "path/to/dir")`
+#' to set the history directory to a custom location.
+#'
#' @noRd
quartohelp_chat_server <- function(
store,
- client = ellmer::chat_openai(model = "gpt-4.1"),
+ client = \() ellmer::chat_openai(model = "gpt-4.1"),
question = NULL,
close_action = c("stop", "clear"),
...
) {
+
+ force(client)
store <- quarto_ragnar_store()
close_action <- match.arg(close_action)
- force(client)
function(input, output, session) {
- if (!inherits(client, "Chat")) {
- # client can be a function returning a ellmer Chat.
- # This is used for apps that need one client per session
- # eg: when hosting this app for multiple users.
- client <- client()
+ chat <- reactiveVal(NULL, "currently active chat")
+ selected <- shiny::reactiveVal(NULL, "id of the currently selected chat")
+ client <- quartohelp_setup_client(client, store)
+
+ complete_task <- shiny::ExtendedTask$new(function(turns, question) {
+ client$set_turns(turns)
+ value <- client$stream_async(!!!question)
+ value |>
+ promises::promise_resolve() |>
+ promises::then(
+ function(stream) {
+ shinychat::chat_append("chat", stream)
+ }
+ )
+ })
+
+ loaded_chats <- quartohelp_load_state(session)
+ if (!is.null(question)) {
+ ch <- new_chat()
+ loaded_chats <- append(list(ch), loaded_chats)
}
- complete_task <- shiny::ExtendedTask$new(function(client, store, question) {
- value <- quartohelp_complete(client, store, question)
- promises::then(
- promises::promise_resolve(value),
- function(stream) {
- shinychat::chat_append("chat", stream)
+ chats <- shiny::reactiveVal(loaded_chats %||% list(new_chat()))
+
+ chatListServer(
+ "chat_list",
+ chats = chats,
+ selected = selected
+ )
+
+ progress <- reactiveVal(NULL)
+ observe({
+ current_chats <- isolate(chats())
+ current_chat <- isolate(chat())
+ p <- shiny::isolate(progress())
+ task_status <- isolate(complete_task$status())
+
+ sel <- selected() %||% current_chats[[1]]$id
+
+ # if a completion is running, we don't switch chat just yet
+ # instead, we show a progress bar and wait a little for completion
+ if(task_status == "running") {
+ if (is.null(p)) {
+ p <- shiny::Progress$new()
+ progress(p)
}
- )
+ p$inc(message = "Wait for completion", amount = 0.05)
+ invalidateLater(500, session = session)
+ return()
+ } else {
+ # cleanup the progress bar when the task is not running
+ if (!is.null(p)) {
+ p$close()
+ progress(NULL)
+ }
+ }
+
+ # save current turns in the chat object
+ if (!is.null(current_chat)) {
+ chats(lapply(current_chats, function(ch) {
+ if (ch$id == current_chat$id) {
+ ch$turns <- client$get_turns()
+ }
+ ch
+ }))
+ }
+
+ for(ch in current_chats) {
+ if (ch$id == sel) {
+ chat(ch)
+ break
+ }
+ }
})
- if (!is.null(question)) {
- complete_task$invoke(client, store, question)
- }
+ observeEvent(chat(), {
+ # Clear the current chat
+ shinychat::chat_clear("chat")
+ client$set_turns(chat()$turns)
+
+ # Append messages of the new chat
+ lapply(chat()$turns, function(x) {
+ msg <- list(
+ role = x@role,
+ content = ellmer::contents_markdown(x)
+ )
+
+ shinychat::chat_append_message("chat", msg, chunk = FALSE)
+ })
+
+ # Invoke question if it's not NULL and reset
+ if (!is.null(question)) {
+ shinychat::chat_append_message(
+ "chat",
+ list(role = "user", content = question),
+ chunk = FALSE
+ )
+ complete_task$invoke(chat()$turns, question)
+ question <<- NULL
+ }
+ })
+
+ observeEvent(input$new_chat, {
+ ch <- new_chat()
+ chats(append(list(ch), chats()))
+ selected(ch$id)
+ })
+
+ observeEvent(input$delete_chat, {
+ current_chats <- chats()
+ sel <- selected()
+
+ filtered_chats <- Filter(
+ function(x) x$id != sel,
+ current_chats
+ )
+
+ if (length(filtered_chats) == 0) {
+ filtered_chats <- list(new_chat())
+ }
+ selected(filtered_chats[[1]]$id)
+ chats(filtered_chats)
+ })
shiny::observeEvent(input$chat_user_input, {
- complete_task$invoke(client, store, input$chat_user_input)
+ complete_task$invoke(chat()$turns, input$chat_user_input)
})
shiny::observeEvent(input$close_btn, {
@@ -150,139 +280,209 @@ quartohelp_chat_server <- function(
shiny::observeEvent(complete_task$status(), {
if (complete_task$status() == "error") {
complete_task$result() # reraise error
+ return()
}
+
+ if (complete_task$status() != "success") {
+ return()
+ }
+
+ # After a successful task complete, we update the chat list
+ # with the current chat's turns and title.
+ chats() |>
+ lapply(function(ch) {
+ if (ch$id == chat()$id) {
+ ch$turns <- client$get_turns()
+ ch$last_message <- Sys.time()
+ }
+ if (is.null(ch$title) && length(ch$turns) > 0) {
+ ch$title <- generate_chat_title(ch$turns)
+ }
+ ch
+ }) |>
+ chats()
+
+ })
+
+ session$onSessionEnded(function() {
+ chats <- isolate(chats())
+ quartohelp_save_state(session, chats)
})
}
}
-# Creates a stream of chat results but instead of directly passing the user input
-# to the model, it first generates a query using a different model, extracts excerpts
-# and then inject those into the turns for the chat model.
-quartohelp_complete <- function(client, store, question, async = TRUE) {
- # only for small questions.
- # also don't do it for follow up questions
- if (nchar(question) < 500 && length(client$get_turns()) < 2) {
- # temporary chat for making the tool call.
- chat <- ellmer::chat_openai("gpt-4.1-nano") |>
- quartohelp_setup_client(store)
-
- queries <- chat$chat_structured(
- echo = FALSE,
- type = ellmer::type_array(
- "queries",
- items = ellmer::type_string("a query. escaped if needed")
- ),
- glue::trim(glue::glue(
- "
- You are going to search on the Quarto Knowledge store. First generate up to
- 3 search queries related to the question below. You don't always need to
- generate 3 queries. Be wise.
-
- {question}
- "
- ))
- )
+#' @param client a closure that returns an ellmer::Chat
+#' @noRd
+quartohelp_setup_client <- function(client, store) {
+ ragnar:::chat_ragnar(
+ function() {
+ client <- client()
+ # Don't override the system prompt if it is already set.
+ if (is.null(client$get_system_prompt())) {
+ client$set_system_prompt(paste(readLines(
+ system.file("prompt", "quartohelp_system.txt", package = "quartohelp")
+ ), collapse = "\n"))
+ }
+ client
+ },
+ store = store
+ )
+}
- # using a fixed retrieve tool for all requests already avoids repeated
- # documents to appear in the output.
- retrieve_tool <- client$get_tools()$rag_retrieve_quarto_excerpts
- tool_requests <- lapply(queries, function(query) {
- ellmer::ContentToolRequest(
- id = rlang::hash(query),
- name = "rag_retrieve_quarto_excerpts",
- arguments = list(text = query),
- # we're faking the request so we don't care about the function
- tool = retrieve_tool
- )
- })
+chatListUI <- function(id) {
+ uiOutput(shiny::NS(id, "chats"))
+}
- client$add_turn(
- ellmer::Turn("user", contents = list(ellmer::ContentText(question))),
- ellmer::Turn("assistant", contents = tool_requests)
- )
+chatListServer <- function(id, chats, selected) {
+ stopifnot(is.reactive(chats))
+ shiny::moduleServer(id, function(input, output, session) {
+ ns <- session$ns
+
+ output$chats <- shiny::renderUI({
+ chats <- chats()
+
+ # reorder chats by last message time
+ if (length(chats)) {
+ chats <- chats[order(sapply(chats, function(x) x$last_message), decreasing = TRUE)]
+ }
- question <-lapply(tool_requests, function(req) {
- ellmer::ContentToolResult(
- request = req,
- value = req@tool@fun(req@arguments$text)
+ sel <- selected() %||% chats[[1]]$id
+
+ shiny::tags$ul(
+ class = "list-group list-group-flush",
+ !!!lapply(
+ chats,
+ function(x) {
+
+ class <- "list-group-item list-group-item-action"
+ if (x$id == sel) {
+ class <- paste(class, "active disabled")
+ }
+
+ unclassedActionButton(
+ inputId = ns(paste0("chat-", x$id)),
+ label = x$title %||% "Untitled chat",
+ class = class
+ )
+ }
+ )
)
})
- } else {
- # we need it to be a list for later
- question <- list(question)
+
+ shiny::observe({
+ lapply(chats(), function(x) {
+ observeEvent(input[[paste0("chat-", x$id)]], {
+ selected(x$id)
+ }, ignoreInit = TRUE)
+ })
+ })
+ })
+}
+
+unclassedActionButton <- function(inputId, label, class = NULL, ...) {
+ shiny::tags$button(
+ id = inputId,
+ class = paste("action-button", class, collapse = " "),
+ type = "button",
+ label,
+ ...
+ )
+}
+
+generate_chat_title <- function(turns) {
+ cli <- ellmer::chat_openai(model = "gpt-4.1-nano")
+ cli$set_turns(turns)
+ cli$chat_structured(
+ "Create a title for this conversation. No more than 4 words.",
+ type = ellmer::type_string()
+ )
+}
+
+new_chat <- function() {
+ list(
+ id = timestamp(),
+ turns = list(),
+ title = NULL,
+ last_message = Sys.time()
+ )
+}
+
+timestamp <- function() {
+ paste0("chat_", format(Sys.time(), "%Y-%m-%d_%H-%M-%OS6"))
+}
+
+quartohelp_history_dir <- function(session) {
+ if (is.function(history <- getOption("quartohelp.history_dir"))) {
+ history(session)
}
- if (async) {
- client$stream_async(!!!question)
- } else {
- client$chat(!!!question)
+ dir <- file.path(tools::R_user_dir("quartohelp", which = "data"), "history")
+ if (!dir.exists(dir)) {
+ dir.create(dir, recursive = TRUE, showWarnings = FALSE)
}
+ dir
}
+quartohelp_load_state <- function(session) {
+ if (is.function(load_state <- getOption("quartohelp.load_state"))) {
+ load_state(session)
+ }
-quartohelp_setup_client <- function(client, store) {
- client$set_system_prompt(glue::trim(
- "
- You are an expert in Quarto documentation. You are concise.
- Always perform a search of the Quarto knowledge store for each user request.
- Every response must cite links to official documentation sources.
- If the request is ambiguous, search first, then ask a clarifying question.
- If docs are unavailable or search fails, inform the user and do NOT answer the question.
-
- Always give answers that include a minimal fully self-contained quarto document.
-
- To display quarto code blocks, use oversized markdown fences, like this:
-
- ````` markdown
- PROSE HERE
- ```{r}
- CODE HERE
- ```
- ```{python}
- CODE HERE
- ```
- `````
- "
- ))
-
- retrieve_tool <- quartohelp_retrieve_tool(store)
-
- client$register_tool(retrieve_tool)
- client
+ res <- list.files(quartohelp_history_dir(session), full.names = TRUE) |>
+ sort(decreasing = TRUE) |>
+ lapply(readRDS)
+ if (length(res) == 0) return(NULL)
+ res
}
+quartohelp_save_state <- function(session, chats) {
+ if (is.function(save_state <- getOption("quartohelp.save_state"))) {
+ save_state(session, chats)
+ }
-quartohelp_retrieve_tool <- function(store) {
- retrieved_ids <- integer()
- rag_retrieve_quarto_excerpts <- function(text) {
- # Retrieve relevant chunks using hybrid (vector/BM25) search,
- # excluding previously returned IDs in this session.
- chunks <- dplyr::tbl(store) |>
- dplyr::filter(!.data$id %in% retrieved_ids) |>
- ragnar::ragnar_retrieve(text, top_k = 10)
+ dir <- quartohelp_history_dir(session)
+ unlink(dir, recursive = TRUE)
+ if (!dir.exists(dir)) {
+ dir.create(dir, recursive = TRUE, showWarnings = FALSE)
+ }
+ lapply(chats, function(chat) {
+ # Do not save empty chats
+ if (length(chat$turns) == 0) {
+ return(NULL)
+ }
+ saveRDS(chat, file.path(dir, paste0(chat$id, ".rds")))
+ })
+}
- retrieved_ids <<- unique(c(retrieved_ids, chunks$id))
+quartohelp_clean_history <- function(session) {
+ dir <- quartohelp_history_dir()
+ files <- list.files(dir, full.names = TRUE)
+ file.remove(files)
+ invisible(NULL)
+}
- stringi::stri_c(
- "",
- chunks$text,
- "",
- sep = "\n",
- collapse = "\n"
+open_chat_links_in_new_window <- function() {
+ tags$script(
+ HTML(
+ r"---(
+ document.getElementById('chat').addEventListener('click', function(event) {
+ // Find the closest link element (in case user clicks on nested elements)
+ const link = event.target.closest('a');
+
+ if (link) {
+ const href = link.href;
+
+ // Check if the link starts with http:// or https://
+ if (href && /^https?:\/\//.test(href)) {
+ // Prevent the default link behavior
+ event.preventDefault();
+
+ // Open the link in a new window
+ window.open(href, '_blank');
+ }
+ }
+ });
+ )---"
)
- }
-
- ellmer::tool(
- rag_retrieve_quarto_excerpts,
- glue::trim(
- "
- Use this tool to retrieve the most relevant excerpts from the Quarto
- knowledge store for a given text input. This function:
- - uses both vector (semantic) similarity and BM25 text search.
- - never returns the same excerpt twice in the same session; it always excludes recently retrieved IDs.
- - returns the results as plain text wrapped in tags.
- "
- ),
- text = ellmer::type_string()
)
}
diff --git a/inst/prompt/quartohelp_system.txt b/inst/prompt/quartohelp_system.txt
new file mode 100644
index 0000000..de414c4
--- /dev/null
+++ b/inst/prompt/quartohelp_system.txt
@@ -0,0 +1,19 @@
+You are an expert in Quarto documentation. You are concise.
+Always perform a search of the Quarto knowledge store for each user request.
+Every response must cite links to official documentation sources.
+If the request is ambiguous, search first, then ask a clarifying question.
+If docs are unavailable or search fails, inform the user and do NOT answer the question.
+
+Always give answers that include a minimal fully self-contained quarto document.
+
+To display quarto code blocks, use oversized markdown fences, like this:
+
+````` markdown
+PROSE HERE
+```{r}
+CODE HERE
+```
+```{python}
+CODE HERE
+```
+`````
diff --git a/man/ask.Rd b/man/ask.Rd
index 9e6afe5..e338acd 100644
--- a/man/ask.Rd
+++ b/man/ask.Rd
@@ -6,7 +6,7 @@
\usage{
ask(
question = NULL,
- client = ellmer::chat_openai(model = "gpt-4.1"),
+ client = function() ellmer::chat_openai(model = "gpt-4.1"),
interactive = TRUE
)
}
@@ -14,8 +14,8 @@ ask(
\item{question}{A character string with the user's question (optional). If
not provided, app opens with a blank chat.}
-\item{client}{An \code{ellmer::Chat} object. Defaults to openai 'gpt-4.1'. Note
-that if a different chat provider is used for chat, an \code{OPENAI_API_KEY}
+\item{client}{A closure that returns an \code{ellmer::Chat} object. Defaults to openai
+'gpt-4.1'. Note that if a different chat provider is used for chat, an \code{OPENAI_API_KEY}
must still be set for embedding vector search.}
\item{interactive}{Logical; whether to launch the interactive Shiny app