R/llm_chat_app.R

Defines functions run_llm_chat_app

Documented in run_llm_chat_app

#' Run the LLM Chat Application in RStudio Window
#'
#' Launches the Shiny application as a Gadget in the RStudio Viewer pane
#' (or a separate window). Interacts with LLM models, managing conversations,
#' attachments, and settings, without blocking the R console when opened in a new window.
#'
#' @export
#' @return Value passed to shiny::stopApp() (typically NULL).
#' @import shiny
#' @importFrom shinyjs useShinyjs runjs enable disable show hide
#' @import future promises httr pdftools readtext tools
#' @importFrom utils tail head
#' @importFrom stats setNames
#' @examples
#' \dontrun{
#' run_llm_chat_app()
#' }
run_llm_chat_app <- function() {

  # Verbose flag
  .is_verbose <- function() getOption("PacketLLM.verbose", default = FALSE)

  # UI
  ui <- fluidPage(
    title = "LLM Chat",
    useShinyjs(),
    tags$head(
      tags$script(HTML("
        function sendClickToServer(inputId, convId) {
          Shiny.setInputValue(inputId, { convId: convId, timestamp: Date.now() }, { priority: 'event' });
        }
        $(document).on('click', '.send-btn-class', function() {
          var convId = $(this).data('conv-id');
          sendClickToServer('dynamic_send_click', convId);
        });
        $(document).on('click', '.add-file-btn-class', function() {
          if ($(this).is(':disabled')) return;
          var convId = $(this).data('conv-id');
          Shiny.setInputValue('dynamic_add_file_click', { convId: convId, timestamp: Date.now() }, { priority: 'event' });
          $('#hidden_file_input').val(null).click();
        });
        $(document).on('shiny:connected', function() {
          Shiny.addCustomMessageHandler('resetFileInput', function(message) {
            var el = $('#' + message.id);
            if (el.length > 0) el.val(null);
          });
        });
      ")),
      # CSS (increase heights to reduce white space on large windows)
      tags$style(HTML("
        body { padding-top: 50px; font-family: sans-serif; }
        .navbar.navbar-default.navbar-fixed-top { border-width: 0 0 1px; min-height: 40px; margin-bottom: 10px; background-color: #ffffff; border-color: #ddd; padding-left: 0; padding-right: 0; }
        .navbar.navbar-default.navbar-fixed-top .container-fluid { display: flex !important; align-items: center !important; flex-wrap: wrap !important; padding-left: 15px !important; padding-right: 15px !important; width: 100%; box-sizing: border-box; }
        .navbar.navbar-default.navbar-fixed-top .navbar-header { float: none !important; flex-shrink: 0 !important; margin: 0 !important; padding: 0 !important; margin-right: 15px !important; }
        .navbar.navbar-default.navbar-fixed-top .navbar-brand { padding-left: 0 !important; padding-right: 0 !important; padding-top: 10px !important; padding-bottom: 10px !important; margin: 0 !important; height: auto !important; font-size: 16px !important; line-height: 20px; display: block !important; }
        .navbar.navbar-default.navbar-fixed-top .navbar-right { float: none !important; flex-shrink: 0 !important; margin: 0 !important; padding: 0 !important; margin-left: auto !important; display: flex !important; align-items: center !important; }
        .navbar.navbar-default.navbar-fixed-top .navbar-right .btn { margin-left: 5px !important; margin-top: 0 !important; margin-bottom: 0 !important; }
        #hidden_file_input { display: none; }
        .close-tab-btn { font-size: 10px; line-height: 1; vertical-align: middle; margin-top: -2px; margin-left: 6px; padding: 1px 4px; }
        .nav-tabs > li > a { padding: 8px 10px; }
        .nav-tabs > li > a .close-tab-btn { visibility: hidden; }
        .nav-tabs > li:hover > a .close-tab-btn { visibility: visible; }
        .nav-tabs > li.active > a .close-tab-btn { visibility: visible; }
        .btn.disabled, .btn[disabled] { cursor: not-allowed; opacity: 0.65; }
        .tab-content > .tab-pane { padding: 15px; border: 1px solid #ddd; border-top: none; min-height: 70vh; }
        .chat-history-container-class { height: 65vh; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 15px; background-color: #f9f9f9; }
        .input-action-row { display: flex; align-items: stretch; gap: 10px; }
        .input-action-row .add-file-btn-class { flex-shrink: 0; height: 56px; width: 56px; font-size: 24px; padding: 0; line-height: 56px; text-align: center; }
        .input-action-row .attachments-container { flex-shrink: 0; flex-basis: 200px; min-width: 100px; max-width: 30%; min-height: 56px; max-height: 80px; overflow-y: auto; border: 1px dashed #ddd; padding: 5px; background-color: #fff; align-self: center; box-sizing: border-box; }
        .input-action-row textarea.form-control { flex-grow: 1; height: 56px; resize: none; margin: 0; box-sizing: border-box; }
        .input-action-row .send-btn-class { flex-shrink: 0; height: 56px; }
        .attachments-input-row, .message-input-row { display: none !important; }
        .message-input-col, .send-button-col { display: none !important; }
        .form-group { margin-bottom: 0px; }
      "))
    ),
    tags$nav(
      class = "navbar navbar-default navbar-fixed-top",
      tags$div(
        class = "container-fluid",
        tags$div(class = "navbar-header", tags$span(class = "navbar-brand", "LLM Chat")),
        tags$div(
          class = "navbar-right",
          actionButton("advanced_settings_btn", "Settings", class = "btn-default btn-sm"),
          actionButton("new_chat_btn", "New Conversation", class = "btn-primary btn-sm"),
          actionButton("close_app_btn", "X", class = "btn btn-danger btn-sm")
        )
      )
    ),
    div(style = "padding-left: 15px; padding-right: 15px;",
        tabsetPanel(id = "chatTabs", type = "tabs")
    ),
    tags$div(style = "display: none;", fileInput("hidden_file_input", NULL, multiple = TRUE))
  )

  # Server
  server <- function(input, output, session) {

    initial_conv_id <- initialize_history_manager()
    active_conv_id_rv <- reactiveVal(NULL)
    open_tab_ids_rv <- reactiveVal(character(0))
    first_tab_created <- reactiveVal(FALSE)
    processing_state <- reactiveValues()
    file_upload_context_rv <- reactiveVal(NULL)
    staged_attachments_rv <- reactiveVal(list())

    # UI helpers
    create_and_append_new_tab <- function(conv_id, select_tab = TRUE) {
      conv_title <- get_conversation_title(conv_id) %||% paste("ID:", conv_id)
      processing_state[[conv_id]] <- FALSE
      current_staged <- staged_attachments_rv(); current_staged[[conv_id]] <- list(); staged_attachments_rv(current_staged)
      tab_content <- tryCatch(create_tab_content_ui(conv_id), error = function(e) {
        shiny::tagList(shiny::h4("Error loading UI for conversation"), shiny::verbatimTextOutput(paste0("error_ui_", conv_id)))
        output[[paste0("error_ui_", conv_id)]] <- shiny::renderPrint(e)
      })
      insertTab(
        inputId = "chatTabs",
        tabPanel(
          title = span(conv_title, actionButton(paste0("close_tab_", conv_id), "x",
                                                class = "btn-xs btn-danger close-tab-btn",
                                                onclick = sprintf("event.stopPropagation(); Shiny.setInputValue('close_tab_request', '%s', {priority: 'event'})", conv_id))),
          value = conv_id,
          tab_content
        ),
        select = select_tab
      )
      open_tab_ids_rv(c(open_tab_ids_rv(), conv_id))
      input_id <- NS(conv_id)("user_message_input")
      observeEvent(input[[input_id]], {
        if (conv_id %in% open_tab_ids_rv()) {
          active_id <- active_conv_id_rv()
          if (!is.null(active_id) && active_id == conv_id) update_send_button_state(conv_id)
        }
      }, ignoreNULL = FALSE, ignoreInit = TRUE, once = FALSE, autoDestroy = TRUE)
    }

    can_send_message <- function(conv_id) {
      if (is.null(conv_id) || !conv_id %in% open_tab_ids_rv()) return(FALSE)
      if (isTRUE(processing_state[[conv_id]])) return(FALSE)
      input_id <- NS(conv_id)("user_message_input")
      input_val <- isolate(input[[input_id]])
      has_text <- nzchar(trimws(input_val %||% ""))
      has_staged_files <- length(isolate(staged_attachments_rv()[[conv_id]]) %||% list()) > 0
      conv_model <- get_conversation_model(conv_id)
      if (is.null(conv_model)) { warning(paste("No model found for conversation ID:", conv_id, "in can_send_message.")); return(FALSE) }
      if (conv_model %in% simplified_models_list) {
        return(has_text)
      } else {
        return(has_text || has_staged_files)
      }
    }

    update_send_button_state <- function(conv_id) {
      req(conv_id)
      if (conv_id %in% open_tab_ids_rv()) {
        button_id_selector <- paste0("#", NS(conv_id)("send_query_btn"))
        if (can_send_message(conv_id)) shinyjs::enable(selector = button_id_selector) else shinyjs::disable(selector = button_id_selector)
      }
    }

    update_add_file_button_state <- function(conv_id) {
      req(conv_id)
      current_model <- get_conversation_model(conv_id)
      if (is.null(current_model)) return()
      button_id_selector <- paste0("#", NS(conv_id)("add_file_btn"))
      if (current_model %in% simplified_models_list) shinyjs::disable(selector = button_id_selector) else shinyjs::enable(selector = button_id_selector)
    }

    # First tab
    observeEvent(TRUE, {
      req(!first_tab_created())
      create_and_append_new_tab(initial_conv_id, select_tab = TRUE)
      first_tab_created(TRUE)
      set_active_conversation(initial_conv_id)
      active_conv_id_rv(initial_conv_id)
      ns <- NS(initial_conv_id)
      output[[ns("chat_history_output")]] <- render_chat_history_ui(get_active_chat_history())
      output[[ns("staged_files_list_output")]] <- render_staged_attachments_list_ui(list())
      update_send_button_state(initial_conv_id)
      update_add_file_button_state(initial_conv_id)
    }, once = TRUE, ignoreInit = FALSE)

    # Active tab change
    observeEvent(input$chatTabs, {
      req(first_tab_created(), input$chatTabs)
      current_tab_id <- input$chatTabs
      if (current_tab_id %in% open_tab_ids_rv() && (is.null(active_conv_id_rv()) || current_tab_id != active_conv_id_rv())) {
        set_active_conversation(current_tab_id)
        active_conv_id_rv(current_tab_id)
        active_history <- get_conversation_history(current_tab_id)
        staged_files_for_tab <- isolate(staged_attachments_rv()[[current_tab_id]]) %||% list()
        if (is.null(active_history)) {
          if (!current_tab_id %in% get_all_conversation_ids()) {
            warning(paste("No data for conversation", current_tab_id, "- likely deleted."))
            return()
          }
          active_history <- list()
        }
        ns <- NS(current_tab_id)
        output[[ns("chat_history_output")]] <- render_chat_history_ui(active_history)
        output[[ns("staged_files_list_output")]] <- render_staged_attachments_list_ui(staged_files_for_tab)
        session$sendCustomMessage(type = "resetFileInput", message = list(id = "hidden_file_input"))
        update_send_button_state(current_tab_id)
        update_add_file_button_state(current_tab_id)
      } else if (!current_tab_id %in% open_tab_ids_rv() && length(open_tab_ids_rv()) > 0) {
        warning(paste("Selected tab", current_tab_id, "is no longer open. Switching to the first available."))
        first_available_tab <- open_tab_ids_rv()[1]
        if (!is.null(first_available_tab)) updateTabsetPanel(session, "chatTabs", selected = first_available_tab) else { active_conv_id_rv(NULL); set_active_conversation(NULL) }
      } else if (length(open_tab_ids_rv()) == 0) {
        active_conv_id_rv(NULL)
        set_active_conversation(NULL)
      }
    }, ignoreNULL = TRUE, ignoreInit = TRUE)

    # New Conversation
    observeEvent(input$new_chat_btn, {
      new_id <- create_new_conversation(activate = FALSE, add_initial_settings = TRUE)
      create_and_append_new_tab(new_id, select_tab = TRUE)
      showNotification("Started a new conversation in a new tab.", type = "message", duration = 3)
    })

    # Send Query
    observeEvent(input$dynamic_send_click, {
      req(input$dynamic_send_click)
      conv_id <- input$dynamic_send_click$convId
      req(conv_id %in% open_tab_ids_rv())
      if (!can_send_message(conv_id)) return()

      processing_state[[conv_id]] <- TRUE
      update_send_button_state(conv_id)

      input_id <- NS(conv_id)("user_message_input")
      user_text <- trimws(isolate(input[[input_id]]) %||% "")
      staged_files_to_send <- isolate(staged_attachments_rv()[[conv_id]]) %||% list()

      final_content <- user_text
      if (length(staged_files_to_send) > 0) {
        attachment_list_str <- paste("-", staged_files_to_send, collapse = "\n")
        if (nzchar(final_content)) {
          final_content <- paste0(final_content, "\n\n<strong>Attached:</strong>\n", attachment_list_str)
        } else {
          final_content <- paste0("<strong>Attached:</strong>\n", attachment_list_str)
        }
      }

      add_error <- FALSE
      original_active_conv_id_before_add <- get_active_conversation_id()
      set_active_conversation(conv_id)

      if (nzchar(trimws(final_content))) {
        tryCatch({
          add_result <- add_message_to_active_history(role = "user", content = final_content)
          if (is.list(add_result) && !is.null(add_result$type) && add_result$type == "error") stop(add_result$message)
        }, error = function(e) {
          showNotification(paste("Error adding message:", e$message), type = "error", duration = 5)
          message(paste("Error in add_message_to_active_history:", e$message))
          set_active_conversation(original_active_conv_id_before_add)
          processing_state[[conv_id]] <- FALSE
          update_send_button_state(conv_id)
          add_error <<- TRUE
        })
        if (add_error) return()

        # Cleanup after send
        updateTextAreaInput(session, input_id, value = "")
        current_staged <- staged_attachments_rv(); current_staged[[conv_id]] <- list(); staged_attachments_rv(current_staged)
        ns_render <- NS(conv_id)
        output[[ns_render("staged_files_list_output")]] <- render_staged_attachments_list_ui(list())

        # Update tab title if set
        if (exists("add_result")) {
          if (is.list(add_result) && !is.null(add_result$type) && add_result$type == "title_set") {
            final_title <- add_result$new_title %||% "Conversation..."
            escaped_title <- gsub("'", "\\'", gsub("\"", "\\\"", final_title))
            shinyjs::runjs(sprintf("$('#chatTabs a[data-value=\"%s\"] > span').contents().filter(function(){ return this.nodeType == 3; }).first().replaceWith('%s');", conv_id, escaped_title))
          }
        }
      } else {
        set_active_conversation(original_active_conv_id_before_add)
        processing_state[[conv_id]] <- FALSE
        update_send_button_state(conv_id)
        return()
      }

      ns_render <- NS(conv_id)
      if (!conv_id %in% get_all_conversation_ids()) {
        message(paste("Conversation", conv_id, "disappeared after adding message. Aborting."))
        set_active_conversation(original_active_conv_id_before_add)
        processing_state[[conv_id]] <- FALSE
        update_send_button_state(conv_id)
        return()
      }
      current_history <- get_conversation_history(conv_id) %||% list()
      output[[ns_render("chat_history_output")]] <- render_chat_history_ui(current_history)

      chat_history_container_selector <- ".chat-history-container-class"
      if (isolate(active_conv_id_rv()) == conv_id) {
        js_code <- sprintf(
          "var activeTabPane = $('#chatTabs .tab-pane.active'); var el = activeTabPane.find('%s'); if (el.length > 0 && el.is(':visible')) { setTimeout(function() { el.scrollTop(el[0].scrollHeight); }, 100); }",
          chat_history_container_selector
        )
        shinyjs::runjs(js_code)
      }

      notification_id <- paste0("api_call_indicator_", conv_id)
      showNotification("Processing request...", id = notification_id, duration = NULL, type = "message")
      request_conv_id_main <- conv_id
      future({
        set_active_conversation(request_conv_id_main)
        if (!request_conv_id_main %in% get_all_conversation_ids()) stop("Conversation was deleted.")
        response_text <- tryCatch(get_assistant_response(), error = function(e) paste("API Error:", e$message))
        list(response = response_text, conv_id = request_conv_id_main)
      }) %...>% (function(result) {
        response_conv_id <- result$conv_id
        assistant_response_content <- result$response
        if (!response_conv_id %in% open_tab_ids_rv()) {
          removeNotification(paste0("api_call_indicator_", response_conv_id))
          processing_state[[response_conv_id]] <- FALSE
          return()
        }
        processing_state[[response_conv_id]] <- FALSE
        update_send_button_state(response_conv_id)
        if (!response_conv_id %in% get_all_conversation_ids()) {
          removeNotification(paste0("api_call_indicator_", response_conv_id))
          return()
        }
        ns_resp <- NS(response_conv_id)
        updated_history <- get_conversation_history(response_conv_id) %||% list()
        output[[ns_resp("chat_history_output")]] <- render_chat_history_ui(updated_history)
        if (isolate(active_conv_id_rv()) == response_conv_id) {
          js_code <- sprintf(
            "var activeTabPane = $('#chatTabs .tab-pane.active'); var el = activeTabPane.find('%s'); if (el.length > 0 && el.is(':visible')) { setTimeout(function() { el.scrollTop(el[0].scrollHeight); }, 100); }",
            chat_history_container_selector
          )
          shinyjs::runjs(js_code)
        }
        removeNotification(paste0("api_call_indicator_", response_conv_id))
        conv_title_for_notif <- get_conversation_title(response_conv_id) %||% response_conv_id
        if (is.character(assistant_response_content) && grepl("^(Error|API Error|Future execution error)", assistant_response_content, ignore.case = TRUE)) {
          showNotification(paste("Processing error for:", conv_title_for_notif), type = "error", duration = 5)
        } else {
          showNotification(paste("Received response for:", conv_title_for_notif), type = "message", duration = 3)
        }
      }) %...!% (function(error) {
        error_conv_id_main <- request_conv_id_main
        message(paste("Future error:", error$message))
        if (!error_conv_id_main %in% open_tab_ids_rv()) {
          removeNotification(paste0("api_call_indicator_", error_conv_id_main))
          processing_state[[error_conv_id_main]] <- FALSE
          return()
        }
        processing_state[[error_conv_id_main]] <- FALSE
        update_send_button_state(error_conv_id_main)
        removeNotification(paste0("api_call_indicator_", error_conv_id_main))
        showNotification(paste("Asynchronous error:", error$message), type = "error", duration = 10)
        tryCatch({
          set_active_conversation(error_conv_id_main)
          if (error_conv_id_main %in% get_all_conversation_ids()) {
            add_message_to_active_history(role = "system", content = paste("Future execution error:", error$message))
            ns_err <- NS(error_conv_id_main)
            err_history <- get_conversation_history(error_conv_id_main) %||% list()
            output[[ns_err("chat_history_output")]] <- render_chat_history_ui(err_history)
            if (isolate(active_conv_id_rv()) == error_conv_id_main) {
              js_code <- sprintf(
                "var activeTabPane = $('#chatTabs .tab-pane.active'); var el = activeTabPane.find('%s'); if (el.length > 0 && el.is(':visible')) { setTimeout(function() { el.scrollTop(el[0].scrollHeight); }, 100); }",
                chat_history_container_selector
              )
              shinyjs::runjs(js_code)
            }
          }
        }, error = function(e2) { warning(paste("Failed to save future error:", e2$message)) })
      })

      set_active_conversation(original_active_conv_id_before_add)
      NULL
    })

    # Add File
    observeEvent(input$dynamic_add_file_click, {
      req(input$dynamic_add_file_click)
      conv_id <- input$dynamic_add_file_click$convId
      req(conv_id %in% open_tab_ids_rv())
      current_model <- get_conversation_model(conv_id)
      if (is.null(current_model)) return()
      if (current_model %in% simplified_models_list) { showNotification(paste("Model", current_model, "does not support attachments."), type = "warning", duration = 4); return() }
      if (isTRUE(processing_state[[conv_id]])) { showNotification("Please wait for the current response to complete.", type = "warning", duration = 4); return() }
      file_upload_context_rv(conv_id)
    })

    # Process selected files
    observeEvent(input$hidden_file_input, {
      conv_id_for_upload <- isolate(file_upload_context_rv())
      file_upload_context_rv(NULL)
      req(conv_id_for_upload, conv_id_for_upload %in% open_tab_ids_rv(), input$hidden_file_input)

      current_model <- get_conversation_model(conv_id_for_upload)
      if (is.null(current_model)) return()
      if (current_model %in% simplified_models_list) { warning("Attempting to process file for a simplified model."); session$sendCustomMessage(type = "resetFileInput", message = list(id = "hidden_file_input")); return() }
      if (isTRUE(processing_state[[conv_id_for_upload]])) return()

      files_info_from_input <- input$hidden_file_input
      files_added_to_staging_success <- 0
      files_failed <- 0
      newly_staged_filenames <- character(0)

      original_active_conv_before_upload <- get_active_conversation_id()
      tryCatch({
        set_active_conversation(conv_id_for_upload)

        for (i in seq_len(nrow(files_info_from_input))) {
          file_info <- files_info_from_input[i, ]
          file_name <- file_info$name
          file_path <- file_info$datapath

          file_content <- tryCatch(read_file_content(file_path), error = function(e_read) {
            warning(paste("Error reading", file_name, ":", e_read$message))
            showNotification(paste("Error reading", file_name), type = "error", duration = 5)
            return(NULL)
          })

          if (!is.null(file_content)) {
            added_persistently <- add_attachment_to_active_conversation(name = file_name, content = file_content)
            if (added_persistently) {
              newly_staged_filenames <- c(newly_staged_filenames, file_name)
              files_added_to_staging_success <- files_added_to_staging_success + 1
            } else {
              files_failed <- files_failed + 1
              showNotification(paste("File", file_name, "already exists or could not be added."), type = "warning", duration = 4)
            }
          } else {
            files_failed <- files_failed + 1
          }
        }

        if (length(newly_staged_filenames) > 0) {
          current_staged <- staged_attachments_rv()
          existing_staged <- current_staged[[conv_id_for_upload]] %||% list()
          combined_staged <- unique(c(existing_staged, newly_staged_filenames))
          current_staged[[conv_id_for_upload]] <- combined_staged
          staged_attachments_rv(current_staged)

          if (conv_id_for_upload %in% get_all_conversation_ids()) {
            ns_files <- NS(conv_id_for_upload)
            output[[ns_files("staged_files_list_output")]] <- render_staged_attachments_list_ui(combined_staged)
            update_send_button_state(conv_id_for_upload)
          } else {
            message(paste("Conversation", conv_id_for_upload, "does not exist after processing files."))
          }
        }

        conv_title_for_notif <- get_conversation_title(conv_id_for_upload) %||% conv_id_for_upload
        if (files_added_to_staging_success > 0) showNotification(paste("Added", files_added_to_staging_success, "file(s) to staging for:", conv_title_for_notif), type = "message", duration = 3)
        if (files_failed > 0) showNotification(paste("Failed to add/read", files_failed, "file(s)."), type = "warning", duration = 5)

      }, error = function(e) {
        error_msg <- paste("Error processing files:", e$message)
        warning(error_msg)
        message(error_msg)
        showNotification(error_msg, type = "error", duration = 8)
      }, finally = {
        update_send_button_state(conv_id_for_upload)
        update_add_file_button_state(conv_id_for_upload)
        set_active_conversation(original_active_conv_before_upload)
        session$sendCustomMessage(type = "resetFileInput", message = list(id = "hidden_file_input"))
      })
      NULL
    }, ignoreNULL = TRUE, ignoreInit = TRUE)

    # Close tab
    observeEvent(input$close_tab_request, {
      req(input$close_tab_request)
      tab_id_to_close <- input$close_tab_request
      if (!tab_id_to_close %in% open_tab_ids_rv()) return()
      open_tabs <- open_tab_ids_rv()
      if (length(open_tabs) <= 1) { showNotification("Cannot close the last tab.", type = "warning", duration = 3); return() }

      processing_state[[tab_id_to_close]] <- NULL
      current_staged <- staged_attachments_rv(); current_staged[[tab_id_to_close]] <- NULL; staged_attachments_rv(current_staged)

      open_tab_ids_rv(setdiff(open_tabs, tab_id_to_close))
      removeTab(inputId = "chatTabs", target = tab_id_to_close)
      delete_conversation(tab_id_to_close)

      current_active_id <- active_conv_id_rv()
      if (!is.null(current_active_id) && current_active_id == tab_id_to_close) {
        active_conv_id_rv(NULL)
        remaining_tabs <- open_tab_ids_rv()
        if (length(remaining_tabs) == 0) set_active_conversation(NULL)
      }
    }, ignoreNULL = TRUE, ignoreInit = TRUE)

    # Settings modal
    observeEvent(input$advanced_settings_btn, {
      active_id <- active_conv_id_rv(); req(active_id, active_id %in% get_all_conversation_ids())
      current_conv <- get_conversation_data(active_id)
      if (is.null(current_conv)) { showNotification("Error retrieving conversation data.", type = "error"); return() }
      current_model <- current_conv$model %||% "gpt-5"
      current_sys_msg <- current_conv$system_message %||% ""
      model_is_locked <- is_conversation_started(active_id)

      showModal(modalDialog(
        title = paste("Settings for:", current_conv$title %||% active_id),
        create_advanced_settings_modal_ui(
          available_models = available_openai_models,
          model_value = current_model,
          sys_msg_value = current_sys_msg
        ),
        footer = tagList(modalButton("Cancel"), actionButton("save_advanced_settings", "Save Changes")),
        easyClose = TRUE, size = "m"
      ))

      observe({
        selected_model_in_modal <- input$modal_model; req(selected_model_in_modal)
        model_selector_id <- "modal_model"; model_lock_msg_id <- "model_locked_message"
        sys_msg_input_id <- "modal_system_message"; sys_msg_msg_id <- "sysmsg_disabled_message"
        is_simplified <- selected_model_in_modal %in% simplified_models_list
        if (model_is_locked) { shinyjs::disable(model_selector_id); shinyjs::show(model_lock_msg_id) } else { shinyjs::enable(model_selector_id); shinyjs::hide(model_lock_msg_id) }
        if (is_simplified) { shinyjs::disable(sys_msg_input_id); shinyjs::show(sys_msg_msg_id) } else { shinyjs::enable(sys_msg_input_id); shinyjs::hide(sys_msg_msg_id) }
      })
    })

    observeEvent(input$save_advanced_settings, {
      active_id <- active_conv_id_rv(); req(active_id, active_id %in% get_all_conversation_ids())
      new_model <- isolate(input$modal_model)
      new_sys_msg <- isolate(input$modal_system_message)
      model_is_locked <- is_conversation_started(active_id)
      model_saved <- FALSE
      current_model_for_check <- get_conversation_model(active_id)

      if (!model_is_locked) {
        if (is.null(new_model) || !new_model %in% available_openai_models) { showNotification("Selected model is invalid.", type = "error"); return() }
        model_saved <- set_conversation_model(active_id, new_model)
        if (model_saved) {
          update_add_file_button_state(active_id)
          current_model_for_check <- new_model
        } else {
          showNotification("Could not save model.", type = "warning")
        }
      } else {
        if (!is.null(new_model) && !identical(new_model, current_model_for_check)) warning("Ignoring attempt to change locked model.")
        model_saved <- TRUE
      }

      if (!model_saved && !model_is_locked) return()
      sys_msg_saved <- FALSE
      if (!current_model_for_check %in% simplified_models_list) {
        if (is.null(new_sys_msg) || !is.character(new_sys_msg) || length(new_sys_msg) != 1) { showNotification("Invalid system message.", type = "error"); return() }
        sys_msg_saved <- set_conversation_system_message(active_id, new_sys_msg)
        if (!sys_msg_saved) showNotification("Failed to save system message.", type = "warning")
      } else {
        sys_msg_saved <- TRUE
      }

      if ((model_is_locked || model_saved) && sys_msg_saved) {
        showNotification("Settings have been saved.", type = "message")
        removeModal(); update_send_button_state(active_id); update_add_file_button_state(active_id)
      } else {
        showNotification("An error occurred while saving settings.", type = "error")
      }
    })

    # Close app
    observeEvent(input$close_app_btn, { shiny::stopApp() })
  }

  shiny::runGadget(ui, server, viewer = shiny::paneViewer(minHeight = 600))
}



#1. devtools::document()
#2. devtools::load_all()
#3. run_llm_chat_app()

Try the PacketLLM package in your browser

Any scripts or data that you put into this service are public.

PacketLLM documentation built on Aug. 23, 2025, 5:08 p.m.