Building a Local AI Chat Assistant from Scratch with React + TypeScript + Ollama: Streaming, Multimodal, and Production-Grade Testing

Run a complete AI chat assistant locally without cloud APIs, with image input support, a modern UI, type safety, and test coverage — a scope of engineering that sounded ambitious just a few years ago, but now the solo-chat project compresses all of it into something you can build over a weekend.

The core takeaway is straightforward: Vite's HMR, Ollama's local inference, Zustand's concise state management, and Vitest's testing ecosystem — combined, they let you get a production-grade AI chat app skeleton running in a single day. solo-chat is the product of this combination. It's not a toy demo; its code structure and engineering practices point to an application ready for real use.

Below, we walk through the setup step by step, starting from environment preparation.


Environment Setup and Project Initialization

The first step is confirming the dependency layer. solo-chat requires Node.js version 18.0.0 or higher, which is the minimum requirement for Vite 6 and Express at their current versions.

npm install

After installation, .env.example in the project root provides templates for all configuration variables. The configuration variables include:

Variable Description Default
PORT Backend port 3002
NODE_ENV Runtime environment development
CORS_ORIGIN Allowed CORS origins
OLLAMA_BASE_URL Ollama service address
LOG_LEVEL Log level

Simply copy it:

cp .env.example .env

Ollama is the core for running local models. You need to install the Ollama service first, then pull a lightweight model. The project example uses gemma4, a small-parameter model well-suited for local inference:

ollama pull gemma4

After the model is pulled, both the backend and frontend can be started simultaneously:

npm run dev

The frontend runs at http://localhost:5173, and the backend API runs at http://localhost:3002. At this point, visiting the frontend page shows a fully structured chat interface, but no connection to Ollama has been established yet.


Frontend Architecture and State Management

solo-chat's frontend architecture follows the Vite + React 18 + TypeScript 5.8 combination, with all components, pages, and utility functions type-checked under TypeScript's strict mode.

The project structure is clearly divided into several layers:

src/
├── components/    # React components
├── hooks/         # Custom hooks
├── lib/           # Utility functions
├── pages/         # Page components (Chat, Settings, Invest, Knowledge)
├── store/         # Zustand state management
└── types/         # TypeScript type definitions

The core of state management is src/store/appStore.ts, using Zustand 5. The store design revolves around the message flow — each message has a clear role (user or assistant), content text, and optional image base64 data.

// Core type from src/types/index.ts
interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  images?: string[];
  timestamp: number;
}

The store's key operations are addMessage and updateLastMessage: the former appends a new record when the user sends a message, and the latter incrementally updates the content of the last assistant message during streaming responses. These two operations, combined with Zustand's persist middleware, implement local persistence of message history.

The persist middleware's configuration logic is: on every state change, automatically write the message list to localStorage; on read, keep only the most recent 100 messages to avoid filling browser storage with large amounts of historical data. This design is common in chat applications, but implementing it with persist is far more concise than manually manipulating localStorage.


Chat Page Implementation — Core Interactions

src/pages/Chat.tsx is the core page of the entire application, responsible for message list display, user input, and send logic.

The page structure can be split into three parts: the message list, the input area, and the send button. The message list renders the messages array read from the Zustand store, with each message displayed by the MessageBubble component, styled differently based on role (user / assistant).

The core of the send logic is a fetch request pointing to the backend /api/chat endpoint:

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    messages: currentMessages,
    model: selectedModel,
    options: { temperature: 0.7 },
    ollamaUrl: ollamaBaseUrl,
  }),
});

This endpoint returns an SSE (Server-Sent Events) streaming response, not a regular JSON. The frontend needs to read the response body line by line, decode it with TextDecoder, parse each line as JSON, and append each segment's content field to the last assistant message:

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

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

  const chunk = decoder.decode(value, { stream: true });
  const lines = chunk.split('\n').filter((line) => line.trim());

  for (const line of lines) {
    const data = JSON.parse(line);
    if (data.content) {
      updateLastMessage(data.content); // Zustand action
    }
    if (line.includes('[DONE]')) break;
  }
}

updateLastMessage is a Zustand store action that finds the last message with role assistant in the message list and appends the provided text to its content field. Paired with React's re-render, the user can see the model's output appear character by character in real time on the page.

Error handling covers two layers: fetch request failures (network disconnect, port errors) and Ollama returning non-200 status codes. The former is caught via try-catch and updates the error prompt in the message list; the latter is uniformly handled by the backend in AppError, returning a standardized error JSON that the frontend reads and displays with the corresponding error code and message.


Image Upload and Multimodal Support

Multimodal support is the key feature that distinguishes solo-chat from pure text chat. Users can click an image selection button next to the input box, and after selecting a local image, it immediately appears as a preview above the input box, with the base64 data embedded into the message object to be sent.

The core logic for image processing lives in src/lib/imageUtils.ts, containing two functions: validateImageFile and processImage.

validateImageFile performs pre-validation: the file type must be image/*, and the file size must not exceed 10MB (this limit also aligns with the backend's express.json({ limit: '10mb' }) configuration). If validation fails, an exception with a clear error message is thrown, which the page layer catches and displays to the user.

processImage is responsible for converting the raw file into a base64 string. For images exceeding a certain size, the function uses the Canvas API for compression — first limiting the maximum side length, then re-encoding as JPEG with a quality factor of 0.8, and finally outputting base64 data via toDataURL('image/jpeg'). This entire process happens on the frontend; the images field in the message received by the backend is already a compressed base64 string, requiring no additional processing.

// Core image processing logic示意 in imageUtils.ts
const canvas = document.createElement('canvas');
canvas.width = Math.min(file.width, MAX_DIMENSION);
canvas.height = Math.min(file.height, MAX_DIMENSION);
const ctx = canvas.getContext('2d');
ctx?.drawImage(file, 0, 0, canvas.width, canvas.height);
const base64 = canvas.toDataURL('image/jpeg', 0.8);

Image data is sent to /api/chat along with the message, and the backend passes it through to Ollama's /api/chat endpoint.


Backend API Implementation

The backend is driven by Express 4, with the entry point in api/app.ts. Route registration, CORS configuration, request body parsing, rate limiting, and error-handling middleware are all wired together in this single file.

// api/app.ts
app.use(cors(corsOptions))
app.use('/api/', limiter)
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' }))

app.use('/api/auth', authRoutes)
app.use('/api/chat', chatRoutes)
app.use('/api/ollama', ollamaRoutes)

The rate-limiting configuration (express-rate-limit) caps requests per IP to 100 within a 15-minute window, returning a standardized error response when exceeded. This configuration is unlikely to trigger during local development, but it effectively prevents API abuse when deployed to production.

The chat route api/routes/chat.ts is the core endpoint, handling POST /api/chat requests. At the entry point, it first validates the body via validateBody(chatRequestSchema) — requests that don't satisfy the schema immediately return 400 without entering business logic.

After validation passes, the route instantiates OllamaService and passes through the messages, model, and options from the request. OllamaService encapsulates all interactions with the Ollama service:

// api/services/ollamaService.ts
class OllamaService {
  private baseUrl: string;

  constructor(baseUrl: string = 'http://localhost:11434') {
    this.baseUrl = baseUrl;
  }

  async checkConnection(): Promise<boolean> {
    try {
      const response = await fetch(`${this.baseUrl}/api/tags`);
      return response.ok;
    } catch {
      return false;
    }
  }

  async listModels(): Promise<string[]> {
    const response = await fetch(`${this.baseUrl}/api/tags`);
    const data: OllamaListResponse = await response.json();
    return data.models.map((model) => model.name);
  }

  async *chatStream(request: OllamaChatRequest): AsyncGenerator<string> {
    const response = await fetch(`${this.baseUrl}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...request, stream: true }),
    });

    if (!response.ok) {
      throw new Error(`Ollama API error: ${response.status}`);
    }

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

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

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n').filter((line) => line.trim());

      for (const line of lines) {
        const data: OllamaChatResponse = JSON.parse(line);
        if (data.message.content) {
          yield data.message.content;
        }
      }
    }
    reader?.releaseLock();
  }
}

chatStream is an async generator that yields text fragments one by one; the upstream route handler iterates over them with for await...of and writes to the response:

// api/routes/chat.ts
const stream = ollamaService.chatStream({ model, messages, options });

for await (const chunk of stream) {
  res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();

This approach decouples the two layers of streaming logic — streaming read (from Ollama server) and streaming write (Express response). OllamaService is only responsible for reading from Ollama and yielding; the route layer is only responsible for converting the yielded content into SSE format and writing it to the response.

The Ollama status check endpoint GET /api/ollama/status returns the currently connected model list and connection status. The frontend calls this endpoint on initialization to determine whether Ollama is available, and displays the list of selectable models on the Settings page.


Test Coverage and Quality Assurance

solo-chat uses Vitest as its testing framework, with frontend tests based on @testing-library/react and DOM environment simulated via jsdom. Testing covers two layers: the API layer and the component layer.

Three test commands are defined in package.json:

npm test         # Run all tests
npm run test:ui  # Open the Vitest UI interactive interface
npm run test:coverage  # Generate a coverage report

Frontend Chat page tests primarily validate two scenarios: whether a new user message is correctly appended to the message list after the user sends a message; and whether the page correctly displays an error prompt when the backend returns an error. Tests use render to render the Chat component, userEvent to simulate input and clicks, waitFor to wait for async state updates, and expect to assert DOM content.

API-layer test files are located in the api/test/ directory, using supertest to simulate HTTP requests and directly invoking route handlers to verify response status codes and error codes under different inputs. Error codes follow the standardized list defined in the README: VALIDATION_ERROR, NOT_FOUND, OLLAMA_ERROR, INTERNAL_ERROR, and so on.

In addition to unit tests, the project configures strict ESLint rules — including TypeScript strict mode, unused variable detection, and React Hooks rule checks. Before committing, you need to run the following in sequence:

npm run check   # TypeScript type checking
npm run lint    # ESLint checking
npm test        # Test suite

All three steps must pass to ensure the code meets the project's quality standards.


Source Code Navigation

  • package.json — Dependencies and script configuration
  • .env.example — Environment variable template
  • README.md — Project documentation and API docs
  • api/app.ts — Express application entry, route and middleware registration
  • api/routes/chat.ts — Chat route, SSE streaming response handling
  • api/services/ollamaService.ts — Ollama service wrapper, core streaming generation logic
  • api/schemas/chat.schema.ts — Zod request body validation
  • src/store/appStore.ts — Zustand state management, message list and persistence
  • src/types/index.ts — TypeScript type definitions
  • src/pages/Chat.tsx — Main chat page component
  • src/components/MessageBubble.tsx — Message bubble component
  • src/lib/imageUtils.ts — Image validation and compression utilities

Where is the Ollama service address configured?

Configured via the OLLAMA_BASE_URL environment variable, with a default value of http://localhost:11434. It can also be modified dynamically at runtime through the frontend Settings page.

How does the frontend receive Ollama's streaming response?

The backend chat route converts Ollama’s JSON Lines response into SSE format (data: {...}\n\n). The frontend reads the response body via fetch, decodes it with TextDecoder, and parses it line by line.

Is there a size limit for image uploads?

Yes. The frontend limits images to 10MB via validateImageFile, and the backend’s express.json limit is also set to 10mb, keeping both layers consistent.

What data does Zustand's persist middleware save?

It saves the message list (messages), keeping only the most recent 100 entries to avoid occupying localStorage with large amounts of historical data.

How do I verify that the backend API is working correctly?

Visit GET /api/health — returning {“success”: true, “message”: “ok”} indicates the backend is running normally. Visit GET /api/ollama/status to check the Ollama connection status and available models.

What order should I follow when debugging test failures?

First run npm run check to inspect TypeScript type errors, then npm run lint to check code style, and finally npm test to view the specific failing test cases. Use the error messages to pinpoint the issue.

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号