DEV Community

Phil IPS
Phil IPS

Posted on

How to Build a Local-First AI App with Tauri + Ollama

How to Build a Local-First AI App with Tauri + Ollama

A complete guide to building a privacy-focused AI application that runs entirely on the user's device.

Why Local-First AI?

  • Privacy: No data leaves the device
  • Cost: Zero API costs at scale
  • Offline: Works without internet
  • Latency: Sub-second response times
  • Compliance: Meets strict data regulations

Architecture Overview

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Tauri Frontend │────▶│  Rust Backend   │────▶│   Ollama API    │
│   (React/Svelte) │     │  (Tauri Core)   │     │  (Local LLM)    │
└─────────────────┘     └─────────────────┘     └─────────────────┘
Enter fullscreen mode Exit fullscreen mode

Prerequisites

# Install Tauri CLI
cargo install tauri-cli

# Install Ollama
curl -fsSL https://ollama.ai/install.sh | sh

# Pull a model
ollama pull llama3.2:3b
Enter fullscreen mode Exit fullscreen mode

Project Setup

# Create Tauri app
cargo create-tauri-app ai-chat --template react-ts
cd ai-chat

# Add dependencies
cargo add reqwest --features json,blocking
cargo add serde --features derive
cargo add tauri-plugin-shell --features process
Enter fullscreen mode Exit fullscreen mode

Rust Backend: Ollama Integration

// src-tauri/src/ollama.rs
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Debug, Serialize, Deserialize)]
pub struct ChatRequest {
    pub model: String,
    pub messages: Vec<Message>,
    pub stream: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub options: Option<ModelOptions>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Message {
    pub role: String,
    pub content: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ModelOptions {
    pub temperature: Option<f32>,
    pub top_p: Option<f32>,
    pub num_predict: Option<i32>,
}

#[derive(Debug, Deserialize)]
pub struct ChatResponse {
    pub message: Message,
    pub done: bool,
}

pub struct OllamaClient {
    client: Client,
    base_url: String,
}

impl OllamaClient {
    pub fn new(base_url: String) -> Self {
        Self {
            client: Client::builder()
                .timeout(Duration::from_secs(120))
                .build()
                .unwrap(),
            base_url,
        }
    }

    pub fn chat(&self, request: ChatRequest) -> Result<ChatResponse, String> {
        let url = format!("{}/api/chat", self.base_url);

        let response = self.client
            .post(&url)
            .json(&request)
            .send()
            .map_err(|e| format!("Request failed: {}", e))?;

        if !response.status().is_success() {
            return Err(format!("API error: {}", response.status()));
        }

        response
            .json::<ChatResponse>()
            .map_err(|e| format!("Parse error: {}", e))
    }

    pub fn list_models(&self) -> Result<Vec<String>, String> {
        let url = format!("{}/api/tags", self.base_url);
        let response = self.client.get(&url).send()
            .map_err(|e| format!("Request failed: {}", e))?;

        #[derive(Deserialize)]
        struct ModelsResponse {
            models: Vec<ModelInfo>,
        }
        #[derive(Deserialize)]
        struct ModelInfo {
            name: String,
        }

        response.json::<ModelsResponse>()
            .map(|r| r.models.into_iter().map(|m| m.name).collect())
            .map_err(|e| format!("Parse error: {}", e))
    }
}
Enter fullscreen mode Exit fullscreen mode

Tauri Commands

// src-tauri/src/lib.rs
use tauri::command;
use crate::ollama::{OllamaClient, ChatRequest, Message};

#[command]
fn chat(model: String, messages: Vec<Message>) -> Result<String, String> {
    let client = OllamaClient::new("http://localhost:11434".to_string());

    let request = ChatRequest {
        model,
        messages,
        stream: false,
        options: Some(ModelOptions {
            temperature: Some(0.7),
            top_p: Some(0.9),
            num_predict: Some(512),
        }),
    };

    let response = client.chat(request)?;
    Ok(response.message.content)
}

#[command]
fn list_models() -> Result<Vec<String>, String> {
    let client = OllamaClient::new("http://localhost:11434".to_string());
    client.list_models()
}
Enter fullscreen mode Exit fullscreen mode

Frontend: React Chat Interface

// src/components/Chat.tsx
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';

interface Message {
  role: 'user' | 'assistant';
  content: string;
}

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [model, setModel] = useState('llama3.2:3b');
  const [models, setModels] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    loadModels();
  }, []);

  async function loadModels() {
    try {
      const available = await invoke<string[]>('list_models');
      setModels(available);
      if (available.length > 0 && !available.includes(model)) {
        setModel(available[0]);
      }
    } catch (e) {
      console.error('Failed to load models:', e);
    }
  }

  async function handleSend() {
    if (!input.trim() || loading) return;

    const userMessage: Message = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setLoading(true);

    try {
      const response = await invoke<string>('chat', {
        model,
        messages: [...messages, userMessage].map(m => ({
          role: m.role,
          content: m.content
        }))
      });

      setMessages(prev => [...prev, { role: 'assistant', content: response }]);
    } catch (e) {
      setMessages(prev => [...prev, { 
        role: 'assistant', 
        content: `Error: ${e}` 
      }]);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="chat-container">
      <div className="model-selector">
        <select value={model} onChange={e => setModel(e.target.value)}>
          {models.map(m => <option key={m} value={m}>{m}</option>)}
        </select>
      </div>

      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i} className={`message ${msg.role}`}>
            <strong>{msg.role === 'user' ? 'You' : 'AI'}:</strong>
            <p>{msg.content}</p>
          </div>
        ))}
      </div>

      <div className="input-area">
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), handleSend())}
          placeholder="Type a message..."
          disabled={loading}
        />
        <button onClick={handleSend} disabled={loading || !input.trim()}>
          {loading ? 'Thinking...' : 'Send'}
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Styling

/* src/components/Chat.css */
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
  padding: 1rem;
}

.model-selector {
  padding: 0.5rem;
  border-bottom: 1px solid #eee;
}

.model-selector select {
  padding: 0.5rem;
  border-radius: 4px;
  border: 1px solid #ddd;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem 0;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.message {
  max-width: 85%;
  padding: 0.75rem 1rem;
  border-radius: 12px;
  line-height: 1.5;
}

.message.user {
  align-self: flex-end;
  background: #3b82f6;
  color: white;
  border-bottom-right-radius: 4px;
}

.message.assistant {
  align-self: flex-start;
  background: #f3f4f6;
  color: #1f2937;
  border-bottom-left-radius: 4px;
}

.input-area {
  display: flex;
  gap: 0.5rem;
  padding-top: 1rem;
  border-top: 1px solid #eee;
}

.input-area input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
}

.input-area button {
  padding: 0.75rem 1.5rem;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 500;
}

.input-area button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
Enter fullscreen mode Exit fullscreen mode

Main App

// src/App.tsx
import { Chat } from './components/Chat';
import './components/Chat.css';

function App() {
  return (
    <div className="app">
      <header>
        <h1>🦙 Local AI Chat</h1>
        <p>Powered by Tauri + Ollama — 100% Private</p>
      </header>
      <Chat />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Running the App

# Terminal 1: Start Ollama
ollama serve

# Terminal 2: Run Tauri dev
cargo tauri dev
Enter fullscreen mode Exit fullscreen mode

Building for Distribution

# Build optimized release
cargo tauri build

# Output: src-tauri/target/release/bundle/
# - .msi (Windows)
# - .dmg (macOS)
# - .AppImage/.deb (Linux)
Enter fullscreen mode Exit fullscreen mode

Key Advantages

Feature Cloud API Local (This Approach)
Monthly Cost $50-5000+ $0
Privacy Data sent to 3rd party Never leaves device
Latency 200-2000ms 50-500ms
Availability Depends on provider Always (if device on)
Model Choice Limited to provider Any GGUF model

Next Steps

  1. Add streaming — Use stream: true and Server-Sent Events for real-time tokens
  2. Persistence — Save conversations to local SQLite via Tauri's SQL plugin
  3. Model management — UI to pull/delete models from Ollama library
  4. System prompt — Let users customize AI behavior
  5. Export/Import — Backup conversations as JSON

Resources


Built with Tauri 2.0, Rust 1.80+, and Ollama. Zero cloud dependencies.

Top comments (0)