Build the mental model
This project combines four ideas from earlier chapters into one working application. Each earlier chapter introduced one of these on its own; a real chat assistant needs all four working together in a single request cycle.
- the local server's chat completions endpoint
- a system prompt
- token-by-token streaming
- conversation history
The chat completions endpoint — the same request shape whether you are pointed at Ollama, llama.cpp's server, or any OpenAI-compatible local runtime — accepts a messages array and returns a completion, but the model behind it holds no memory of your last message. Every call is a fresh, stateless inference pass.
That is why the backend route in this project resends the entire conversation on every turn: a system message setting a Burmese-first tutor persona, followed by every prior user and assistant message in order, followed by the newest question. Drop that history and the model forgets who it is and what you already discussed.
Streaming matters here for the same reason it mattered in the chapter that introduced it: a full local answer can take several seconds to generate token by token, and showing nothing until the last token arrives feels broken even though the model is working correctly. The frontend instead reads the response as a stream and appends each token as it arrives.
Context window becomes a real constraint here
Finally, this project is where context window budget stops being an abstract number and becomes a real constraint: every resent message costs tokens, so a long conversation eventually has to be trimmed or summarized to keep fitting inside the model's window, exactly as the earlier context-window chapter warned it would.
LOCAL CHAT ASSISTANT DATA FLOW
------------------------------
BROWSER BACKEND ROUTE LOCAL MODEL API
(chat UI) (/api/chat) (e.g. Ollama)
[user submits] ---POST---> add system prompt ---> POST /api/chat
messages[] + full history stream: true
| |
|<--- token, token, ... ----|
[tokens render] <---stream-- pipes response straight through
|
v
messages[] += { role: assistant, content: full reply }
|
v
NEXT turn re-sends the WHOLE messages[] array again --
the model itself is stateless between calls; the array IS the memory.
history tokens + new reply tokens must fit the model's context window.Connect it to a real scenario
Structure the project as two small pieces: a backend route, and a frontend chat panel.
Build the backend route
A route like POST /api/chat that owns the system prompt and the local model's address, so the browser never talks to the local server directly — keeping the base URL and any future API key out of client-side code.
Prepend history and forward the request
The route accepts the running conversation array from the frontend, prepends the fixed Burmese-tutor system message, and forwards the request to the local server's chat completions endpoint.
Stream the response straight through
Set stream to true and pipe the response body straight through to the browser as it arrives, rather than buffering it.
Build the frontend chat panel
Keep conversation state as an array of {role, content} messages and send that array on every submit.
Render tokens as they arrive
Read the streamed response using the Fetch API's reader, appending each decoded chunk to the last assistant message as it lands; when the stream ends, push the full assistant message onto the history array for the next turn.
"Done" for this project means:
- sending a Burmese question gets a Burmese-appropriate answer influenced by the system prompt
- the reply visibly streams instead of appearing all at once
- asking a follow-up question that depends on the first answer works correctly
- disconnecting the local server produces a visible error message instead of a silent hang
Try the working example
// app/api/chat/route.ts -- backend proxy to a local model server.
// The browser never talks to the local server directly: this route owns
// the system prompt and the local base URL.
import { NextRequest } from "next/server";
const LOCAL_MODEL_URL = "http://localhost:11434/api/chat";
const MODEL_NAME = "llama3.1:8b";
const SYSTEM_PROMPT = `You are a patient tutor. Answer in Burmese first,
in clear everyday language, and give the English technical term in
parentheses the first time it appears. Keep answers short unless the
user asks for more detail. If you don't know something, say so plainly
instead of guessing.`;
type ChatMessage = { role: "user" | "assistant"; content: string };
export async function POST(req: NextRequest) {
const { messages } = (await req.json()) as { messages: ChatMessage[] };
const upstream = await fetch(LOCAL_MODEL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL_NAME,
// System prompt + full prior history + newest message, every turn --
// the model itself remembers nothing between requests.
messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
stream: true,
}),
});
if (!upstream.ok || !upstream.body) {
return new Response(
JSON.stringify({ error: "Local model server is unreachable." }),
{ status: 502, headers: { "Content-Type": "application/json" } },
);
}
// Re-stream the upstream NDJSON response straight through to the browser
// instead of buffering the whole reply before responding.
return new Response(upstream.body, {
headers: { "Content-Type": "application/x-ndjson" },
});
}
// --- frontend: consume the stream and grow the visible reply ---
//
// Called with the running conversation array and a callback that receives
// the assistant's partial text after every new token.
export async function sendMessage(
history: ChatMessage[],
onPartialReply: (text: string) => void,
): Promise<string> {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
});
if (!res.ok || !res.body) {
const message = "[Error: could not reach the local model. Is it running?]";
onPartialReply(message);
return message;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; // keep the last, possibly-incomplete line
for (const line of lines) {
if (!line.trim()) continue;
const chunk = JSON.parse(line) as {
message?: { content: string };
done: boolean;
};
if (chunk.message?.content) {
assistantText += chunk.message.content;
onPartialReply(assistantText); // repaint with the growing reply
}
}
}
return assistantText;
}Not runnable here
This code calls your own local AI server (e.g. Ollama) — this site's browser playground cannot reach a local server on your machine (network access is restricted there for safety). Run it yourself in a terminal or file where a local server is actually running.
When it runs correctly, submitting a question makes the assistant's reply grow on screen a few characters at a time -- streaming, not one full paragraph appearing at once. The system prompt makes replies lead in Burmese, with any English technical term given in parentheses the first time it's used. A follow-up question can correctly reference the earlier answer because the full history was resent. If the local model server is stopped or unreachable, the backend route returns a clear error rather than hanging, and the UI shows a visible "could not reach the local model" message instead of silently freezing.5-minute try-it
Add logic that trims or summarizes the oldest messages once the conversation history array crosses an estimated token count, so it keeps fitting inside the model's context window. Test it with a long back-and-forth and confirm the trim/summarize step actually triggers.
One important caution
Calling the local model server directly from the frontend instead of through the backend proxy route -- it exposes the base URL and system prompt in client-side code and gives up control over history
Sending only the newest message instead of the full conversation history on every turn -- the model loses all context and forgets everything said before
Ollama API Reference -- Generate a Chat Completion — Local AI / Local LLM