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/reactStep 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 (<divkey={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"><formonSubmit={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"><inputtype="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"/><buttontype="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"><svgxmlns="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
Post a Comment