Créer une app JavaScript avec un LLM local (Ollama)

Tutoriel complet pour créer une application web JavaScript connectée à Ollama. Backend Node.js et frontend vanilla JS pour dialoguer avec un LLM local.

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 :

  1. La méthode : utiliser l'IA comme tuteur, pas comme bequille
  2. Les bases : variables, types, fonctions, boucles, avec des prompts qui font apprendre
  3. Le DOM : sélection, événements, création dynamique
  4. L'asynchrone : callbacks, Promises, async/await, fetch
  5. 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


Suivant : 06 - Glossaire | Retour a l'introduction : Apprendre JavaScript avec l'IA

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.