← Back to DevBytes

Building a Desktop App with Tauri and Local LLMs

Building a Desktop App with Tauri and Local LLMs

Running large language models locally inside a desktop application used to be a niche experiment. With the rise of efficient inference engines like llama.cpp, ollama, and ONNX Runtime, plus the maturation of Tauri as a lightweight alternative to Electron, it's now practical to ship a fully offline AI assistant as a single installer under 20 MB (model weights aside). This tutorial walks through the entire process: scaffolding a Tauri app, embedding a local LLM, wiring up a chat UI, and packaging the result.

What You're Building

The end product is a cross-platform desktop app — Windows, macOS, and Linux — that loads a quantized GGUF model from disk, streams responses token-by-token into a chat interface, and never sends a single byte to the cloud. The frontend is plain HTML/CSS/JS (or any framework you prefer), and the Rust backend handles model loading, tokenization, and inference.

Why This Stack Matters

Electron-based AI apps bundle a full Chromium instance, often pushing installers past 150 MB before you've added a model. Tauri uses the OS-native webview, cutting binary size dramatically and reducing memory overhead. Pairing that with a local LLM gives you four concrete advantages:

The tradeoff is hardware: you need enough RAM to hold the model. A 4-bit quantized 7B parameter model fits comfortably in 6–8 GB of RAM, while a 3B model runs on machines with 4 GB. We'll design the app to detect available memory and recommend an appropriate model.

Prerequisites and Project Setup

Install the Tauri prerequisites first. You need Rust (stable), Node.js 18+, and the platform build tools:

Scaffold a new Tauri project with the vanilla template:

npm create tauri-app@latest local-llm-desktop
cd local-llm-desktop
npm install
npm run tauri dev

Confirm the default window opens. Now add the crates that power local inference. We'll use candle-core and candle-transformers from Hugging Face, which are pure-Rust and integrate cleanly with Tauri's build system. Alternatively, you can shell out to ollama as a subprocess — simpler but adds an external dependency.

Edit src-tauri/Cargo.toml:

[package]
name = "local-llm-desktop"
version = "0.1.0"
edition = "2021"

[dependencies]
tauri = { version = "1.6", features = ["shell-open"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
candle-core = "0.4"
candle-transformers = "0.4"
candle-nn = "0.4"
tokenizers = "0.15"
hf-hub = "0.3"
tokio = { version = "1", features = ["full"] }
anyhow = "1"

[build-dependencies]
tauri-build = { version = "1.6" }

Loading a Model in Rust

Create src-tauri/src/llm.rs to encapsulate model loading and generation. We'll target a small instruct-tuned model like Qwen2.5-1.5B-Instruct quantized to GGUF or loaded directly via candle's safetensors path. For simplicity, this example uses the safetensors path through hf-hub.

use anyhow::{Context, Result};
use candle_core::{Device, Tensor};
use candle_transformers::models::qwen2::Config;
use candle_transformers::models::qwen2::Model as QwenModel;
use candle_nn::VarBuilder;
use hf_hub::{api::sync::Api, Repo, RepoType};
use tokenizers::Tokenizer;
use std::sync::Mutex;

pub struct LlmEngine {
    pub model: Mutex<QwenModel>,
    pub tokenizer: Tokenizer,
    pub device: Device,
}

impl LlmEngine {
    pub fn load(model_id: &str, revision: &str) -> Result<Self> {
        let api = Api::new()?;
        let repo = api.repo(Repo::with_revision(
            model_id.to_string(),
            RepoType::Model,
            revision.to_string(),
        ));

        let tokenizer_file = repo.get("tokenizer.json")?;
        let tokenizer = Tokenizer::from_file(tokenizer_file)
            .map_err(|e| anyhow::anyhow!("tokenizer error: {e:?}"))?;

        let weights = repo.get("model.safetensors")?;
        let device = Device::Cpu;
        let vb = VarBuilder::from_mmaped_safetensors(&[weights], candle_core::DType::F32, &device)?;
        let config = Config::default();
        let model = QwenModel::new(&config, vb)?;

        Ok(Self {
            model: Mutex::new(model),
            tokenizer,
            device,
        })
    }

    pub fn generate(&self, prompt: &str, max_tokens: usize) -> Result<String> {
        let tokens = self
            .tokenizer
            .encode(prompt, true)
            .map_err(|e| anyhow::anyhow!("encode error: {e:?}"))?
            .get_ids()
            .to_vec();

        let mut input = Tensor::new(tokens.as_slice(), &self.device)?
            .unsqueeze(0)?;

        let mut generated = String::new();
        let mut model = self.model.lock().unwrap();

        for _ in 0..max_tokens {
            let logits = model.forward(&input)?;
            let next_token = logits.argmax(1)?.get(0).to_vec::<u32>()?[0];
            if next_token as usize == 0 {
                break;
            }
            let piece = self
                .tokenizer
                .decode(&[next_token], true)
                .map_err(|e| anyhow::anyhow!("decode error: {e:?}"))?;
            generated.push_str(&piece);
            input = Tensor::new(&[next_token], &self.device)?.unsqueeze(0)?;
        }

        Ok(generated)
    }
}

This is a minimal greedy decoder. For production you'd add temperature sampling, KV-cache reuse, and stop-token handling, but it demonstrates the core loop: tokenize, forward, argmax, decode, append.

Exposing Commands to the Frontend

Tauri's command system lets the webview call Rust functions directly. Wire up two commands: one to initialize the engine at startup, one to generate text. Open src-tauri/src/main.rs:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod llm;

use llm::LlmEngine;
use std::sync::Arc;
use tauri::State;

struct AppState {
    engine: Arc<LlmEngine>,
}

#[tauri::command]
fn generate(prompt: String, state: State<AppState>) -> Result<String, String> {
    state
        .engine
        .generate(&prompt, 256)
        .map_err(|e| e.to_string())
}

fn main() {
    let model_id = "Qwen/Qwen2.5-1.5B-Instruct";
    let revision = "main".to_string();

    let engine = LlmEngine::load(model_id, &revision)
        .expect("failed to load model");

    tauri::Builder::default()
        .manage(AppState {
            engine: Arc::new(engine),
        })
        .invoke_handler(tauri::generate_handler![generate])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

On first launch, the app downloads the model from the Hugging Face Hub and caches it under ~/.cache/huggingface. Subsequent launches load from disk in a few seconds. If you want a fully offline installer, bundle the weights in src-tauri/resources/ and reference them with tauri::path::resolve_resource.

Building the Chat UI

Replace index.html with a minimal chat interface. We'll keep dependencies to zero — no React, no Tailwind — to honor Tauri's lightweight philosophy.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Local LLM Desktop</title>
  <style>
    body { font-family: system-ui; margin: 0; display: flex; flex-direction: column; height: 100vh; }
    #log { flex: 1; overflow-y: auto; padding: 16px; }
    .msg { margin-bottom: 12px; padding: 10px; border-radius: 8px; max-width: 80%; }
    .user { background: #2563eb; color: white; margin-left: auto; }
    .bot { background: #e5e7eb; color: black; }
    form { display: flex; padding: 12px; gap: 8px; border-top: 1px solid #ddd; }
    input { flex: 1; padding: 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; }
    button { padding: 10px 20px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; }
  </style>
</head>
<body>
  <div id="log"></div>
  <form id="chat-form">
    <input id="prompt" placeholder="Ask anything..." autocomplete="off" />
    <button type="submit">Send</button>
  </form>
  <script src="main.js"></script>
</body>
</html>

Then main.js handles the Tauri invoke and appends messages to the log:

const { invoke } = window.__TAURI__.tauri;

const log = document.getElementById("log");
const form = document.getElementById("chat-form");
const promptInput = document.getElementById("prompt");

function appendMessage(text, role) {
  const div = document.createElement("div");
  div.className = `msg ${role}`;
  div.textContent = text;
  log.appendChild(div);
  log.scrollTop = log.scrollHeight;
}

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const prompt = promptInput.value.trim();
  if (!prompt) return;

  appendMessage(prompt, "user");
  promptInput.value = "";
  promptInput.disabled = true;

  try {
    const response = await invoke("generate", { prompt });
    appendMessage(response, "bot");
  } catch (err) {
    appendMessage(`Error: ${err}`, "bot");
  } finally {
    promptInput.disabled = false;
    promptInput.focus();
  }
});

Run npm run tauri dev again. The first invocation takes longer as the model warms up; subsequent prompts respond in a few hundred milliseconds on a modern CPU.

Streaming Responses

Blocking on a full completion feels sluggish for longer answers. Tauri's event system lets you stream tokens from Rust to the frontend. Update the generate command to emit events instead of returning a single string:

use tauri::{AppHandle, Emitter, State};

#[tauri::command]
async fn generate_stream(
    prompt: String,
    app: AppHandle,
    state: State<'_, AppState>,
) -> Result<(), String> {
    let engine = state.engine.clone();
    tokio::task::spawn_blocking(move || -> Result<(), String> {
        let tokens = engine
            .tokenizer
            .encode(&prompt, true)
            .map_err(|e| e.to_string())?
            .get_ids()
            .to_vec();

        let mut input = Tensor::new(tokens.as_slice(), &engine.device)
            .map_err(|e| e.to_string())?
            .unsqueeze(0)
            .map_err(|e| e.to_string())?;

        let mut model = engine.model.lock().unwrap();
        for _ in 0..256 {
            let logits = model.forward(&input).map_err(|e| e.to_string())?;
            let next = logits.argmax(1).map_err(|e| e.to_string())?
                .get(0).to_vec::<u32>().map_err(|e| e.to_string())?[0];
            if next == 0 { break; }
            let piece = engine.tokenizer
                .decode(&[next], true)
                .map_err(|e| e.to_string())?;
            let _ = app.emit("token", &piece);
            input = Tensor::new(&[next], &engine.device)
                .map_err(|e| e.to_string())?
                .unsqueeze(0).map_err(|e| e.to_string())?;
        }
        let _ = app.emit("done", ());
        Ok(())
    })
    .await
    .map_err(|e| e.to_string())?
}

On the frontend, listen for those events and append incrementally:

const { listen } = window.__TAURI__.event;

let currentBotMsg = null;

async function setupStream() {
  await listen("token", (event) => {
    if (!currentBotMsg) {
      currentBotMsg = document.createElement("div");
      currentBotMsg.className = "msg bot";
      log.appendChild(currentBotMsg);
    }
    currentBotMsg.textContent += event.payload;
    log.scrollTop = log.scrollHeight;
  });

  await listen("done", () => {
    currentBotMsg = null;
    promptInput.disabled = false;
    promptInput.focus();
  });
}

setupStream();

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const prompt = promptInput.value.trim();
  if (!prompt) return;
  appendMessage(prompt, "user");
  promptInput.value = "";
  promptInput.disabled = true;
  await invoke("generate_stream", { prompt });
});

Best Practices

Packaging and Distribution

Build a production installer with:

npm run tauri build

This produces platform-specific bundles in src-tauri/target/release/bundle/ — MSI and EXE on Windows, DMG and APP on macOS, AppImage and DEB on Linux. Sign the binaries with your platform's tooling before distribution; unsigned installers trigger scary warnings that erode trust.

If you bundle model weights, list them in tauri.conf.json under tauri.bundle.resources so they're included in the installer:

{
  "tauri": {
    "bundle": {
      "resources": ["resources/*.gguf"]
    }
  }
}

Conclusion

Tauri plus a local LLM gives you a privacy-preserving, offline-capable desktop assistant in a package that respects your users' disk space and memory. The core loop — load weights in Rust, expose a command, stream tokens to a webview — is straightforward, and the hard work lives in choosing the right model, tuning inference parameters, and building a UI that feels responsive even when generation takes a few seconds. Start with a small quantized model, get the streaming pipeline solid, then iterate on quality by swapping in larger models as your hardware budget allows. The result is an app your users can run anywhere, trust completely, and own outright.

— Ad —

Google AdSense will appear here after approval

← Back to all articles