diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 65f63c06..f024a52b 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # shinychat (development version) +* Added support for displaying thinking/reasoning content from models that support extended thinking. Thinking content appears in a collapsible block during streaming and on reload (@simonpcouch, #167). + * Fixed an issue where user chat messages would display the default assistant icon. (#162) # shinychat 0.3.0 diff --git a/pkg-r/R/chat.R b/pkg-r/R/chat.R index c73a5f8c..23f6932f 100644 --- a/pkg-r/R/chat.R +++ b/pkg-r/R/chat.R @@ -12,11 +12,13 @@ chat_deps <- function() { src = "lib/shiny", script = list( list(src = "chat/chat.js", type = "module"), - list(src = "markdown-stream/markdown-stream.js", type = "module") + list(src = "markdown-stream/markdown-stream.js", type = "module"), + list(src = "thinking/thinking.js") ), stylesheet = c( "chat/chat.css", - "markdown-stream/markdown-stream.css" + "markdown-stream/markdown-stream.css", + "thinking/thinking.css" ) ) } @@ -504,6 +506,14 @@ rlang::on_load( chat_append_("", chunk = "start", icon = icon) res <- fastmap::fastqueue(200) + thinking_id <- NULL + + send_thinking <- function(type, content = "") { + session$sendCustomMessage( + "shinychat-thinking", + list(id = thinking_id, type = type, content = content) + ) + } for (msg in stream) { if (promises::is.promising(msg)) { @@ -521,6 +531,21 @@ rlang::on_load( } } + if (S7::S7_inherits(msg, ellmer::ContentThinking)) { + if (is.null(thinking_id)) { + thinking_id <- paste0( + "think-", + format(Sys.time(), "%H%M%S"), + "-", + sample.int(1e6, 1) + ) + send_thinking("start", msg@thinking) + } else { + send_thinking("update", msg@thinking) + } + next + } + if (S7::S7_inherits(msg, ellmer::Content)) { msg <- contents_shinychat(msg) } @@ -528,6 +553,10 @@ rlang::on_load( chat_append_(msg) } + if (!is.null(thinking_id)) { + send_thinking("done") + } + chat_append_("", chunk = "end") res <- res$as_list() diff --git a/pkg-r/R/contents_shinychat.R b/pkg-r/R/contents_shinychat.R index 02e2b260..9bd4153c 100644 --- a/pkg-r/R/contents_shinychat.R +++ b/pkg-r/R/contents_shinychat.R @@ -136,6 +136,26 @@ S7::method(contents_shinychat, ellmer::ContentText) <- function(content) { content@text } +S7::method(contents_shinychat, ellmer::ContentThinking) <- function(content) { + text <- content@thinking + preview <- strsplit(trimws(text), "\n")[[1]][1] + if (nchar(preview) > 60) { + preview <- paste0(substr(preview, 1, 60), "\u2026") + } + + htmltools::div( + class = "shinychat-thinking", + `data-open` = "false", + htmltools::tags$button( + type = "button", + class = "shinychat-thinking-toggle", + `aria-expanded` = "false", + htmltools::span(class = "shinychat-thinking-preview", preview) + ), + htmltools::div(class = "shinychat-thinking-content", text) + ) +} + new_tool_card <- function(type, request_id, tool_name, ...) { type <- arg_match(type, c("request", "result")) @@ -356,10 +376,34 @@ tool_string <- function(x) { } } +flush_thinking_buffer <- function(thinking_buffer) { + if (length(thinking_buffer) == 0) { + return(list()) + } + combined <- ellmer::ContentThinking(paste(thinking_buffer, collapse = "\n\n")) + list(combined) +} + +consolidate_thinking <- function(contents) { + consolidated <- list() + thinking_buffer <- character() + + for (item in contents) { + if (S7::S7_inherits(item, ellmer::ContentThinking)) { + thinking_buffer <- c(thinking_buffer, item@thinking) + } else { + consolidated <- c(consolidated, flush_thinking_buffer(thinking_buffer)) + thinking_buffer <- character() + consolidated <- c(consolidated, list(item)) + } + } + + c(consolidated, flush_thinking_buffer(thinking_buffer)) +} S7::method(contents_shinychat, ellmer::Turn) <- function(content) { - # Process all contents in the turn, filtering out empty results - compact(map(content@contents, contents_shinychat)) + consolidated <- consolidate_thinking(content@contents) + compact(map(consolidated, contents_shinychat)) } S7::method(contents_shinychat, S7::new_S3_class(c("Chat", "R6"))) <- function( diff --git a/pkg-r/inst/lib/shiny/thinking/thinking.css b/pkg-r/inst/lib/shiny/thinking/thinking.css new file mode 100644 index 00000000..fbc11673 --- /dev/null +++ b/pkg-r/inst/lib/shiny/thinking/thinking.css @@ -0,0 +1,48 @@ +.shinychat-thinking { + margin-bottom: 0.75rem; + border-left: 2px solid var(--bs-border-color, #dee2e6); + padding-left: 0.75rem; + font-size: 0.9em; + font-style: italic; + color: var(--bs-secondary-color, #6c757d); + cursor: pointer; +} + +.shinychat-thinking:hover { + color: var(--bs-body-color, #212529); +} + +.shinychat-thinking-toggle { + background: none; + border: none; + padding: 0; + display: flex; + align-items: center; + color: inherit; + font-size: inherit; + font-style: inherit; + text-align: left; + width: 100%; +} + +.shinychat-thinking-preview { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.shinychat-thinking-content { + display: none; + margin-top: 0.5rem; + white-space: pre-wrap; +} + +.shinychat-thinking[data-open="true"] .shinychat-thinking-preview { + display: none; +} + +.shinychat-thinking[data-open="true"] .shinychat-thinking-content { + display: block; +} diff --git a/pkg-r/inst/lib/shiny/thinking/thinking.js b/pkg-r/inst/lib/shiny/thinking/thinking.js new file mode 100644 index 00000000..e3bc8fe1 --- /dev/null +++ b/pkg-r/inst/lib/shiny/thinking/thinking.js @@ -0,0 +1,106 @@ +(function() { + const thinkingBlocks = new Map(); + + function findStreamingMessage() { + const messages = document.querySelector('shiny-chat-messages'); + if (!messages) return null; + return messages.querySelector('shiny-chat-message[streaming]'); + } + + function createThinkingContainer(id) { + const wrapper = document.createElement('div'); + wrapper.className = 'shinychat-thinking'; + wrapper.dataset.id = id; + wrapper.dataset.open = 'false'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'shinychat-thinking-toggle'; + button.setAttribute('aria-expanded', 'false'); + + const preview = document.createElement('span'); + preview.className = 'shinychat-thinking-preview'; + preview.textContent = '…'; + + button.appendChild(preview); + + const content = document.createElement('div'); + content.className = 'shinychat-thinking-content'; + + wrapper.appendChild(button); + wrapper.appendChild(content); + + return { wrapper, preview, content, fullText: '' }; + } + + function handleThinkingMessage(payload) { + const { id, type, content: text } = payload; + const retries = payload._retries || 0; + + if (type === 'start') { + const message = findStreamingMessage(); + if (!message) { + if (retries < 20) { + setTimeout(() => handleThinkingMessage({ ...payload, _retries: retries + 1 }), 50); + } + return; + } + + const parts = createThinkingContainer(id); + thinkingBlocks.set(id, parts); + + const target = message.querySelector('shiny-markdown-stream') || message; + target.insertAdjacentElement('afterbegin', parts.wrapper); + } + + if (type === 'update' || type === 'start') { + const parts = thinkingBlocks.get(id); + if (!parts) return; + + if (text) { + parts.fullText += text; + parts.content.textContent = parts.fullText; + + const previewText = parts.fullText.trim().split('\n')[0]; + parts.preview.textContent = previewText.length > 60 + ? previewText.slice(0, 60) + '…' + : previewText || '…'; + } + } + + if (type === 'done') { + const parts = thinkingBlocks.get(id); + if (!parts) return; + + if (!parts.fullText.trim()) { + parts.wrapper.remove(); + } + thinkingBlocks.delete(id); + } + } + + function handleThinkingClick(event) { + const wrapper = event.target.closest('.shinychat-thinking'); + if (!wrapper) return; + + const button = wrapper.querySelector('button'); + const isOpen = wrapper.dataset.open === 'true'; + wrapper.dataset.open = (!isOpen).toString(); + if (button) { + button.setAttribute('aria-expanded', (!isOpen).toString()); + } + } + + function register() { + if (!window.Shiny) return; + Shiny.addCustomMessageHandler('shinychat-thinking', handleThinkingMessage); + } + + document.addEventListener('click', handleThinkingClick); + + if (window.Shiny) { + register(); + } else { + document.addEventListener('shiny:connected', register); + } +})(); diff --git a/pkg-r/tests/testthat/_snaps/chat.md b/pkg-r/tests/testthat/_snaps/chat.md index 8b0eefaf..f451e1e1 100644 --- a/pkg-r/tests/testthat/_snaps/chat.md +++ b/pkg-r/tests/testthat/_snaps/chat.md @@ -55,7 +55,7 @@ "1.0.0", "")), span("world")))) Output $deps - [{"name":"foo","all_files":true},{"name":"shinychat","script":[{"src":"chat/chat.js","type":"module"},{"src":"markdown-stream/markdown-stream.js","type":"module"}],"stylesheet":["chat/chat.css","markdown-stream/markdown-stream.css"],"all_files":true},{"name":"bslib-tag-require","script":"tag-require.js","all_files":true},{"name":"htmltools-fill","stylesheet":"fill.css","all_files":true}] + [{"name":"foo","all_files":true},{"name":"shinychat","script":[{"src":"chat/chat.js","type":"module"},{"src":"markdown-stream/markdown-stream.js","type":"module"},{"src":"thinking/thinking.js"}],"stylesheet":["chat/chat.css","markdown-stream/markdown-stream.css","thinking/thinking.css"],"all_files":true},{"name":"bslib-tag-require","script":"tag-require.js","all_files":true},{"name":"htmltools-fill","stylesheet":"fill.css","all_files":true}] $html diff --git a/pkg-r/tests/testthat/test-contents_shinychat.R b/pkg-r/tests/testthat/test-contents_shinychat.R index 47b38147..33170af9 100644 --- a/pkg-r/tests/testthat/test-contents_shinychat.R +++ b/pkg-r/tests/testthat/test-contents_shinychat.R @@ -37,6 +37,17 @@ test_that("opt_shinychat_tool_display handles options and environment variables" }) }) +test_that("ContentThinking renders as collapsible HTML", { + thinking <- ellmer::ContentThinking("Let me think about this...") + result <- contents_shinychat(thinking) + + expect_s3_class(result, "shiny.tag") + html <- as.character(result) + expect_match(html, "shinychat-thinking") + expect_match(html, "shinychat-thinking-content") + expect_match(html, "Let me think about this") +}) + test_that("basic Content handling works", { ContentHTML <- S7::new_class( "ContentHTML",