R/setup.R

Defines functions ollama_installed_models_detail delete_model pull_model wait_for_ollama try_start_ollama_daemon open_download_page ollama_install_candidate try_install_ollama install_prereqs ollama_installed_models ollama_health find_ollama_binary check_setup

Documented in check_setup delete_model find_ollama_binary install_prereqs ollama_health ollama_installed_models ollama_installed_models_detail pull_model

#' Verify local setup for `screenllm`
#'
#' Confirms that Ollama is reachable at the configured URL, that R can
#' talk to it, and (optionally) that the four default models are available.
#' Returns `TRUE` invisibly on success and prints a diagnostic panel;
#' returns `FALSE` on failure with actionable messages.
#'
#' @param models Character vector of model tags to require. Defaults to the
#'   four LLMs pinned by `default_ensemble()`. Pass `NULL` to skip the
#'   model-availability check.
#' @param ollama_url Base URL of the local Ollama server. Defaults to the
#'   `screenllm.ollama_url` option (usually `http://localhost:11434`).
#' @return Invisible logical.
#' @export
#' @examples
#' \donttest{
#' check_setup()
#' check_setup(models = NULL) # just check the server, not the models
#' }
check_setup <- function(models = .PINNED_DEFAULT_MODELS,
                        ollama_url = getOption("screenllm.ollama_url")) {
  cli::cli_h1("screenllm setup check")

  server_ok <- ollama_health(ollama_url = ollama_url, quiet = TRUE)
  if (server_ok) {
    cli::cli_alert_success("Ollama reachable at {.url {ollama_url}}")
  } else {
    cli::cli_alert_danger(
      "Ollama not reachable at {.url {ollama_url}}. Start it with `ollama serve` \\
      (macOS: it also runs from the tray app)."
    )
    return(invisible(FALSE))
  }

  if (is.null(models)) {
    cli::cli_alert_info("Skipping model check (models = NULL).")
    return(invisible(TRUE))
  }

  installed <- ollama_installed_models(ollama_url = ollama_url)
  missing <- setdiff(models, installed)
  if (length(missing) == 0) {
    cli::cli_alert_success(
      "All requested models installed ({length(models)}): {.val {models}}"
    )
    invisible(TRUE)
  } else {
    cli::cli_alert_warning(
      "{length(missing)} model{?s} missing: {.val {missing}}. \\
      Pull with `pull_model(<name>)` or `ollama pull <name>` in a shell."
    )
    invisible(FALSE)
  }
}

#' Locate the Ollama binary on disk
#'
#' `Sys.which("ollama")` returns `""` when the parent process's PATH
#' doesn't include the install location. This bites macOS GUI R.app
#' users hard: brew installs Ollama at `/usr/local/bin/ollama` (Intel)
#' or `/opt/homebrew/bin/ollama` (Apple Silicon), and the double-click
#' R.app launcher inherits a truncated PATH that omits both. Users
#' with a fully working `ollama` in Terminal end up with a `screenllm`
#' app that refuses to start because it can't find the binary.
#'
#' This helper first tries `Sys.which()`, then probes a list of
#' known install locations per platform.
#'
#' @return Full path to the binary, or `""` if none found.
#' @keywords internal
find_ollama_binary <- function() {
  path <- unname(Sys.which("ollama"))
  if (nzchar(path)) return(path)
  candidates <- switch(
    Sys.info()[["sysname"]],
    Darwin = c(
      "/usr/local/bin/ollama",
      "/opt/homebrew/bin/ollama",
      "/Applications/Ollama.app/Contents/Resources/ollama"
    ),
    Linux = c(
      "/usr/local/bin/ollama",
      "/usr/bin/ollama",
      "~/.local/bin/ollama"
    ),
    Windows = c(
      file.path(Sys.getenv("LOCALAPPDATA"),
                "Programs/Ollama/ollama.exe"),
      "C:/Program Files/Ollama/ollama.exe"
    ),
    character()
  )
  for (c in candidates) {
    expanded <- path.expand(c)
    if (nzchar(expanded) && file.exists(expanded)) return(expanded)
  }
  ""
}

#' Ping the Ollama server
#'
#' Returns `TRUE` if the Ollama HTTP API responds within a short timeout.
#' Used by `check_setup()` and callable directly by backends.
#'
#' @param ollama_url Base URL of the Ollama server.
#' @param quiet If `FALSE` (the default when interactive), print status.
#' @return Logical.
#' @export
ollama_health <- function(ollama_url = getOption("screenllm.ollama_url"),
                          quiet = !interactive()) {
  req <- try(
    httr2::request(paste0(ollama_url, "/api/tags")) |>
      httr2::req_timeout(3) |>
      httr2::req_error(is_error = function(...) FALSE) |>
      httr2::req_perform(),
    silent = TRUE
  )
  ok <- !inherits(req, "try-error") && httr2::resp_status(req) == 200L
  if (!quiet) {
    if (ok) {
      cli::cli_alert_success("Ollama OK at {.url {ollama_url}}")
    } else {
      cli::cli_alert_danger("Ollama not reachable at {.url {ollama_url}}")
    }
  }
  ok
}

#' List Ollama models installed on the local server
#'
#' @param ollama_url Base URL of the Ollama server.
#' @param include_embedding Logical. Include known embedding-only
#'   models (e.g. `mxbai-embed-large`, `nomic-embed-text`,
#'   `snowflake-arctic-embed`, `all-minilm`) in the returned list.
#'   Defaults to `FALSE`; these models do not respond to Ollama's
#'   `/api/generate` in the shape `screenllm` needs, so exposing them
#'   in a chat-model picker would silently produce all-NA rankings.
#' @return Character vector of model tags. Empty character vector if the
#'   server is not reachable.
#' @keywords internal
ollama_installed_models <- function(
    ollama_url = getOption("screenllm.ollama_url"),
    include_embedding = FALSE) {
  resp <- try(
    httr2::request(paste0(ollama_url, "/api/tags")) |>
      httr2::req_timeout(5) |>
      httr2::req_error(is_error = function(r) FALSE) |>
      httr2::req_perform(),
    silent = TRUE
  )
  if (inherits(resp, "try-error")) return(character())
  # A captive portal / corporate proxy intercepting localhost can
  # return an HTML sign-in page with status 200. resp_body_json()
  # would throw "parse error: premature EOF" and crash the whole
  # Setup tab. Also treat any 4xx/5xx as "not reachable" -- the
  # daemon is either not up (503 during boot) or the endpoint has
  # changed (404 on very old Ollama versions).
  if (httr2::resp_status(resp) >= 400L) return(character())
  body <- tryCatch(
    httr2::resp_body_json(resp, simplifyVector = TRUE),
    error = function(e) NULL
  )
  if (is.null(body)) return(character())
  mods <- body$models
  # A fresh Ollama install with zero models pulled returns
  # {"models": []}, which simplifyVector parses to an empty list().
  # nrow(list()) is NULL, so the previous guard cascaded to NA in `if`
  # and crashed the whole Setup tab's reactive graph. Handle each
  # shape (NULL / empty list / empty data.frame / populated
  # data.frame) explicitly.
  if (is.null(mods)) return(character())
  n_mods <- if (is.data.frame(mods)) nrow(mods) else length(mods)
  if (n_mods == 0L) return(character())
  tags <- if (is.data.frame(mods)) {
    as.character(mods$name)
  } else {
    vapply(mods, function(m) as.character(m$name %||% ""), character(1))
  }
  if (isTRUE(include_embedding)) return(tags)
  # Drop known embedding-model families. The naming isn't strictly
  # enforced by Ollama; this covers the popular ones.
  embed_pattern <- paste0(
    "^(mxbai-embed|nomic-embed|snowflake-arctic-embed|all-minilm|",
    "bge-|granite-embedding|embeddinggemma)"
  )
  tags[!grepl(embed_pattern, tags, ignore.case = TRUE)]
}

#' One-stop setup for a fresh machine
#'
#' Walks a non-technical user through everything needed to run `screenllm`:
#'
#' 1. Detects the OS and checks whether Ollama is installed.
#' 2. If Ollama is missing, offers to install it (`brew` on macOS,
#'    `winget` on Windows, the official install script on Linux). If the
#'    user declines or the package manager is unavailable, opens
#'    <https://ollama.com/download> in the browser.
#' 3. Waits for the Ollama daemon to come up (up to `wait_seconds`).
#' 4. Pulls each model in `models` that is not already installed.
#' 5. Verifies with `check_setup()`.
#'
#' Safe to re-run: it is a no-op if everything is already in place.
#'
#' @param preset One of `"paper"` (the four ~20-30B paper models, ~65
#'   GB), `"light"` (four ~3-7B models, ~10 GB) or `"none"` (skip the
#'   model pull). Overrides `models` when set. Defaults to `NULL`,
#'   which means "use `models`".
#' @param models Character vector of Ollama model tags to ensure are
#'   installed. Ignored if `preset` is supplied. Defaults to the
#'   four-LLM paper ensemble.
#' @param interactive Whether to prompt the user before running install
#'   commands or pulling large models. Defaults to `base::interactive()`.
#'   When `FALSE`, the function reports missing components but never
#'   installs or downloads anything.
#' @param wait_seconds Seconds to wait for the Ollama daemon to come up
#'   after (re)starting it. Defaults to 60.
#' @param ollama_url Base URL of the Ollama server.
#' @return Invisible logical: `TRUE` if all prerequisites are ready
#'   after the call, `FALSE` otherwise.
#' @export
#' @examples
#' \donttest{
#' # Check for Ollama without pulling any models. In a non-interactive
#' # session this only reports status and installs nothing.
#' install_prereqs(preset = "none")
#' }
#' \dontrun{
#' # Interactive setup that offers to install Ollama and pull models:
#' install_prereqs(preset = "light") # ~10 GB; runs on 8-16 GB RAM
#' install_prereqs(preset = "paper") # ~65 GB; needs a workstation
#' }
install_prereqs <- function(preset = NULL,
                            models = .PINNED_DEFAULT_MODELS,
                            interactive = base::interactive(),
                            wait_seconds = 60L,
                            ollama_url = getOption("screenllm.ollama_url")) {
  if (!is.null(preset)) {
    preset <- match.arg(preset, c("paper", "light", "none"))
    models <- switch(preset,
                     paper = .PINNED_DEFAULT_MODELS,
                     light = .PINNED_LIGHT_MODELS,
                     none  = NULL)
  }
  cli::cli_h1("screenllm: install prerequisites")

  # Step 1: is Ollama on PATH (or in a known install location)?
  binary_path <- find_ollama_binary()
  have_binary <- nzchar(binary_path)
  if (have_binary) {
    cli::cli_alert_success("Ollama binary found at {.path {binary_path}}")
  } else {
    cli::cli_alert_warning("Ollama binary not found on PATH.")
    if (!interactive) {
      cli::cli_alert_info(
        "Non-interactive mode: skipping install. \\
        Visit {.url https://ollama.com/download} to install manually."
      )
      return(invisible(FALSE))
    }
    installed <- try_install_ollama()
    if (!isTRUE(installed)) {
      cli::cli_alert_info(
        "Please finish installing Ollama, then re-run `install_prereqs()`."
      )
      return(invisible(FALSE))
    }
  }

  # Step 2: is the daemon reachable? If not, try to start it and wait.
  if (!ollama_health(ollama_url = ollama_url, quiet = TRUE)) {
    cli::cli_alert_info("Ollama daemon not responding; attempting to start it.")
    try_start_ollama_daemon()
    ok <- wait_for_ollama(seconds = wait_seconds, ollama_url = ollama_url)
    if (!ok) {
      cli::cli_alert_danger(
        "Ollama daemon did not come up within {wait_seconds}s. \\
        Try running {.code ollama serve} in a separate terminal, then re-run \\
        `install_prereqs()`."
      )
      return(invisible(FALSE))
    }
  }
  cli::cli_alert_success("Ollama daemon reachable at {.url {ollama_url}}")

  # Step 3: pull any missing models.
  if (is.null(models) || length(models) == 0L) {
    cli::cli_alert_info("Skipping model pull ({.code models = NULL}).")
    return(invisible(TRUE))
  }
  installed <- ollama_installed_models(ollama_url = ollama_url)
  missing <- setdiff(models, installed)
  if (length(missing) == 0L) {
    cli::cli_alert_success("All requested models already installed.")
    return(invisible(TRUE))
  }
  cli::cli_alert_info(
    "{length(missing)} model{?s} to pull: {.val {missing}}"
  )
  if (interactive) {
    ans <- utils::menu(
      c("Yes, pull them now.", "No, skip the model pull."),
      title = "Pull these models? (Large download; can take an hour on the paper ensemble.)"
    )
    if (ans != 1L) {
      cli::cli_alert_info(
        "Skipping model pull. Rerun `install_prereqs()` when you are ready."
      )
      return(invisible(FALSE))
    }
  }
  for (m in missing) {
    ok <- try(pull_model(m, ollama_url = ollama_url, verbose = TRUE),
              silent = TRUE)
    if (inherits(ok, "try-error") || !isTRUE(ok)) {
      cli::cli_alert_danger("Failed to pull {.val {m}}.")
      return(invisible(FALSE))
    }
  }
  cli::cli_alert_success("All requested models installed.")
  cli::cli_alert_info(
    "Setup complete. Launch the app with {.code screenllm::launch_app()}."
  )
  invisible(TRUE)
}

# ---- helpers for install_prereqs() -------------------------------------

# Attempt to install Ollama via a package manager. Only runs after user
# confirms. Returns TRUE if we can confirm the binary is present after
# the attempt, FALSE otherwise.
try_install_ollama <- function() {
  sysname <- Sys.info()[["sysname"]]
  candidate <- ollama_install_candidate(sysname)
  if (is.null(candidate)) {
    cli::cli_alert_info(
      "No supported package manager detected on {.val {sysname}}."
    )
    open_download_page()
    return(FALSE)
  }
  cli::cli_alert_info(
    "Detected {.val {candidate$manager}}. Proposed install command:"
  )
  cli::cli_code(candidate$command)
  ans <- utils::menu(
    c(sprintf("Yes, run `%s`.", candidate$command),
      "No, open the download page in my browser instead.",
      "Cancel."),
    title = "Install Ollama?"
  )
  if (ans == 1L) {
    status <- system(candidate$command)
    if (!identical(status, 0L)) {
      cli::cli_alert_danger("Install command exited with status {status}.")
      return(FALSE)
    }
    return(nzchar(find_ollama_binary()))
  } else if (ans == 2L) {
    open_download_page()
    return(FALSE)
  } else {
    return(FALSE)
  }
}

# For a given OS, return a list(manager, command) that would install
# Ollama, or NULL if no supported manager is available.
ollama_install_candidate <- function(sysname = Sys.info()[["sysname"]]) {
  # macOS GUI R.app inherits a truncated PATH that omits both brew
  # locations, so Sys.which("brew") returns "" for users who have
  # brew working fine in Terminal. Probe known locations too.
  find_brew <- function() {
    p <- unname(Sys.which("brew"))
    if (nzchar(p)) return(p)
    for (c in c("/usr/local/bin/brew", "/opt/homebrew/bin/brew")) {
      if (file.exists(c)) return(c)
    }
    ""
  }
  has <- function(cmd) nzchar(Sys.which(cmd))
  switch(
    sysname,
    "Darwin" = {
      brew <- find_brew()
      if (nzchar(brew)) {
        list(manager = "Homebrew",
             command = paste(shQuote(brew), "install ollama"))
      } else NULL
    },
    "Windows" = if (has("winget"))
      list(manager = "winget",
           command = "winget install -e --id Ollama.Ollama") else NULL,
    "Linux" = list(manager = "official install script",
                   command = "curl -fsSL https://ollama.com/install.sh | sh"),
    NULL
  )
}

# Open ollama.com/download in the user's browser (best-effort).
open_download_page <- function() {
  url <- "https://ollama.com/download"
  cli::cli_alert_info(
    "Please install Ollama from {.url {url}} and then re-run \\
    `install_prereqs()`."
  )
  try(utils::browseURL(url), silent = TRUE)
  invisible(NULL)
}

# Try to launch `ollama serve` in the background. Best-effort; on macOS
# and Windows the tray app usually handles this itself once installed.
try_start_ollama_daemon <- function() {
  ollama <- find_ollama_binary()
  if (!nzchar(ollama)) return(invisible(FALSE))
  # Fire-and-forget; ignore output.
  suppressWarnings(system2(ollama, "serve",
                           stdout = FALSE, stderr = FALSE, wait = FALSE))
  invisible(TRUE)
}

# Poll `ollama_health()` up to `seconds` seconds. Returns TRUE if it
# comes up, FALSE otherwise.
wait_for_ollama <- function(seconds = 60L,
                            ollama_url = getOption("screenllm.ollama_url"),
                            poll_interval = 1) {
  deadline <- Sys.time() + as.numeric(seconds)
  repeat {
    if (ollama_health(ollama_url = ollama_url, quiet = TRUE)) return(TRUE)
    if (Sys.time() >= deadline) return(FALSE)
    Sys.sleep(poll_interval)
  }
}

#' Pull an Ollama model to the local server
#'
#' Wrapper around Ollama's HTTP pull endpoint. Streams progress messages
#' if `verbose = TRUE`. Blocks until the pull completes.
#'
#' @param model The model tag (e.g. `"gemma3:27b"`).
#' @param ollama_url Base URL of the Ollama server.
#' @param verbose Logical.
#' @return Invisible `TRUE` on success.
#' @export
pull_model <- function(model,
                       ollama_url = getOption("screenllm.ollama_url"),
                       verbose = getOption("screenllm.verbose", TRUE)) {
  stopifnot(is.character(model), length(model) == 1L, nzchar(model))
  if (verbose) cli::cli_alert_info("Pulling {.val {model}} - may take several minutes.")
  req <- httr2::request(paste0(ollama_url, "/api/pull")) |>
    httr2::req_body_json(list(name = model, stream = FALSE)) |>
    httr2::req_timeout(60 * 60) |>
    httr2::req_perform()
  ok <- httr2::resp_status(req) == 200L
  if (ok && verbose) cli::cli_alert_success("Pulled {.val {model}}")
  invisible(ok)
}

#' Delete an Ollama model to free up disk space
#'
#' Wrapper around Ollama's `DELETE /api/delete` endpoint. Removes the
#' model's blobs from the Ollama data directory. The operation is
#' irreversible from the API side but the model can always be pulled
#' back later with `pull_model()`.
#'
#' @param model Model tag (e.g. `"mistral:7b"`).
#' @param ollama_url Base URL of the Ollama server.
#' @param verbose Show progress messages.
#' @return Invisible `TRUE` on success, `FALSE` if the API returned
#'   a non-2xx status.
#' @export
delete_model <- function(model,
                         ollama_url = getOption("screenllm.ollama_url"),
                         verbose = getOption("screenllm.verbose", TRUE)) {
  stopifnot(is.character(model), length(model) == 1L, nzchar(model))
  if (verbose) cli::cli_alert_info("Deleting {.val {model}} from Ollama.")
  # Ollama's delete endpoint expects a JSON body with `model` (older
  # versions used `name`); send both to survive across releases. Use
  # req_error(is_error = FALSE) so we can read the status code
  # ourselves rather than have httr2 abort on 404 (model not
  # installed) or 500.
  resp <- try(
    httr2::request(paste0(ollama_url, "/api/delete")) |>
      httr2::req_method("DELETE") |>
      httr2::req_body_json(list(model = model, name = model)) |>
      httr2::req_timeout(60) |>
      httr2::req_error(is_error = function(r) FALSE) |>
      httr2::req_perform(),
    silent = TRUE
  )
  if (inherits(resp, "try-error")) {
    if (verbose) cli::cli_alert_danger(
      "Delete failed: {conditionMessage(attr(resp, 'condition'))}"
    )
    return(invisible(FALSE))
  }
  ok <- httr2::resp_status(resp) >= 200L &&
    httr2::resp_status(resp) < 300L
  if (ok) {
    if (verbose) cli::cli_alert_success("Deleted {.val {model}}.")
  } else if (verbose) {
    cli::cli_alert_danger("Delete returned HTTP {httr2::resp_status(resp)}.")
  }
  invisible(ok)
}

#' Installed Ollama models with disk-space metadata
#'
#' Like `ollama_installed_models()`, but returns a data.frame with
#' one row per installed model and columns `name` and `size_bytes`
#' -- suitable for a "manage models / free up disk space" UI.
#'
#' @param ollama_url Base URL of the Ollama server.
#' @return A data.frame; zero rows if Ollama is unreachable or has
#'   no models installed.
#' @export
ollama_installed_models_detail <- function(
    ollama_url = getOption("screenllm.ollama_url")) {
  resp <- try(
    httr2::request(paste0(ollama_url, "/api/tags")) |>
      httr2::req_timeout(5) |>
      httr2::req_error(is_error = function(r) FALSE) |>
      httr2::req_perform(),
    silent = TRUE
  )
  empty <- data.frame(name = character(), size_bytes = numeric(),
                      stringsAsFactors = FALSE)
  if (inherits(resp, "try-error")) return(empty)
  if (httr2::resp_status(resp) >= 400L) return(empty)
  body <- tryCatch(
    httr2::resp_body_json(resp, simplifyVector = TRUE),
    error = function(e) NULL
  )
  if (is.null(body)) return(empty)
  mods <- body$models
  if (is.null(mods)) return(empty)
  n_mods <- if (is.data.frame(mods)) nrow(mods) else length(mods)
  if (n_mods == 0L) return(empty)
  if (is.data.frame(mods)) {
    data.frame(
      name = as.character(mods$name),
      size_bytes = as.numeric(mods$size %||% NA_real_),
      stringsAsFactors = FALSE
    )
  } else {
    data.frame(
      name = vapply(mods, function(m) as.character(m$name %||% ""),
                    character(1)),
      size_bytes = vapply(mods,
                          function(m) as.numeric(m$size %||% NA_real_),
                          numeric(1)),
      stringsAsFactors = FALSE
    )
  }
}

Try the screenllm package in your browser

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

screenllm documentation built on Sept. 24, 2026, 5:11 p.m.