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) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
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
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
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))
}
}
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()
}
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>
);
}
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;
}
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;
Running the App
# Terminal 1: Start Ollama
ollama serve
# Terminal 2: Run Tauri dev
cargo tauri dev
Building for Distribution
# Build optimized release
cargo tauri build
# Output: src-tauri/target/release/bundle/
# - .msi (Windows)
# - .dmg (macOS)
# - .AppImage/.deb (Linux)
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
-
Add streaming — Use
stream: trueand Server-Sent Events for real-time tokens - Persistence — Save conversations to local SQLite via Tauri's SQL plugin
- Model management — UI to pull/delete models from Ollama library
- System prompt — Let users customize AI behavior
- Export/Import — Backup conversations as JSON
Resources
Built with Tauri 2.0, Rust 1.80+, and Ollama. Zero cloud dependencies.
Top comments (0)