Tous les tutoriels d'IA que vous avez lus cette année commencent probablement de la même façon : obtenez une clé API, configurez la facturation, envoyez les données de vos utilisateurs vers un serveur tiers. Celui-ci fait exactement l'inverse. À la fin de ce tutoriel, vous aurez une application Next.js qui exécute de vrais modèles de machine learning — embeddings de texte, LLM de chat et reconnaissance vocale Whisper — entièrement dans le navigateur de l'utilisateur, accélérés par le GPU grâce à WebGPU.
La bibliothèque qui rend cela possible est Transformers.js de Hugging Face. Désormais en version 4, elle exécute des modèles convertis en ONNX sur WebAssembly ou WebGPU, prend en charge plus de 150 architectures de modèles et sert plus d'un million d'utilisateurs uniques chaque mois. Le backend WebGPU offre une inférence souvent 10 à 100 fois plus rapide que le repli WASM — assez rapide pour rendre les petits LLM réellement utilisables sur des ordinateurs portables grand public.
Pourquoi est-ce important ? Trois raisons qui pèsent particulièrement lourd si vous développez pour des utilisateurs de la région MENA :
- Coût par token nul. Le modèle se télécharge une fois, se met en cache dans le navigateur, et chaque inférence suivante est gratuite. Pas de factures d'usage qui explosent avec votre trafic.
- Confidentialité totale. Enregistrements vocaux, documents et requêtes ne quittent jamais l'appareil. Pour la santé, le juridique ou le secteur public avec des exigences strictes de résidence des données, ce n'est pas un bonus — c'est la seule architecture qui se qualifie.
- Résilience hors ligne. Une fois en cache, les modèles fonctionnent sur des connexions instables, dans le train et derrière des pare-feux restrictifs.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ et npm ou pnpm installés
- Des connaissances de base en React et Next.js (App Router)
- Un navigateur compatible WebGPU : Chrome ou Edge 113+, ou des versions récentes de Firefox/Safari avec WebGPU activé
- Une machine avec un GPU (les puces graphiques intégrées suffisent — Apple Silicon fonctionne à merveille)
Pas de compte Hugging Face, pas de clés API et pas de Python nécessaires.
Ce que vous allez construire
Une page unique « Boîte à outils d'IA privée » avec trois onglets, chacun illustrant un pipeline différent :
- Recherche sémantique — saisissez des notes, transformez-les en vecteurs avec un modèle sentence-transformer et cherchez par sens plutôt que par mots-clés.
- Chat local — une conversation en streaming avec Qwen2.5-0.5B-Instruct, un LLM quantifié tournant sur votre GPU.
- Transcription vocale — enregistrez de l'audio et transcrivez-le avec Whisper, entièrement hors ligne.
Toute l'inférence s'exécute dans un Web Worker pour que l'interface ne gèle jamais, et chaque modèle est mis en cache par le navigateur après le premier téléchargement.
Étape 1 : Configuration du projet
Créez un projet Next.js neuf et installez Transformers.js :
npx create-next-app@latest browser-ai --typescript --app --tailwind
cd browser-ai
npm install @huggingface/transformersNotez le nom du paquet : la bibliothèque moderne vit dans @huggingface/transformers. L'ancien paquet @xenova/transformers correspond à la lignée v2 héritée — évitez-le pour les nouveaux projets.
Transformers.js embarque des bindings Node.js (onnxruntime-node, sharp) qui ne doivent pas être inclus dans le bundle client. Dites à webpack de les ignorer dans next.config.js :
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
sharp$: false,
"onnxruntime-node$": false,
};
return config;
},
};
module.exports = nextConfig;Sans cela, le build échoue avec des erreurs cryptiques sur des modules natifs. C'est l'erreur de configuration la plus fréquente avec Transformers.js dans Next.js.
Étape 2 : Comprendre les devices et la quantification
Deux options contrôlent l'exécution d'un modèle, et bien les régler fait la différence entre une application réactive et un onglet gelé.
device sélectionne le backend :
"wasm"— WebAssembly sur le CPU. Fonctionne partout, le plus lent."webgpu"— accélération GPU. Massivement plus rapide, mais exige le support du navigateur.
dtype sélectionne le niveau de quantification — l'agressivité de la compression des poids du modèle :
| dtype | Précision | Usage typique |
|---|---|---|
fp32 | 32 bits complets | Défaut WebGPU, téléchargement le plus lourd |
fp16 | Demi-précision | Bon équilibre WebGPU |
q8 | Quantifié 8 bits | Défaut WASM, petit et précis |
q4 | Quantifié 4 bits | LLM dans le navigateur — téléchargement minimal |
Pour un LLM d'un demi-milliard de paramètres, q4 ramène le téléchargement à environ 350 Mo — lourd pour une ressource web, mais il se télécharge une seule fois et le navigateur le met en cache durablement.
Détecter le support WebGPU tient en quelques lignes. Créez lib/gpu.ts :
export async function detectWebGPU(): Promise<boolean> {
if (!("gpu" in navigator)) return false;
try {
const adapter = await (navigator as any).gpu.requestAdapter();
return adapter !== null;
} catch {
return false;
}
}Nous l'utiliserons pour choisir webgpu quand il est disponible et nous replier sur wasm sinon, afin que l'application fonctionne pour chaque visiteur.
Étape 3 : Le Web Worker et le singleton de pipeline
L'inférence de modèles est lourde. Si vous l'exécutez sur le thread principal, la saisie rame, les animations saccadent et le navigateur peut afficher un avertissement « page ne répond pas ». La solution est le pattern canonique de Transformers.js : un Web Worker qui possède les modèles, plus un singleton pour que chaque pipeline ne se charge qu'une seule fois.
Créez app/worker.ts :
import {
pipeline,
TextStreamer,
env,
} from "@huggingface/transformers";
// Models always come from the Hugging Face Hub (cached after first load)
env.allowLocalModels = false;
// One singleton per task: load once, reuse forever
class Pipelines {
static instances: Record<string, Promise<any>> = {};
static get(task: string, model: string, options: object = {}) {
const key = `${task}:${model}`;
if (!(key in this.instances)) {
this.instances[key] = pipeline(task as any, model, {
...options,
progress_callback: (p: any) =>
self.postMessage({ status: "progress", task, data: p }),
});
}
return this.instances[key];
}
}
self.addEventListener("message", async (event: MessageEvent) => {
const { type, payload, device } = event.data;
try {
switch (type) {
case "embed": {
const extractor = await Pipelines.get(
"feature-extraction",
"mixedbread-ai/mxbai-embed-xsmall-v1",
{ device },
);
const output = await extractor(payload.texts, {
pooling: "mean",
normalize: true,
});
self.postMessage({
status: "complete",
type: "embed",
data: output.tolist(),
});
break;
}
case "chat": {
const generator = await Pipelines.get(
"text-generation",
"onnx-community/Qwen2.5-0.5B-Instruct",
{ device, dtype: "q4" },
);
const streamer = new TextStreamer(generator.tokenizer, {
skip_prompt: true,
callback_function: (text: string) =>
self.postMessage({ status: "token", data: text }),
});
const result = await generator(payload.messages, {
max_new_tokens: 512,
do_sample: false,
streamer,
});
self.postMessage({
status: "complete",
type: "chat",
data: result[0].generated_text.at(-1).content,
});
break;
}
case "transcribe": {
const transcriber = await Pipelines.get(
"automatic-speech-recognition",
"onnx-community/whisper-tiny.en",
{ device },
);
const output = await transcriber(payload.audio);
self.postMessage({
status: "complete",
type: "transcribe",
data: output.text,
});
break;
}
}
} catch (err: any) {
self.postMessage({ status: "error", data: err.message });
}
});Trois points à remarquer :
progress_callbackse déclenche pendant le téléchargement du modèle avec noms de fichiers et pourcentages — une UX indispensable pour un téléchargement de 350 Mo.TextStreamerrenvoie chaque token généré à l'interface dès sa production, pour un chat vivant au lieu d'un écran figé puis vidé d'un coup.- La map de singletons garantit que changer d'onglet ne re-télécharge ni ne réinitialise jamais un modèle.
Étape 4 : Le hook React
Maintenant, un hook qui dialogue avec le worker. Créez app/useAI.ts :
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { detectWebGPU } from "@/lib/gpu";
export function useAI() {
const worker = useRef<Worker | null>(null);
const [device, setDevice] = useState<"webgpu" | "wasm">("wasm");
const [progress, setProgress] = useState<string>("");
const [streamText, setStreamText] = useState<string>("");
const resolvers = useRef<Map<string, (data: any) => void>>(new Map());
useEffect(() => {
detectWebGPU().then((ok) => setDevice(ok ? "webgpu" : "wasm"));
worker.current = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
worker.current.addEventListener("message", (e: MessageEvent) => {
const { status, type, data } = e.data;
if (status === "progress" && data.status === "progress") {
setProgress(`${data.file}: ${Math.round(data.progress)}%`);
} else if (status === "token") {
setStreamText((prev) => prev + data);
} else if (status === "complete") {
setProgress("");
resolvers.current.get(type)?.(data);
}
});
return () => worker.current?.terminate();
}, []);
const run = useCallback(
(type: string, payload: object): Promise<any> => {
setStreamText("");
return new Promise((resolve) => {
resolvers.current.set(type, resolve);
worker.current?.postMessage({ type, payload, device });
});
},
[device],
);
return { run, device, progress, streamText };
}Le hook expose quatre éléments : une fonction run() basée sur des promesses pour n'importe quelle tâche, le device détecté, la progress de téléchargement pour les indicateurs de chargement, et streamText qui accumule les tokens du chat en temps réel.
Étape 5 : Recherche sémantique par embeddings
Le modèle d'embedding convertit le texte en vecteurs de 384 dimensions où les sens proches se retrouvent voisins. Comme le worker normalise déjà les vecteurs, la similarité cosinus se réduit à un produit scalaire. Créez app/components/SemanticSearch.tsx :
"use client";
import { useState } from "react";
function dot(a: number[], b: number[]): number {
return a.reduce((sum, v, i) => sum + v * b[i], 0);
}
export function SemanticSearch({ run }: { run: Function }) {
const [notes, setNotes] = useState<string[]>([
"The quarterly invoice for the Tunis office is due Friday",
"Couscous recipe: steam twice, never boil",
"WebGPU shaders compile asynchronously in Chrome",
]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<
Array<[string, number]>
>([]);
async function search() {
const vectors: number[][] = await run("embed", {
texts: [query, ...notes],
});
const [queryVec, ...noteVecs] = vectors;
const scored = notes
.map((note, i): [string, number] => [note, dot(queryVec, noteVecs[i])])
.sort((a, b) => b[1] - a[1]);
setResults(scored);
}
return (
<div className="space-y-4">
<input
className="w-full rounded border p-2"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by meaning, e.g. 'cooking instructions'"
/>
<button onClick={search} className="rounded bg-blue-600 px-4 py-2 text-white">
Search
</button>
<ul>
{results.map(([note, score]) => (
<li key={note} className="border-b py-2">
<span className="font-mono text-sm text-gray-500">
{score.toFixed(3)}
</span>{" "}
{note}
</li>
))}
</ul>
</div>
);
}Essayez de chercher "cooking instructions" — la note sur le couscous arrive en tête alors qu'elle ne partage aucun mot-clé avec la requête. C'est la recherche sémantique, exécutée localement, en quelques millisecondes, sur un modèle téléchargé en quelques secondes.
Étape 6 : Chat en streaming avec un LLM local
L'onglet chat envoie l'historique des messages et affiche les tokens au fil du streaming. Créez app/components/LocalChat.tsx :
"use client";
import { useState } from "react";
type Message = { role: string; content: string };
export function LocalChat({
run,
streamText,
}: {
run: Function;
streamText: string;
}) {
const [messages, setMessages] = useState<Message[]>([
{ role: "system", content: "You are a concise, helpful assistant." },
]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
async function send() {
const next = [...messages, { role: "user", content: input }];
setMessages(next);
setInput("");
setBusy(true);
const reply: string = await run("chat", { messages: next });
setMessages([...next, { role: "assistant", content: reply }]);
setBusy(false);
}
return (
<div className="space-y-4">
{messages
.filter((m) => m.role !== "system")
.map((m, i) => (
<p key={i} className={m.role === "user" ? "text-right" : ""}>
<strong>{m.role}:</strong> {m.content}
</p>
))}
{busy && streamText && (
<p>
<strong>assistant:</strong> {streamText}
</p>
)}
<div className="flex gap-2">
<input
className="flex-1 rounded border p-2"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !busy && send()}
/>
<button
onClick={send}
disabled={busy}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
}Le premier message déclenche le téléchargement du modèle q4 (environ 350 Mo — affichez bien en évidence la chaîne progress du hook). Ensuite, le modèle se charge depuis le cache en quelques secondes, et sur WebGPU un modèle d'un demi-milliard de paramètres génère à un rythme tout à fait exploitable sur des portables ordinaires.
Qwen2.5-0.5B est un petit modèle — attendez-vous à des résumés, reformulations et questions-réponses corrects, pas à du raisonnement profond. Pour une meilleure qualité, remplacez-le par un modèle ONNX plus grand de la communauté en gardant le même code ; seuls l'identifiant du modèle et la taille du téléchargement changent.
Étape 7 : Transcription vocale avec Whisper
Le dernier onglet enregistre l'audio du micro et l'envoie à Whisper. Le détail clé : Whisper attend de l'audio mono Float32 à 16 kHz, donc nous décodons l'enregistrement avec un AudioContext fixé à 16000 Hz. Créez app/components/Transcriber.tsx :
"use client";
import { useRef, useState } from "react";
export function Transcriber({ run }: { run: Function }) {
const recorder = useRef<MediaRecorder | null>(null);
const chunks = useRef<Blob[]>([]);
const [recording, setRecording] = useState(false);
const [text, setText] = useState("");
async function start() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recorder.current = new MediaRecorder(stream);
chunks.current = [];
recorder.current.ondataavailable = (e) => chunks.current.push(e.data);
recorder.current.onstop = async () => {
const blob = new Blob(chunks.current);
const ctx = new AudioContext({ sampleRate: 16000 });
const buffer = await ctx.decodeAudioData(await blob.arrayBuffer());
const audio = buffer.getChannelData(0); // mono Float32Array
const result: string = await run("transcribe", { audio });
setText(result);
stream.getTracks().forEach((t) => t.stop());
};
recorder.current.start();
setRecording(true);
}
function stop() {
recorder.current?.stop();
setRecording(false);
}
return (
<div className="space-y-4">
<button
onClick={recording ? stop : start}
className={`rounded px-4 py-2 text-white ${
recording ? "bg-red-600" : "bg-blue-600"
}`}
>
{recording ? "Stop" : "Record"}
</button>
{text && <p className="rounded bg-gray-100 p-4">{text}</p>}
</div>
);
}whisper-tiny.en ne pèse qu'environ 40 Mo et transcrit des extraits courts bien en dessous de la seconde sur WebGPU. La voix de vos utilisateurs ne touche jamais un serveur — une garantie qui compte pour la dictée médicale, les notes juridiques ou tout flux de travail réglementé.
Enfin, assemblez les trois composants dans app/page.tsx avec un simple état d'onglets, passez-leur run et streamText, et affichez les indicateurs device et progress dans un en-tête. Le hook s'occupe du reste.
Tester votre implémentation
- Lancez
npm run devet ouvrez Chrome 113+. - L'en-tête doit afficher webgpu — s'il indique wasm, vérifiez l'état de WebGPU dans
chrome://gpu. - Dans l'onglet recherche, le modèle d'embedding (environ 30 Mo) se télécharge avec une progression visible ; les requêtes renvoient des résultats classés.
- Dans les DevTools, allez dans Application puis Cache storage — vous verrez
transformers-cachecontenant les fichiers ONNX. Rechargez la page : les modèles s'initialisent désormais depuis le cache sans aucun trafic réseau. - Basculez la limitation réseau des DevTools sur Offline après le premier chargement — tout fonctionne encore. C'est précisément l'intérêt.
Dépannage
Le build échoue en mentionnant sharp ou onnxruntime-node. Vous avez sauté les alias webpack de l'étape 1. Ils sont obligatoires pour l'usage côté client.
navigator.gpu est undefined. Le navigateur n'a pas WebGPU, ou la page est servie en HTTP simple. WebGPU exige un contexte sécurisé — localhost compte, mais une IP de réseau local non.
La première réponse du chat prend des minutes. C'est le téléchargement q4, unique. Affichez le callback progress dans l'interface pour que les utilisateurs voient les pourcentages au lieu d'un bouton mort.
Plantage mémoire sur mobile. Un LLM de 350 Mo dépasse les capacités de nombreux téléphones. Détectez la mémoire avec navigator.deviceMemory et conditionnez l'onglet chat, ou proposez un modèle plus petit.
Transcription incohérente. Votre audio n'est pas en mono 16 kHz. Décodez toujours via new AudioContext({ sampleRate: 16000 }) et passez le canal 0.
Prochaines étapes
- Ajoutez du RAG sur l'appareil : découpez des documents, créez leurs embeddings, stockez les vecteurs dans IndexedDB et injectez les meilleures correspondances dans le prompt du chat — un pipeline de récupération entièrement privé.
- Essayez Whisper multilingue (
onnx-community/whisper-small) pour transcrire de l'audio en arabe et en français pour les utilisateurs MENA. - Explorez le guide WebGPU et l'organisation
onnx-communitysur Hugging Face pour des centaines de modèles déjà convertis. - Combinez ce tutoriel avec notre tutoriel de chatbot IA local avec Ollama pour comparer l'inférence navigateur à l'inférence auto-hébergée, ou notre guide Vercel AI Gateway pour la voie cloud hybride.
Conclusion
Vous avez construit une application Next.js qui calcule des embeddings, discute et transcrit sans un seul appel d'inférence côté serveur. L'architecture est compacte et reproductible : un Web Worker propriétaire de pipelines singletons, WebGPU avec repli WASM, des modèles quantifiés mis en cache par le navigateur et un hook basé sur des promesses reliant worker et interface.
L'IA dans le navigateur ne remplacera pas les modèles cloud de pointe pour le raisonnement complexe. Mais pour les embeddings, la transcription, la classification et la génération légère — les tâches qui composent l'essentiel des fonctionnalités d'IA en production — elle offre ce qu'aucune API ne peut offrir : un coût marginal nul, une confidentialité totale et un fonctionnement hors ligne. Pour les développeurs qui servent des marchés où la souveraineté des données et les coûts de bande passante sont des contraintes quotidiennes, ce n'est pas un tour de démonstration. C'est une architecture qui mérite d'être mise en production.