Le projet final : une app de chat avec un LLM local
Dernier article de la serie "Apprendre JavaScript avec l'IA". On a couvert les bases, le DOM et l'asynchrone. Maintenant on rassemble tout dans un vrai projet.
On va construire une app de chat web qui discute avec un LLM qui tourne sur ta machine via Ollama. Pas de cloud, pas d'abonnement, pas de clé API. Tout est local et gratuit.
Le projet a deux parties :
- Un backend Node.js qui sert de proxy vers Ollama
- Un frontend en vanilla JavaScript (zero framework) qui affiche le chat
Installer Ollama et telecharger un modèle
Recupere Ollama sur ollama.com. C'est dispo sur macOS, Linux et Windows. L'installation prend 2 minutes.
bash# Telecharge Llama 3 (environ 4 Go)
ollama pull llama3
# Verifie que ca marche
ollama run llama3 "Dis bonjour en une phrase"
Ollama expose une API REST sur le port 11434. Verifie qu'elle répond :
bashcurl http://localhost:11434/api/generate -d '{
"model": "llama3",
"prompt": "Bonjour",
"stream": false
}'
Tu dois recevoir un JSON avec un champ response. Si ca ne marche pas, lance ollama serve dans un terminal séparé.
Le format de l'API Ollama est simple. L'endpoint /api/generate accepte un POST avec trois champs : model (le nom du modèle), prompt (le texte) et stream (true pour du streaming, false pour une réponse complète). Il existe aussi /api/chat pour les conversations multi-tours avec un format messages, mais on va utiliser /api/generate pour rester simple.
Structure du projet
ollama-chat/
server.js # Backend Node.js
public/
index.html # Page HTML
style.css # Styles
app.js # Frontend JavaScript
bashmkdir ollama-chat && cd ollama-chat && mkdir public
Le backend : Node.js sans dépendance externe
Zero npm install. On utilise uniquement les modules natifs de Node.js. Pas besoin d'Express pour un projet aussi simple, et ca t'évité de gerer des dépendances.
Cree server.js :
javascriptconst http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const PORT = 3000;
const OLLAMA_URL = "http://localhost:11434";
const TYPES_MIME = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
};
function servirFichier(req, res) {
let fichier = req.url === "/" ? "/index.html" : req.url;
fichier = path.join(__dirname, "public", fichier);
const ext = path.extname(fichier);
const type = TYPES_MIME[ext] || "text/plain";
fs.readFile(fichier, (err, contenu) => {
if (err) {
res.writeHead(404);
res.end("Fichier non trouve");
return;
}
res.writeHead(200, { "Content-Type": type });
res.end(contenu);
});
}
async function proxyOllama(req, res) {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", async () => {
try {
const reponse = await fetch(`${OLLAMA_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body,
});
if (!reponse.ok) {
res.writeHead(502);
res.end(JSON.stringify({ error: "Ollama ne repond pas" }));
return;
}
const donnees = await reponse.json();
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
});
res.end(JSON.stringify(donnees));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({
error: "Connexion a Ollama impossible. Lance 'ollama serve' dans un terminal."
}));
}
});
}
const serveur = http.createServer((req, res) => {
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end();
return;
}
if (req.method === "POST" && req.url === "/api/chat") {
proxyOllama(req, res);
} else {
servirFichier(req, res);
}
});
serveur.listen(PORT, () => {
console.log(`Serveur : http://localhost:${PORT}`);
console.log(`Ollama attendu sur ${OLLAMA_URL}`);
});
Ce serveur fait deux choses. Il sert les fichiers statiques (HTML, CSS, JS) et il transmet les requêtes du frontend vers Ollama. Pourquoi un proxy ? Parce que le navigateur bloque les requêtes vers localhost:11434 depuis une page servie sur localhost:3000 (restriction CORS). Le proxy contourne ca.
Le frontend : HTML
Cree public/index.html :
html<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Ollama</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header>
<h1>Chat Ollama</h1>
<p>LLM local - aucune donnee envoyee sur internet</p>
</header>
<main id="messages"></main>
<form id="formulaire-chat">
<input
type="text"
id="champ-message"
placeholder="Pose ta question..."
autocomplete="off"
required
>
<button type="submit" id="btn-envoyer">Envoyer</button>
</form>
</div>
<script src="app.js"></script>
</body>
</html>
Le style : CSS minimaliste
Cree public/style.css :
css* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
height: 100vh;
}
#app {
max-width: 720px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
}
header { padding: 1rem; border-bottom: 1px solid #2a2a2a; }
header h1 { font-size: 1.2rem; }
header p { font-size: 0.8rem; color: #888; }
#messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message {
padding: 0.75rem 1rem;
border-radius: 8px;
max-width: 85%;
line-height: 1.5;
white-space: pre-wrap;
}
.message.utilisateur { background: #1a3a5c; align-self: flex-end; }
.message.assistant { background: #1e1e1e; align-self: flex-start; }
.message.chargement { color: #888; font-style: italic; }
#formulaire-chat {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #2a2a2a;
}
#champ-message {
flex: 1;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 1rem;
}
#champ-message:focus { outline: none; border-color: #4a9eff; }
#btn-envoyer {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
background: #4a9eff;
color: white;
font-size: 1rem;
cursor: pointer;
}
#btn-envoyer:disabled { opacity: 0.5; cursor: not-allowed; }
Le coeur : le JavaScript frontend
Cree public/app.js :
javascriptconst formulaire = document.getElementById("formulaire-chat");
const champMessage = document.getElementById("champ-message");
const conteneurMessages = document.getElementById("messages");
const btnEnvoyer = document.getElementById("btn-envoyer");
const historique = [];
function ajouterMessage(texte, role) {
const div = document.createElement("div");
div.classList.add("message", role);
div.textContent = texte;
conteneurMessages.appendChild(div);
conteneurMessages.scrollTop = conteneurMessages.scrollHeight;
return div;
}
async function envoyerMessage(messageUtilisateur) {
ajouterMessage(messageUtilisateur, "utilisateur");
historique.push(messageUtilisateur);
const indicateur = ajouterMessage("Le modele reflechit...", "chargement");
btnEnvoyer.disabled = true;
champMessage.disabled = true;
try {
const contexte = historique.slice(-6).join("\n");
const prompt = `Conversation precedente :\n${contexte}\n\nReponds en francais : ${messageUtilisateur}`;
const reponse = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3",
prompt: prompt,
stream: false,
}),
});
if (!reponse.ok) {
throw new Error(`Erreur serveur : ${reponse.status}`);
}
const donnees = await reponse.json();
indicateur.textContent = donnees.response || "Pas de reponse.";
indicateur.classList.remove("chargement");
indicateur.classList.add("assistant");
} catch (erreur) {
indicateur.textContent = `Erreur : ${erreur.message}`;
indicateur.classList.remove("chargement");
} finally {
btnEnvoyer.disabled = false;
champMessage.disabled = false;
champMessage.focus();
}
}
formulaire.addEventListener("submit", (e) => {
e.preventDefault();
const message = champMessage.value.trim();
if (!message) return;
champMessage.value = "";
envoyerMessage(message);
});
champMessage.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
formulaire.dispatchEvent(new Event("submit"));
}
});
Ce que ce code utilise (tout ce qu'on a appris)
Regarde : const et let pour les variables. Arrow functions pour les callbacks. createElement et classList pour le DOM. addEventListener pour les événements. async/await et fetch pour l'asynchrone. try/catch/finally pour les erreurs. Template literals pour les chaînes. C'est la synthese des quatre articles précédents.
Lancer le projet
bashnode server.js
Ouvre http://localhost:3000. Tape un message. Ollama répond. Si tu vois une erreur de connexion, vérifié qu'Ollama tourne :
bashollama serve
La première réponse peut prendre 10-20 secondes (le modèle se charge en mémoire). Les suivantes sont plus rapides.
Aller plus loin : 4 ameliorations a faire toi-meme
Le projet est volontairement simple. Voici des ameliorations que tu peux implementer en utilisant l'IA comme copilote. Pour chacune, essaie d'abord seul, puis demande de l'aide.
Streaming des réponses. Actuellement, on attend la réponse complète. Ollama supporte le streaming (les mots arrivent un par un). Change stream: false en stream: true et utilise ReadableStream + TextDecoder cote frontend. Demande a ton LLM :
"Comment lire une réponse en streaming avec fetch et ReadableStream en JavaScript ? Ollama envoie du JSON ligne par ligne."
Choix du modèle. Ollama expose la liste des modèles installes via GET http://localhost:11434/api/tags. Cree un endpoint /api/models dans le serveur et un <select> dans le frontend.
Sauvegarde des conversations. Utilise localStorage pour persister l'historique :
javascript// Sauvegarder
localStorage.setItem("historique", JSON.stringify(historique));
// Restaurer au chargement
const saved = JSON.parse(localStorage.getItem("historique") || "[]");
Rendu Markdown. Les LLM formatent souvent en markdown. La bibliothèque marked (12 Ko) convertit le markdown en HTML. Attention : si tu injectes du HTML, utilise DOMPurify pour éviter les failles XSS.
Ce que cette serie t'a appris
Cinq articles, du zero au projet fonctionnel :
- La méthode : utiliser l'IA comme tuteur, pas comme bequille
- Les bases : variables, types, fonctions, boucles, avec des prompts qui font apprendre
- Le DOM : sélection, événements, création dynamique
- L'asynchrone : callbacks, Promises, async/await, fetch
- Un projet réel : backend Node.js, frontend vanilla JS, connexion Ollama
Le code lui-meme est secondaire. Ce qui compte, c'est la demarche : essayer d'abord, demander ensuite, vérifier toujours, comprendre avant de passer a la suite. C'est comme ca qu'on devient développeur, pas en copiant-collant du code généré.
Si tu veux aller plus loin et intégrer l'IA dans un outil métier ou automatiser des workflows avec des LLM, c'est exactement ce qu'on fait chez paltemps.fr. Les bases que tu as apprises dans cette serie sont le point de depart.
Continue a construire des trucs. C'est le seul vrai moyen d'apprendre.
Sources
- Ollama API Documentation -- documentation officielle de l'API REST
- MDN - Fetch API -- référencé fetch
- MDN - ReadableStream -- pour implementer le streaming
- Node.js HTTP module -- documentation du module http natif
- marked.js -- parser Markdown leger pour le navigateur
Suivant : 06 - Glossaire | Retour a l'introduction : Apprendre JavaScript avec l'IA