Back to roadmaps ollama Course

Project: Building a Private Desktop AI Assistant

In this project, we will build a private desktop chat terminal. The frontend uses a clean React interface, communicating with a local backend that forwards requests to your offline Ollama service.


1. System Topology

graph LR
    A[React Desktop App] -->|POST /api/chat| B[Local Node Server Proxy]
    B -->|SDK port 11434| C[Ollama Llama/Qwen]

2. Implementing the Backend Gateway Route

To bypass CORS restrictions when making requests directly from frontend browsers, proxy queries through your local server API:

// app/api/local-chat/route.ts
import { NextResponse } from "next/server";
import ollama from "@ollama/ollama";

export async function POST(req: Request) {
  try {
    const { messages } = await req.json();

    const responseStream = await ollama.chat({
      model: "qwen2.5",
      messages,
      stream: true, // Enables token streaming
    });

    const stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        
        for await (const part of responseStream) {
          const text = part.message.content;
          if (text) {
            controller.enqueue(encoder.encode(text));
          }
        }
        controller.close();
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
      },
    });
  } catch (err: any) {
    return NextResponse.json({ error: err.message }, { status: 500 });
  }
}

3. Implementing the React Desktop Chat UI

Write the chat console component that consumes the local server stream:

// app/chat/DesktopConsole.tsx
"use client";

import React, { useState } from "react";

export default function DesktopConsole() {
  const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);

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

    const userMessage = { role: "user", content: input };
    const updatedHistory = [...messages, userMessage];
    
    setMessages(updatedHistory);
    setInput("");
    setLoading(true);

    try {
      const response = await fetch("/api/local-chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: updatedHistory }),
      });

      const reader = response.body?.getReader();
      const decoder = new TextDecoder();
      
      if (!reader) return;

      const assistantMessagePlaceholder = { role: "assistant", content: "" };
      setMessages((prev) => [...prev, assistantMessagePlaceholder]);

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        const token = decoder.decode(value);
        setMessages((prev) => {
          const next = [...prev];
          const lastIndex = next.length - 1;
          next[lastIndex].content += token; // Append stream character
          return next;
        });
      }
    } catch (err) {
      console.error("Local inference network error:", err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="max-w-2xl mx-auto border rounded-3xl bg-white shadow-lg flex flex-col h-[500px] mt-12 p-6 justify-between">
      <div className="flex-1 overflow-y-auto space-y-4 pr-2">
        {messages.map((msg, idx) => (
          <div key={idx} className={`p-3 rounded-2xl text-sm max-w-lg ${msg.role === "user" ? "bg-blue-600 text-white ml-auto" : "bg-gray-100 text-gray-800"}`}>
            <strong>{msg.role === "user" ? "You" : "Local AI"}:</strong>
            <p className="mt-1 whitespace-pre-wrap">{msg.content}</p>
          </div>
        ))}
      </div>

      <div className="mt-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleSend()}
          className="flex-1 border px-4 py-2.5 rounded-xl text-sm focus:outline-none"
          placeholder="Type private prompt..."
        />
        <button onClick={handleSend} className="bg-blue-600 text-white font-semibold px-4 py-2 rounded-xl text-sm transition hover:bg-blue-700">
          Send
        </button>
      </div>
    </div>
  );
}
Published on Last updated: