Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Imports:
bslib,
coro,
ellmer,
fastmap,
htmltools,
jsonlite,
promises (>= 1.3.2),
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

* 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)

* The promise returned by `chat_append()` now resolves to the content streamed into the chat. (#49)

## Bug fixes

* `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)
Expand Down
25 changes: 20 additions & 5 deletions R/chat.R
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,12 @@ chat_ui <- function(
#' @param role The role of the message (either "assistant" or "user"). Defaults
#' to "assistant".
#' @param session The Shiny session object
#' @returns Returns a promise. This promise resolves when the message has been
#' successfully sent to the client; note that it does not guarantee that the
#' message was actually received or rendered by the client. The promise
#' rejects if an error occurs while processing the response (see the "Error
#' handling" section).
#'
#' @returns Returns a promise that resolves to the contents of the stream, or an
#' error. This promise resolves when the message has been successfully sent to
#' the client; note that it does not guarantee that the message was actually
#' received or rendered by the client. The promise rejects if an error occurs
#' while processing the response (see the "Error handling" section).
#'
#' @examplesIf interactive()
#' library(shiny)
Expand Down Expand Up @@ -460,13 +461,19 @@ rlang::on_load(
chunk = "start",
session = session
)

res <- fastmap::fastqueue(200)

for (msg in stream) {
if (promises::is.promising(msg)) {
msg <- await(msg)
}
if (coro::is_exhausted(msg)) {
break
}

res$add(msg)

chat_append_message(
id,
list(role = role, content = msg),
Expand All @@ -475,13 +482,21 @@ rlang::on_load(
session = session
)
}

chat_append_message(
id,
list(role = role, content = ""),
chunk = "end",
operation = "append",
session = session
)

res <- res$as_list()
if (every(res, is.character)) {
paste(unlist(res), collapse = "")
} else {
res
}
})
)

Expand Down
1 change: 1 addition & 0 deletions R/shinychat-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ NULL

ignore_unused_imports <- function() {
jsonlite::fromJSON
fastmap::fastqueue
}

release_bullets <- function() {
Expand Down
10 changes: 5 additions & 5 deletions man/chat_append.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions tests/testthat/helper-sync.R
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ sync <- function(expr) {
success
}
}

expect_promise <- function(p, state = NULL) {
name <- deparse(substitute(p))
expect(
promises::is.promise(p),
sprintf("`%s` is not a promise", name)
)
if (!is.null(state)) {
expect_equal(attr(p, "promise_impl")$status(), state)
}
}
46 changes: 43 additions & 3 deletions tests/testthat/test-chat.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,48 @@ test_that("Chat component markup", {
# TODO: it'd be nice to mock the shinyChatMessage custom messages
})

test_that("chat_append_stream() returns the stream contents as string if all text", {
local_mocked_bindings(
chat_append_message = coro::async(function(...) invisible())
)

stream <- coro::async_generator(function() {
for (i in c("Hello", ",", " world", "!")) {
yield(i)
}
})

p <- chat_append_stream("chat", stream())
res <- sync(p)

expect_promise(p, "fulfilled")
expect_equal(res, "Hello, world!")
})

test_that("chat_append_stream() returns the stream contents as list if not all text", {
local_mocked_bindings(
chat_append_message = coro::async(function(...) invisible())
)

stream <- coro::async_generator(function() {
for (i in c("Hello", ",", " world", "!")) {
yield(ellmer::ContentText(i))
}
})

p <- chat_append_stream("chat", stream())
res <- sync(p)

expect_promise(p, "fulfilled")

expect_true(is.list(res))
expect_true(every(res, inherits, "ellmer::ContentText"))
expect_equal(
paste(map_chr(res, ellmer::contents_text), collapse = ""),
"Hello, world!"
)
})

test_that("chat_append_stream() handles errors in the stream", {
local_mocked_bindings(
chat_append_message = coro::async(function(...) invisible())
Expand All @@ -61,9 +103,7 @@ test_that("chat_append_stream() handles errors in the stream", {
regexp = 'chat_append_stream'
)

expect_s3_class(p, "promise")
expect_true(promises::is.promise(p))
expect_equal(attr(p, "promise_impl")$status(), "rejected")
expect_promise(p, "rejected")

expect_s3_class(res, class = c("condition", "error"))
expect_s3_class(res, class = "shiny.silent.error")
Expand Down