Building a Local LLM Chat Client with Ollama

This is the most substantial project in our collection of Tauri examples, and I think it’s also the most rewarding. We’re going to build a full-featured chat client for Ollama, the popular tool for running large language models locally on your own hardware. We developed an Ollama client library in an earlier chapter that we reuse here (see directory source-code/llm_local_models). The finished application gives you a dark-themed, multi-session chat interface that streams responses token-by-token, renders markdown in assistant messages, auto-detects which models you have installed, and persists your conversation history across restarts.

What I find particularly interesting about this project is the architecture: the Rust backend does almost nothing. All of the intelligence including the streaming HTTP calls to Ollama, the session management, the rendering lives in a single TypeScript file. Tauri is simply providing us with a native window and the freedom to make cross-origin HTTP requests to Ollama’s local API. This is a great example of how Tauri v2 lets you build desktop applications that are really just well-packaged web apps with superpowers. This is not a book on Rust programming so I am not using one of the powerful features of Tauri.

Project Structure

Let’s look at how the project is organized:

 1 ollama-client-app/
 2 ├── index.htmlFull chat layout with sidebar
 3 ├── package.jsonDependencies and scripts
 4 ├── vite.config.tsVite dev server config for Tauri
 5 ├── src/
 6 │   ├── main.tsAll application logic (532 lines)
 7 │   └── styles.cssDark theme design system (596 lines)
 8 └── src-tauri/
 9     ├── tauri.conf.jsonWindow size, security, bundling
10     └── src/lib.rsMinimal Rust that just bootstraps the window

The file count is small, but don’t let that fool you because there’s a lot happening in main.ts and styles.css. I’ve intentionally kept everything in vanilla TypeScript with no framework: no React, no Vue, no Svelte. Here we just use the DOM APIs that browsers give us for free. For an application of this complexity, that’s a deliberate choice: the entire chat client loads instantly, has zero framework overhead, and is easy to understand top-to-bottom.

The HTML Shell

The index.html file defines the complete layout structure. It’s a two-column design: a sidebar on the left for session management and model selection, and a main area on the right for the chat conversation and input bar.

  1 <!doctype html>
  2 <html lang="en">
  3   <head>
  4     <meta charset="UTF-8" />
  5     <link rel="stylesheet" href="/src/styles.css" />
  6     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  7     <title>Ollama Chat</title>
  8     <meta name="description"
  9           content="A local Ollama chat client with conversation
 10                    memory and session history." />
 11     <link rel="preconnect" href="https://fonts.googleapis.com" />
 12     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
 13     <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
 14           rel="stylesheet" />
 15     <script type="module" src="/src/main.ts" defer></script>
 16   </head>
 17 
 18   <body>
 19     <div id="app">
 20       <!-- Sidebar: Session History -->
 21       <aside id="sidebar" class="sidebar">
 22         <div class="sidebar-header">
 23           <h2 class="sidebar-title">Sessions</h2>
 24           <button id="btn-new-session" class="btn-new-session"
 25                   title="New Session">
 26             <svg width="18" height="18" viewBox="0 0 24 24" fill="none"
 27                  stroke="currentColor" stroke-width="2.5"
 28                  stroke-linecap="round">
 29               <line x1="12" y1="5" x2="12" y2="19"></line>
 30               <line x1="5" y1="12" x2="19" y2="12"></line>
 31             </svg>
 32             <span>New Chat</span>
 33           </button>
 34         </div>
 35         <div id="session-list" class="session-list">
 36           <!-- Session entries rendered dynamically -->
 37         </div>
 38         <div class="sidebar-footer">
 39           <div class="model-selector">
 40             <label for="model-select">Model</label>
 41             <select id="model-select">
 42               <option value="gemma4:12b-it-qat">gemma4:12b-it-qat</option>
 43               <option value="nemotron-3-nano:4b">nemotron-3-nano:4b</option>
 44             </select>
 45           </div>
 46         </div>
 47       </aside>
 48 
 49       <!-- Main Chat Area -->
 50       <main id="main" class="main">
 51         <!-- Chat Messages -->
 52         <div id="chat-messages" class="chat-messages">
 53           <div id="welcome-screen" class="welcome-screen">
 54             <div class="welcome-icon">
 55               <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
 56                    stroke="currentColor" stroke-width="1.5"
 57                    stroke-linecap="round" stroke-linejoin="round">
 58                 <path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3
 59                          5.74V17a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-2.26C6.19
 60                          13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
 61                 <line x1="10" y1="22" x2="14" y2="22"/>
 62               </svg>
 63             </div>
 64             <h1 class="welcome-title">Ollama Chat</h1>
 65             <p class="welcome-subtitle">Start a conversation with your
 66                                          local LLM</p>
 67           </div>
 68         </div>
 69 
 70         <!-- Input Bar -->
 71         <div class="input-bar">
 72           <form id="chat-form" class="chat-form">
 73             <div class="input-wrapper">
 74               <textarea
 75                 id="chat-input"
 76                 class="chat-input"
 77                 placeholder="Send a message…"
 78                 rows="1"
 79                 autofocus
 80               ></textarea>
 81               <button type="submit" id="btn-send" class="btn-send"
 82                       title="Send message" disabled>
 83                 <svg width="20" height="20" viewBox="0 0 24 24"
 84                      fill="currentColor">
 85                   <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
 86                 </svg>
 87               </button>
 88             </div>
 89           </form>
 90           <div class="input-footer">
 91             <span id="status-indicator" class="status-indicator">
 92               <span class="status-dot"></span>
 93               <span id="status-text">Checking Ollama…</span>
 94             </span>
 95           </div>
 96         </div>
 97       </main>
 98     </div>
 99   </body>
100 </html>

A few things worth noticing here. The sidebar has three vertical sections: a header with the “New Chat” button, a scrollable session list that gets populated dynamically by our TypeScript, and a footer with the model selector dropdown. The model <select> starts with some hardcoded options, but as you’ll see shortly, our code replaces these with whatever models Ollama actually has available.

The main area uses a flexbox column layout. The chat messages area takes up all available space (via flex: 1), and the input bar sticks to the bottom. There’s a welcome screen shown when no session is active it gets removed dynamically once the user starts chatting.

Notice the status indicator at the very bottom: a small dot and text that shows whether Ollama is reachable. This is a simple UX touch that saves users from wondering why nothing is happening when they haven’t started Ollama.

The Application Logic: main.ts

This is where all the interesting work happens. Let’s walk through the file section by section.

Types and Constants

 1 // main.ts – Ollama Chat Client
 2 // Modeled after the LocalAssistant pattern from
 3 // llm_local_models/ollama_memory.ts,
 4 // adapted for browser fetch against Ollama's HTTP API.
 5 
 6 // ── Types ──────────────────────────────────────────
 7 
 8 type Role = "system" | "user" | "assistant";
 9 interface Msg {
10   role: Role;
11   content: string;
12 }
13 
14 interface Session {
15   id: string;
16   title: string;
17   messages: Msg[];
18   model: string;
19   createdAt: number;
20 }
21 
22 interface OllamaModel {
23   name: string;
24   model: string;
25 }
26 
27 // ── Constants ──────────────────────────────────────
28 
29 const OLLAMA_BASE = "http://localhost:11434";
30 const DEFAULT_MODEL = "llama3.2:3b";
31 const SYSTEM_PROMPT =
32   "You are a helpful, concise assistant. " +
33   "Format your answers with markdown when appropriate.";
34 const STORAGE_KEY = "ollama-chat-sessions";

The type definitions mirror Ollama’s own API conventions. The Msg interface matches the message format that Ollama’s /api/chat endpoint expects: each message has a role (system, user, or assistant) and a content string. This is the same conversation format used by OpenAI’s API, so if you’ve worked with ChatGPT’s API before, this will feel familiar.

The Session interface is our own invention. Each session tracks a unique id, a display title (derived from the first user message), the full messages array including the system prompt, which model to use, and a createdAt timestamp. We store an array of these sessions in localStorage.

OLLAMA_BASE points to Ollama’s default local server. If you’ve configured Ollama to run on a different port, you’d change this constant. The SYSTEM_PROMPT instructs the model to be concise and use markdown which helps the assistant produce structured responses that our markdown renderer can format nicely.

Application State and DOM References

 1 // ── State ──────────────────────────────────────────
 2 
 3 let sessions: Session[] = [];
 4 let activeSessionId: string | null = null;
 5 let isStreaming = false;
 6 
 7 // ── DOM References ─────────────────────────────────
 8 
 9 let chatMessages: HTMLElement;
10 let chatInput: HTMLTextAreaElement;
11 let btnSend: HTMLButtonElement;
12 let btnNewSession: HTMLButtonElement;
13 let sessionList: HTMLElement;
14 let modelSelect: HTMLSelectElement;
15 let statusDot: HTMLElement;
16 let statusText: HTMLElement;
17 let welcomeScreen: HTMLElement | null;

The state is deliberately simple: a flat array of sessions, the ID of whichever session is currently active, and a boolean flag to prevent the user from sending messages while a response is still streaming. That isStreaming flag is important because without it, a user could fire off multiple requests simultaneously and get interleaved responses.

The DOM references are declared at module scope and assigned during initialization. This is a pattern I like for vanilla TypeScript applications: you grab all your references once at startup, and then every function can use them without re-querying the DOM. It’s fast and explicit.

Helper Functions

 1 // ── Helpers ────────────────────────────────────────
 2 
 3 function generateId(): string {
 4   return Date.now().toString(36) +
 5          Math.random().toString(36).slice(2, 7);
 6 }
 7 
 8 function escapeHtml(text: string): string {
 9   const div = document.createElement("div");
10   div.textContent = text;
11   return div.innerHTML;
12 }

The generateId() function creates short, unique identifiers by combining a base-36 timestamp with a few random characters. It’s not cryptographically secure, but it’s more than adequate for session IDs in a local application.

The escapeHtml() function is a classic browser trick: by setting textContent on a DOM element and then reading back innerHTML, the browser automatically escapes any HTML special characters (<, >, &, quotes). This protects us from XSS if a user types something that looks like HTML tags.

The Markdown Renderer

This is one of my favorite parts of the codebase. Instead of pulling in a heavy markdown library, we use a series of regex replacements to handle the most common markdown patterns:

 1 /** Minimal markdown → HTML for assistant messages */
 2 function renderMarkdown(text: string): string {
 3   let html = escapeHtml(text);
 4 
 5   // Code blocks (```...```)
 6   html = html.replace(
 7     /```(\w*)\n?([\s\S]*?)```/g,
 8     (_m, _lang, code) => {
 9       return `<pre><code>${code.trim()}</code></pre>`;
10     }
11   );
12 
13   // Inline code
14   html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
15 
16   // Bold
17   html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
18 
19   // Italic
20   html = html.replace(
21     /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g,
22     "<em>$1</em>"
23   );
24 
25   // Unordered lists
26   html = html.replace(/(^|\n)- (.+)/g, "$1<li>$2</li>");
27   html = html.replace(
28     /(<li>.*<\/li>\n?)+/g,
29     (m) => `<ul>${m}</ul>`
30   );
31 
32   // Paragraphs (double newline)
33   html = html.replace(/\n\n/g, "</p><p>");
34   html = `<p>${html}</p>`;
35   html = html.replace(/<p><\/p>/g, "");
36 
37   // Single newlines to <br>
38   html = html.replace(/\n/g, "<br>");
39 
40   return html;
41 }

The key insight here is the order of operations. We process code blocks first, before any other transformation, because code blocks should be treated as literal text: we don’t want bold or italic processing inside a code fence. After code blocks, we handle inline code, then bold (**text**), then italic (*text*). The italic regex uses negative lookbehinds and lookaheads ((?<!\*) and (?!\*)) to avoid matching the ** sequences that have already been handled as bold.

The list processing is a two-pass approach: first we wrap each - item line in <li> tags, then we group consecutive <li> elements inside a <ul>. Finally, double newlines become paragraph breaks and single newlines become <br> tags.

Is this a full markdown parser? Absolutely not, it doesn’t handle headers, ordered lists, links, images, or nested structures. But for chat responses from an LLM, it covers the patterns that matter most: code blocks, inline code, bold, italic, and bullet lists. That’s a good engineering tradeoff for a chat client.

Session Persistence

 1 // ── Persistence ────────────────────────────────────
 2 
 3 function saveSessions(): void {
 4   try {
 5     localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
 6   } catch {
 7     // Storage full or unavailable – silently degrade
 8   }
 9 }
10 
11 function loadSessions(): void {
12   try {
13     const raw = localStorage.getItem(STORAGE_KEY);
14     if (raw) {
15       sessions = JSON.parse(raw);
16     }
17   } catch {
18     sessions = [];
19   }
20 }

We’re using localStorage for persistence, which means your chat history survives page reloads and application restarts. The entire sessions array, including all messages, is serialized to JSON and stored under a single key. Both functions wrap their logic in try/catch blocks because localStorage can throw in several scenarios: the storage quota is exceeded, the browser is in private mode with storage disabled, or the stored JSON is corrupt.

The trade-off with this approach is that localStorage has a 5–10 MB limit depending on the browser. For a local chat client, that’s quite a lot of conversations, but if you were building a production application you might want to use IndexedDB or a SQLite database via a Tauri plugin. For our purposes, localStorage keeps things simple and requires zero additional dependencies.

Session Management

 1 // ── Session Management ─────────────────────────────
 2 
 3 function createSession(): Session {
 4   const session: Session = {
 5     id: generateId(),
 6     title: "New Chat",
 7     messages: [{ role: "system", content: SYSTEM_PROMPT }],
 8     model: modelSelect?.value || DEFAULT_MODEL,
 9     createdAt: Date.now(),
10   };
11   sessions.unshift(session);
12   saveSessions();
13   return session;
14 }
15 
16 function getActiveSession(): Session | undefined {
17   return sessions.find((s) => s.id === activeSessionId);
18 }
19 
20 function switchToSession(id: string): void {
21   activeSessionId = id;
22   renderSessionList();
23   renderChatMessages();
24 }
25 
26 function deleteSession(id: string): void {
27   sessions = sessions.filter((s) => s.id !== id);
28   saveSessions();
29 
30   if (activeSessionId === id) {
31     if (sessions.length > 0) {
32       switchToSession(sessions[0].id);
33     } else {
34       activeSessionId = null;
35       renderSessionList();
36       renderChatMessages();
37     }
38   } else {
39     renderSessionList();
40   }
41 }
42 
43 function updateSessionTitle(session: Session): void {
44   // Use the first user message as the title (truncated)
45   const firstUserMsg =
46     session.messages.find((m) => m.role === "user");
47   if (firstUserMsg) {
48     session.title =
49       firstUserMsg.content.slice(0, 50) +
50       (firstUserMsg.content.length > 50 ? "…" : "");
51   }
52 }

Every new session starts with the system prompt already in its message array. This is important: when we later send messages to Ollama, the system prompt is included as the first message in the conversation, which sets the assistant’s behavior for the entire session. Note that sessions.unshift(session) puts new sessions at the top of the list, so the most recent conversation is always first.

The deleteSession() function handles an edge case that’s easy to overlook: if the user deletes the currently active session, we need to switch to another one (or show the welcome screen if no sessions remain). The updateSessionTitle() function auto-generates a title from the first user message, truncated to 50 characters with an ellipsis. This gives the session list meaningful labels without requiring the user to manually name their conversations.

Rendering the UI

The rendering functions are where our vanilla TypeScript approach shows its character. Without a framework’s virtual DOM or reactive bindings, we’re building DOM elements imperatively:

 1 // ── Rendering ──────────────────────────────────────
 2 
 3 function renderSessionList(): void {
 4   sessionList.innerHTML = "";
 5 
 6   if (sessions.length === 0) {
 7     sessionList.innerHTML = `<div style="padding: 20px;
 8       text-align: center; color: var(--text-tertiary);
 9       font-size: 12px;">No sessions yet</div>`;
10     return;
11   }
12 
13   for (const session of sessions) {
14     const item = document.createElement("div");
15     item.className = `session-item${
16       session.id === activeSessionId ? " active" : ""
17     }`;
18     item.setAttribute("data-id", session.id);
19 
20     const msgCount =
21       session.messages.filter((m) => m.role !== "system").length;
22     const timeStr = new Date(session.createdAt)
23       .toLocaleDateString(undefined, {
24         month: "short",
25         day: "numeric",
26         hour: "2-digit",
27         minute: "2-digit",
28       });
29 
30     item.innerHTML = `
31       <div class="session-item-content">
32         <div class="session-item-title">
33           ${escapeHtml(session.title)}
34         </div>
35         <div class="session-item-meta">
36           ${msgCount} msg · ${timeStr}
37         </div>
38       </div>
39       <button class="btn-delete" title="Delete session"
40               data-delete-id="${session.id}">
41         <svg width="14" height="14" viewBox="0 0 24 24"
42              fill="none" stroke="currentColor" stroke-width="2"
43              stroke-linecap="round">
44           <polyline points="3 6 5 6 21 6"></polyline>
45           <path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0
46                    01-2-2L5 6"></path>
47           <path d="M10 11v6"></path>
48           <path d="M14 11v6"></path>
49         </svg>
50       </button>
51     `;
52 
53     // Click to switch session
54     item.addEventListener("click", (e) => {
55       const target = e.target as HTMLElement;
56       if (target.closest(".btn-delete")) return;
57       switchToSession(session.id);
58     });
59 
60     // Delete button
61     const deleteBtn =
62       item.querySelector(".btn-delete") as HTMLElement;
63     deleteBtn.addEventListener("click", (e) => {
64       e.stopPropagation();
65       const deleteId =
66         deleteBtn.getAttribute("data-delete-id")!;
67       deleteSession(deleteId);
68     });
69 
70     sessionList.appendChild(item);
71   }
72 }

Each session item in the sidebar shows the title, a message count (excluding the system prompt), and a formatted timestamp. The delete button is initially invisible (via CSS opacity: 0) and only appears on hover which is a common UX pattern that keeps the interface clean while still providing the functionality.

Notice how the click handler on the session item checks target.closest(".btn-delete") before switching sessions. This prevents a click on the delete button from also triggering a session switch. The delete button gets its own click handler with e.stopPropagation() to prevent the event from bubbling up to the parent.

 1 function renderChatMessages(): void {
 2   const session = getActiveSession();
 3 
 4   if (!session) {
 5     chatMessages.innerHTML = `
 6       <div class="welcome-screen">
 7         <div class="welcome-icon">
 8           <svg width="48" height="48" viewBox="0 0 24 24"
 9                fill="none" stroke="currentColor"
10                stroke-width="1.5" stroke-linecap="round"
11                stroke-linejoin="round">
12             <path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3
13                      5.74V17a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-2.26
14                      C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
15             <line x1="10" y1="22" x2="14" y2="22"/>
16           </svg>
17         </div>
18         <h1 class="welcome-title">Ollama Chat</h1>
19         <p class="welcome-subtitle">Start a conversation with
20                                      your local LLM</p>
21       </div>
22     `;
23     return;
24   }
25 
26   chatMessages.innerHTML = "";
27 
28   for (const msg of session.messages) {
29     if (msg.role === "system") continue;
30     appendMessageBubble(
31       msg.role as "user" | "assistant",
32       msg.content
33     );
34   }
35 
36   scrollToBottom();
37 }
38 
39 function appendMessageBubble(
40   role: "user" | "assistant",
41   content: string,
42   streaming = false
43 ): HTMLElement {
44   // Remove welcome screen if present
45   const welcome =
46     chatMessages.querySelector(".welcome-screen");
47   if (welcome) welcome.remove();
48 
49   const wrapper = document.createElement("div");
50   wrapper.className = `message ${role}`;
51 
52   const avatar = document.createElement("div");
53   avatar.className = "message-avatar";
54   avatar.textContent = role === "user" ? "Y" : "A";
55 
56   const bubble = document.createElement("div");
57   bubble.className = `message-bubble${
58     streaming ? " streaming-cursor" : ""
59   }`;
60 
61   if (role === "assistant") {
62     bubble.innerHTML = renderMarkdown(content);
63   } else {
64     bubble.innerHTML =
65       escapeHtml(content).replace(/\n/g, "<br>");
66   }
67 
68   wrapper.appendChild(avatar);
69   wrapper.appendChild(bubble);
70   chatMessages.appendChild(wrapper);
71 
72   return bubble;
73 }
74 
75 function scrollToBottom(): void {
76   requestAnimationFrame(() => {
77     chatMessages.scrollTop = chatMessages.scrollHeight;
78   });
79 }

The renderChatMessages() function does a full re-render of the chat area. When there’s no active session, it shows the welcome screen. Otherwise, it iterates through all messages, skipping the system prompt (which is for the LLM’s eyes only, not the user’s). Each message gets passed to appendMessageBubble(), which constructs a message element with an avatar and a content bubble.

User messages get simple HTML escaping with newline-to-<br> conversion, while assistant messages go through our renderMarkdown() function. The streaming parameter controls whether a blinking cursor CSS class is applied and this gives the user a visual cue that more text is coming.

The scrollToBottom() function uses requestAnimationFrame to ensure the scroll happens after the DOM has been updated. This is a subtle but important detail because without requestAnimationFrame, the scroll might happen before the browser has laid out the new content, and the chat wouldn’t scroll all the way down.

Ollama API Integration

Now we get to the heart of the application: the code that talks to Ollama.

 1 // ── Ollama API ─────────────────────────────────────
 2 
 3 async function checkOllama(): Promise<boolean> {
 4   try {
 5     const res = await fetch(`${OLLAMA_BASE}/api/tags`);
 6     if (res.ok) {
 7       const data = await res.json();
 8       populateModels(data.models || []);
 9       setStatus("connected", "Ollama connected");
10       return true;
11     }
12     setStatus("error", "Ollama not responding");
13     return false;
14   } catch {
15     setStatus("error",
16               "Ollama not found – is it running?");
17     return false;
18   }
19 }
20 
21 function populateModels(models: OllamaModel[]): void {
22   const currentValue = modelSelect.value;
23   modelSelect.innerHTML = "";
24 
25   if (models.length === 0) {
26     const opt = document.createElement("option");
27     opt.value = DEFAULT_MODEL;
28     opt.textContent = DEFAULT_MODEL;
29     modelSelect.appendChild(opt);
30     return;
31   }
32 
33   for (const m of models) {
34     const opt = document.createElement("option");
35     opt.value = m.name;
36     opt.textContent = m.name;
37     modelSelect.appendChild(opt);
38   }
39 
40   // Restore selection if still available
41   if (models.some((m) => m.name === currentValue)) {
42     modelSelect.value = currentValue;
43   }
44 }
45 
46 function setStatus(
47   state: "connected" | "error" | "checking",
48   text: string
49 ): void {
50   statusDot.className = `status-dot${
51     state === "connected"
52       ? " connected"
53       : state === "error"
54         ? " error"
55         : ""
56   }`;
57   statusText.textContent = text;
58 }

The checkOllama() function serves two purposes: it verifies that Ollama is running and reachable, and it discovers which models are available. Ollama’s /api/tags endpoint returns a JSON object with a models array, each entry containing a name field (like "llama3.2:3b" or "gemma4:12b-it-qat"). We use this to dynamically populate the model dropdown.

The populateModels() function is careful to preserve the user’s current selection if that model is still available. This matters because we call checkOllama() at startup so for example if the user had previously selected a specific model and it’s still installed, the dropdown should show it.

The status indicator uses three states: connected (green dot), error (red dot), and checking (amber dot, the default). The CSS handles the visual styling with colored backgrounds and subtle glowing box-shadows.

Streaming Responses: The Core of the Chat Client

This is the most technically interesting part of the entire application. When the user sends a message, we need to stream the response from Ollama token by token, updating the UI in real-time as each token arrives:

  1 async function sendMessage(userText: string): Promise<void> {
  2   if (isStreaming || !userText.trim()) return;
  3 
  4   // Ensure we have a session
  5   let session = getActiveSession();
  6   if (!session) {
  7     session = createSession();
  8     activeSessionId = session.id;
  9     renderSessionList();
 10   }
 11 
 12   // Add user message
 13   session.messages.push({ role: "user", content: userText });
 14   updateSessionTitle(session);
 15   saveSessions();
 16   renderSessionList();
 17 
 18   // Render user bubble
 19   appendMessageBubble("user", userText);
 20   scrollToBottom();
 21 
 22   // Show thinking indicator
 23   isStreaming = true;
 24   updateInputState();
 25 
 26   const thinkingBubble =
 27     appendMessageBubble("assistant", "", true);
 28   thinkingBubble.innerHTML =
 29     `<div class="thinking-indicator">` +
 30     `<span></span><span></span><span></span></div>`;
 31   scrollToBottom();
 32 
 33   // Stream response from Ollama
 34   let fullResponse = "";
 35   try {
 36     const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
 37       method: "POST",
 38       headers: { "Content-Type": "application/json" },
 39       body: JSON.stringify({
 40         model: session.model,
 41         messages: session.messages,
 42         stream: true,
 43       }),
 44     });
 45 
 46     if (!res.ok || !res.body) {
 47       throw new Error(`Ollama error: ${res.status}`);
 48     }
 49 
 50     const reader = res.body.getReader();
 51     const decoder = new TextDecoder();
 52     let buffer = "";
 53 
 54     while (true) {
 55       const { done, value } = await reader.read();
 56       if (done) break;
 57 
 58       buffer += decoder.decode(value, { stream: true });
 59 
 60       // Process complete JSON lines
 61       const lines = buffer.split("\n");
 62       buffer = lines.pop() || "";
 63 
 64       for (const line of lines) {
 65         if (!line.trim()) continue;
 66         try {
 67           const chunk = JSON.parse(line);
 68           if (chunk.message?.content) {
 69             fullResponse += chunk.message.content;
 70             thinkingBubble.innerHTML =
 71               renderMarkdown(fullResponse);
 72             thinkingBubble.className =
 73               "message-bubble streaming-cursor";
 74             scrollToBottom();
 75           }
 76         } catch {
 77           // Skip malformed JSON
 78         }
 79       }
 80     }
 81 
 82     // Process remaining buffer
 83     if (buffer.trim()) {
 84       try {
 85         const chunk = JSON.parse(buffer);
 86         if (chunk.message?.content) {
 87           fullResponse += chunk.message.content;
 88         }
 89       } catch {
 90         // Skip
 91       }
 92     }
 93   } catch (err) {
 94     fullResponse =
 95       `⚠️ Error: ${
 96         err instanceof Error
 97           ? err.message
 98           : "Failed to reach Ollama"
 99       }`;
100     setStatus("error", "Request failed");
101     // Re-check connection
102     setTimeout(checkOllama, 2000);
103   }
104 
105   // Finalize
106   thinkingBubble.className = "message-bubble";
107   thinkingBubble.innerHTML = renderMarkdown(fullResponse);
108   scrollToBottom();
109 
110   // Save assistant response
111   session.messages.push({
112     role: "assistant",
113     content: fullResponse,
114   });
115   saveSessions();
116   renderSessionList();
117 
118   isStreaming = false;
119   updateInputState();
120 }

Let me walk through the streaming architecture in detail, because it’s the most important pattern in this chapter.

The Ollama Chat API. We POST to /api/chat with three key fields: the model name, the full messages array (including the system prompt and all previous conversation turns), and stream: true. That last field is crucial because it tells Ollama to send the response as a series of newline delimited JSON (NDJSON) chunks rather than waiting for the complete response.

ReadableStream + TextDecoder. When stream: true is set, fetch() returns a response whose body is a ReadableStream. We obtain a reader via res.body.getReader() and create a TextDecoder to convert the raw byte chunks into strings. The { stream: true } option on decoder.decode() tells the decoder not to flush which is important because a multi-byte UTF-8 character might be split across two chunks.

NDJSON parsing. Each chunk from Ollama is a JSON object on its own line, like:

1 {"model":"llama3.2:3b","message":{"role":"assistant","content":"Hello"},"done":false}
2 {"model":"llama3.2:3b","message":{"role":"assistant","content":" there"},"done":false}
3 {"model":"llama3.2:3b","message":{"role":"assistant","content":"!"},"done":true}

The tricky part is that a network chunk doesn’t necessarily align with JSON line boundaries. A single reader.read() call might return half a JSON line, or two-and-a-half lines. That’s why we maintain a buffer. After each read, we split the buffer on newlines: all lines except the last one are guaranteed to be complete (because they have a newline after them), so we can parse those immediately. The last element — which might be an incomplete line — becomes the new buffer for the next iteration.

Real-time UI updates. For each successfully parsed chunk, we append the new content to fullResponse and re-render the entire assistant bubble with renderMarkdown(fullResponse). Yes, we’re re-rendering the markdown on every token. This might seem wasteful, but markdown rendering needs the full text to produce correct output (a code fence that started three tokens ago needs to be detected as a whole), and the regex-based renderer is fast enough that you won’t notice any lag.

Error handling. If the fetch fails or Ollama returns an error, we display the error message in the assistant bubble and schedule a reconnection check after 2 seconds. The isStreaming flag is always reset in the finalization block, regardless of whether the request succeeded or failed.

The thinking indicator. Before the first token arrives, we show an animated three-dot indicator (styled with CSS animations). The moment the first content token comes through, the thinking indicator is replaced with the actual response text plus a blinking cursor. When streaming completes, the cursor class is removed.

Input Handling

 1 // ── Input Handling ─────────────────────────────────
 2 
 3 function updateInputState(): void {
 4   const hasText = chatInput.value.trim().length > 0;
 5   btnSend.disabled = !hasText || isStreaming;
 6 }
 7 
 8 function autoResizeTextarea(): void {
 9   chatInput.style.height = "auto";
10   chatInput.style.height =
11     Math.min(chatInput.scrollHeight, 160) + "px";
12 }

The updateInputState() function keeps the send button disabled when there’s no text or when a response is currently streaming. This provides visual feedback and prevents accidental duplicate submissions.

The autoResizeTextarea() function implements a common pattern for auto-growing textareas: first set the height to "auto" to let the browser calculate the natural height, then set it to scrollHeight (capped at 160 pixels) to match the actual content height. This lets the textarea grow as the user types multiple lines, up to a maximum height, after which it becomes scrollable.

Initialization and Event Wiring

 1 // ── Init ───────────────────────────────────────────
 2 
 3 window.addEventListener("DOMContentLoaded", () => {
 4   // Grab DOM references
 5   chatMessages =
 6     document.getElementById("chat-messages")!;
 7   chatInput =
 8     document.getElementById("chat-input") as
 9     HTMLTextAreaElement;
10   btnSend =
11     document.getElementById("btn-send") as
12     HTMLButtonElement;
13   btnNewSession =
14     document.getElementById("btn-new-session") as
15     HTMLButtonElement;
16   sessionList =
17     document.getElementById("session-list")!;
18   modelSelect =
19     document.getElementById("model-select") as
20     HTMLSelectElement;
21   statusDot =
22     document.querySelector(".status-dot")!;
23   statusText =
24     document.getElementById("status-text")!;
25   welcomeScreen =
26     document.getElementById("welcome-screen");
27 
28   // Load persisted sessions
29   loadSessions();
30   if (sessions.length > 0) {
31     activeSessionId = sessions[0].id;
32   }
33   renderSessionList();
34   renderChatMessages();
35 
36   // Check Ollama connectivity
37   checkOllama();
38 
39   // ── Event Listeners ────────────────────────────
40 
41   // New Session
42   btnNewSession.addEventListener("click", () => {
43     const session = createSession();
44     switchToSession(session.id);
45     chatInput.focus();
46   });
47 
48   // Send message
49   document.getElementById("chat-form")!
50     .addEventListener("submit", (e) => {
51       e.preventDefault();
52       const text = chatInput.value.trim();
53       if (text) {
54         chatInput.value = "";
55         chatInput.style.height = "auto";
56         updateInputState();
57         sendMessage(text);
58       }
59     });
60 
61   // Textarea auto-resize & enter-to-send
62   chatInput.addEventListener("input", () => {
63     updateInputState();
64     autoResizeTextarea();
65   });
66 
67   chatInput.addEventListener("keydown", (e) => {
68     if (e.key === "Enter" && !e.shiftKey) {
69       e.preventDefault();
70       if (!btnSend.disabled) {
71         const text = chatInput.value.trim();
72         if (text) {
73           chatInput.value = "";
74           chatInput.style.height = "auto";
75           updateInputState();
76           sendMessage(text);
77         }
78       }
79     }
80   });
81 
82   // Model selector – update active session model
83   modelSelect.addEventListener("change", () => {
84     const session = getActiveSession();
85     if (session) {
86       session.model = modelSelect.value;
87       saveSessions();
88     }
89   });
90 });

The initialization function does everything in the right order: grab DOM references, load persisted sessions, render the initial UI, and check Ollama connectivity. The event listeners implement a few UX conventions that users expect from a chat application:

  • Enter to send: Pressing Enter submits the message. Shift+Enter inserts a newline (for multi-line messages). This matches the behavior of every modern chat app.
  • Form submission: The chat form also handles the submit event, which is triggered both by the Enter key handler and by clicking the send button.
  • Model switching: When the user selects a different model from the dropdown, the active session’s model is updated immediately and persisted. This means you can switch models mid-conversation if you want.

The Rust Backend

As I mentioned earlier, the Rust backend is deliberately minimal:

 1 // Ollama Chat – Tauri backend
 2 // All Ollama communication happens via the frontend fetch API.
 3 // The Rust backend just bootstraps the Tauri window.
 4 
 5 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 6 pub fn run() {
 7     tauri::Builder::default()
 8         .plugin(tauri_plugin_opener::init())
 9         .run(tauri::generate_context!())
10         .expect("error while running tauri application");
11 }

There are no custom Rust commands here. All communication with Ollama happens via the browser’s fetch() API from the TypeScript frontend. This works because Tauri’s webview allows cross-origin requests to localhost — something a normal browser would block with CORS restrictions. The tauri.conf.json has "csp": null in the security section, which disables Content Security Policy restrictions entirely. In a production application you’d want to tighten this, but for a local dev tool that only talks to localhost, it’s perfectly reasonable.

The tauri.conf.json also sets the window dimensions and minimum size:

 1 {
 2   "$schema": "https://schema.tauri.app/config/2",
 3   "productName": "Ollama Chat",
 4   "version": "0.1.0",
 5   "identifier": "com.markwatson.ollama-client-app",
 6   "build": {
 7     "beforeDevCommand": "npm run dev",
 8     "devUrl": "http://localhost:1420",
 9     "beforeBuildCommand": "npm run build",
10     "frontendDist": "../dist"
11   },
12   "app": {
13     "withGlobalTauri": true,
14     "windows": [
15       {
16         "title": "Ollama Chat",
17         "width": 1100,
18         "height": 750,
19         "minWidth": 700,
20         "minHeight": 500
21       }
22     ],
23     "security": {
24       "csp": null
25     }
26   },
27   "bundle": {
28     "active": true,
29     "targets": "all",
30     "icon": [
31       "icons/32x32.png",
32       "icons/128x128.png",
33       "icons/128x128@2x.png",
34       "icons/icon.icns",
35       "icons/icon.ico"
36     ]
37   }
38 }

The 1100×750 default size gives comfortable room for the sidebar and chat area side by side, while the 700×500 minimum prevents the layout from collapsing into an unusable state.

The Dark Theme Design System

The CSS file is nearly 600 lines, but the most important part is the design token system at the top. Here’s a representative excerpt:

 1 :root {
 2   /* Colors – dark theme inspired by modern chat UIs */
 3   --bg-primary: #0d0f13;
 4   --bg-secondary: #151820;
 5   --bg-sidebar: #111318;
 6   --bg-surface: #1a1d26;
 7   --bg-hover: #1f2330;
 8   --bg-active: #252a38;
 9   --bg-input: #1a1d26;
10   --bg-user-msg: #2563eb;
11   --bg-assistant-msg: #1e2230;
12 
13   --text-primary: #e8eaf0;
14   --text-secondary: #8b8fa6;
15   --text-tertiary: #5c6078;
16 
17   --accent: #3b82f6;
18   --accent-hover: #2563eb;
19   --accent-glow: rgba(59, 130, 246, 0.15);
20   --danger: #ef4444;
21   --success: #22c55e;
22   --warning: #f59e0b;
23 
24   --radius-sm: 8px;
25   --radius-md: 12px;
26   --radius-lg: 16px;
27 
28   --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
29   --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
30 
31   --sidebar-width: 280px;
32   --input-max-width: 768px;
33   --font-family: 'Inter', -apple-system, BlinkMacSystemFont,
34                  'Segoe UI', system-ui, sans-serif;
35 }

By defining all colors, spacing, and animation curves as CSS custom properties, the entire theme can be modified from a single location. The color palette uses very dark, slightly blue-tinted backgrounds (notice how --bg-primary is #0d0f13, not pure black) with blue accents. This gives the UI a modern, professional look that’s easy on the eyes during extended use.

A few CSS techniques worth highlighting:

The streaming cursor uses a pseudo-element with a blinking animation:

1 .streaming-cursor::after {
2   content: '▎';
3   display: inline-block;
4   animation: blink 1s step-end infinite;
5   color: var(--accent);
6 }

The thinking indicator uses three dots with staggered animations:

 1 .thinking-indicator span {
 2   width: 6px;
 3   height: 6px;
 4   border-radius: 50%;
 5   background: var(--text-tertiary);
 6   animation: thinking 1.4s infinite ease-in-out both;
 7 }
 8 .thinking-indicator span:nth-child(2) {
 9   animation-delay: 0.16s;
10 }
11 .thinking-indicator span:nth-child(3) {
12   animation-delay: 0.32s;
13 }

Message bubbles have different border-radius corners depending on the role — user messages have a small bottom-right radius (creating a “speech bubble” effect pointing right), while assistant messages have a small bottom-left radius (pointing left):

1 .message.user .message-bubble {
2   background: var(--bg-user-msg);
3   border-bottom-right-radius: var(--radius-sm);
4 }
5 .message.assistant .message-bubble {
6   background: var(--bg-assistant-msg);
7   border-bottom-left-radius: var(--radius-sm);
8 }

Running the Example

To run this project, you need two things: Ollama running locally, and the Tauri development toolchain.

Prerequisites:

  1. Install Ollama from ollama.com and start it. Verify it’s running:
1 ollama list

You should see at least one model. If you don’t have any models yet, pull one:

1 ollama pull llama3.2:3b
  1. Install dependencies in the project directory:
1 cd ollama-client-app
2 npm install
  1. Start the Tauri development server:
1 npm run tauri dev

This starts both Vite (the frontend dev server on port 1420) and the Tauri native window. You’ll see Rust compilation output the first time, which takes a minute or two. After that, hot module reload will give you instant feedback as you edit the TypeScript or CSS.

When the application launches, check the status indicator at the bottom of the window. If it shows a green dot with “Ollama connected,” you’re ready to chat. If it shows a red dot, make sure Ollama is running (ollama serve in a terminal, or the Ollama desktop app).

The model dropdown in the sidebar footer will automatically populate with all the models you have installed locally. Select one, type a message in the text area, and press Enter. You’ll see the three-dot thinking animation briefly, followed by the response streaming in token by token.

Try creating multiple sessions, switching between them, and deleting old ones. Your conversation history persists in the webview’s localStorage, so it survives application restarts.

Wrap Up

This chapter covered a lot of ground. We built a full-featured chat client that demonstrates several important patterns:

  • Streaming HTTP with ReadableStream: The NDJSON parsing approach — maintaining a buffer, splitting on newlines, processing complete lines while keeping partial lines for the next iteration — is a pattern you’ll use whenever you work with streaming APIs. It applies equally to OpenAI’s streaming endpoint, Server-Sent Events, and any other newline-delimited protocol.

  • Vanilla TypeScript without frameworks: For applications of moderate complexity, you don’t always need React or Vue. The imperative DOM manipulation approach keeps the bundle tiny, the startup instant, and the code easy to debug. The trade-off is that you need to manually manage state synchronization (calling renderSessionList() and saveSessions() in the right places), which a framework would handle declaratively.

  • Tauri as a thin wrapper: The Rust backend did almost nothing, and that’s the point. Tauri gave us a native window, cross-origin fetch capability, and the ability to bundle the app as a standalone desktop application — all without writing any Rust logic. For applications that primarily consume HTTP APIs, this “thin Tauri” pattern is very effective.

  • Progressive UX: Small touches like the connection status indicator, the animated thinking dots, the streaming cursor, and the auto-growing textarea collectively create an experience that feels polished and responsive. These aren’t hard to implement individually, but together they make the difference between a prototype and something you actually want to use.

The Ollama chat client is a practical tool — I use it regularly — and I hope walking through its implementation gives you confidence to build your own local AI applications. The patterns here generalize well beyond Ollama: any streaming API, any chat-style interface, any session management system will use similar techniques.