Shiny AI Chat Bots

How to combine {shiny}, {ellmer} and {htmltools}
to create AI applications


Dr. Albert Rapp
ShinyConf 2025

What we’re going to build today

πŸ”— 3 Minutes Wednesdays (Dec 04 - 18, 2024)

Hi, I’m Dr. Albert Rapp πŸ‘‹

Senior Consultant
Content Creator
Freelancer





@rappa753
Dr. Albert Rapp
3mw.albert-rapp.de
info@albert-rapp.de

Roadmap / Components

Start with AI

Create a chat with ellmer

library(ellmer)
chat <- chat_claude(
  model = 'claude-3-7-sonnet-20250219',
  system_prompt = 'You are a helpful AI bot'
)

Stream results

chat$stream('What is the meaning of life?')
## <generator/instance>
## function (self, private, user_turn, stream, echo) 
## {
##     while (!is.null(user_turn)) {
##         for (chunk in private$submit_turns(user_turn, stream = stream, 
##             echo = echo)) {
##             yield(chunk)
##         }
##         user_turn <- private$invoke_tools()
##     }
## }
## <environment: 0x5acc0c634648>


stream <- chat$stream('What is the meaning of life?')
coro::loop(for (chunk in stream) {
  cat(chunk)
})

Building a UI

Building a UI

library(htmltools)
bslib::page_fluid(
  div(
    id = 'chat_container',

  )
)

Building a UI

library(htmltools)
bslib::page_fluid(
  div(
    id = 'chat_container',
    div(
      id = 'chat_output',
    ),
    div(
      id = 'chat_input',
    )
  )
)

Building a UI

library(htmltools)
bslib::page_fluid(
  div(
    id = 'chat_container',
    div(
      id = 'chat_output',
      div(
        class = 'chat_reply',
        "How can I help you?"
      )
    ),
    div(
      id = 'chat_input',
    )
  )
)

Building a UI

library(htmltools)
bslib::page_fluid(
  div(
    id = 'chat_container',
    div(
      id = 'chat_output',
      div(
        class = 'chat_reply',
        "How can I help you?"
      )
    ),
    div(
      id = 'chat_input',
      textAreaInput(
        'textarea_chat_input',
        ## other args
      ),
      actionButton(
        'send_text',
        ## other args
      )
    )
  )
)

Weaving things together with Shiny

πŸ”— Shiny Courses

10% off with code β€œRAPP10”

Setup Shiny Server

server <- function(input, output, session) {
  chat <- ellmer::chat_claude('You are a helpful assistant')

  observe({
    ## Logic after button click
  }) |> bindEvent(input$send_text)
}

Insert Request

server <- function(input, output, session) {
  chat <- ellmer::chat_claude('You are a helpful assistant')

  observe({
    ## Logic after button click
    insertUI( 
      '#chat_output',
      where = 'beforeEnd',
      ui = div(
        class = 'chat_input',
        input$textarea_chat_input 
      ),
      immediate = TRUE
    )
  }) |> bindEvent(input$send_text)
}

Insert Empty Reply

server <- function(input, output, session) {
  chat <- ellmer::chat_claude('You are a helpful assistant')

  observe({
    ## Logic after button click
    insertUI( 
      '#chat_output',
      where = 'beforeEnd',
      ui = div(
        class = 'chat_input',
        input$textarea_chat_input 
      ),
      immediate = TRUE
    )
    insertUI( 
      '#chat_output',
      where = 'beforeEnd',
      ui = div(
        class = 'chat_reply',
        ''
      ),
      immediate = TRUE
    )    
  }) |> bindEvent(input$send_text)
}

Stream Response

server <- function(input, output, session) {
  chat <- ellmer::chat_claude('You are a helpful assistant')

  observe({
    ## Logic after button click
    insertUI( 
      '#chat_output',
      where = 'beforeEnd',
      ui = div(
        class = 'chat_input',
        input$textarea_chat_input 
      ),
      immediate = TRUE
    )
    insertUI( 
      '#chat_output',
      where = 'beforeEnd',
      ui = div(
        class = 'chat_reply',
        ''
      ),
      immediate = TRUE
    ) 

    stream <- chat$stream(input$textarea_chat_input)
    coro::loop(for (chunk in stream) {
      insertUI( 
        '.chat_reply:last',
        where = 'beforeEnd',
        ui = chunk,
        immediate = TRUE
      )
    })
  }) |> bindEvent(input$send_text)
}

No Markdown Formatting

Send Custom Message

stream <- chat$stream(input$textarea_chat_input)
coro::loop(for (chunk in stream) {
  session$sendCustomMessage("updateReply", chunk)
})

Catch Custom Message

bslib::page_fluid(
    tags$script(HTML("
        Shiny.addCustomMessageHandler('updateReply', function(chunk) {
            // JS code goes here
        });"
    ))
    ## ...rest of UI
)

Collect Chunks and Set UI Content

// Actual JS Code
Shiny.addCustomMessageHandler('updateReply', function(chunk) {
    // JS code goes here
});

Collect Chunks and Set UI Content

// Actual JS Code
var chunks = '';
Shiny.addCustomMessageHandler('updateReply', function(chunk) {
    // JS code goes here
});

Collect Chunks and Set UI Content

// Actual JS Code
var chunks = '';
Shiny.addCustomMessageHandler('updateReply', function(chunk) {
    chunks = chunks + chunk;
});

Collect Chunks and Set UI Content

// Actual JS Code
var chunks = '';
Shiny.addCustomMessageHandler('updateReply', function(chunk) {
    chunks = chunks + chunk;
    $('.chat_reply:last')[0].innerHTML = chunks;
});

Add Markdown Support

bslib::page_fluid(
    tags$script(
        src = "https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"
    ),
    tags$script(HTML("..."))
    ## ...rest of UI
)


// Actual JS Code
var chunks = '';
Shiny.addCustomMessageHandler('updateReply', function(chunk) {
    chunks = chunks + chunk;
    const md = markdownit();
    $('.chat_reply:last')[0].innerHTML = md.render(chunks);
});

Result

Resources