Skip to content

Commit 8c788ac

Browse files
gadenbuiejcheng5
andauthored
feat: Add chat_app(), chat_mod_ui() and chat_mod_server() (#36)
* feat: Move `ellmer::live_browser()` implementation to shinychat * chore: Avoid rcmdcheck warning about `getFromNamespace()` * chore: Avoid rcmdcheck note about unused imports * feat: Add `chat_mod_ui()` and `chat_mod_server()` * feat: Use an ExtendedTask for chat turn * chore(chat_mod_ui): Handle existing vs initial messages, `client` not required * chore(chat_mod_server): Catch and handle streaming errors * docs(chat_mod): Add example * feat(chat_mod_server): Returns last turn on successful completion * docs(chat_mod): Use side-by-side layout * docs: Add NEWS item * chore(chat_mod_server): Use straight promises * chore(chat_mod_server): Strip ANSI codes from errors * docs: Use cards * chore: Emit errors to the console as warnings * include module id in warning * chore: Increment dev version to v0.1.1.9001 * chore: Apply code suggestion Co-authored-by: Joe Cheng <[email protected]> --------- Co-authored-by: Joe Cheng <[email protected]>
1 parent 0fdb965 commit 8c788ac

File tree

8 files changed

+587
-6
lines changed

8 files changed

+587
-6
lines changed

DESCRIPTION

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: shinychat
22
Title: Chat UI Component for 'shiny'
3-
Version: 0.1.1.9000
3+
Version: 0.1.1.9001
44
Authors@R: c(
55
person("Joe", "Cheng", , "[email protected]", role = c("aut", "cre")),
66
person("Carson", "Sievert", , "[email protected]", role = c("aut")),
@@ -15,6 +15,7 @@ BugReports: https://github.com/jcheng5/shinychat/issues
1515
Imports:
1616
bslib,
1717
coro,
18+
ellmer,
1819
htmltools,
1920
jsonlite,
2021
promises (>= 1.3.2),

NAMESPACE

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
export(chat_app)
34
export(chat_append)
45
export(chat_append_message)
56
export(chat_clear)
7+
export(chat_mod_server)
8+
export(chat_mod_ui)
69
export(chat_ui)
710
export(markdown_stream)
811
export(output_markdown_stream)
12+
import(rlang)
913
importFrom(coro,async)
1014
importFrom(htmltools,HTML)
1115
importFrom(htmltools,css)
1216
importFrom(htmltools,tag)
13-
importFrom(rlang,"%||%")
1417
importFrom(shiny,getDefaultReactiveDomain)

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
* `chat_append()`, `chat_append_message()` and `chat_clear()` now all work in Shiny modules without needing to namespace the `id` of the Chat component. (#37)
1010

11+
* Added `chat_app()`, `chat_mod_ui()` and `chat_mod_server()`. `chat_app()` takes an `ellmer::Chat` client and launches a simple Shiny app interface with the chat. `chat_mod_ui()` and `chat_mod_server()` replicate the interface as a Shiny module, for easily adding a simple chat interface connected to a specific `ellmer::Chat` client. (#36)
12+
1113
# shinychat 0.1.1
1214

1315
* Initial CRAN submission.

R/chat_app.R

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#' Open a live chat application in the browser
2+
#'
3+
#' @description
4+
#' Create a simple Shiny app for live chatting using an [ellmer::Chat] object.
5+
#' Note that these functions will mutate the input `client` object as
6+
#' you chat because your turns will be appended to the history.
7+
#'
8+
#' @examples
9+
#' \dontrun{
10+
#' # Interactive in the console ----
11+
#' client <- ellmer::chat_claude()
12+
#' chat_app(client)
13+
#'
14+
#' # Inside a Shiny app ----
15+
#' library(shiny)
16+
#' library(bslib)
17+
#' library(shinychat)
18+
#'
19+
#' ui <- page_fillable(
20+
#' titlePanel("shinychat example"),
21+
#'
22+
#' layout_columns(
23+
#' card(
24+
#' card_header("Chat with Claude"),
25+
#' chat_mod_ui(
26+
#' "claude",
27+
#' messages = list(
28+
#' "Hi! Use this chat interface to chat with Anthropic's `claude-3-5-sonnet`."
29+
#' )
30+
#' )
31+
#' ),
32+
#' card(
33+
#' card_header("Chat with ChatGPT"),
34+
#' chat_mod_ui(
35+
#' "openai",
36+
#' messages = list(
37+
#' "Hi! Use this chat interface to chat with OpenAI's `gpt-4o`."
38+
#' )
39+
#' )
40+
#' )
41+
#' )
42+
#' )
43+
#'
44+
#' server <- function(input, output, session) {
45+
#' claude <- ellmer::chat_claude(model = "claude-3-5-sonnet-latest") # Requires ANTHROPIC_API_KEY
46+
#' openai <- ellmer::chat_openai(model = "gpt-4o") # Requires OPENAI_API_KEY
47+
#'
48+
#' chat_mod_server("claude", claude)
49+
#' chat_mod_server("openai", openai)
50+
#' }
51+
#'
52+
#' shinyApp(ui, server)
53+
#' }
54+
#'
55+
#' @param client A chat object created by \pkg{ellmer}, e.g.
56+
#' [ellmer::chat_openai()] and friends.
57+
#' @param ... In `chat_app()`, additional arguments are passed to
58+
#' [shiny::shinyApp()]. In `chat_mod_ui()`, additional arguments are passed to
59+
#' [chat_ui()].
60+
#'
61+
#' @returns
62+
#' * `chat_app()` returns a [shiny::shinyApp()] object.
63+
#' * `chat_mod_ui()` returns the UI for a shinychat module.
64+
#' * `chat_mod_server()` includes the shinychat module server logic, and
65+
#' and returns the last turn upon successful chat completion.
66+
#'
67+
#' @describeIn chat_app A simple Shiny app for live chatting.
68+
#' @export
69+
chat_app <- function(client, ...) {
70+
check_ellmer_chat(client)
71+
72+
ui <- bslib::page_fillable(
73+
chat_mod_ui("chat", client = client, height = "100%"),
74+
shiny::actionButton(
75+
"close_btn",
76+
label = "",
77+
class = "btn-close",
78+
style = "position: fixed; top: 6px; right: 6px;"
79+
)
80+
)
81+
82+
server <- function(input, output, session) {
83+
chat_mod_server("chat", client)
84+
85+
shiny::observeEvent(input$close_btn, {
86+
shiny::stopApp()
87+
})
88+
}
89+
90+
shiny::shinyApp(ui, server, ...)
91+
}
92+
93+
check_ellmer_chat <- function(client) {
94+
if (!inherits(client, "Chat")) {
95+
abort("`client` must be an `ellmer::Chat` object.")
96+
}
97+
}
98+
99+
#' @describeIn chat_app A simple chat app module UI.
100+
#' @param id The chat module ID.
101+
#' @param messages Initial messages shown in the chat, used when `client` is not
102+
#' provided or when the chat `client` doesn't already contain turns. Passed to
103+
#' `messages` in [chat_ui()].
104+
#' @export
105+
chat_mod_ui <- function(id, ..., client = NULL, messages = NULL) {
106+
if (!is.null(client)) {
107+
check_ellmer_chat(client)
108+
109+
client_msgs <- map(client$get_turns(), function(turn) {
110+
content <- ellmer::contents_markdown(turn)
111+
if (is.null(content) || identical(content, "")) {
112+
return(NULL)
113+
}
114+
list(role = turn@role, content = content)
115+
})
116+
client_msgs <- compact(client_msgs)
117+
118+
if (length(client_msgs)) {
119+
if (!is.null(messages)) {
120+
warn(
121+
"`client` was provided and has initial messages, `messages` will be ignored."
122+
)
123+
}
124+
messages <- client_msgs
125+
}
126+
}
127+
128+
shinychat::chat_ui(
129+
shiny::NS(id, "chat"),
130+
messages = messages,
131+
...
132+
)
133+
}
134+
135+
#' @describeIn chat_app A simple chat app module server.
136+
#' @export
137+
chat_mod_server <- function(id, client) {
138+
check_ellmer_chat(client)
139+
140+
append_stream_task <- shiny::ExtendedTask$new(
141+
function(client, ui_id, user_input) {
142+
promises::then(
143+
promises::promise_resolve(client$stream_async(user_input)),
144+
function(stream) {
145+
chat_append(ui_id, stream)
146+
}
147+
)
148+
}
149+
)
150+
151+
shiny::moduleServer(id, function(input, output, session) {
152+
shiny::observeEvent(input$chat_user_input, {
153+
append_stream_task$invoke(
154+
client,
155+
"chat",
156+
input$chat_user_input
157+
)
158+
})
159+
160+
shiny::observe({
161+
if (append_stream_task$status() == "error") {
162+
tryCatch(
163+
append_stream_task$result(),
164+
error = notify_error("chat", session = session, module_id = id)
165+
)
166+
}
167+
})
168+
169+
shiny::reactive({
170+
if (append_stream_task$status() == "success") {
171+
client$last_turn()
172+
}
173+
})
174+
})
175+
}
176+
177+
notify_error <- function(
178+
id,
179+
module_id,
180+
session = shiny::getDefaultReactiveDomain()
181+
) {
182+
function(err) {
183+
rlang::warn(
184+
sprintf(
185+
"ERROR: An error occurred in `chat_mod_server(id=\"%s\")`",
186+
module_id
187+
),
188+
parent = err
189+
)
190+
191+
needs_sanitized <-
192+
isTRUE(getOption("shiny.sanitize.errors")) &&
193+
!inherits(err, "shiny.custom.error")
194+
if (needs_sanitized) {
195+
msg <- "**An error occurred.** Please try again or contact the app author."
196+
} else {
197+
msg <- sprintf(
198+
"**An error occurred:**\n\n```\n%s\n```",
199+
strip_ansi(conditionMessage(err))
200+
)
201+
}
202+
203+
chat_append_message(
204+
id,
205+
msg = list(role = "assistant", content = msg),
206+
chunk = TRUE,
207+
operation = "append",
208+
session = session
209+
)
210+
chat_append_message(
211+
id,
212+
list(role = "assistant", content = ""),
213+
chunk = "end",
214+
operation = "append",
215+
session = session
216+
)
217+
}
218+
}
219+
220+
strip_ansi <- function(text) {
221+
# Matches codes like "\x1B[31;43m", "\x1B[1;3;4m"
222+
ansi_pattern <- "(\x1B|\x033)\\[[0-9;?=<>]*[@-~]"
223+
gsub(ansi_pattern, "", text)
224+
}

0 commit comments

Comments
 (0)