diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/README.md b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/README.md new file mode 100644 index 000000000..c06b33e85 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/README.md @@ -0,0 +1,82 @@ +# Screenshot and video generation for the shinychat post + +Every image and video in `../images/` is generated programmatically — no manual +screen recording. Each subdirectory here is one demo: + +- `app.R` — a Shiny app using `bslib::page_chat()` + shinychat, backed by a + mocked ellmer client (`_mock-client.R`: keyword-matched canned replies, + streamed via a `coro::async_generator`; `mock_stream_reply()` streams tool + requests in as running activity rows before their results land and text + word by word). No network or LLM calls. +- `screenshot.R` — a chromote (Chrome DevTools Protocol) automation script that + drives the app in headless Chrome and writes PNGs/MP4s into `../images/`. + +Run one demo or everything: + +```bash +Rscript apps/edit-branches/screenshot.R +Rscript apps/run-all.R +``` + +Requires R packages `chromote`, `callr`, `jsonlite`, `httpuv`, `shiny`, +`bslib`, `shinychat`, `ellmer`, `coro`, plus `ffmpeg` on PATH and a +launchable headless Chrome for chromote. + +## `_common.R` harness + +Shared helpers sourced by every `screenshot.R`: + +- App lifecycle: `start_app()` (runs `shiny::runApp()` in a `callr` background + process on a random free port, waits for readiness), `stop_app()`. +- Browser session: `connect()` (opens the app with a viewport and forces + `prefers-color-scheme: light`), `set_viewport()`, `set_zoom()`. + `set_zoom()` uses CSS zoom so the desktop layout survives at smaller + viewport widths; because vh units scale with zoom, it also shrinks the + `shiny-chat-page` root to keep the app exactly filling the window. +- Waiting: `wait_for()` / `wait_for_text()` / `wait_for_gone()` / + `wait_for_app_ready()`. +- Interaction: `chat_type()` (CDP `Input$insertText` + optional Enter key), + `press_enter()` (Enter key alone, e.g. after `type_natural()`), + `click_selector()`, plus `js()` for arbitrary expressions. +- Stills: `viewport_png()`, `full_page_png()`, `element_png()`, + `union_png()` (screenshot of the bounding box of several selectors, with + padding — used for the tight cropped figures in the post). +- Movies: `movie_start(b, fps)` returns a recorder; `rec$snap()` captures a + JPEG frame with a timestamp, `rec$loop(seconds)` snapshots at the target + fps for that long, and `rec$loop_until(expr)` snapshots while polling for a + JS condition (so streaming responses stay animated). Actions performed + between `loop()` calls are captured as single frames, so anything not + wrapped in `loop()` appears instant. + `movie_save(rec, path, fps = 15, width = NULL)` writes frames to a temp + dir, builds an ffmpeg concat list with real inter-frame durations, and runs + roughly `ffmpeg -f concat -i list.txt -vf "fps=15,scale=2400:-2,..."`. + Set `rec$clip <- movie_clip_fit(b, selectors)` to record only part of the + app (e.g. just the conversation, no header or input); the clip is fit to + exactly 4:3 so it fills the `aspect-ratio="4x3"` embed, and `movie_save()` + should then be called with `width = rec$clip$width * 2` to keep the output + at native retina resolution. +- Fake cursor: CDP clicks are invisible in captured frames, so + `cursor_start(b)` injects a pointer element that `cursor_glideto()` / + `cursor_glideto_el()` animate in step with the movie frames, dispatching + real CDP mouse events along the way (these also trigger `:hover`, which is + what reveals the message edit buttons). `rect_of_text()` finds the bounding + box of the first element matching a selector *and* containing text, for + gliding to a specific history entry. `cursor_click_here()` clicks with a + brief press animation, `cursor_leave()` retreats off the right edge. + Coordinates are client space; because root CSS zoom scales the rendered + position of fixed elements, `cursor_place()` divides by the zoom factor. +- Natural input: `type_natural()` types one character at a time with + human-ish delays, `select_edit_text()` selects an exact substring inside a + contenteditable editor, and `press_backspace()` deletes the selection. + +## Notes for editing recordings + +- Videos are embedded with `aspect-ratio="4x3"`, so the recorded viewport must + be exactly 4:3 (e.g. 1200x900); see the comment in + `edit-branches/screenshot.R`. +- `movie_save(width = 2400)` scales output to 2400px wide for retina + sharpness; `-movflags +faststart` for web embedding. +- Timing tweaks (lead-in, how long a streaming response plays) are just + `rec$loop()` durations around the actions. +- Frame captures run at ~12 fps while actions happen, so fast UI transitions + may only get one frame; wrap anything that must look animated in `loop()`. diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_common.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_common.R new file mode 100644 index 000000000..0107698eb --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_common.R @@ -0,0 +1,862 @@ +# Screenshot and screen-recording harness for the shinychat post demos. +# Sourced by each apps//screenshot.R script. + +suppressPackageStartupMessages({ + library(chromote) + library(callr) + library(jsonlite) + library(httpuv) +}) + +# ---- paths ----------------------------------------------------------------- + +file_arg <- grep("^--file=", commandArgs(FALSE), value = TRUE) +file_arg <- sub("^--file=", "", file_arg) +file_arg <- file_arg[file_arg != "-"] +if (length(file_arg)) { + demo_dir <- dirname(normalizePath(file_arg[1])) +} else { + demo_dir <- getwd() +} +# demo scripts live in apps//; when sourcing from the apps/ dir +# directly (e.g. an ad-hoc probe), treat that dir as apps_dir. +if (file.exists(file.path(demo_dir, "app.R"))) { + apps_dir <- dirname(demo_dir) +} else { + apps_dir <- demo_dir +} +post_dir <- dirname(apps_dir) +images_dir <- file.path(post_dir, "images") +dir.create(images_dir, showWarnings = FALSE, recursive = TRUE) + +shot_path <- function(name) file.path(images_dir, name) +app_path <- function(name) file.path(apps_dir, name) + +# ---- app lifecycle --------------------------------------------------------- + +free_port <- function() { + for (i in 1:20) { + port <- as.integer(sample(20000:39999, 1)) + s <- tryCatch( + httpuv::startServer("127.0.0.1", port, list()), + error = function(e) NULL + ) + if (!is.null(s)) { + on.exit(s$stop()) + return(port) + } + } + stop("Could not find a free port.") +} + +start_app <- function( + app_dir, + env = character(0), + port = free_port(), + timeout = 30 +) { + px <- callr::r_bg( + function(dir, p) { + shiny::runApp(dir, port = p, host = "127.0.0.1", launch.browser = FALSE) + }, + args = list(dir = app_dir, p = port), + env = env, + stdout = "|", + stderr = "|" + ) + app <- list( + process = px, + port = port, + url = sprintf("http://127.0.0.1:%d", port) + ) + deadline <- Sys.time() + timeout + up <- FALSE + while (Sys.time() < deadline) { + up <- tryCatch( + { + con <- suppressWarnings(url(app$url, open = "rb", method = "libcurl")) + readBin(con, "raw", n = 10) + close(con) + TRUE + }, + error = function(e) FALSE + ) + if (up || !px$is_alive()) { + break + } + Sys.sleep(0.25) + } + if (!up) { + stop( + "App failed to start at ", + app$url, + "\n", + paste(px$read_error(), collapse = "\n") + ) + } + app +} + +stop_app <- function(app) { + if (!app$process$is_alive()) { + return(invisible(NULL)) + } + app$process$signal(tools::SIGTERM) + for (i in 1:10) { + if (!app$process$is_alive()) { + return(invisible(NULL)) + } + Sys.sleep(0.2) + } + app$process$kill() +} + +# ---- browser session ------------------------------------------------------- + +connect <- function( + app, + width = 1440, + height = 1000, + scale = 2, + mobile = FALSE +) { + b <- ChromoteSession$new() + b$Emulation$setDeviceMetricsOverride( + width = width, + height = height, + deviceScaleFactor = scale, + mobile = mobile + ) + # Keep captures consistent regardless of the OS color scheme + b$Emulation$setEmulatedMedia( + features = list(list(name = "prefers-color-scheme", value = "light")) + ) + b$Page$navigate(app$url) + for (i in 1:60) { + if ( + isTRUE(tryCatch( + js(b, "document.readyState === 'complete'"), + error = function(e) FALSE + )) + ) { + break + } + Sys.sleep(0.25) + } + b +} + +js <- function(b, expr, by_value = TRUE) { + res <- b$Runtime$evaluate(expr, returnByValue = by_value) + err <- res$exceptionDetails + if (!is.null(err)) { + stop("JS error: ", paste(deparse(err), collapse = " ")) + } + res$result$value +} + +wait_for <- function(b, expr, timeout = 15000, interval = 0.2) { + deadline <- Sys.time() + timeout + while (Sys.time() < deadline) { + if (isTRUE(tryCatch(js(b, expr), error = function(e) FALSE))) { + return(invisible(TRUE)) + } + Sys.sleep(interval) + } + stop("Timed out waiting for: ", expr) +} + +wait_for_app_ready <- function( + b, + expr = "!!document.querySelector('.shiny-chat-messages')", + timeout = 20000 +) { + wait_for(b, expr, timeout = timeout) +} + +set_viewport <- function(b, width, height, scale = 2, mobile = FALSE) { + b$Emulation$setDeviceMetricsOverride( + width = width, + height = height, + deviceScaleFactor = scale, + mobile = mobile + ) + Sys.sleep(0.4) +} + +# CSS zoom enlarges layout and fonts without affecting media queries, so the +# desktop layout survives at any viewport width >= 800px. vh units scale with +# zoom, so the page_chat root (sized in vh) is shrunk to keep the app exactly +# filling the window instead of overflowing and triggering page scroll. +set_zoom <- function(b, zoom = 1.2) { + js( + b, + sprintf( + "(() => { + document.documentElement.style.zoom = %g; + const page = document.querySelector('shiny-chat-page'); + if (page) page.style.height = (window.innerHeight / %g) + 'px'; + window.scrollTo(0, 0); + })()", + zoom, + zoom + ) + ) + Sys.sleep(0.4) + invisible(TRUE) +} + +click_selector <- function(b, selector) { + sel <- jsonlite::toJSON(selector, auto_unbox = TRUE) + clicked <- js( + b, + sprintf( + "(() => { + const el = document.querySelector(%s); + if (!el) return false; + el.scrollIntoView({ block: 'center' }); + el.click(); + return true; + })()", + sel + ) + ) + if (!isTRUE(clicked)) { + stop("Element not found: ", selector) + } + invisible(TRUE) +} + +union_png <- function( + b, + path, + selectors, + pad = 24, + dx = 0, + dy = 0, + beyond = TRUE +) { + sel <- jsonlite::toJSON(selectors, auto_unbox = FALSE) + r <- js( + b, + sprintf( + "(() => { + let u = null; + for (const s of %s) { + for (const el of document.querySelectorAll(s)) { + const r = el.getBoundingClientRect(); + const box = { x: r.x + window.scrollX, y: r.y + window.scrollY, w: r.width, h: r.height }; + if (!u) { u = {...box}; continue; } + u.x = Math.min(u.x, box.x); + u.y = Math.min(u.y, box.y); + u.w = Math.max(u.x + u.w, box.x + box.w) - u.x; + u.h = Math.max(u.y + u.h, box.y + box.h) - u.y; + } + } + return u; + })()", + sel + ) + ) + if (is.null(r)) { + stop("No elements found for: ", paste(selectors, collapse = ", ")) + } + save_png( + b, + path, + list( + x = max(0, r$x - pad + dx), + y = max(0, r$y - pad + dy), + width = r$w + 2 * pad, + height = r$h + 2 * pad, + scale = 1 + ), + beyond = beyond + ) +} + +html <- function(b, expr) { + js( + b, + sprintf( + "(() => { const el = %s; return el ? el.outerHTML : null; })()", + expr + ) + ) +} + +document_html <- function(b) js(b, "document.documentElement.outerHTML") + +# ---- chat input helpers ---------------------------------------------------- + +chat_focus <- function(b, selector = "#chat_user_input .ProseMirror") { + js( + b, + sprintf( + "document.querySelector(%s).focus()", + jsonlite::toJSON(selector, auto_unbox = TRUE) + ) + ) +} + +press_enter <- function(b) { + b$Input$dispatchKeyEvent( + type = "keyDown", + key = "Enter", + code = "Enter", + windowsVirtualKeyCode = 13, + nativeVirtualKeyCode = 13, + text = "\r" + ) + b$Input$dispatchKeyEvent( + type = "keyUp", + key = "Enter", + code = "Enter", + windowsVirtualKeyCode = 13, + nativeVirtualKeyCode = 13 + ) + invisible(TRUE) +} + +chat_type <- function(b, text, press_enter = FALSE) { + chat_focus(b) + b$Input$insertText(text = text) + Sys.sleep(0.3) + if (press_enter) { + press_enter(b) + } + invisible(TRUE) +} + +chat_send_click <- function(b) { + click_selector(b, ".shiny-chat-btn-send:not([disabled])") +} + +wait_for_text <- function(b, text, timeout = 20000) { + wait_for( + b, + sprintf( + "document.body.textContent.includes(%s)", + jsonlite::toJSON(text, auto_unbox = TRUE) + ), + timeout = timeout + ) +} + +wait_for_gone <- function(b, selector, timeout = 10000) { + wait_for( + b, + sprintf( + "!document.querySelector(%s)", + jsonlite::toJSON(selector, auto_unbox = TRUE) + ), + timeout = timeout + ) +} + +# ---- screenshots ----------------------------------------------------------- + +capture_clip <- function(b, clip, beyond = TRUE) { + img <- b$Page$captureScreenshot( + format = "png", + clip = clip, + captureBeyondViewport = beyond + ) + jsonlite::base64_dec(img$data) +} + +save_png <- function(b, path, clip, beyond = TRUE) { + writeBin(capture_clip(b, clip, beyond = beyond), path) + invisible(path) +} + +viewport_png <- function(b, path) { + vs <- b$Page$getLayoutMetrics()$cssVisualViewport + save_png( + b, + path, + list( + x = vs$pageX, + y = vs$pageY, + width = vs$clientWidth, + height = vs$clientHeight, + scale = 1 + ) + ) +} + +full_page_png <- function(b, path) { + cs <- b$Page$getLayoutMetrics()$cssContentSize + save_png( + b, + path, + list( + x = 0, + y = 0, + width = cs$width, + height = cs$height, + scale = 1 + ) + ) +} + +element_png <- function(b, path, selector, pad = 10) { + sel <- jsonlite::toJSON(selector, auto_unbox = TRUE) + r <- js( + b, + sprintf( + "(() => { + const el = document.querySelector(%s); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + window.scrollX, y: r.y + window.scrollY, w: r.width, h: r.height }; + })()", + sel + ) + ) + if (is.null(r)) { + stop("Element not found: ", selector) + } + save_png( + b, + path, + list( + x = max(0, r$x - pad), + y = max(0, r$y - pad), + width = r$w + 2 * pad, + height = r$h + 2 * pad, + scale = 1 + ) + ) +} + +# ---- movies ---------------------------------------------------------------- +# +# Capture a timed sequence of JPEG frames while the script performs actions, +# then assemble them into an MP4 with ffmpeg at real-time pacing. + +movie_start <- function(b, fps = 12) { + rec <- new.env(parent = emptyenv()) + rec$b <- b + rec$fps <- fps + rec$frames <- list() + rec$t0 <- Sys.time() + rec$clip <- NULL + rec$snap <- function() { + args <- list(format = "jpeg", quality = 90) + if (!is.null(rec$clip)) { + args$clip <- rec$clip + } + img <- do.call(rec$b$Page$captureScreenshot, args) + rec$frames[[length(rec$frames) + 1]] <<- list( + data = img$data, + t = as.numeric(difftime(Sys.time(), rec$t0, units = "secs")) + ) + invisible(length(rec$frames)) + } + rec$beat <- function(fps = rec$fps) { + start <- Sys.time() + rec$snap() + elapsed <- as.numeric(difftime(Sys.time(), start, units = "secs")) + Sys.sleep(max(0, 1 / fps - elapsed)) + } + rec$loop <- function(seconds, fps = rec$fps) { + dt <- 1 / fps + n <- max(1L, ceiling(seconds / dt)) + for (i in seq_len(n)) { + rec$beat(fps) + } + invisible(NULL) + } + rec$loop_until <- function(expr, timeout = 20000, fps = rec$fps) { + deadline <- Sys.time() + timeout / 1000 + while (Sys.time() < deadline) { + if (isTRUE(tryCatch(js(rec$b, expr), error = function(e) FALSE))) { + return(invisible(TRUE)) + } + rec$beat(fps) + } + stop("Timed out waiting for: ", expr) + } + rec +} + +movie_save <- function(rec, path, fps = 15, width = NULL) { + if (length(rec$frames) < 2) { + stop("Not enough frames recorded.") + } + tmp <- file.path(tempdir(), sprintf("shinychat-movie-%d", Sys.getpid())) + dir.create(tmp, recursive = TRUE, showWarnings = FALSE) + on.exit(unlink(tmp, recursive = TRUE), add = TRUE) + + times <- vapply(rec$frames, `[[`, numeric(1), "t") + durations <- diff(c(0, times)) + lines <- character() + for (i in seq_along(rec$frames)) { + f <- file.path(tmp, sprintf("f%04d.jpg", i)) + writeBin(jsonlite::base64_dec(rec$frames[[i]]$data), f) + lines <- c( + lines, + sprintf("file '%s'\nduration %.3f", f, max(durations[i], 0.02)) + ) + } + lines <- c( + lines, + sprintf( + "file '%s'", + file.path(tmp, sprintf("f%04d.jpg", length(rec$frames))) + ) + ) + concat <- file.path(tmp, "list.txt") + writeLines(lines, concat) + + vf <- sprintf("fps=%d,format=yuv420p", fps) + if (!is.null(width)) { + vf <- sprintf("fps=%d,scale=%d:-2,format=yuv420p", fps, width) + } + cmd <- sprintf( + "ffmpeg -y -hide_banner -loglevel error -f concat -safe 0 -i %s -vf %s -movflags +faststart %s", + shQuote(concat), + shQuote(vf), + shQuote(path) + ) + status <- system(cmd) + if (status != 0) { + stop("ffmpeg failed while writing ", path) + } + invisible(path) +} + +# A 4:3 screenshot clip (CSS px) covering the union of `selectors`, for movies +# that should show only part of the app. Anchored at the top of the content so +# headroom for growth (streaming responses, edit boxes) stays inside the clip. +movie_clip_fit <- function( + b, + selectors, + ratio = 4 / 3, + pad = 24, + anchor = "top", + container = NULL +) { + sel <- jsonlite::toJSON(selectors, auto_unbox = FALSE) + r <- js( + b, + sprintf( + "(() => { + let u = null; + for (const s of %s) { + for (const el of document.querySelectorAll(s)) { + const r = el.getBoundingClientRect(); + if (!u) { u = { x: r.x, y: r.y, w: r.width, h: r.height }; continue; } + u.x = Math.min(u.x, r.x); + u.y = Math.min(u.y, r.y); + u.w = Math.max(u.x + u.w, r.x + r.width) - u.x; + u.h = Math.max(u.y + u.h, r.y + r.height) - u.y; + } + } + return u; + })()", + sel + ) + ) + if (is.null(r)) { + stop("No elements found for: ", paste(selectors, collapse = ", ")) + } + vw <- js(b, "window.innerWidth") + vh <- js(b, "window.innerHeight") + w <- min(vw, r$w + 2 * pad) + w <- round(w / 4) * 4 + h <- w / ratio + if (h > vh) { + h <- vh + w <- round(h * ratio / 4) * 4 + } + x <- r$x + r$w / 2 - w / 2 + x <- max(0, min(vw - w, x)) + y <- if (identical(anchor, "top")) r$y - pad else r$y + r$h / 2 - h / 2 + y <- max(0, min(vh - h, y)) + if (!is.null(container)) { + # Never reach above the container: keeps header borders and shadows, + # which sit just above its top edge, out of the recording. + top <- js( + b, + sprintf( + "(() => { + const el = document.querySelector(%s); + return el ? el.getBoundingClientRect().y : null; + })()", + jsonlite::toJSON(container, auto_unbox = TRUE) + ) + ) + if (!is.null(top)) { + y <- max(y, top + 2) + } + } + list( + x = round(x), + y = round(y), + width = round(w), + height = round(h), + scale = 1 + ) +} + +# ---- fake cursor ------------------------------------------------------------ +# CDP clicks are invisible in captured frames, so demos that need a visible +# pointer render their own cursor element and move it in step with the movie +# frames. Movement uses real CDP mouse events, which also trigger :hover (the +# message edit buttons only reveal on hover) and produce native clicks. + +cdp_mouse_move <- function(b, x, y) { + b$Input$dispatchMouseEvent( + type = "mouseMoved", + x = round(x), + y = round(y) + ) +} + +cdp_mouse_click <- function(b, x, y) { + b$Input$dispatchMouseEvent( + type = "mousePressed", + x = round(x), + y = round(y), + button = "left", + buttons = 1, + clickCount = 1 + ) + b$Input$dispatchMouseEvent( + type = "mouseReleased", + x = round(x), + y = round(y), + button = "left", + buttons = 0, + clickCount = 1 + ) +} + +ease_in_out <- function(t) { + if (t < 0.5) 4 * t^3 else 1 - (-2 * t + 2)^3 / 2 +} + +cursor_start <- function(b) { + vw <- js(b, "window.innerWidth") + vh <- js(b, "window.innerHeight") + svg <- paste0( + "", + "" + ) + js( + b, + sprintf( + "(() => { + let el = document.getElementById('demo-cursor'); + if (!el) { + el = document.createElement('div'); + el.id = 'demo-cursor'; + el.style.cssText = [ + 'position:fixed', 'left:0', 'top:0', 'z-index:2147483647', + 'pointer-events:none', 'will-change:transform', + 'filter:drop-shadow(0 1px 2px rgba(0,0,0,.35))' + ].join(';'); + el.innerHTML = %s; + document.body.appendChild(el); + } + return true; + })()", + jsonlite::toJSON(svg) + ) + ) + cur <- new.env(parent = emptyenv()) + cur$b <- b + # Root CSS zoom scales the rendered position of fixed elements, so cursor + # placement divides client-space coordinates by it. + cur$zoom <- js(b, "parseFloat(document.documentElement.style.zoom) || 1") + cur$x <- vw + 60 + cur$y <- vh * 0.45 + cursor_place(cur) + cur +} + +cursor_place <- function(cur, scale = 1) { + js( + cur$b, + sprintf( + "document.getElementById('demo-cursor').style.transform = 'translate(%fpx, %fpx) scale(%f)'", + cur$x / cur$zoom, + cur$y / cur$zoom, + scale + ) + ) + invisible(cur) +} + +rect_of <- function(b, selector, which = "last") { + sel <- jsonlite::toJSON(selector, auto_unbox = TRUE) + idx <- if (identical(which, "last")) { + "els.length - 1" + } else { + as.integer(which) - 1 + } + r <- js( + b, + sprintf( + "(() => { + const els = document.querySelectorAll(%s); + if (!els.length) return null; + const r = els[%s].getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + })()", + sel, + idx + ) + ) + if (is.null(r)) { + stop("Element not found: ", selector) + } + r +} + +rect_of_text <- function(b, selector, text) { + r <- js( + b, + sprintf( + "(() => { + const needle = %s; + for (const el of document.querySelectorAll(%s)) { + if (!el.textContent.includes(needle)) continue; + const r = el.getBoundingClientRect(); + if (!r.width) continue; + return { x: r.x, y: r.y, w: r.width, h: r.height }; + } + return null; + })()", + jsonlite::toJSON(text, auto_unbox = TRUE), + jsonlite::toJSON(selector, auto_unbox = TRUE) + ) + ) + if (is.null(r)) { + stop("No element matching text: ", text) + } + r +} + +cursor_glideto <- function(cur, x, y, seconds, rec) { + n <- max(1L, round(seconds * rec$fps)) + x0 <- cur$x + y0 <- cur$y + for (i in seq_len(n)) { + e <- ease_in_out(i / n) + cur$x <- x0 + (x - x0) * e + cur$y <- y0 + (y - y0) * e + cursor_place(cur) + cdp_mouse_move(cur$b, cur$x, cur$y) + rec$beat() + } + invisible(cur) +} + +cursor_glideto_el <- function( + cur, + selector, + seconds, + rec, + which = "last", + dx = 0, + dy = 0 +) { + r <- rect_of(cur$b, selector, which = which) + cursor_glideto(cur, r$x + r$w / 2 + dx, r$y + r$h / 2 + dy, seconds, rec) +} + +cursor_leave <- function(cur, rec, seconds = 0.5) { + vw <- js(cur$b, "window.innerWidth") + cursor_glideto(cur, vw + 60, cur$y, seconds, rec) +} + +cursor_click_here <- function(cur, rec) { + cursor_place(cur, scale = 0.8) + rec$beat() + Sys.sleep(0.12) + cdp_mouse_click(cur$b, cur$x, cur$y) + cursor_place(cur) + rec$beat() + invisible(cur) +} + +# Type text one character at a time with human-ish pacing, capturing frames. +type_natural <- function( + b, + rec, + text, + selector = "#chat_user_input .ProseMirror", + min_delay = 0.04, + max_delay = 0.12 +) { + chat_focus(b, selector) + for (ch in strsplit(text, "")[[1]]) { + b$Input$insertText(text = ch) + rec$beat() + Sys.sleep(runif(1, min_delay, max_delay)) + } + invisible(TRUE) +} + +# Select an exact substring inside a contenteditable editor. +select_edit_text <- function( + b, + text, + selector = ".shiny-chat-edit-box .ProseMirror" +) { + js( + b, + sprintf( + "(() => { + const pm = document.querySelector(%s); + pm.focus(); + const walker = document.createTreeWalker(pm, NodeFilter.SHOW_TEXT); + const nodes = []; + let node, full = ''; + while ((node = walker.nextNode())) { nodes.push(node); full += node.textContent; } + const i = full.indexOf(%s); + if (i < 0) return false; + const range = document.createRange(); + let pos = 0, startSet = false; + for (const n of nodes) { + const len = n.textContent.length; + if (!startSet && i < pos + len) { range.setStart(n, i - pos); startSet = true; } + if (i + %d <= pos + len) { range.setEnd(n, i + %d - pos); break; } + pos += len; + } + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + return true; + })()", + jsonlite::toJSON(selector, auto_unbox = TRUE), + jsonlite::toJSON(text, auto_unbox = TRUE), + nchar(text), + nchar(text) + ) + ) +} + +press_backspace <- function(b) { + b$Input$dispatchKeyEvent( + type = "keyDown", + key = "Backspace", + code = "Backspace", + windowsVirtualKeyCode = 8, + nativeVirtualKeyCode = 8 + ) + b$Input$dispatchKeyEvent( + type = "keyUp", + key = "Backspace", + code = "Backspace", + windowsVirtualKeyCode = 8, + nativeVirtualKeyCode = 8 + ) + invisible(TRUE) +} diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_mock-client.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_mock-client.R new file mode 100644 index 000000000..fc199a9db --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/_mock-client.R @@ -0,0 +1,185 @@ +# Mock ellmer client and canned-response helpers shared by the demo apps. +# Modeled on the echo client bundled with shinychat's own examples: no +# network calls are ever made. + +suppressPackageStartupMessages({ + library(shiny) + library(bslib) + library(shinychat) + library(ellmer) + library(coro) +}) + +mock_client <- function(model = "shinychat-mock", name = "Mock") { + stored_turns <- list() + client <- list( + get_turns = function() stored_turns, + set_turns = function(value) { + stored_turns <<- value + invisible(client) + }, + get_system_prompt = function() NULL, + get_tools = function() list(), + get_provider = function() { + ellmer::Provider( + name = name, + base_url = "http://127.0.0.1:1", + model = model + ) + }, + get_model = function() model, + clone = function() mock_client(model = model, name = name) + ) + class(client) <- c("Chat", "R6") + client +} + +mock_record <- function(client, user_text, assistant) { + contents <- if (is.character(assistant)) { + list(ellmer::ContentText(assistant)) + } else { + lapply(assistant, function(piece) { + if (is.character(piece)) ellmer::ContentText(piece) else piece + }) + } + client$set_turns(c( + client$get_turns(), + list( + ellmer::UserTurn(contents = list(ellmer::ContentText(user_text))), + ellmer::AssistantTurn(contents = contents) + ) + )) +} + +mock_stream <- coro::async_generator(function(text, delay = 0.04) { + pieces <- strsplit(text, "(?<=\\s)", perl = TRUE)[[1]] + for (piece in pieces) { + coro::yield(piece) + if (delay > 0) coro::await(coro::async_sleep(delay)) + } +}) + +# A streaming reply that never touches a model +mock_respond <- function( + id, + text, + delay = 0.04, + session = shiny::getDefaultReactiveDomain() +) { + shinychat::chat_append( + id, + mock_stream(text, delay = delay), + session = session + ) +} + +# Converts ellmer content objects (tool requests/results, thinking, ...) +# into the blocks shinychat renders. `contents_shinychat()` is internal +# but stable across the converters shinychat itself uses for streams. +contents_shinychat <- function(content) { + getFromNamespace("contents_shinychat", "shinychat")(content) +} + +mock_tool_def <- function( + name, + description, + title, + arguments = list(), + icon = NULL, + grouping = NULL +) { + ellmer::ToolDef( + function(...) NULL, + name = name, + description = description, + arguments = do.call(ellmer::type_object, arguments), + annotations = list(title = title, icon = icon, grouping = grouping) + ) +} + +mock_tool_request <- function(id, name, arguments, tool = NULL) { + ellmer::ContentToolRequest( + id = id, + name = name, + arguments = arguments, + tool = tool + ) +} + +mock_tool_result <- function(request, value, display = NULL, error = NULL) { + extra <- if (!is.null(display)) list(display = display) else list() + ellmer::ContentToolResult( + value = value, + error = error, + request = request, + extra = extra + ) +} + +# Build the content list for a rich assistant message: markdown strings, +# ellmer content objects, and htmltools tags can be mixed. Pass the result +# to chat_append_message(id, list(role = "assistant", content = msg)). +mock_message <- function(...) { + lapply(list(...), function(item) { + if (inherits(item, "ellmer::Content")) contents_shinychat(item) else item + }) +} + +mock_append_message <- function( + id, + contents, + session = shiny::getDefaultReactiveDomain() +) { + shinychat::chat_append_message( + id, + list(role = "assistant", content = contents), + chunk = FALSE, + session = session + ) +} + +# Stream a rich reply through chat_append(): tool requests appear as running +# activity rows before their results land, plain text streams word by word, +# and asides (inline HTML citations) are yielded whole. `on_done` runs +# server-side after the last chunk, e.g. to open the artifact drawer. +mock_stream_reply <- function( + id, + contents, + block_delay = 1, + word_delay = 0.035, + on_done = NULL, + session = shiny::getDefaultReactiveDomain() +) { + shinychat::chat_append( + id, + coro::async_generator(function() { + for (block in contents) { + if (inherits(block, "ellmer::ContentToolResult")) { + yield(block@request) + coro::await(coro::async_sleep(block_delay / 2)) + yield(block) + coro::await(coro::async_sleep(block_delay / 2)) + } else if (is.character(block) && !startsWith(block, "%s', + label, + url, + span, + paste0(...) + ) +} + +reply <- mock_message( + paste0( + "The training report recommends **a smaller batch size** for the next run ", + aside( + "Internal report", + "https://intranet.example/reports/training", + "a smaller batch size", + "The report recommends reducing the batch size from 256 to 64 after observing ", + "unstable gradients late in training." + ), + ", and suggests **freezing the embedding layer** for the first two epochs ", + aside( + "Experiment log", + "https://wiki.example/experiments/run-42", + "freezing the embedding layer", + "Run 42 froze the embedding layer for two epochs, which reduced warm-up loss ", + "by 12% compared to the baseline." + ), + "." + ) +) + +ui <- page_chat( + "Assistant", + id = "chat", + placeholder = "Ask about the training report..." +) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) input$chat_user_input[[1]] else input$chat_user_input + mock_append_message("chat", reply) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/citations/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/citations/screenshot.R new file mode 100644 index 000000000..a30939312 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/citations/screenshot.R @@ -0,0 +1,54 @@ +source("../_common.R") + +app <- start_app(app_path("citations")) +b <- connect(app, width = 1000, height = 760) + +wait_for_app_ready(b) +set_zoom(b, 1.25) +Sys.sleep(1) + +chat_type(b, "What does the training report recommend?", press_enter = TRUE) +wait_for_text(b, "embedding layer", timeout = 20000) +# The mock streams in chunks; wait until the message text stops changing so +# layout is stable before measuring/screenshotting. +len <- function() { + js(b, "document.querySelector('.shiny-chat-messages').textContent.length") +} +repeat { + n1 <- len() + Sys.sleep(0.7) + n2 <- len() + if (n1 == n2) break +} +Sys.sleep(0.5) + +click_selector(b, ".shiny-aside-pill") +Sys.sleep(0.8) +# Floating-ui positions the popover in unzoomed coordinates, so under the +# emulated page zoom it drifts away from the pill. Re-anchor it just below +# the pill, dividing by the zoom to land at the intended visual position. +js( + b, + "(() => { + const pill = document.querySelector('.shiny-aside-pill'); + const pop = document.querySelector('.shiny-aside-popover'); + if (!pill || !pop) return false; + const p = pill.getBoundingClientRect(); + const z = parseFloat(document.documentElement.style.zoom) || 1; + pop.style.transform = 'none'; + pop.style.left = (p.left / z) + 'px'; + pop.style.top = ((p.bottom + 8) / z) + 'px'; + return true; +})()" +) +Sys.sleep(0.5) +union_png( + b, + shot_path("citations-popover.png"), + c(".shiny-chat-message", ".shiny-aside-popover"), + pad = 32, + dy = 14 +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/app.R new file mode 100644 index 000000000..f3724e3e0 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/app.R @@ -0,0 +1,259 @@ +source("../_mock-client.R") + +Sys.setenv(SHINYCHAT_ASIDE_FAVICON = "false") + +`%||%` <- function(a, b) if (is.null(a)) b else a + +aside <- function(label, url, span, ...) { + sprintf( + '%s', + label, + url, + span, + paste0(...) + ) +} + +tool_defs <- list( + query_observations = mock_tool_def( + name = "query_observations", + description = "Query the survey observation database", + title = "Querying survey observations", + arguments = list(query = ellmer::type_string("The observation query")), + grouping = "all" + ), + read_notebook = mock_tool_def( + name = "read_notebook", + description = "Read an entry from the field notebook", + title = "Reading the field notebook", + arguments = list(path = ellmer::type_string("Notebook path")), + grouping = "all" + ) +) + +counts_reply <- list( + contents = list( + mock_tool_result( + mock_tool_request( + "call-obs-1", + "query_observations", + list(query = "adelie pair counts by month"), + tool_defs$query_observations + ), + "412 rows returned", + display = shinychat::tool_result_display( + title = "Queried survey observations", + label = "adelie pair counts by month", + value_preview = "412 rows" + ) + ), + mock_tool_result( + mock_tool_request( + "call-obs-2", + "read_notebook", + list(path = "notes/may-survey.md"), + tool_defs$read_notebook + ), + "# May survey\n\nCalm seas. Counted 118 active nests at the north colony...", + display = shinychat::tool_result_display( + title = "Read field notebook", + label = "notes/may-survey.md", + value_preview = "18 entries" + ) + ), + "Across the season, Adelie pair counts at Cape Crozier **peaked in May at 118 active nests**", + aside( + "Survey observations", + "https://data.example/penguins/cape-crozier", + "peaked in May at 118 active nests", + "Monthly survey counts, 2026 season. The May count reflects the height of incubation." + ), + ", before declining through June. Field notes attribute the June drop to two storm events that flooded low-lying nests", + aside( + "Field notebook", + "https://notes.example/may-survey", + "flooded low-lying nests", + "From `notes/may-survey.md`: *Calm seas. Counted 118 active nests at the north colony.*", + "\n\nThe June entry records standing water across the terrace after the June 8 storm." + ), + "." + ) +) + +compare_reply <- list( + contents = list( + mock_tool_result( + mock_tool_request( + "call-obs-3", + "query_observations", + list(query = "pairs per colony, 2026 season"), + tool_defs$query_observations + ), + "3 colonies, 223 pairs total", + display = shinychat::tool_result_display( + title = "Queried survey observations", + label = "pairs per colony, 2026 season", + value_preview = "3 colonies" + ) + ), + "The **north colony at Cape Crozier is the largest with 118 active pairs**", + aside( + "Survey observations", + "https://data.example/penguins/colonies", + "largest with 118 active pairs", + "Colony totals for the 2026 season, all three occupied colonies." + ), + ", followed by **64 pairs at Cape Bird** and **41 at Cape Royds**.", + "\n\nCape Royds has declined for three seasons in a row; the field notebook links the drop to storm flooding in consecutive Junes." + ), + drawer_title = "Colony comparison" +) + +fallback_reply <- list( + contents = list( + paste0( + "I'm the research assistant demo. Ask me about the penguin survey data ", + "and I'll query the observation database and the field notebook for you." + ) + ) +) + +reply_for <- function(text) { + text <- tolower(text) + if (grepl("count|survey|observ|season", text)) { + counts_reply + } else if (grepl("coloni|compare", text)) { + compare_reply + } else { + fallback_reply + } +} + +# A small static plot for the artifact drawer +plot_file <- file.path(tempdir(), "colony-counts.png") +grDevices::png(plot_file, width = 720, height = 480, res = 110) +par(mar = c(4.5, 4, 2, 1)) +colonies <- c("Cape Crozier", "Cape Bird", "Cape Royds") +pairs_counted <- c(118, 64, 41) +barplot( + pairs_counted, + names.arg = colonies, + col = "#447099", + border = NA, + ylim = c(0, 130), + ylab = "Adelie pairs counted", + main = "Adelie pairs by colony, 2026 season" +) +grDevices::dev.off() +addResourcePath("assets", dirname(plot_file)) + +drawer_plot <- tags$div( + tags$img( + src = "assets/colony-counts.png", + style = "width: 100%; border-radius: 6px;" + ), + tags$p( + "Count of Adelie breeding pairs at each occupied colony this season.", + style = "font-size: 0.85rem; color: var(--bs-secondary-color);" + ) +) + +greeting_md <- paste0( + "## Penguin research assistant\n\n", + "I can query the survey observation database and read the field notebook.\n\n", + "* What do the observations say about penguin counts?\n", + "* How do the colonies compare?\n" +) + +ui <- page_chat( + "Research assistant", + id = "chat", + icon = tags$span("\U1F427"), + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "new_survey", + "New survey", + icon = bsicons::bs_icon("plus-lg") + ) + ), + pages_navbar = list( + chat_nav_panel( + "Sources", + tags$p("Sources selected during this session appear here.") + ) + ), + sidebar = chat_sidebar(history = TRUE, open = FALSE), + drawer = chat_drawer( + tags$p("Select a result to inspect it here."), + title = "Latest result", + open = FALSE + ), + greeting = greeting_md, + placeholder = "Ask about the penguin survey...", + icon_assistant = FALSE +) + +server <- function(input, output, session) { + client <- mock_client(model = "penguin-assistant", name = "Penguin assistant") + chat_enable_history( + "chat", + client, + options = history_options( + store = "memory", + title = function(recorded_turns) { + first <- Filter(function(t) t$role == "user", recorded_turns) + if (length(first)) { + txt <- first[[1]]$content + if (is.list(txt)) { + txt <- txt[[1]] + } + if (nchar(txt) > 32) { + txt <- paste0(substr(txt, 1, 32), "...") + } + txt + } else { + "New conversation" + } + } + ) + ) + + # New conversations clear the messages but leave a dismissed greeting in + # place; clearing it re-arms greeting_requested so the greeting returns. + observeEvent(input$chat_history_new, { + chat_clear("chat", greeting = TRUE) + }) + observeEvent(input$chat_greeting_requested, { + chat_set_greeting("chat", greeting_md) + }) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) { + input$chat_user_input[[1]] + } else { + input$chat_user_input + } + reply <- reply_for(text) + mock_record(client, text, reply$contents) + mock_stream_reply( + "chat", + reply$contents, + block_delay = 1.6, + word_delay = 0.05, + on_done = function() { + if (is.null(reply$drawer_title)) { + return(invisible(NULL)) + } + chat_drawer_update( + "chat", + drawer_plot, + title = reply$drawer_title, + session = session + ) + chat_drawer_show("chat", session = session) + } + ) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/screenshot.R new file mode 100644 index 000000000..1b8b45868 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/complete-app/screenshot.R @@ -0,0 +1,135 @@ +source("../_common.R") + +app <- start_app(app_path("complete-app")) +b <- connect(app, width = 1200, height = 900) + +wait_for_app_ready(b) +set_zoom(b, 1.1) +Sys.sleep(1) + +stream_done <- function(rec, timeout = 30000) { + rec$loop_until( + "!document.querySelector('.shiny-chat-streaming, .shiny-chat-pending-indicator')", + timeout = timeout + ) +} + +# Blank slate: greeting and suggestions only; the sidebar and drawer start +# closed. The cursor enters from the right, picks the first suggestion, and +# the response streams in with its tool activity rows. +rec <- movie_start(b, fps = 12) +cur <- cursor_start(b) +rec$loop(1) + +cursor_glideto_el( + cur, + ".shiny-chat-greeting .suggestion.submit .shiny-chat-suggestion-list-item-body", + 1.2, + rec +) +cursor_click_here(cur, rec) +rec$loop_until( + "document.body.textContent.includes('peaked in May')", + timeout = 30000 +) +stream_done(rec) +rec$loop(1.2) + +# Mouse retreats while the reader looks over the answer, then opens the +# history sidebar +cursor_leave(cur, rec, 0.5) +rec$loop(0.6) +cursor_glideto_el(cur, ".shiny-chat-page-sidebar-toggle", 0.9, rec) +cursor_click_here(cur, rec) +wait_for( + b, + "!!document.querySelector('.shiny-chat-history-new') && !document.querySelector('.shiny-chat-history-new').disabled" +) +rec$loop(1) + +# Start a new conversation; the greeting returns with its suggestions +cursor_glideto_el(cur, ".shiny-chat-history-new", 0.7, rec) +cursor_click_here(cur, rec) +rec$loop_until( + "!!document.querySelector('.shiny-chat-greeting')", + timeout = 15000 +) +rec$loop(1) + +# Ask a second question: click into the input, type it out, submit with Enter. +# This response opens the artifact drawer with the plot. +cursor_glideto_el(cur, "#chat_user_input .ProseMirror", 0.8, rec) +cursor_click_here(cur, rec) +rec$loop(0.8) +type_natural(b, rec, "How do the colonies compare?") +rec$loop(0.5) +press_enter(b) +rec$loop_until( + "document.body.textContent.includes('Cape Royds')", + timeout = 30000 +) +stream_done(rec) +rec$loop_until( + "(() => { const d = document.querySelector('.shiny-chat-drawer'); return !!d && !d.hidden; })()", + timeout = 15000 +) +rec$loop(1.5) + +# Hide the drawer before changing conversations +cursor_glideto_el(cur, ".shiny-chat-drawer-close", 0.7, rec) +cursor_click_here(cur, rec) +rec$loop(0.8) + +# Return to the first conversation from the history sidebar +r <- rect_of_text( + b, + ".shiny-chat-history-item-select", + "What do the observations say" +) +cursor_glideto(cur, r$x + r$w / 2, r$y + r$h / 2, 0.9, rec) +cursor_click_here(cur, rec) +wait_for_text(b, "peaked in May", timeout = 15000) +rec$loop(1.5) + +# Start another conversation — the greeting returns — then hide the sidebar, +# back to the blank slate the video opened with +cursor_glideto_el(cur, ".shiny-chat-history-new", 0.7, rec) +cursor_click_here(cur, rec) +rec$loop_until( + "!!document.querySelector('.shiny-chat-greeting')", + timeout = 15000 +) +rec$loop(1) +cursor_glideto_el(cur, ".shiny-chat-page-sidebar-toggle", 0.8, rec) +cursor_click_here(cur, rec) +rec$loop(1) + +movie_save(rec, shot_path("complete-app.mp4"), width = 2400) + +# Stills: rebuild the hero state — second conversation in view, sidebar open, +# drawer showing the plot. Asking the colonies question again reopens the +# drawer since drawer state isn't restored from history. +click_selector(b, ".shiny-chat-page-sidebar-toggle") +Sys.sleep(0.8) +chat_type(b, "How do the colonies compare?", press_enter = TRUE) +wait_for_text(b, "Cape Royds", timeout = 30000) +wait_for_gone(b, ".shiny-chat-streaming, .shiny-chat-pending-indicator") +Sys.sleep(1) + +set_viewport(b, width = 1440, height = 920) +set_zoom(b, 1.1) +Sys.sleep(1) +js(b, "if (document.activeElement) document.activeElement.blur()") +Sys.sleep(0.3) +viewport_png(b, shot_path("complete-app.png")) + +# Navigation pages and the artifact drawer: visit the Sources page and +# return home, with the drawer still showing the plot from the last response +click_selector(b, ".shiny-chat-page-nav-link:not(.shiny-chat-page-home-link)") +Sys.sleep(1) +click_selector(b, ".shiny-chat-page-home-link") +Sys.sleep(2.5) +viewport_png(b, shot_path("complete-app-nav-drawer.png")) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/app.R new file mode 100644 index 000000000..9e22372ef --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/app.R @@ -0,0 +1,64 @@ +source("../_mock-client.R") + +replies <- list( + africa = paste0( + "**Mount Kilimanjaro** is the tallest mountain in Africa, at 5,895 meters (19,341 ft) ", + "above sea level. It's a free-standing volcanic massif in Tanzania." + ), + south_america = paste0( + "**Aconcagua** is the tallest mountain in South America, at 6,961 meters (22,838 ft) ", + "above sea level. It's the highest peak anywhere outside Asia." + ), + asia = paste0( + "**Mount Everest** is the tallest mountain in Asia -- and the world -- at 8,849 meters ", + "(29,032 ft) above sea level, on the border between Nepal and China." + ), + fallback = "I'm a demo assistant running on canned responses." +) + +reply_for <- function(text) { + text <- tolower(text) + if (grepl("africa", text)) { + replies$africa + } else if (grepl("asia", text)) { + replies$asia + } else if (grepl("south america", text)) { + replies$south_america + } else { + replies$fallback + } +} + +ui <- page_chat( + "Assistant", + id = "chat", + placeholder = "Ask a question..." +) + +server <- function(input, output, session) { + client <- mock_client() + chat_enable_history( + "chat", + client, + options = history_options( + store = "memory", + title = function(recorded_turns) { + first <- Filter(function(t) t$role == "user", recorded_turns) + if (!length(first)) return("New conversation") + txt <- first[[1]]$content + if (is.list(txt)) txt <- txt[[1]] + if (nchar(txt) > 30) txt <- paste0(substr(txt, 1, 30), "...") + txt + } + ) + ) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) input$chat_user_input[[1]] else input$chat_user_input + mock_respond("chat", reply_for(text)) + mock_record(client, text, reply_for(text)) + mock_save_history("chat") + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/screenshot.R new file mode 100644 index 000000000..6aad2dfb9 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/edit-branches/screenshot.R @@ -0,0 +1,148 @@ +source("../_common.R") + +app <- start_app(app_path("edit-branches")) +b <- connect(app, width = 1000, height = 760) + +wait_for_app_ready(b) +set_zoom(b, 1.25) +Sys.sleep(1) + +ask <- function(text, wait_for) { + chat_type(b, text, press_enter = TRUE) + wait_for_text(b, wait_for, timeout = 20000) + Sys.sleep(0.5) +} + +ask("What is the tallest mountain in Africa?", "Kilimanjaro") +ask("And what is the tallest in South America?", "Aconcagua") + +click_last <- function(b, selector) { + sel <- jsonlite::toJSON(selector, auto_unbox = TRUE) + clicked <- js( + b, + sprintf( + "(() => { + const els = document.querySelectorAll(%s); + if (!els.length) return false; + const el = els[els.length - 1]; + el.scrollIntoView({ block: 'center' }); + el.click(); + return true; + })()", + sel + ) + ) + if (!isTRUE(clicked)) { + stop("Element not found: ", selector) + } + invisible(TRUE) +} + +# Movie needs an exactly 4:3 visual viewport (embedded with aspect-ratio 4:3) +set_viewport(b, 1200, 900) +set_zoom(b, 1.25) + +rec <- movie_start(b, fps = 12) +cur <- cursor_start(b) + +# Record only the conversation (no header, no input), still exactly 4:3 +rec$clip <- movie_clip_fit( + b, + c(".shiny-chat-user-message", ".shiny-chat-message"), + pad = 24, + container = ".shiny-chat-messages" +) + +rec$loop(0.8) + +# Mouse enters from the right edge and hovers the second user message, which +# reveals its edit button +r <- rect_of(b, ".shiny-chat-user-message") +cursor_glideto(cur, r$x + r$w * 0.85, r$y + r$h / 2, 0.9, rec) +wait_for( + b, + "(() => { + const els = document.querySelectorAll('.shiny-chat-edit-btn'); + return els.length && getComputedStyle(els[els.length - 1]).opacity === '1'; +})()" +) +rec$loop(0.4) + +cursor_glideto_el(cur, ".shiny-chat-edit-btn", 0.5, rec) +cursor_click_here(cur, rec) +wait_for(b, "!!document.querySelector('.shiny-chat-edit-box .ProseMirror')") +rec$loop(0.5) + +select_edit_text(b, "South America?") +rec$loop(0.6) +press_backspace(b) +wait_for( + b, + "!document.querySelector('.shiny-chat-edit-box .ProseMirror').textContent.includes('South America')", + timeout = 5000 +) +rec$loop(0.3) +type_natural(b, rec, "Asia?", selector = ".shiny-chat-edit-box .ProseMirror") +rec$loop(0.4) + +cursor_glideto_el(cur, ".shiny-chat-edit-box .shiny-chat-btn-send", 0.6, rec) +cursor_click_here(cur, rec) +rec$loop_until( + "!document.querySelector('.shiny-chat-edit-box')", + timeout = 5000 +) +rec$loop_until("document.body.textContent.includes('Everest')", timeout = 20000) +rec$loop(0.8) +rec$loop_until( + "!!document.querySelector('button[aria-label=\"Previous version\"]')", + timeout = 10000 +) + +# Mouse retreats off the right edge, then returns for the branch navigation +cursor_leave(cur, rec, 0.5) +rec$loop(0.4) + +cursor_glideto_el(cur, 'button[aria-label="Previous version"]', 0.9, rec) +cursor_click_here(cur, rec) +rec$loop(1.2) + +cursor_glideto_el(cur, 'button[aria-label="Next version"]', 0.7, rec) +cursor_click_here(cur, rec) +rec$loop(1.5) + +movie_save( + rec, + shot_path("edit-branches-edit.mp4"), + width = rec$clip$width * 2 +) + +wait_for_text(b, "Everest", timeout = 20000) +Sys.sleep(1) +union_png( + b, + shot_path("edit-branches-new.png"), + c( + ".shiny-chat-user-message", + ".shiny-chat-message", + 'button[aria-label="Previous version"]' + ), + pad = 32, + dy = 14 +) + +click_selector(b, 'button[aria-label="Previous version"]') +Sys.sleep(1) +union_png( + b, + shot_path("edit-branches-original.png"), + c( + ".shiny-chat-user-message", + ".shiny-chat-message", + 'button[aria-label="Previous version"]' + ), + pad = 32, + dy = 14 +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-stream/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-stream/app.R new file mode 100644 index 000000000..dffa84181 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-stream/app.R @@ -0,0 +1,51 @@ +source("../_mock-client.R") + +# The greeting the "model" writes: a variation of the penguin assistant +# greeting from greeting-suggestions. Text blocks stream word by word; the +# suggestion list items are yielded whole so the cards pop in as cards rather +# than as half-written HTML. +greeting_blocks <- c( + "## Welcome to the penguins assistant!\n\n", + paste0( + "Hi! I'm ready to help you explore the penguins dataset. ", + "What would you like to do?\n\n" + ), + "* Summarize the penguins dataset\n", + "* Plot bill length by species\n" +) + +greeting_stream <- coro::async_generator( + function(start_delay = 2, word_delay = 0.09, card_delay = 0.35) { + coro::await(coro::async_sleep(start_delay)) + for (block in greeting_blocks) { + if (grepl(" { const c = document.querySelector('.shiny-chat-composer'); ", + "return !!c && getComputedStyle(c).opacity === '1'; })()" +) + +# Pass 1: let the greeting stream to completion so its final layout sizes the +# movie clip. The greeting and composer are vertically centered, so measuring +# the empty chat would put the clip in the wrong place. +wait_for_app_ready(b) +set_zoom(b, zoom) +wait_for_text(b, "Plot bill length by species") +wait_for(b, composer_settled) +Sys.sleep(0.5) + +clip <- movie_clip_fit( + b, + c(".shiny-chat-greeting", "#chat_user_input"), + pad = 32, + container = ".shiny-chat-messages" +) + +# Pass 2: fresh session, same clip. The greeting streams again on load -- +# lead-in on the empty chat, the welcome message arrives word by word, then +# the suggestion cards appear. +b$Page$navigate(app$url) +wait_for_app_ready(b) +set_zoom(b, zoom) +wait_for(b, composer_settled) +Sys.sleep(0.3) + +rec <- movie_start(b, fps = 12) +rec$clip <- clip + +rec$loop(0.6) +rec$loop_until( + "document.body.textContent.includes('Plot bill length by species')", + timeout = 20000 +) +rec$loop(1.5) + +movie_save(rec, shot_path("greeting-stream.mp4"), width = clip$width * 2) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/app.R new file mode 100644 index 000000000..450b229a9 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/app.R @@ -0,0 +1,68 @@ +source("../_mock-client.R") + +greeting <- paste0( + "## Welcome to the penguins assistant!\n\n", + "What would you like to do?\n\n", + "* Get a quick summary of the penguins dataset\n", + "* Plot bill length by species\n", + "* Explain what my R script does\n" +) + +replies <- list( + summarize = paste0( + "Here's a quick summary of the **palmerpenguins** dataset:\n\n", + "| Species | N | Mean bill length (mm) |\n", + "|---|---|---|\n", + "| Adelie | 152 | 38.8 |\n", + "| Chinstrap | 68 | 48.8 |\n", + "| Gentoo | 124 | 47.6 |\n\n", + "Ask me to summarize a subset, or say \"Create a plot\" to see the distribution." + ), + plot = paste0( + "Here's what I can tell you about **bill length by species**:\n\n", + "- *Gentoo* penguins have the longest bills on average (47.6 mm).\n", + "- *Adelie* penguins have the shortest (38.8 mm).\n\n", + "Say **Create a plot** again and I'll draw a box plot of the distribution." + ), + explain = paste0( + "Paste your script in a message (or attach it with the paperclip) and I'll ", + "walk through it block by block." + ), + fallback = "I'm a demo assistant running on canned responses -- try one of the suggestions!" +) + +reply_for <- function(text) { + text <- tolower(text) + if (grepl("summar", text)) { + replies$summarize + } else if (grepl("plot|bill", text)) { + replies$plot + } else if (grepl("explain", text)) { + replies$explain + } else { + replies$fallback + } +} + +ui <- page_chat( + "Assistant", + id = "chat", + greeting = greeting, + placeholder = "Ask about the penguins dataset...", + allow_attachments = TRUE +) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) { + input$chat_user_input[[1]] + } else { + input$chat_user_input + } + mock_respond("chat", reply_for(text)) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/screenshot.R new file mode 100644 index 000000000..03ef7cfae --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/greeting-suggestions/screenshot.R @@ -0,0 +1,106 @@ +source("../_common.R") + +app <- start_app(app_path("greeting-suggestions")) +b <- connect(app, width = 1100, height = 720) + +wait_for_app_ready(b) +set_zoom(b, 1.2) +# Let the composer finish its entrance fade so the input isn't caught +# mid-animation in the greeting screenshot +wait_for( + b, + "(() => { const c = document.querySelector('.shiny-chat-composer'); + return !!c && getComputedStyle(c).opacity === '1'; })()" +) +Sys.sleep(0.5) + +# 1. Fresh chat: greeting with three suggestion cards. Focus the input so +# the composer shows its enabled look instead of the idle dimmed style. +chat_focus(b) +Sys.sleep(1) +union_png( + b, + shot_path("greeting-suggestions-greeting.png"), + c(".shiny-chat-greeting", "#chat_user_input"), + pad = 32 +) + +# 2. A suggestion clicked fills the input; the greeting stays put +click_selector( + b, + ".shiny-chat-greeting .suggestion:not(.submit) .shiny-chat-suggestion-list-item-body" +) +Sys.sleep(0.5) +union_png( + b, + shot_path("greeting-suggestions-fill-input.png"), + c(".shiny-chat-greeting", "#chat_user_input"), + pad = 32 +) + +# 3. An attached plot and a prompt, clipped to the left side of the composer +# so the attach button and the attachment chip are the focus +plot_file <- file.path(tempdir(), "penguin-bill-length.png") +ggplot2::ggsave( + plot_file, + ggplot2::ggplot( + palmerpenguins::penguins, + ggplot2::aes(x = species, y = bill_length_mm, fill = species) + ) + + ggplot2::geom_boxplot() + + ggplot2::labs(y = "Bill length (mm)") + + ggplot2::theme_minimal(base_size = 14), + width = 5, + height = 3.5, + dpi = 150, + bg = "white" +) +b$Page$navigate(app$url) +wait_for_app_ready(b) +set_zoom(b, 1.2) +wait_for( + b, + "(() => { const c = document.querySelector('.shiny-chat-composer'); + return !!c && getComputedStyle(c).opacity === '1'; })()" +) +Sys.sleep(0.5) + +doc <- b$DOM$getDocument() +file_node <- b$DOM$querySelector(doc$root$nodeId, "input[type=file]") +b$DOM$setFileInputFiles(files = list(plot_file), nodeId = file_node$nodeId) +wait_for( + b, + "(() => { const a = document.querySelector('.shiny-chat-input-attachments'); + return !!a && a.children.length > 0; })()" +) +# The input gets a disabled class while the attachment processes +wait_for( + b, + "!document.querySelector('.shiny-chat-input').className.includes('disabled')" +) +Sys.sleep(0.3) +chat_type(b, "Explain this plot") +Sys.sleep(0.5) + +clip <- js( + b, + "(() => { + const c = document.querySelector('.shiny-chat-composer').getBoundingClientRect(); + return { x: c.x + window.scrollX, y: c.y + window.scrollY, w: c.width, h: c.height }; +})()" +) +save_png( + b, + shot_path("attachments-plot.png"), + list( + x = clip$x - 12, + y = clip$y - 12, + width = clip$w * 0.62, + height = clip$h + 24, + scale = 1 + ), + beyond = FALSE +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/app.R new file mode 100644 index 000000000..58b5a8621 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/app.R @@ -0,0 +1,68 @@ +source("../_mock-client.R") + +replies <- list( + mountain_africa = paste0( + "**Mount Kilimanjaro** is the tallest mountain in Africa, at 5,895 meters (19,341 ft) ", + "above sea level. It's a free-standing volcanic massif in Tanzania." + ), + pivot_r = paste0( + "To make data longer in R, use `tidyr::pivot_longer()`:\n\n", + "``` r\n", + "tidyr::pivot_longer(df, cols = -id, names_to = \"key\", values_to = \"value\")\n", + "```\n\n", + "Give it the columns to stack and names for the new key and value columns." + ), + recycling = paste0( + "In R, **vector recycling** repeats a shorter vector when it's combined with a longer one. ", + "If the lengths aren't multiples, R warns and still recycles. Prefer `vctrs::vec_recycle()` ", + "when you need explicit control." + ), + fallback = "I'm a demo assistant running on canned responses." +) + +reply_for <- function(text) { + text <- tolower(text) + if (grepl("mountain", text)) { + replies$mountain_africa + } else if (grepl("pivot|longer", text)) { + replies$pivot_r + } else if (grepl("recycl", text)) { + replies$recycling + } else { + replies$fallback + } +} + +ui <- page_chat( + "Assistant", + id = "chat", + placeholder = "Ask a question..." +) + +server <- function(input, output, session) { + client <- mock_client() + chat_enable_history( + "chat", + client, + options = history_options( + store = "memory", + title = function(recorded_turns) { + first <- Filter(function(t) t$role == "user", recorded_turns) + if (!length(first)) return("New conversation") + txt <- first[[1]]$content + if (is.list(txt)) txt <- txt[[1]] + if (nchar(txt) > 30) txt <- paste0(substr(txt, 1, 30), "...") + txt + } + ) + ) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) input$chat_user_input[[1]] else input$chat_user_input + mock_respond("chat", reply_for(text)) + mock_record(client, text, reply_for(text)) + mock_save_history("chat") + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/screenshot.R new file mode 100644 index 000000000..7282b7bdf --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/history/screenshot.R @@ -0,0 +1,104 @@ +source("../_common.R") + +app <- start_app(app_path("history")) +b <- connect(app, width = 1440, height = 800) + +wait_for_app_ready(b) +set_zoom(b, 1.15) +Sys.sleep(1) + +open_history <- function() { + click_selector(b, ".shiny-chat-page-sidebar-toggle") + Sys.sleep(0.6) +} + +new_conversation <- function() { + click_selector(b, ".shiny-chat-history-new") + Sys.sleep(0.6) +} + +ask <- function(text, wait_for) { + chat_type(b, text, press_enter = TRUE) + wait_for_text(b, wait_for, timeout = 20000) + Sys.sleep(0.5) +} + +drawer_png <- function(path, pad = 16) { + union_png( + b, + path, + c( + ".shiny-chat-history-toprow", + ".shiny-chat-history-item", + ".shiny-chat-history-menu" + ), + pad = pad, + # captureBeyondViewport's re-render pass distorts the sidebar while the + # search field is focused; these clips are inside the viewport anyway + beyond = FALSE + ) +} + +open_history() + +# Conversation 1 +ask("What is the tallest mountain in Africa?", "Tanzania") + +# Conversation 2 +new_conversation() +ask("How do I pivot a data frame to longer format in R?", "value columns") + +# Conversation 3 +new_conversation() +ask("Explain vector recycling in R", "explicit control") + +# The history drawer with several saved conversations +Sys.sleep(0.5) +viewport_png(b, shot_path("history-list.png")) + +# Searching conversations: the field ignores synthetic input events, +# so type through CDP key events instead +js(b, "document.querySelector('.shiny-chat-history-search').focus()") +Sys.sleep(0.3) +b$Input$insertText(text = "pivot") +# Wait until the list is actually filtered down to the pivot conversation +wait_for( + b, + "(() => { + const drawer = document.querySelector('.shiny-chat-history'); + if (!drawer) return false; + const els = [...drawer.querySelectorAll('*')].filter( + el => el.offsetParent && el.children.length === 0 && + el.textContent.includes('tallest') + ); + return els.length === 0; +})()", + timeout = 15000 +) +Sys.sleep(0.5) +drawer_png(shot_path("history-search.png")) + +# Clear the search and open a conversation's actions menu +for (i in 1:8) { + b$Input$dispatchKeyEvent( + type = "keyDown", + key = "Backspace", + code = "Backspace", + windowsVirtualKeyCode = 8, + nativeVirtualKeyCode = 8 + ) + b$Input$dispatchKeyEvent( + type = "keyUp", + key = "Backspace", + code = "Backspace", + windowsVirtualKeyCode = 8, + nativeVirtualKeyCode = 8 + ) +} +Sys.sleep(0.8) +click_selector(b, 'button[aria-label="Conversation actions"]') +Sys.sleep(0.8) +drawer_png(shot_path("history-actions-menu.png")) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/app.R new file mode 100644 index 000000000..c939be242 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/app.R @@ -0,0 +1,70 @@ +source("../_mock-client.R") + +addResourcePath("assets", getwd()) + +greeting <- paste0( + "\"shinychat\n\n", + "## Complete chat applications in shinychat\n\n", + "Bringing history, greetings, suggestions, ", + "citations, and tools together around the conversation.\n\n", + "* New in v0.5.0\n", + "* New in v0.7.1\n" +) + +greeting_css <- " +:root { + --bs-body-bg: #E8EDF2; + --bs-body-color: #294A62; + --bs-secondary-color: #294a62c8; +} +.shiny-chat-greeting-content p { + text-wrap: balance; +} +.shiny-chat-page-header { display: none; } +body, +shiny-chat-page, +.shiny-chat-page-body, +.shiny-chat-page-main, +.shiny-chat-page-panel { + --shiny-chat-page-canvas-bg: #E8EDF2; + --shiny-chat-page-surface-bg: #E8EDF2; + background-color: #E8EDF2; +} +.shiny-chat-greeting { text-align: center; } +.shiny-chat-greeting-content img { margin: 0 auto 0.5rem; } +.shiny-chat-greeting-content .shiny-chat-suggestion-list { justify-content: center; } +.shiny-chat-greeting-content .shiny-chat-suggestion-list-item { text-align: left; } +shiny-chat-container .shiny-chat-suggestion-list .shiny-chat-suggestion-list-item { + background-color: #f9fafb; +} +.form-control { + background-color: #f9fafb; +} +" + +ui <- page_chat( + tags$style(HTML(greeting_css)), + "shinychat", + id = "chat", + greeting = greeting, + placeholder = "Ask about building chat apps with shinychat...", + icon_assistant = FALSE +) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) { + input$chat_user_input[[1]] + } else { + input$chat_user_input + } + mock_respond( + "chat", + "This is a demo assistant running on canned responses. See the blog post for the real thing!" + ) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/hex.svg b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/hex.svg new file mode 100644 index 000000000..6cba45e32 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/hex.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/screenshot.R new file mode 100644 index 000000000..0aae25c50 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/og-header/screenshot.R @@ -0,0 +1,20 @@ +source("../_common.R") + +app <- start_app(app_path("og-header")) +b <- connect(app, width = 1200, height = 630) + +wait_for_app_ready(b) +set_zoom(b, 1.4) +# Let the composer finish its entrance fade so the input isn't caught +# mid-animation in the screenshot +wait_for( + b, + "(() => { const c = document.querySelector('.shiny-chat-composer'); + return !!c && getComputedStyle(c).opacity === '1'; })()" +) +Sys.sleep(0.5) + +viewport_png(b, shot_path("og-header.png")) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/run-all.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/run-all.R new file mode 100644 index 000000000..ad1b7072f --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/run-all.R @@ -0,0 +1,37 @@ +# Run every demo's screenshot.R in its own R process, in sequence. + +file_arg <- grep("^--file=", commandArgs(FALSE), value = TRUE) +file_arg <- sub("^--file=", "", file_arg) +if (!length(file_arg)) { + stop("Run with Rscript: Rscript apps/run-all.R") +} +apps_dir <- dirname(normalizePath(file_arg[1])) + +demos <- sort(list.dirs(apps_dir, recursive = FALSE)) +demos <- demos[!grepl("_", basename(demos))] + +results <- lapply(demos, function(dir) { + message("Running ", basename(dir), "...") + t0 <- Sys.time() + callr::r( + function(d) { + setwd(d) + source("screenshot.R") + }, + args = list(d = dir), + stdout = "|", + stderr = "|", + show = TRUE + ) + message( + "Done ", + basename(dir), + " in ", + round(as.numeric(difftime(Sys.time(), t0, units = "secs")), 1), + "s" + ) + TRUE +}) + +names(results) <- basename(demos) +invisible(results) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/app.R new file mode 100644 index 000000000..616d5d4da --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/app.R @@ -0,0 +1,40 @@ +source("../_mock-client.R") + +ui <- page_chat( + "Assistant", + id = "chat", + placeholder = "Ask a question..." +) + +server <- function(input, output, session) { + client <- mock_client() + chat <- chat_server("chat", client, history = FALSE) + + chat$slash_command("help", "Show what this assistant can do", function() { + chat$append( + paste0( + "Here's what I can help with:\n\n", + "- **/search topic** -- search the documentation and send a richer prompt\n", + "- **/clear** -- clear the conversation without calling the model\n", + "- **/help** -- show this guidance again" + ), + role = "assistant" + ) + }, echo = FALSE) + + chat$slash_command("search", "Search the documentation", function(content) { + chat$append( + paste0( + "Searching the documentation for **", content$user_text, "**...\n\n", + "Found 3 relevant pages. I'll include summaries from each in my next response." + ), + role = "assistant" + ) + }, echo = TRUE) + + chat$slash_command("clear", "Clear the conversation", function() { + chat$clear() + }, echo = FALSE) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/screenshot.R new file mode 100644 index 000000000..02a014d65 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/slash-commands/screenshot.R @@ -0,0 +1,21 @@ +source("../_common.R") + +app <- start_app(app_path("slash-commands")) +b <- connect(app, width = 1000, height = 760) + +wait_for_app_ready(b) +set_zoom(b, 1.25) +Sys.sleep(1) + +chat_type(b, "/") +wait_for(b, "!!document.querySelector('.shiny-chat-slash-palette')") +Sys.sleep(0.8) +union_png( + b, + shot_path("slash-commands-palette.png"), + c(".shiny-chat-slash-palette", "#chat_user_input"), + pad = 24 +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/app.R new file mode 100644 index 000000000..e5b45314e --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/app.R @@ -0,0 +1,49 @@ +source("../_mock-client.R") + +thinking_text <- paste0( + "The user is asking about penguin body mass. I should compare the three ", + "commonly studied species, note that Gentoos are the heaviest on average, ", + "and mention that males outweigh females." +) + +answer_text <- paste0( + "Gentoo penguins are the heaviest of the three species commonly found on ", + "the Antarctic Peninsula, averaging **5.5 kg**. Chinstrap penguins average ", + "**4.6 kg**, and Adelie penguins average **4.3 kg**. Within each species, ", + "males typically weigh 10--20% more than females." +) + +ui <- page_chat( + "Assistant", + id = "chat", + placeholder = "Ask a question...", + enable_cancel = TRUE +) + +thinking_stream <- coro::async_generator(function() { + pieces <- strsplit(thinking_text, "(?<=\\s)", perl = TRUE)[[1]] + n <- length(pieces) + group <- ceiling(n / 20) + for (i in seq(1, n, by = group)) { + yield(structure( + paste(pieces[i:min(i + group - 1, n)], collapse = ""), + class = "shinychat_thinking" + )) + await(coro::async_sleep(0.25)) + } + answer_pieces <- strsplit(answer_text, "(?<=\\s)", perl = TRUE)[[1]] + for (piece in answer_pieces) { + yield(piece) + await(coro::async_sleep(0.05)) + } +}) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$chat_user_input, { + chat_append("chat", thinking_stream()) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/screenshot.R new file mode 100644 index 000000000..246859a3e --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/thinking/screenshot.R @@ -0,0 +1,63 @@ +source("../_common.R") + +app <- start_app(app_path("thinking")) +b <- connect(app, width = 1000, height = 760) + +wait_for_app_ready(b) +set_zoom(b, 1.25) +Sys.sleep(1) + +chat_type(b, "Which penguin species is the heaviest?", press_enter = TRUE) + +# While the model is thinking, the send button becomes a stop button. +# Wide clip: the stop button with some of the input box for context. +wait_for( + b, + "!!document.querySelector('.shiny-chat-btn-send[data-state=\"cancel\"]')", + timeout = 10000 +) +Sys.sleep(1) +clip <- js( + b, + "(() => { + const i = document.querySelector('.shiny-chat-input').getBoundingClientRect(); + const c = document.querySelector('.shiny-chat-btn-send[data-state=\"cancel\"]').getBoundingClientRect(); + return { + x: i.x + window.scrollX, y: Math.min(i.y, c.y) + window.scrollY, + right: Math.max(i.right, c.right) + window.scrollX, + bottom: Math.max(i.bottom, c.bottom) + window.scrollY + }; +})()" +) +save_png( + b, + shot_path("streaming-stop.png"), + list( + x = clip$x + (clip$right - clip$x) * 0.38, + y = clip$y - 12, + width = clip$right - clip$x - (clip$right - clip$x) * 0.38 + 12, + height = clip$bottom - clip$y + 24, + scale = 1 + ) +) + +# The thinking panel collapses on its own once the answer starts; wait for +# the completed "Thought for Ns" label before capturing +wait_for_gone( + b, + ".shiny-chat-btn-send[data-state=\"cancel\"]", + timeout = 30000 +) +wait_for_text(b, "Thought for ", timeout = 15000) +wait_for_gone(b, ".shiny-chat-pending-indicator, .shiny-chat-streaming") +Sys.sleep(1) +union_png( + b, + shot_path("thinking-collapsed.png"), + c(".shiny-chat-user-message", ".shiny-chat-thinking", ".shiny-chat-message"), + pad = 32, + dy = 14 +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/app.R new file mode 100644 index 000000000..7966646bf --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/app.R @@ -0,0 +1,85 @@ +source("../_mock-client.R") + +tool_defs <- list( + run_sql = mock_tool_def( + name = "run_sql", + description = "Run a read-only SQL query against the sales warehouse", + title = "Running SQL query", + arguments = list(query = ellmer::type_string("The SQL query to run")) + ), + read_schema = mock_tool_def( + name = "read_schema", + description = "Read the schema of a table in the sales warehouse", + title = "Reading table schema", + arguments = list(table = ellmer::type_string("Table name")) + ) +) + +reply <- mock_message( + mock_tool_result( + mock_tool_request( + "call-sql-1", + "run_sql", + list(query = "SELECT region, SUM(amount) FROM sales_2026 GROUP BY region"), + tool_defs$run_sql + ), + "region,total\nEast,412300\nWest,387150\nNorth,298400\nSouth,275900\nCentral,220050", + display = shinychat::tool_result_display( + title = "Run SQL query", + label = "SELECT region, SUM(amount) FROM sales_2026...", + value_preview = "5 rows" + ) + ), + mock_tool_result( + mock_tool_request( + "call-sql-2", + "run_sql", + list(query = "SELECT COUNT(*) FROM orders WHERE quarter = 'Q2'"), + tool_defs$run_sql + ), + "1834", + display = shinychat::tool_result_display( + title = "Run SQL query", + label = "SELECT COUNT(*) FROM orders WHERE...", + value_preview = "1,834" + ) + ), + mock_tool_result( + mock_tool_request( + "call-schema-1", + "read_schema", + list(table = "sales_2026"), + tool_defs$read_schema + ), + "order_id TEXT, region TEXT, amount NUMERIC, quarter TEXT, ...", + display = shinychat::tool_result_display( + title = "Read table schema", + label = "sales_2026", + value_preview = "8 columns" + ) + ), + paste0( + "Here's the regional picture for 2026 so far:\n\n", + "| Region | Sales |\n|---|---|\n| East | $412,300 |\n| West | $387,150 |\n", + "| North | $298,400 |\n| South | $275,900 |\n| Central | $220,050 |\n\n", + "The **East** region leads, and there are **1,834 orders** in Q2. ", + "Want me to break any region down by product line?" + ) +) + +ui <- page_chat( + "Sales assistant", + id = "chat", + placeholder = "Ask about the sales warehouse..." +) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) input$chat_user_input[[1]] else input$chat_user_input + mock_append_message("chat", reply) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/screenshot.R new file mode 100644 index 000000000..379ed3df3 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/tool-calls/screenshot.R @@ -0,0 +1,62 @@ +source("../_common.R") + +app <- start_app(app_path("tool-calls")) +b <- connect(app, width = 1000, height = 780) + +wait_for_app_ready(b) +set_zoom(b, 1.25) +Sys.sleep(1) + +chat_type(b, "How did each region perform this year?", press_enter = TRUE) +wait_for_text(b, "break any region down by product line", timeout = 20000) +wait_for_gone(b, ".shiny-chat-pending-indicator, .shiny-chat-streaming") +Sys.sleep(3) + +# Scroll the chat back to the top so the user question is in frame and the +# scroll-to-bottom affordance is gone before capturing. +js( + b, + "(() => { const m = document.querySelector('.shiny-chat-messages'); if (m) m.scrollTop = 0; })()" +) +Sys.sleep(0.5) + +union_png( + b, + shot_path("tool-calls-collapsed.png"), + ".shiny-chat-messages", + pad = 32, + dy = 14 +) + +click_selector(b, ".shiny-chat-tool-group__row") +wait_for( + b, + "(() => { const ul = document.querySelector('.shiny-chat-tool-group__calls'); return !!ul && !ul.hidden && getComputedStyle(ul).display !== 'none'; })()", + timeout = 5000 +) +Sys.sleep(0.8) +union_png( + b, + shot_path("tool-calls-expanded.png"), + ".shiny-chat-messages", + pad = 32, + dy = 14 +) + +click_selector(b, ".shiny-chat-tool-call-row__summary") +wait_for( + b, + "(() => { const d = document.querySelector('.shiny-chat-tool-call-row__detail'); return !!d && !d.hidden && d.getBoundingClientRect().height > 20; })()", + timeout = 5000 +) +Sys.sleep(0.8) +union_png( + b, + shot_path("tool-calls-result.png"), + ".shiny-chat-messages", + pad = 32, + dy = 14 +) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/app.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/app.R new file mode 100644 index 000000000..31b675f60 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/app.R @@ -0,0 +1,91 @@ +source("../_mock-client.R") + +ui <- page_chat( + "Research assistant", + id = "chat", + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "clear_chat", + "Clear conversation", + icon = bsicons::bs_icon("arrow-counterclockwise") + ) + ), + toolbar_global = bslib::toolbar( + bslib::toolbar_input_button( + "help", + "Help", + icon = bsicons::bs_icon("question-circle") + ), + bslib::toolbar_input_button( + "show_settings", + "Answer settings", + icon = bsicons::bs_icon("gear") + ) + ), + toolbar_input = bslib::toolbar( + shiny::selectInput( + "response_style", + label = NULL, + choices = c("Concise", "Detailed"), + width = "150px" + ) + ), + pages_navbar = list( + chat_nav_panel( + "Sources", + tags$div( + tags$p("Sources selected during this session appear here."), + tags$ul( + tags$li(tags$a(href = "#", "Survey observations (2026 season)")), + tags$li(tags$a(href = "#", "Field notebook, May")) + ) + ), + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "refresh_sources", + "Refresh", + icon = bsicons::bs_icon("arrow-repeat") + ) + ) + ) + ), + greeting = paste0( + "## Research assistant\n\n", + "Ask about the study, or open the **Sources** page to see what I'm reading from." + ), + placeholder = "Ask about the study..." +) + +server <- function(input, output, session) { + chat_server("chat", mock_client(), history = FALSE) + + observeEvent(input$show_settings, { + bslib::show_offcanvas( + bslib::offcanvas( + title = "Answer settings", + id = "answer_settings", + placement = "right", + sliderInput("length", "Target length", 100, 1000, 400), + checkboxInput("citations", "Request citations", TRUE) + ), + session = session + ) + }) + + observeEvent(input$chat_user_input, { + text <- if (is.list(input$chat_user_input)) { + input$chat_user_input[[1]] + } else { + input$chat_user_input + } + mock_respond( + "chat", + paste0( + "The study tracked monthly surveys across the 2026 season. ", + "Open the **Sources** page to see the underlying documents." + ) + ) + }) +} + +shinyApp(ui, server) diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/screenshot.R b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/screenshot.R new file mode 100644 index 000000000..69a448be4 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/apps/toolbars/screenshot.R @@ -0,0 +1,24 @@ +source("../_common.R") + +app <- start_app(app_path("toolbars")) +b <- connect(app, width = 1440, height = 760) + +wait_for_app_ready(b) +set_zoom(b, 1.15) +Sys.sleep(3) + +# Home page with an active conversation: page-scoped toolbar, global +# toolbar, and the input toolbar +chat_type(b, "What did the study find?", press_enter = TRUE) +wait_for_text(b, "monthly surveys", timeout = 20000) +Sys.sleep(1.5) +viewport_png(b, shot_path("toolbars-home.png")) + +# The global toolbar settings button opens the answer settings offcanvas +click_selector(b, "#show_settings") +wait_for(b, "!!document.querySelector('.offcanvas.show')") +Sys.sleep(1) +viewport_png(b, shot_path("toolbars-offcanvas.png")) + +stop_app(app) +b$close() diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/attachments-plot.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/attachments-plot.png new file mode 100644 index 000000000..88a058529 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/attachments-plot.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/citations-popover.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/citations-popover.png new file mode 100644 index 000000000..97615e693 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/citations-popover.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app-nav-drawer.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app-nav-drawer.png new file mode 100644 index 000000000..ff0f4c520 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app-nav-drawer.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.mp4 b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.mp4 new file mode 100644 index 000000000..18b56bf93 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.mp4 differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.png new file mode 100644 index 000000000..ef18f5f3c Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/complete-app.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-edit.mp4 b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-edit.mp4 new file mode 100644 index 000000000..86765bf56 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-edit.mp4 differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-new.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-new.png new file mode 100644 index 000000000..4e7460219 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-new.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-original.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-original.png new file mode 100644 index 000000000..22ef9e645 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/edit-branches-original.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-stream.mp4 b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-stream.mp4 new file mode 100644 index 000000000..0f83dcf23 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-stream.mp4 differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-fill-input.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-fill-input.png new file mode 100644 index 000000000..9679e05ee Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-fill-input.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-greeting.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-greeting.png new file mode 100644 index 000000000..74e9ae1f4 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/greeting-suggestions-greeting.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-actions-menu.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-actions-menu.png new file mode 100644 index 000000000..f4037a43c Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-actions-menu.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-list.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-list.png new file mode 100644 index 000000000..792604e19 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-list.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-search.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-search.png new file mode 100644 index 000000000..741dc2930 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/history-search.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/og-header.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/og-header.png new file mode 100644 index 000000000..0b47429c3 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/og-header.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/slash-commands-palette.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/slash-commands-palette.png new file mode 100644 index 000000000..308a7f33f Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/slash-commands-palette.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/streaming-stop.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/streaming-stop.png new file mode 100644 index 000000000..0249c61ac Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/streaming-stop.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/thinking-collapsed.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/thinking-collapsed.png new file mode 100644 index 000000000..99c3f6eac Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/thinking-collapsed.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-collapsed.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-collapsed.png new file mode 100644 index 000000000..d1e3bcf29 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-collapsed.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-expanded.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-expanded.png new file mode 100644 index 000000000..2119304e9 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-expanded.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-result.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-result.png new file mode 100644 index 000000000..7c54cb1c8 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/tool-calls-result.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-home.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-home.png new file mode 100644 index 000000000..c13031c84 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-home.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-offcanvas.png b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-offcanvas.png new file mode 100644 index 000000000..59420e046 Binary files /dev/null and b/content/blog/shinychat-r-0.5.0-python-0.7.1/images/toolbars-offcanvas.png differ diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/index.md b/content/blog/shinychat-r-0.5.0-python-0.7.1/index.md new file mode 100644 index 000000000..40f8bb0e0 --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/index.md @@ -0,0 +1,578 @@ +--- +title: 'Complete chat applications in shinychat: R 0.5.0 and Python 0.7.1' +date: 2026-09-15T00:00:00.000Z +people: + - Garrick Aden-Buie + - Carson Sievert +description: > + shinychat v0.5.0 for R and v0.7.1 for Python make it easier to build complete, + conversation-centered Shiny chat applications with history, editing, + branching, greetings, suggestions, citations, tool displays, and more. +image: images/og-header.png +image-alt: > + A shinychat chat application greeting: the shinychat hex sticker above a + heading reading "Complete chat applications in shinychat", a short + introduction, and two suggestion cards — one for shinychat v0.5.0 for R and + one for shinychat v0.7.1 for Python. +topics: + - Artificial Intelligence + - Interactive Apps +software: + - shinychat +languages: + - R + - Python +source: shiny +hidesubscription: false +--- + + +We're excited to announce [shinychat v0.5.0 for R](https://posit-dev.github.io/shinychat/r/) and [shinychat v0.7.1 for Python](https://posit-dev.github.io/shinychat/py/). +This release brings the pieces of a complete chat application together around the conversation itself. + +shinychat is a toolkit for building complete, conversation-centered chat applications with Shiny. +The R package pairs with [ellmer](https://ellmer.tidyverse.org/), and the Python package pairs with [chatlas](https://posit-dev.github.io/chatlas/). +Install the latest releases from CRAN or PyPI: + +
+ +
+ +``` r +install.packages("shinychat") +``` + +
+
+ +``` bash +pip install -U shinychat +``` + +
+
+ +We cover a lot in this post, and there's even more in the releases. +See the [R release notes](https://github.com/posit-dev/shinychat/blob/main/pkg-r/NEWS.md) and the [Python changelog](https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.md) for the complete list of changes, including [a few changes for existing apps](#a-few-changes-for-existing-apps) if you're upgrading. + +## Build a chat application + +{{< video src="images/complete-app.mp4" aspect-ratio="4x3" title="A complete chat application: the history sidebar lists saved conversations, the assistant answers with a tool activity row and citations, and the artifact drawer opens beside the chat with a plot" >}} + +A useful chat application needs more than a text box and a streaming response. +Your users need a way to return to an earlier conversation, start a new one, correct a question, compare answers, inspect sources, and see what the model is doing when it calls a tool. +They may also need to upload a file, open a preview, or move between the chat and the rest of the application. + +shinychat gives you sensible starting points for building that experience. +Pair it with [ellmer](https://ellmer.tidyverse.org/) in R or [chatlas](https://posit-dev.github.io/chatlas/) in Python, and you can get a working chat app running with little setup. +The chat application model has three layers: + +1. `page_chat()` gives you a full-window chat app with space for navigation, history, tools, and supporting content. +2. `chat_ui()` lets you place chat wherever it fits best in your application. +3. `chat_server()` for R or `Chat(client=...)` for Python connects your app to an `ellmer` or `chatlas` client and enables the integrated chat features. + +When you want a fully custom experience or need a model client other than ellmer or chatlas, the lower-level pieces are still available for you to assemble yourself. + +## Start with `page_chat()` + +When chat is the center of your application, use `page_chat()`. +It gives your users a full-window experience with a [chat home](#create-a-chat-app), [navigation pages](#complete-application), [sidebars](#complete-application), [toolbars](#toolbars), [conversation history](#return-to-earlier-conversations), and an [artifact drawer](#artifact-drawer). +Users can move to a settings or sources page while their conversation keeps working and streaming. +The [Get started](https://posit-dev.github.io/shinychat/r/articles/get-started.html) guide for R and the [Page chat](https://posit-dev.github.io/shinychat/py/page-chat.html) guide for Python walk through the full layout. + +### Create a chat app + +Build a shinychat application starts similarly in both languages: + +
+ +
+ +``` r +library(shiny) +library(shinychat) + +ui <- page_chat(title = "Assistant", id = "chat") + +server <- function(input, output, session) { + client <- ellmer::chat_openai( + system_prompt = "You are a helpful assistant." + ) + + chat_server("chat", client) +} + +shinyApp(ui, server) +``` + +
+
+ +``` python +from chatlas import ChatAnthropic +from shinychat.express import Chat, page_chat + +client = ChatAnthropic(system_prompt="You are a helpful assistant.") +chat = Chat(id="chat", client=client) + +page_chat(title="Assistant", id="chat") +``` + +
+
+ +With just a few lines of code, you'll have a working chat app backed by a live LLM. +Passing a client to `chat_server()` in R, or to `Chat()` in Python, does all the hard work for you, fulling connecting your app to the model client and giving you a complete multi-user chat application[^1]. + +For a personal chat UI you can use while you develop locally, pass an ellmer client to [`chat_app()`](https://posit-dev.github.io/shinychat/r/reference/chat_app.html) in R, or a chatlas client to [`Chat(client=...)`](https://posit-dev.github.io/shinychat/py/api/Chat.html), and then call `.app()` in Python. + +### Welcome users + + + +When you're app opens, don't leave your users hanging with an empty chat canvas, gree them with `chat_greeting()` ([R](https://posit-dev.github.io/shinychat/r/reference/chat_greeting.html), [Python](https://posit-dev.github.io/shinychat/py/api/chat_greeting.html))! + +Greetings can be used to explain the application, set expectations, and give users a useful first step before they write their first message. +By default, they disappear when the user starts chatting, but you can set `persistent = TRUE` in R or `persistent=True` in Python to keep one at the top of the conversation history. + +Greetings are written in markdown and can even provide actionable suggestions. +Users can click a suggestion to fill the input, ready to edit before sending, or send it immediately. + +``` markdown +## Welcome! + +What would you like to do? + +* Summarize my data +* Create a plot +* Explain this code +``` + + + +You don't have to greet your users with the same message every time, you can use LLMs to generate fresh custom greetings. +To learn more, we'll point you to the `chat_greeting()` documentation pages ([R](https://posit-dev.github.io/shinychat/r/reference/chat_greeting.html), [Python](https://posit-dev.github.io/shinychat/py/api/chat_greeting.html)), but it's worth noting that dynamic greetings can stream into the chat like any other response. + +{{< video src="images/greeting-stream.mp4" aspect-ratio="4x3" title="A generated greeting streams into the empty chat: the welcome message arrives word by word, then two suggestion cards appear" >}} + +## Return to earlier conversations + + + +One of the biggest features to arrive in this release is conversation history, giving your chat app the ability to save and return to previous conversations. +It will also persist the current conversation across page reloads and other disconnects, virtually eliminating the possibility of losing your progress. +As usual, when you connect shinychat with an ellmer or chatlas client, conversation history is wired up and enabled for you! + +### Save conversations + +The history drawer lets users: + +- Start a new conversation. +- Switch between saved conversations. +- Search conversations. +- Rename a conversation. +- Delete a conversation. +- Return to the conversation that was active when they last opened the app. + +shinychat generates a short title once the conversation has enough content. +Users can replace that title, and title generation never overwrites a manual rename. + +
+ +
+ + + +
+
+ + + +
+
+ +You can `history_options()` in R or `HistoryOptions` in Python to configure the conversations that shinychat saves. +The main options are: + +- `restore_mode`, which controls which conversation opens when a user returns to the app: + - `"browser"` is the default. It returns that browser to its most recent conversation without changing the URL. + - `"url"` puts the active conversation ID in the address bar, so users can bookmark or share a specific conversation. + - `"bookmark"` restores the conversation with the rest of the app state when your app uses Shiny server bookmarking. +- `store` controls where shinychat saves conversations. Use `"memory"` for local development or tests, or `"file"` to save them on disk. +- `title` controls how the automated conversation titles are generated. + +For example, this configuration stores conversations on disk and puts the active conversation ID in the URL: + +
+ +
+ +``` r +history <- history_options( + restore_mode = "url", + store = "file" +) + +chat_server("chat", client, history = history) +``` + +
+
+ +``` python +from shinychat import Chat +from shinychat.types import HistoryOptions + +history = HistoryOptions( + restore_mode="url", + store="file", +) + +chat = Chat("chat", client=client, history=history) +``` + +
+
+ +On Posit Connect, conversation history is included with the platform and is enabled automatically when you provide a model client. +The default configuration uses Connect's [persistent storage](https://docs.posit.co/connect/user/structuring-content/#persistent-storage-on-posit-connect) and scopes conversations to the authenticated user. +That gives every user a private conversation history without an additional history service or per-user setup. + +In every restore mode, shinychat keeps the transcript in its configured store instead of putting the full conversation in the URL. + +### Edit a message and compare answers + +{{< video src="images/edit-branches-edit.mp4" aspect-ratio="4x3" title="Editing an earlier message and resending it starts a new branch, and the sibling navigation control appears on the response" >}} + +Editing a message now creates a new conversation **branch**. +When a user edits and resends an earlier message, shinychat forks the conversation at that point: the original question and its later messages remain on one branch, while the edited question begins another. Users can move between the answers with the branch controls in the message. + +
+ +
+ + + +
+
+ + + +
+
+ +Branches help when a prompt is almost right or when a model takes an unhelpful direction, and they make comparing answers easy without starting over. +And they are part of the saved conversation, so users return to their place in the conversation after a reload. + +## Add content and controls + +When chat is part of a larger application, your users still need access to filters, settings, sources, and results. +`page_chat()` gives you a place to put those alongside the conversation: a drawer for results, toolbars for controls, and offcanvas panels for settings you would rather keep off screen. + +### Artifact drawer + +`chat_drawer()` gives you a place to show previews, rendered reports, tables, plots, or other bits of Shiny UI next to your chat. +Your users can keep the conversation visible while they inspect a result. + + + +See the [drawer documentation for R](https://posit-dev.github.io/shinychat/r/reference/chat_drawer.html) or [Python](https://posit-dev.github.io/shinychat/py/api/chat_drawer.html) for the full API. +The [complete application example](#complete-application) combines a drawer with the rest of the application layout. + +### Toolbars + + + +Your app may need a Help button that works on every page, while the chat home needs an action that's only relevant when you're looking at the conversation. `page_chat()` gives each action a home through scoped [toolbars](../../blog/2026-05-26_introducing-toolbars/), built on the toolbar components that bslib and Shiny shipped earlier this year. + +If you want an action to follow users through the whole app --- pass it to `toolbar_global`. Put chat-home actions in `toolbar` in `page_chat()`, and give a [secondary page](#complete-application) its own `toolbar` through `chat_nav_panel()`. `toolbar_input` puts related actions below the message box. + +See the [R get started guide](https://posit-dev.github.io/shinychat/r/articles/get-started.html) or the [Python Page chat guide](https://posit-dev.github.io/shinychat/py/page-chat.html) for the full toolbar API. + +### Offcanvas panels + + + +`page_chat()` pairs [offcanvas panels](../../blog/2026-08-04_shiny-r-1-14-python-1-7/) with secondary content, such as an answer-length slider or citation setting, and a toolbar button can open an **Answer settings** panel from any page: + +### Complete application + +As your app grows, `page_chat()` can grow around the conversation. You can add secondary pages and a sidebar for filters or other app UI and the application menu keeps those options available on narrow screens. + +The following example brings the toolbars, sidebar, navigation, and drawer together. + +
+ +A complete page_chat() example + +
+
+ +
+ +``` r +ui <- page_chat( + "Research assistant", + id = "chat", + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "clear_chat", + "Clear conversation", + icon = bsicons::bs_icon("arrow-counterclockwise") + ) + ), + toolbar_global = bslib::toolbar( + bslib::toolbar_input_button( + "help", + "Help", + icon = bsicons::bs_icon("question-circle") + ) + ), + sidebar = chat_sidebar( + tags$p("Use filters to focus the results."), + history = FALSE + ), + pages_navbar = list( + chat_nav_panel( + "Sources", + tags$p("Sources selected during this session appear here."), + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "refresh_sources", + "Refresh", + icon = bsicons::bs_icon("arrow-repeat") + ) + ) + ) + ), + drawer = chat_drawer( + tags$p("Select a result to inspect it here."), + title = "Latest result", + open = FALSE + ) +) +``` + +
+
+ +``` python +from faicons import icon_svg +from shiny import ui +from shinychat import chat_drawer, chat_nav_panel, chat_sidebar +from shinychat.express import page_chat + +page_chat( + "Research assistant", + id="chat", + toolbar=ui.toolbar( + ui.toolbar_input_button( + id="clear_chat", + label="Clear conversation", + icon=icon_svg("arrow-counterclockwise"), + ) + ), + toolbar_global=ui.toolbar( + ui.toolbar_input_button( + id="help", + label="Help", + icon=icon_svg("question-circle"), + ) + ), + sidebar=chat_sidebar( + ui.p("Use filters to focus the results."), + history=False, + ), + pages_navbar=[ + chat_nav_panel( + "Sources", + ui.p("Sources selected during this session appear here."), + toolbar=ui.toolbar( + ui.toolbar_input_button( + id="refresh_sources", + label="Refresh", + icon=icon_svg("arrow-repeat"), + ) + ), + ) + ], + drawer=chat_drawer( + ui.p("Select a result to inspect it here."), + title="Latest result", + open=False, + ), +) +``` + +
+
+ +Users see the **Clear conversation** button while they chat, the **Help** button on every page, a **Sources** page with its own **Refresh** toolbar, and a **Latest result** drawer beside the conversation. + +
+
+ +## Show how the model reached an answer + +Understanding how an LLM arrived at an answer is just as --- if not more --- important than getting the answer from the model. +A response can include ordinary text, thinking content, web activity, citations, tool calls, tool results, and custom UI. +shinychat works hard to make the model's work visible and presents each part in a way that helps users understand the answer and what produced it. + +### Keep tool calls readable + + + +Tool calls are now shown as compact activity rows instead of letting them take over the conversation, refining the [tool-call cards shinychat introduced last year](../../blog/2025-11-20_shinychat-tool-ui/). +By default, related calls are grouped together into a single row, and users can still expand a group, open an individual call, and inspect the request and result when they need more detail. + + + +Opening an individual call shows the request and the result in a card: + + + +Grouping keeps the answer readable, and the request and result stay one click away. +To customize grouping or register tools, see [Tool UI in shinychat for R](https://posit-dev.github.io/shinychat/r/articles/tool-ui.html), [Tools in Shiny for Python](https://shiny.posit.co/py/docs/genai-tools.html), [tool/function calling in ellmer](https://ellmer.tidyverse.org/articles/tool-calling.html), or [tool calling in chatlas](https://posit-dev.github.io/chatlas/get-started/tools.html). + +### Show citations for web search and fetch + + + +Many LLM providers offer built-in web search and web fetch tools that let your agent search the web, and their APIs return citations when the model uses that content in a reply. shinychat now displays those citations automatically. + +For example, here's how to register Claude's tools with an ellmer or chatlas client: + +
+ +
+ +``` r +library(ellmer) + +client <- chat_anthropic() +client$register_tool(claude_tool_web_search()) +client$register_tool(claude_tool_web_fetch()) +``` + +
+
+ +``` python +from chatlas import ChatAnthropic, tool_web_fetch, tool_web_search + +client = ChatAnthropic( + kwargs={ + "default_headers": { + "anthropic-beta": "web-fetch-2025-09-10" + } + } +) +client.register_tool(tool_web_search()) +client.register_tool(tool_web_fetch()) +``` + +
+
+ +When this client is used with `chat_server()`, citations are connected directly to the portions of the assistant's response that they support. + +Custom retrieval applications, like the RAG systems you can build with [ragnar](https://ragnar.tidyverse.org/) or [raghilda](../../blog/2026-04-14_rag-with-raghilda/), can use the same citation UI by prompting the assistant to use a `` tag to attach a source to a claim. + +### Stream responses and show thinking + + + +With `chat_server()` in R or `Chat(client=...)` in Python, shinychat streams responses and shows supported thinking content in a collapsible panel. +Users can cancel a slow response with the stop button or the Escape key, and the partial response stays in the conversation. + + + +## Add files and shortcuts + +### Attach files + + + +File attachments are now supported in shinychat! Your users can send images, PDFs, and text files through a file picker, drag and drop, or paste, and shinychat sends each file to the model alongside the user's message. When you use `chat_server()` in R or `Chat(client=...)` in Python, your app gets that support for free. + +### Add slash commands + + + +You can now register chat shortcuts, or *slash commands*, with [`chat$slash_command()`](https://posit-dev.github.io/shinychat/r/reference/chat_server.html) in R or [`@chat.slash_command()`](https://posit-dev.github.io/shinychat/py/api/Chat.html) in Python. The command palette appears when users type `/`, and they serve as a way to trigger server-side code, inject context or additional prompting, or even just take an action in your app, all from the chat input. + +Check out the [shinychat for R](https://posit-dev.github.io/shinychat/r/) or [shinychat for Python](https://posit-dev.github.io/shinychat/py/) documentation for details. + +## More shinychat-powered apps + +The next release of [querychat](https://posit-dev.github.io/querychat/) will bring these chat features to data applications, including conversation history, attachments, tool displays, and citations. +It will introduce a page-first `querychat_app()` workflow and a new `page()` API for adding querychat to an existing Shiny page. + +[btw 1.5.0](https://posit-dev.github.io/btw/news/index.html#btw-150) already uses shinychat 0.5.0 to give `btw_app()` a complete coding assistant for your R projects. +It adds conversation history, a `page_chat()` layout, and slash commands to an assistant that can use your R session, project files, and package documentation. + +## A few changes for existing apps + +Existing `chat_ui()` applications remain supported when chat shares a page with other top-level content. When the conversation should fill the application instead, choose `page_chat()` and use it as the outermost page container; nesting it inside another page layout breaks the full-window layout and history experience. + +In R, `chat_mod_ui()` and `chat_mod_server()` are soft-deprecated in favor of pairing `chat_ui()` and `chat_server()` by ID. In both languages, a startup message no longer seeds a conversation when history is enabled; use a greeting or append messages through the chat object instead. + +The release also protects users from unsafe model-authored Markdown, shows an error when a response fails before streaming starts, and preserves tool results, citations, attachments, and other rich content when users return to a conversation. + +With `page_chat()`, `chat_server()` or `Chat(client=...)`, and the history options, you can now give your users a complete chat application: saved conversations they can return to, messages they can edit into new branches, greetings and suggestions to start from, and responses with visible tool calls, citations, and thinking. + +Read the [shinychat for R documentation](https://posit-dev.github.io/shinychat/r/) or the [shinychat for Python documentation](https://posit-dev.github.io/shinychat/py/) to explore the examples. +For the complete list of changes, see the [R release notes](https://github.com/posit-dev/shinychat/blob/main/pkg-r/NEWS.md) and the [Python changelog](https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.md). + +## Acknowledgements + +We thank everyone who contributed to these releases, for opening issues, +submitting pull requests, and providing feedback: +[@bastianolea](https://github.com/bastianolea), +[@bianchenhao](https://github.com/bianchenhao), +[@christophsax](https://github.com/christophsax), +[@cpsievert](https://github.com/cpsievert), +[@crissthiandi](https://github.com/crissthiandi), +[@elnelson575](https://github.com/elnelson575), +[@gadenbuie](https://github.com/gadenbuie), +[@Harshit28j](https://github.com/Harshit28j), +[@JamesHWade](https://github.com/JamesHWade), +[@jcheng5](https://github.com/jcheng5), +[@jlxAtNovozymes](https://github.com/jlxAtNovozymes), +[@jnhyeon](https://github.com/jnhyeon), +[@jose-c-milliman](https://github.com/jose-c-milliman), +[@kaipingyang](https://github.com/kaipingyang), +[@lucasrod16](https://github.com/lucasrod16), +[@markmcd](https://github.com/markmcd), +[@nbenn](https://github.com/nbenn), +[@parmsam](https://github.com/parmsam), +[@schloerke](https://github.com/schloerke), +[@shea-parkes](https://github.com/shea-parkes), +[@simonpcouch](https://github.com/simonpcouch), +[@slupczynskim](https://github.com/slupczynskim), +[@thisisnic](https://github.com/thisisnic), +[@wlandau](https://github.com/wlandau), and +[@xx02al](https://github.com/xx02al). + +[^1]: If you're new to LLM apps with Shiny, [Build Your First LLM App with Shiny](../../blog/2025-09-15_shiny-side-of-llms-part-3/) walks through the process from the beginning in detail. diff --git a/content/blog/shinychat-r-0.5.0-python-0.7.1/index.qmd b/content/blog/shinychat-r-0.5.0-python-0.7.1/index.qmd new file mode 100644 index 000000000..1b37f6e3c --- /dev/null +++ b/content/blog/shinychat-r-0.5.0-python-0.7.1/index.qmd @@ -0,0 +1,524 @@ +--- +title: "Complete chat applications in shinychat: R 0.5.0 and Python 0.7.1" +date: 2026-09-15 +people: + - Garrick Aden-Buie + - Carson Sievert +description: > + shinychat v0.5.0 for R and v0.7.1 for Python make it easier to build + complete, conversation-centered Shiny chat applications with history, editing, + branching, greetings, suggestions, citations, tool displays, and more. +image: "images/og-header.png" +image-alt: > + A shinychat chat application greeting: the shinychat hex sticker above a + heading reading "Complete chat applications in shinychat", a short + introduction, and two suggestion cards — one for shinychat v0.5.0 for R and + one for shinychat v0.7.1 for Python. +topics: + - Artificial Intelligence + - Interactive Apps +software: + - shinychat +languages: + - R + - Python +source: shiny +hidesubscription: false +--- + +We're excited to announce [shinychat v0.5.0 for R](https://posit-dev.github.io/shinychat/r/) and [shinychat v0.7.1 for Python](https://posit-dev.github.io/shinychat/py/). +This release brings the pieces of a complete chat application together around the conversation itself. + +shinychat is a toolkit for building complete, conversation-centered chat applications with Shiny. +The R package pairs with [ellmer](https://ellmer.tidyverse.org/), and the Python package pairs with [chatlas](https://posit-dev.github.io/chatlas/). +Install the latest releases from CRAN or PyPI: + +::: {.panel-tabset group="language"} +### R + +``` r +install.packages("shinychat") +``` + +### Python + +``` bash +pip install -U shinychat +``` +::: + +We cover a lot in this post, and there's even more in the releases. +See the [R release notes](https://github.com/posit-dev/shinychat/blob/main/pkg-r/NEWS.md) and the [Python changelog](https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.md) for the complete list of changes, including [a few changes for existing apps](#a-few-changes-for-existing-apps) if you're upgrading. + +## Build a chat application + +{{< video images/complete-app.mp4 aspect-ratio="4x3" title="A complete chat application: the history sidebar lists saved conversations, the assistant answers with a tool activity row and citations, and the artifact drawer opens beside the chat with a plot" >}} + +A useful chat application needs more than a text box and a streaming response. +Your users need a way to return to an earlier conversation, start a new one, correct a question, compare answers, inspect sources, and see what the model is doing when it calls a tool. +They may also need to upload a file, open a preview, or move between the chat and the rest of the application. + +shinychat gives you sensible starting points for building that experience. +Pair it with [ellmer](https://ellmer.tidyverse.org/) in R or [chatlas](https://posit-dev.github.io/chatlas/) in Python, and you can get a working chat app running with little setup. +The chat application model has three layers: + +1. `page_chat()` gives you a full-window chat app with space for navigation, history, tools, and supporting content. +2. `chat_ui()` lets you place chat wherever it fits best in your application. +3. `chat_server()` for R or `Chat(client=...)` for Python connects your app to an `ellmer` or `chatlas` client and enables the integrated chat features. + +When you want a fully custom experience or need a model client other than ellmer or chatlas, the lower-level pieces are still available for you to assemble yourself. + +## Start with `page_chat()` + +When chat is the center of your application, use `page_chat()`. +It gives your users a full-window experience with a [chat home](#create-a-chat-app), [navigation pages](#complete-application), [sidebars](#complete-application), [toolbars](#toolbars), [conversation history](#return-to-earlier-conversations), and an [artifact drawer](#artifact-drawer). +Users can move to a settings or sources page while their conversation keeps working and streaming. +The [Get started](https://posit-dev.github.io/shinychat/r/articles/get-started.html) guide for R and the [Page chat](https://posit-dev.github.io/shinychat/py/page-chat.html) guide for Python walk through the full layout. + +### Create a chat app + +Build a shinychat application starts similarly in both languages: + +::: {.panel-tabset group="language"} +### R + +``` r +library(shiny) +library(shinychat) + +ui <- page_chat(title = "Assistant", id = "chat") + +server <- function(input, output, session) { + client <- ellmer::chat_openai( + system_prompt = "You are a helpful assistant." + ) + + chat_server("chat", client) +} + +shinyApp(ui, server) +``` + +### Python + +``` python +from chatlas import ChatAnthropic +from shinychat.express import Chat, page_chat + +client = ChatAnthropic(system_prompt="You are a helpful assistant.") +chat = Chat(id="chat", client=client) + +page_chat(title="Assistant", id="chat") +``` +::: + +With just a few lines of code, you'll have a working chat app backed by a live LLM. +Passing a client to `chat_server()` in R, or to `Chat()` in Python, does all the hard work for you, fulling connecting your app to the model client and giving you a complete multi-user chat application[^first-llm-app]. + +[^first-llm-app]: If you're new to LLM apps with Shiny, [Build Your First LLM App with Shiny](/blog/2025-09-15_shiny-side-of-llms-part-3/) walks through the process from the beginning in detail. + +For a personal chat UI you can use while you develop locally, pass an ellmer client to [`chat_app()`](https://posit-dev.github.io/shinychat/r/reference/chat_app.html) in R, or a chatlas client to [`Chat(client=...)`](https://posit-dev.github.io/shinychat/py/api/Chat.html), and then call `.app()` in Python. + +### Welcome users + +![](images/greeting-suggestions-greeting.png){fig-alt="A new chat with a short welcome message and a grid of three suggestion cards beneath it."} + +When you're app opens, don't leave your users hanging with an empty chat canvas, gree them with `chat_greeting()` ([R](https://posit-dev.github.io/shinychat/r/reference/chat_greeting.html), [Python](https://posit-dev.github.io/shinychat/py/api/chat_greeting.html))! + +Greetings can be used to explain the application, set expectations, and give users a useful first step before they write their first message. +By default, they disappear when the user starts chatting, but you can set `persistent = TRUE` in R or `persistent=True` in Python to keep one at the top of the conversation history. + +Greetings are written in markdown and can even provide actionable suggestions. +Users can click a suggestion to fill the input, ready to edit before sending, or send it immediately. + +``` markdown +## Welcome! + +What would you like to do? + +* Summarize my data +* Create a plot +* Explain this code +``` + +![](images/greeting-suggestions-fill-input.png){fig-alt="Clicking a suggestion card fills the chat input with the suggested prompt, ready to edit before sending."} + +You don't have to greet your users with the same message every time, you can use LLMs to generate fresh custom greetings. +To learn more, we'll point you to the `chat_greeting()` documentation pages ([R](https://posit-dev.github.io/shinychat/r/reference/chat_greeting.html), [Python](https://posit-dev.github.io/shinychat/py/api/chat_greeting.html)), but it's worth noting that dynamic greetings can stream into the chat like any other response. + +{{< video images/greeting-stream.mp4 aspect-ratio="4x3" title="A generated greeting streams into the empty chat: the welcome message arrives word by word, then two suggestion cards appear" >}} + +## Return to earlier conversations + +![](images/history-list.png){fig-alt="The conversation history drawer open beside the chat, listing several named conversations under Today with a search field and a New conversation button."} + +One of the biggest features to arrive in this release is conversation history, giving your chat app the ability to save and return to previous conversations. +It will also persist the current conversation across page reloads and other disconnects, virtually eliminating the possibility of losing your progress. +As usual, when you connect shinychat with an ellmer or chatlas client, conversation history is wired up and enabled for you! + +### Save conversations + +The history drawer lets users: + +- Start a new conversation. +- Switch between saved conversations. +- Search conversations. +- Rename a conversation. +- Delete a conversation. +- Return to the conversation that was active when they last opened the app. + +shinychat generates a short title once the conversation has enough content. +Users can replace that title, and title generation never overwrites a manual rename. + +::: {.panel-tabset} +### Rename + +![](images/history-actions-menu.png){fig-alt="The menu on a saved conversation with options to rename and delete it."} + +### Search + +![](images/history-search.png){fig-alt="Typing in the history drawer search field narrows the conversation list to matching titles."} +::: + +You can `history_options()` in R or `HistoryOptions` in Python to configure the conversations that shinychat saves. +The main options are: + +- `restore_mode`, which controls which conversation opens when a user returns to the app: + - `"browser"` is the default. It returns that browser to its most recent conversation without changing the URL. + - `"url"` puts the active conversation ID in the address bar, so users can bookmark or share a specific conversation. + - `"bookmark"` restores the conversation with the rest of the app state when your app uses Shiny server bookmarking. +- `store` controls where shinychat saves conversations. Use `"memory"` for local development or tests, or `"file"` to save them on disk. +- `title` controls how the automated conversation titles are generated. + +For example, this configuration stores conversations on disk and puts the active conversation ID in the URL: + +::: {.panel-tabset group="language"} +### R + +``` r +history <- history_options( + restore_mode = "url", + store = "file" +) + +chat_server("chat", client, history = history) +``` + +### Python + +``` python +from shinychat import Chat +from shinychat.types import HistoryOptions + +history = HistoryOptions( + restore_mode="url", + store="file", +) + +chat = Chat("chat", client=client, history=history) +``` +::: + +On Posit Connect, conversation history is included with the platform and is enabled automatically when you provide a model client. +The default configuration uses Connect's [persistent storage](https://docs.posit.co/connect/user/structuring-content/#persistent-storage-on-posit-connect) and scopes conversations to the authenticated user. +That gives every user a private conversation history without an additional history service or per-user setup. + +In every restore mode, shinychat keeps the transcript in its configured store instead of putting the full conversation in the URL. + +### Edit a message and compare answers + +{{< video images/edit-branches-edit.mp4 aspect-ratio="4x3" title="Editing an earlier message and resending it starts a new branch, and the sibling navigation control appears on the response" >}} + +Editing a message now creates a new conversation **branch**. +When a user edits and resends an earlier message, shinychat forks the conversation at that point: the original question and its later messages remain on one branch, while the edited question begins another. Users can move between the answers with the branch controls in the message. + +::: {.panel-tabset} +### Branch 1 +![](images/edit-branches-original.png){fig-alt="The original conversation with an assistant response showing a 1 / 2 sibling navigation control."} + +### Branch 2 +![](images/edit-branches-new.png){fig-alt="The same conversation after editing a message, with the new branch's response selected and the sibling navigation control showing 2 / 2."} +::: + +Branches help when a prompt is almost right or when a model takes an unhelpful direction, and they make comparing answers easy without starting over. +And they are part of the saved conversation, so users return to their place in the conversation after a reload. + +## Add content and controls + +When chat is part of a larger application, your users still need access to filters, settings, sources, and results. +`page_chat()` gives you a place to put those alongside the conversation: a drawer for results, toolbars for controls, and offcanvas panels for settings you would rather keep off screen. + +### Artifact drawer + +`chat_drawer()` gives you a place to show previews, rendered reports, tables, plots, or other bits of Shiny UI next to your chat. +Your users can keep the conversation visible while they inspect a result. + +![](images/complete-app-nav-drawer.png){fig-alt="The research assistant app with the Research assistant and Sources navigation pages in the header, the conversation in the main region, and the artifact drawer open beside the chat showing a bar chart of penguin counts."} + +See the [drawer documentation for R](https://posit-dev.github.io/shinychat/r/reference/chat_drawer.html) or [Python](https://posit-dev.github.io/shinychat/py/api/chat_drawer.html) for the full API. +The [complete application example](#complete-application) combines a drawer with the rest of the application layout. + +### Toolbars + +![](images/toolbars-home.png){fig-alt="A close-up of a research assistant chat. Part of the conversation is visible beside the global toolbar's Refresh, Help, and Answer settings buttons, with a response style selector below the chat input."} + +Your app may need a Help button that works on every page, while the chat home needs an action that's only relevant when you're looking at the conversation. `page_chat()` gives each action a home through scoped [toolbars](/blog/2026-05-26_introducing-toolbars/), built on the toolbar components that bslib and Shiny shipped earlier this year. + +If you want an action to follow users through the whole app — pass it to `toolbar_global`. Put chat-home actions in `toolbar` in `page_chat()`, and give a [secondary page](#complete-application) its own `toolbar` through `chat_nav_panel()`. `toolbar_input` puts related actions below the message box. + +See the [R get started guide](https://posit-dev.github.io/shinychat/r/articles/get-started.html) or the [Python Page chat guide](https://posit-dev.github.io/shinychat/py/page-chat.html) for the full toolbar API. + +### Offcanvas panels + +![](images/toolbars-offcanvas.png){fig-alt="The research assistant app with the Answer settings offcanvas open along the right edge, showing a target length slider and a citations checkbox beside the conversation."} + +`page_chat()` pairs [offcanvas panels](/blog/2026-08-04_shiny-r-1-14-python-1-7/) with secondary content, such as an answer-length slider or citation setting, and a toolbar button can open an **Answer settings** panel from any page: + +### Complete application + +As your app grows, `page_chat()` can grow around the conversation. You can add secondary pages and a sidebar for filters or other app UI and the application menu keeps those options available on narrow screens. + +The following example brings the toolbars, sidebar, navigation, and drawer together. + +:::: {.callout-tip collapse="true"} + +### A complete `page_chat()` example + +::: {.panel-tabset group="language"} +### R + +``` r +ui <- page_chat( + "Research assistant", + id = "chat", + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "clear_chat", + "Clear conversation", + icon = bsicons::bs_icon("arrow-counterclockwise") + ) + ), + toolbar_global = bslib::toolbar( + bslib::toolbar_input_button( + "help", + "Help", + icon = bsicons::bs_icon("question-circle") + ) + ), + sidebar = chat_sidebar( + tags$p("Use filters to focus the results."), + history = FALSE + ), + pages_navbar = list( + chat_nav_panel( + "Sources", + tags$p("Sources selected during this session appear here."), + toolbar = bslib::toolbar( + bslib::toolbar_input_button( + "refresh_sources", + "Refresh", + icon = bsicons::bs_icon("arrow-repeat") + ) + ) + ) + ), + drawer = chat_drawer( + tags$p("Select a result to inspect it here."), + title = "Latest result", + open = FALSE + ) +) +``` + +### Python + +``` python +from faicons import icon_svg +from shiny import ui +from shinychat import chat_drawer, chat_nav_panel, chat_sidebar +from shinychat.express import page_chat + +page_chat( + "Research assistant", + id="chat", + toolbar=ui.toolbar( + ui.toolbar_input_button( + id="clear_chat", + label="Clear conversation", + icon=icon_svg("arrow-counterclockwise"), + ) + ), + toolbar_global=ui.toolbar( + ui.toolbar_input_button( + id="help", + label="Help", + icon=icon_svg("question-circle"), + ) + ), + sidebar=chat_sidebar( + ui.p("Use filters to focus the results."), + history=False, + ), + pages_navbar=[ + chat_nav_panel( + "Sources", + ui.p("Sources selected during this session appear here."), + toolbar=ui.toolbar( + ui.toolbar_input_button( + id="refresh_sources", + label="Refresh", + icon=icon_svg("arrow-repeat"), + ) + ), + ) + ], + drawer=chat_drawer( + ui.p("Select a result to inspect it here."), + title="Latest result", + open=False, + ), +) +``` +::: + +Users see the **Clear conversation** button while they chat, the **Help** button on every page, a **Sources** page with its own **Refresh** toolbar, and a **Latest result** drawer beside the conversation. + +:::: + +## Show how the model reached an answer + +Understanding how an LLM arrived at an answer is just as --- if not more --- important than getting the answer from the model. +A response can include ordinary text, thinking content, web activity, citations, tool calls, tool results, and custom UI. +shinychat works hard to make the model's work visible and presents each part in a way that helps users understand the answer and what produced it. + +### Keep tool calls readable + +![](images/tool-calls-collapsed.png){fig-alt="A sales assistant conversation where two SQL queries and a schema read appear as compact activity rows above the answer."} + +Tool calls are now shown as compact activity rows instead of letting them take over the conversation, refining the [tool-call cards shinychat introduced last year](/blog/2025-11-20_shinychat-tool-ui/). +By default, related calls are grouped together into a single row, and users can still expand a group, open an individual call, and inspect the request and result when they need more detail. + +![](images/tool-calls-expanded.png){fig-alt="The grouped tool-call row expanded to show the two SQL queries with row count and result previews."} + +Opening an individual call shows the request and the result in a card: + +![](images/tool-calls-result.png){fig-alt="The SQL query expanded to a card showing the full tool call arguments and the query result as a small table."} + +Grouping keeps the answer readable, and the request and result stay one click away. +To customize grouping or register tools, see [Tool UI in shinychat for R](https://posit-dev.github.io/shinychat/r/articles/tool-ui.html), [Tools in Shiny for Python](https://shiny.posit.co/py/docs/genai-tools.html), [tool/function calling in ellmer](https://ellmer.tidyverse.org/articles/tool-calling.html), or [tool calling in chatlas](https://posit-dev.github.io/chatlas/get-started/tools.html). + +### Show citations for web search and fetch + +![](images/citations-popover.png){fig-alt="An assistant response where each cited claim is underlined and a pill reading Internal report +1 marks the message's sources, with the citation popover open just below the pill showing the source name, a link, the supporting passage, and controls to move between the message's two citations."} + +Many LLM providers offer built-in web search and web fetch tools that let your agent search the web, and their APIs return citations when the model uses that content in a reply. shinychat now displays those citations automatically. + +For example, here's how to register Claude's tools with an ellmer or chatlas client: + +::: {.panel-tabset group="language"} +### R + +``` r +library(ellmer) + +client <- chat_anthropic() +client$register_tool(claude_tool_web_search()) +client$register_tool(claude_tool_web_fetch()) +``` + +### Python + +``` python +from chatlas import ChatAnthropic, tool_web_fetch, tool_web_search + +client = ChatAnthropic( + kwargs={ + "default_headers": { + "anthropic-beta": "web-fetch-2025-09-10" + } + } +) +client.register_tool(tool_web_search()) +client.register_tool(tool_web_fetch()) +``` +::: + +When this client is used with `chat_server()`, citations are connected directly to the portions of the assistant's response that they support. + +Custom retrieval applications, like the RAG systems you can build with [ragnar](https://ragnar.tidyverse.org/) or [raghilda](/blog/2026-04-14_rag-with-raghilda/), can use the same citation UI by prompting the assistant to use a `` tag to attach a source to a claim. + +### Stream responses and show thinking + +![](images/thinking-collapsed.png){fig-alt="An assistant response with a collapsed panel reading Thought for 4s between the user's question and the answer."} + +With `chat_server()` in R or `Chat(client=...)` in Python, shinychat streams responses and shows supported thinking content in a collapsible panel. +Users can cancel a slow response with the stop button or the Escape key, and the partial response stays in the conversation. + +![](images/streaming-stop.png){fig-alt="While a response streams in, the send button at the right of the chat input becomes a red stop button."} + +## Add files and shortcuts + +### Attach files + +![](images/attachments-plot.png){fig-alt="A plot attached to the chat input as a thumbnail chip above the prompt Explain this plot, with the attach button at the left of the input."} + +File attachments are now supported in shinychat! Your users can send images, PDFs, and text files through a file picker, drag and drop, or paste, and shinychat sends each file to the model alongside the user's message. When you use `chat_server()` in R or `Chat(client=...)` in Python, your app gets that support for free. + +### Add slash commands + +![](images/slash-commands-palette.png){fig-alt="The slash command palette open above the chat input, listing /help, /search, and /clear with short descriptions."} + +You can now register chat shortcuts, or _slash commands_, with [`chat$slash_command()`](https://posit-dev.github.io/shinychat/r/reference/chat_server.html) in R or [`@chat.slash_command()`](https://posit-dev.github.io/shinychat/py/api/Chat.html) in Python. The command palette appears when users type `/`, and they serve as a way to trigger server-side code, inject context or additional prompting, or even just take an action in your app, all from the chat input. + +Check out the [shinychat for R](https://posit-dev.github.io/shinychat/r/) or [shinychat for Python](https://posit-dev.github.io/shinychat/py/) documentation for details. + +## More shinychat-powered apps + +The next release of [querychat](https://posit-dev.github.io/querychat/) will bring these chat features to data applications, including conversation history, attachments, tool displays, and citations. +It will introduce a page-first `querychat_app()` workflow and a new `page()` API for adding querychat to an existing Shiny page. + +[btw 1.5.0](https://posit-dev.github.io/btw/news/index.html#btw-150) already uses shinychat 0.5.0 to give `btw_app()` a complete coding assistant for your R projects. +It adds conversation history, a `page_chat()` layout, and slash commands to an assistant that can use your R session, project files, and package documentation. + +## A few changes for existing apps + +Existing `chat_ui()` applications remain supported when chat shares a page with other top-level content. When the conversation should fill the application instead, choose `page_chat()` and use it as the outermost page container; nesting it inside another page layout breaks the full-window layout and history experience. + +In R, `chat_mod_ui()` and `chat_mod_server()` are soft-deprecated in favor of pairing `chat_ui()` and `chat_server()` by ID. In both languages, a startup message no longer seeds a conversation when history is enabled; use a greeting or append messages through the chat object instead. + +The release also protects users from unsafe model-authored Markdown, shows an error when a response fails before streaming starts, and preserves tool results, citations, attachments, and other rich content when users return to a conversation. + +With `page_chat()`, `chat_server()` or `Chat(client=...)`, and the history options, you can now give your users a complete chat application: saved conversations they can return to, messages they can edit into new branches, greetings and suggestions to start from, and responses with visible tool calls, citations, and thinking. + +Read the [shinychat for R documentation](https://posit-dev.github.io/shinychat/r/) or the [shinychat for Python documentation](https://posit-dev.github.io/shinychat/py/) to explore the examples. +For the complete list of changes, see the [R release notes](https://github.com/posit-dev/shinychat/blob/main/pkg-r/NEWS.md) and the [Python changelog](https://github.com/posit-dev/shinychat/blob/main/pkg-py/CHANGELOG.md). + +## Acknowledgements + +We thank everyone who contributed to these releases, for opening issues, +submitting pull requests, and providing feedback: +[@bastianolea](https://github.com/bastianolea), +[@bianchenhao](https://github.com/bianchenhao), +[@christophsax](https://github.com/christophsax), +[@cpsievert](https://github.com/cpsievert), +[@crissthiandi](https://github.com/crissthiandi), +[@elnelson575](https://github.com/elnelson575), +[@gadenbuie](https://github.com/gadenbuie), +[@Harshit28j](https://github.com/Harshit28j), +[@JamesHWade](https://github.com/JamesHWade), +[@jcheng5](https://github.com/jcheng5), +[@jlxAtNovozymes](https://github.com/jlxAtNovozymes), +[@jnhyeon](https://github.com/jnhyeon), +[@jose-c-milliman](https://github.com/jose-c-milliman), +[@kaipingyang](https://github.com/kaipingyang), +[@lucasrod16](https://github.com/lucasrod16), +[@markmcd](https://github.com/markmcd), +[@nbenn](https://github.com/nbenn), +[@parmsam](https://github.com/parmsam), +[@schloerke](https://github.com/schloerke), +[@shea-parkes](https://github.com/shea-parkes), +[@simonpcouch](https://github.com/simonpcouch), +[@slupczynskim](https://github.com/slupczynskim), +[@thisisnic](https://github.com/thisisnic), +[@wlandau](https://github.com/wlandau), and +[@xx02al](https://github.com/xx02al).