Agentul pe care l-am construit în partea 1 locuiește într-o consolă R, care se potrivește unei singure persoane. O echipă de încercare are nevoie de o aplicație în browser, unde mai multe persoane pot pune întrebări deodată, fără a vedea conversațiile reciproce.
Aceasta este partea a doua a unei serii de cinci părți despre introducerea agenților AI în producție cu R. Fiecare parte se bazează pe aceeași aplicație, un asistent de siguranță pentru studii clinice. Piesele sunt:
Ca o reamintire, agentul răspunde la întrebări despre datele de siguranță ale unui studiu clinic cu un model, patru instrumente și o buclă. Modelul alege ce unealtă să apeleze, iar instrumentele numără în R. De asemenea, modelului i se spune să nu facă niciodată matematica în sine, iar partea 3 verifică asta. (Partea 1 explică și termenii procesului.)
În această postare noi:
creați o aplicație de chat în jurul agentului cu {shiny} și {shinychat}
oferiți fiecărui utilizator propria conversație și testați-o
afișați apelurile instrumentului agentului în chat
opriți încărcarea fișierelor pe model
gestionați modelul lent sau indisponibil și implementați-l pe Posit Connect
Ce este {shinychat}?
{shinychat} este pachetul Posit de componente de chat pentru Shiny și avem nevoie de două dintre ele. Fereastra de chat vine de la chat_ui(). În spatele ei, chat_server() conectează fereastra la un client de chat {ellmer}. Trimite fiecare întrebare către model, transmite răspunsul înapoi și arată fiecare apel de instrument așa cum se întâmplă.
Clientul este partea 1 make_agent() build-uri, astfel încât instrumentele, promptul de sistem și bucla sunt transferate neschimbate. Dacă ai folosit chat_mod_ui() şi chat_mod_server() înainte, {shinychat} 0.5.0 le depreciază în favoarea acestor două.
Aplicația
Păstrăm pagina în propria sa funcție, app_ui()astfel încât testele să îl poată verifica fără a porni aplicația:
GREETING <- paste(
"Ask me about adverse events in CDISC pilot study 01: a xanomeline skin patch at two doses, against placebo.",
"For example: *Which adverse events were most common on the high dose?*",
"I only know this trial's safety data, and I can't give medical advice.",
sep = "\n\n"
)
app_ui <- function() {
bslib::page_fillable(
title = "Trial safety assistant",
shinychat::chat_ui(
"chat",
greeting = GREETING,
placeholder = "Ask about the trial's adverse events",
allow_attachments = FALSE
)
)
}
Salutul spune oamenilor pentru ce este asistentul. Nu face decât să stabilească așteptări, totuși. Regula împotriva sfaturilor medicale intră în promptul de sistem în partea 3, unde testăm și dacă agentul o păstrează.
Aplicația în sine este scurtă, deoarece munca se întâmplă în fișierele din R/: partea 1, plus patru noi (ui.R, display.R, wrap.R şi attachments.R), pe care Shiny surse înainte app.R ruleaza:
library(shiny)
library(bslib)
library(shinychat)
# shinychat writes a failed answer's error into the chat. Sanitised, it says
# "An error occurred" instead of showing the provider's raw message.
options(shiny.sanitize.errors = TRUE)
# Shiny sources every file in R/ before this runs.
trial_data <- load_trial_data()
ui <- app_ui()
server <- function(input, output, session) {
# One client per session. A client made outside server() would share one
# conversation between every user.
client <- make_agent(tools = with_display(trial_tools(trial_data)))
refuse_attachments(client)
chat <- chat_server("chat", client, history = FALSE)
observeEvent(chat$last_error(), {
error <- chat$last_error()
message <- if (inherits(error, "trial_attachment_refused")) {
conditionMessage(error)
} else {
"The assistant couldn't answer just now. Please try again in a minute."
}
showNotification(message, type = "error", duration = NULL)
})
}
shinyApp(ui, server)
Pentru fiecare persoană care deschide aplicația, server() face patru lucruri:
Îi face propriul client de chat.make_agent() îl construiește cu cele patru unelte, fiecare înfășurat cu with_display() astfel încât rezultatele să se citească bine în chat.
Verifică fiecare cerere înainte de a ajunge la modelcu refuse_attachments().
Conectează clientul la fereastra de chat. Asta e chat_server()cu istoricul de conversații încorporat dezactivat (history = FALSE). Lăsat, {shinychat} 0.5.0 salvează fiecare conversație ca fișiere pe server, inclusiv înregistrările pacienților, în afara jurnalului de audit și a oricărei reguli de păstrare, și îi cere modelului să le denumească pe fiecare. Partea 4 păstrează în schimb o singură înregistrare.
Îi spune utilizatorului ce să facă dacă o întrebare eșueazăprin vizionare chat$last_error().
Datele sunt încărcate o dată, în exterior server()și partajat de fiecare sesiune. Este sigur pentru că nimic nu o schimbă.
Pentru a rula aplicația, obțineți folderul acestei părți ca în partea 1 și deschideți-l ca proiect. Cheia dvs. nu vine cu ea, deoarece git ignoră .Renvironașa că copiați acel fișier peste folderul părții 1.
Apoi fugi renv::restore()care este rapid deoarece {renv} reutiliza pachetele pe care le-a instalat anterior sau le instalează pe cele noi cu install.packages(c("shiny", "shinychat", "rsconnect")). În sfârșit, sunați shiny::runApp().
Aplicația răspunde la o întrebare. Rândul „Rezumat evenimente adverse” de deasupra răspunsului este apelul instrumentului; deschide-l pentru a vedea ce a cerut agentul și ce a revenit.
O conversație per utilizator
Un client de chat {ellmer} trimite conversația de până acum cu fiecare întrebare nouă. Acesta este ceea ce face ca urmăririle să funcționeze și înseamnă că un client aparține unei singure persoane.
Shiny rulează un proces R pentru mulți utilizatori și apelează server() o dată pentru fiecare sesiune de browser. Orice creat în interior server() aparține acelei sesiuni, în timp ce orice creat în partea de sus app.R este împărtășită de toată lumea.
Deci, un client creat în partea de sus a fișierului pune fiecare utilizator în aceeași conversație. Cea de-a doua persoană care sosește poate întreba „ce tocmai am întrebat?” și primiți întrebarea primei persoane înapoi.
Fiecare sesiune de browser are propriul client de chat. Numai datele de încercare, care nu se modifică niciodată, sunt partajate.
Nu veți înțelege acest lucru încercând aplicația pe cont propriu, așa că o testăm. Cele strălucitoare testServer() rulează funcția server fără browser. Testul nostru creează o aplicație cu shiny::shinyAppDir()aleargă testServer() pe el de două ori și verifică dacă cele două sesiuni nu împărtășesc niciodată a chat$client.
Pentru testele de browser care merg alături testServer()vezi seria noastră pe {shinytest2}.
test_that("two sessions never share a client", {
withr::local_envvar(GEMINI_API_KEY = "test-key")
# One app, as on a server: both sessions run inside it. Given a folder,
# testServer() builds a fresh app each time, which would hide a shared client.
app <- shiny::shinyAppDir(test_path("..", ".."))
seen <- new.env()
for (i in 1:2) {
shiny::testServer(app, assign(paste0("client", i), chat$client, envir = seen))
}
expect_false(identical(seen$client1, seen$client2))
})
Arată lucrarea
{shinychat} arată apelurile instrumentului ca rânduri deasupra răspunsului. Fiecare rând își ia titlul din tool_annotations(title = …) am oferit instrumentul în partea 1, cum ar fi „Numărarea subiecților” sau „Rezumarea evenimentelor adverse”. Deschiderea unui rând arată ce a cerut agentul și ce a revenit, astfel încât o echipă de încercare poate verifica de unde provine un număr.
În mod implicit, apelurile către același instrument partajează un rând, care listează fiecare apel când îl deschideți. Set tool_grouping = "none" în chat_ui() pentru a da fiecărui apel propriul rând.
Rezultatele brute sunt JSON, care se potrivește modelului, dar este greu de citit de către oameni. Aşa with_display() împachetează fiecare instrument, iar oamenii văd un tabel care poate fi citit în timp ce modelul încă primește JSON:
with_display <- function(tools) {
lapply(tools, function(tool) {
wrap_tool(tool, function(run, args) {
value <- run()
# The display is for people. If it can't be built, the model still
# gets its answer.
markdown <- tryCatch(result_markdown(value), error = function(e) NULL)
if (is.null(markdown)) return(value)
ellmer::ContentToolResult(
value = value,
extra = list(display = shinychat::tool_result_display(markdown = markdown))
)
})
})
}
Doi ajutoare fac treaba. Primul, wrap_tool() în R/wrap.Rcopiază un instrument astfel încât fiecare apel să treacă printr-o funcție pe care i-o oferim. În cadrul acestei funcții, run() apelează instrumentul original și args deține argumentele cu care instrumentul va rula, inclusiv toate valorile implicite pe care modelul le-a lăsat afară.
Al doilea, result_markdown() în R/display.Rtransformă JSON într-un tabel sau o listă. Noi folosim wrap_tool() din nou în partea 4, pentru jurnalul de audit.
Nu toate instrumentele returnează JSON. Instrumentul de predare din partea 3 returnează o propoziție simplă, iar prima noastră versiune a afișajului s-a spart pe ea, luând și întregul apel în jos. Acum se întoarce la text simplu.
Fără încărcări de fișiere
Implicit, chat_server() permite încărcarea fișierelor. Oamenii pot atașa un fișier CSV, un PDF sau o imagine și merge direct la model. Asta anulează designul părții 1. Modelul ar citi datele brute în sine în loc să ceară instrumentele noastre testate, iar întregul fișier ar merge la furnizor, atunci când o întrebare are nevoie doar de câteva numere.
Setare allow_attachments = FALSE în chat_ui() ascunde doar butonul de încărcare și oricine poate trimite o solicitare fără a utiliza pagina. Deci serverul verifică și fiecare cerere. Refuză orice rând de utilizator care conține mai mult decât textul tastat și rezultatele instrumentului ({ellmer} trimite rezultatele instrumentului înapoi pe măsură ce se întoarce utilizatorul).
Fișierele text au nevoie de încă o verificare. {shinychat} trimite un fișier CSV sau TXT ca text simplu într-un ambalaj, așa că cecul refuză și asta. {ellmer} construiește turnurile și conținutul lor ca obiecte S7, astfel încât codul le testează clasa S7::S7_inherits() și le citește câmpurile cu @:
attachment_check <- function() {
allowed <- function(content) {
if (S7::S7_inherits(content, ellmer::ContentText)) {
# shinychat sends a text file (CSV, TXT, JSON and so on) as text in a
# wrapper, so text can be a file too.
return(!grepl("^\\s*
The check can’t stop someone pasting a file’s contents into the question, because no check can tell pasted data from a question. So what limits the risk is who can open the app and which provider you send text to, and part 4 covers both.
When the model is down
As we saw in part 1, Gemini’s free tier often answers “503: high demand” or “429: quota exceeded”. When the provider is busy or rate-limited, {ellmer} tries each request up to three times, waiting longer each time, and options(ellmer_max_tries = 5) allows more.
If the last try fails, chat$last_error() fires. The chat shows {shinychat}’s generic line, “An error occurred. Please try again or contact the app author.”, and our notification tells the user what to do. Our post on dynamic alt text shows the same idea outside a chat app, with tryCatch() and a fallback.
Two users at once
R does one thing at a time. If one person’s 20-second question blocked the R process, everyone else’s app would freeze until it finished.
{shinychat}’s chat_server() avoids that. It streams each answer with {ellmer}’s asynchronous stream_async(), inside a Shiny ExtendedTask, so R keeps serving other sessions while one waits for the model. Tool calls still run one at a time in the shared process, so keep your tools quick. Ours take milliseconds.
Waiting for the model doesn't block R, so a second person's question starts straight away.
Pentru a verifica manual, deschideți două ferestre ale browserului. Întreabă în primul și, în timp ce răspunsul încă sosește, întreabă în al doilea. Al doilea răspuns ar trebui să înceapă imediat. Rezumatul nostru Shiny in Production 2025 are mai multe despre Shiny asincron.
Se implementează în Posit Connect
Posit Connect rulează aplicații Shiny pentru o echipă, cu conectare și control al accesului.
Tratați această primă implementare ca pe un test privat. Nivelul gratuit este bine pentru aceste date publice, dar nu pentru propriul pacient sau pentru datele confidențiale. Și o aplicație oferită utilizatorilor din Marea Britanie, SEE sau Elveția trebuie să utilizeze serviciul plătit al Google, așa cum explică partea 4.
Această versiune permite oricui care poate deschide aplicația să vadă înregistrările pacienților și nu păstrează niciun jurnal. Părțile 3 și 4 le fixează pe ambele.
Vă înregistrați contul Connect o dată, iar codul README conține pașii. După aceea, implementarea este un apel din folderul proiectului. The appFiles argumentul listează ceea ce include pachetul: aplicația, aplicația R/ folder și promptul de sistem. Testele și fișierele locale rămân în urmă.
Cheia API nu intră niciodată în cod sau pachet. In schimb, envVars îl copiază din sesiunea dvs. R, din care îl citește .Renvironîn aplicația Vars panoul din Connect, printr-o conexiune criptată, înainte ca aplicația să pornească pentru prima dată. La fiecare implementare ulterioară, îl copiază din nou, așa că schimbați cheia în dvs .Renviron cât şi în panou.
Dincolo de un test privat, utilizați o cheie din contul propriu al organizației dvs., conform condițiilor plătite, mai degrabă decât cea personală.
L-am implementat pe propriul nostru server Connect, versiunea 2026.06.1, și am repetat verificarea în două ferestre acolo. Fiecare fereastră a ținut propria conversație.
Pachetul conținea doar cele nouă fișiere enumerate. Pentru că folderul are un renv.locktotuși, {rsconnect} a preluat lista de pachete din ea, așa că Connect a instalat toate cele 100 de pachete din fișierul de blocare, inclusiv cele folosite doar de teste. Acest lucru face ca prima implementare să fie mai lentă, dar nu dăunează.
Rezumat
Agentul din partea 1 este acum o aplicație pe care mai multe persoane o pot folosi simultan. Fiecare persoană are propria conversație, iar apelurile instrumentului sunt vizibile. Fișierele atașate sunt refuzate, o întrebare nereușită îi spune utilizatorului ce trebuie să facă în continuare, iar aplicația rulează pe Posit Connect.
Înainte de a-l distribui, parcurgeți cele cinci verificări înainte de zbor ale lui Tim pentru tabloul de bord. Se aplică și unei aplicații de chat.
Data viitoare, ne asumăm mai întâi întrebarea pe care fiecare echipă de încercare o pune: poți avea încredere în ea? Vom testa instrumentele, vom evalua agentul și vom verifica de unde provine fiecare număr dintr-un răspuns. De asemenea, vom lăsa doar recenzenții să deschidă un dosar al pacientului și să le confirme pe fiecare.
Tot codul pentru această parte, inclusiv aplicația și testele acesteia, se află în depozitul nostru de blog.
Dacă construiți instrumente AI cu Shiny, veniți și vorbiți despre asta la AI în producție 2027, unde apelul pentru discuții este deschis până pe 29 ianuarie 2027. Cursul nostru de aplicații bazate pe LLM cu R și Python acoperă și adăugarea unui chat la o aplicație Shiny.
Pentru actualizări și revizuiri ale acestui articol, consultați postarea inițială