Building a free Local AI English teacher

Building your own AI English tutor allows you to practice speaking, grammar, and vocabulary locally without relying on expensive monthly API subscriptions.

In this step-by-step tutorial, you will learn how to set up a Next.js web application connected to a local LLM via Ollama and Vercel AI SDK.


Prerequisites

Before starting, ensure you have installed:

Run the following command in your terminal to download a lightweight, fast model optimized for conversation:

ollama run llama3.1
(or ollama run qwen2.5:3b)

Step 1: Project Setup & Package Installation

Create a new Next.js project with App Router and install the required dependencies:

npx create-next-app@latest local-ai-tutor --typescript --tailwind --app
cd local-ai-tutor
npm install ai @ai-sdk/openai @ai-sdk/react

Step 2: Create the API Route (app/api/chat/route.ts)

When integrating Ollama with @ai-sdk/openai, direct message objects containing UI-specific properties can cause an unknown input item type: "item_reference" error on multi-turn conversations.

To prevent this, map the incoming messages array into a flat role-and-content structure, and use toUIMessageStreamResponse() for smooth text streaming.


import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';

const ollama = createOpenAI({
    baseURL: process.env.AI_BASE_URL || 'http://localhost:11434/v1',
    apiKey: 'ollama',
});

export async function POST(req: Request) {
    const { messages } = await req.json();
    const formattedMessages = messages.map((m: any) => {
        let contentText = '';
        if (typeof m.content === 'string') {
            contentText = m.content;
        } else if (Array.isArray(m.parts)) {
            contentText = m.parts
                .filter((part: any) => part.type === 'text')
                .map((part: any) => part.text)
                .join('');
        }

        return {
            role: m.role,
            content: contentText || '',
        };
    });

    const result = await streamText({
        model: ollama(process.env.AI_MODEL_NAME || 'llama3.1'),
        system: `You are an expert English teacher on study.englishconv.com.
    Your goal is to converse with the user in natural English, correct any grammatical errors gently,
    and keep the conversation engaging.`,
        messages: formattedMessages,
    });

    return result.toUIMessageStreamResponse();
}

Step 3: Build a Stable Frontend Interface (app/page.tsx)

To prevent layout jumping and flickering while streaming tokens, isolate the chat container within a fixed viewport height and anchor the input form to the bottom.


'use client';

import { useState, useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';

export default function EnglishTutorPage() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status } = useChat();
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const isThinking = status === 'submitted' || status === 'streaming';

  // Automatically scroll to the bottom when there is a new message or during streaming.
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, status]);

  const handleSend = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isThinking) return;

    sendMessage({
      role: 'user',
      parts: [{ type: 'text', text: input }],
    });
    setInput('');
  };

  return (
    <div className="flex flex-col h-screen bg-[#131314] text-[#e3e3e3] font-sans antialiased overflow-hidden">
      {/* Header */}
      <header className="flex items-center justify-between px-6 py-4 border-b border-[#282a2c] bg-[#131314]/80 backdrop-blur z-10">
        <div className="flex items-center gap-3">
          <div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 via-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-sm shadow-md">
            AI
          </div>
          <span className="font-medium text-lg tracking-wide text-gray-200">
            English Tutor <span className="text-xs text-gray-400 font-normal">study.englishconv.com</span>
          </span>
        </div>
      </header>

      {/* Centered message scroll frame */}
      <main className="flex-1 overflow-y-auto px-4 py-6 md:px-0">
        <div className="max-w-3xl mx-auto space-y-6">
          {messages.length === 0 ? (
            <div className="flex flex-col items-center justify-center min-h-[50vh] text-center space-y-4">
              <h2 className="text-3xl font-semibold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
                Hello! How can I help you learn English today?
              </h2>
              <p className="text-gray-400 max-w-md text-sm">
                Start a conversation, ask grammar questions, or practice speaking with your AI English Teacher.
              </p>
            </div>
          ) : (
            messages.map((m) => {
              const textContent =
                m.parts
                  ?.filter((part) => part.type === 'text')
                  .map((part) => (part as { text: string }).text)
                  .join('') || (m as unknown as { content: string }).content || '';

              const isUser = m.role === 'user';

              return (
                <div
                  key={m.id}
                  className={`flex gap-4 p-4 rounded-2xl transition-colors ${isUser ? 'bg-[#1e1f20] ml-auto max-w-[85%]' : 'bg-transparent mr-auto w-full'
                    }`}
                >
                  {!isUser && (
                    <div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-purple-600 flex items-center justify-center shrink-0 mt-0.5 text-xs font-bold text-white shadow">
                      AI
                    </div>
                  )}

                  <div className="flex-1 space-y-1 overflow-hidden">
                    <div className="text-xs font-semibold text-gray-400">
                      {isUser ? 'You' : 'AI Teacher'}
                    </div>
                    <div className="text-sm leading-relaxed whitespace-pre-wrap text-gray-100 break-words">
                      {textContent}
                    </div>
                  </div>
                </div>
              );
            })
          )}

          {/* The blur effect is waiting for AI to type. */}
          {status === 'submitted' && (
            <div className="flex gap-4 p-4 w-full">
              <div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-purple-600 flex items-center justify-center shrink-0 text-xs font-bold text-white animate-pulse">
                AI
              </div>
              <div className="flex items-center space-x-2">
                <div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce"></div>
                <div className="w-2 h-2 bg-purple-400 rounded-full animate-bounce [animation-delay:0.2s]"></div>
                <div className="w-2 h-2 bg-pink-400 rounded-full animate-bounce [animation-delay:0.4s]"></div>
              </div>
            </div>
          )}

          <div ref={messagesEndRef} />
        </div>
      </main>

      {/* Fixed floating input bar at the bottom */}
      <footer className="p-4 bg-[#131314]">
        <div className="max-w-3xl mx-auto">
          <form
            onSubmit={handleSend}
            className="relative flex items-center bg-[#1e1f20] rounded-full border border-[#333537] focus-within:border-gray-500 transition-all px-4 py-2 shadow-lg"
          >
            <input
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              placeholder="Ask your English tutor anything..."
              className="w-full bg-transparent text-gray-100 placeholder-gray-500 text-sm focus:outline-none pr-12 py-2"
            />
            <button
              type="submit"
              disabled={!input.trim() || isThinking}
              className="absolute right-2 p-2 rounded-full bg-blue-600 hover:bg-blue-500 disabled:opacity-30 disabled:hover:bg-blue-600 text-white transition-all shadow"
            >
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="currentColor"
                className="w-4 h-4"
              >
                <path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
              </svg>
            </button>
          </form>
          <div className="text-center mt-2 text-[11px] text-gray-500">
            EnglishConv.com AI Tutor
          </div>
        </div>
      </footer>
    </div>
  );
}

Comments