Intelligence Artificielle

Chrome Built-in AI : Gemini Nano dans le navigateur

Chrome-Built-In-Ai Gemini-Nano Prompt-Api Summarizer-Api Translator-Api Writer-Api Ia-Locale Javascript Webgpu Privacy No-Cloud Llm Progressive-Enhancement Ia-Generative
Chrome Built-in AI : Gemini Nano dans le navigateur

Exploitez les API IA natives de Chrome avec Gemini Nano : Prompt API, Summarizer, Translator et Writer, sans cle API, sans cout et 100 % en local.

Pourquoi une IA embarquee dans le navigateur

En bref : Chrome embarque désormais un modèle de langage, Gemini Nano, accessible directement en JavaScript par des API standardisées. Pas de clé API, pas d'appel réseau, pas de facture au token, et des données qui ne quittent jamais la machine de l'utilisateur. En contrepartie : un petit modèle, une fenêtre de contexte réduite et une disponibilité qui dépend du matériel.

Jusqu'ici, ajouter une fonctionnalité IA à une application web supposait toujours la même plomberie : une clé API à protéger, un endpoint back-end pour ne pas l'exposer, un budget mensuel à surveiller, une latence réseau incompressible et une politique de confidentialité à mettre à jour parce que le texte de vos utilisateurs part chez un tiers. Pour une fonctionnalité centrale, ce coût est justifié. Pour un bouton « résumer ce commentaire » ou « reformuler ce message », il est disproportionné.

Les API IA intégrées de Chrome renversent complètement ce compromis. Le modèle est téléchargé une seule fois par le navigateur, stocké au niveau du profil utilisateur et partagé par tous les sites qui l'utilisent. Votre application ne paie ni le téléchargement, ni l'inférence, ni le stockage. Le code se réduit à quelques lignes de JavaScript, sans dépendance, sans build particulier, sans serveur.

Ce que ce modele n'est pas

Il faut poser tout de suite la limite, parce qu'elle conditionne tous les cas d'usage : Gemini Nano n'est pas un GPT-4o local. C'est un modèle de quelques milliards de paramètres, quantifié pour tenir en mémoire vidéo sur une machine grand public. Il excelle sur les tâches de transformation de texte court — résumer, reformuler, classifier, extraire, traduire — et se montre nettement moins fiable dès qu'on lui demande du raisonnement multi-étapes, des connaissances factuelles précises ou de la génération de code non triviale.

La bonne façon de le penser : c'est une primitive de traitement du langage disponible localement, au même titre que Intl.DateTimeFormat est une primitive de formatage. On ne lui confie pas la fonctionnalité qui justifie l'existence du produit, on lui confie les dizaines de micro-tâches textuelles qui, jusqu'ici, ne valaient pas un appel API.

Positionnement par rapport aux autres approches

Approche Ou tourne le modele Cout Bon pour
Chrome Built-in AI Dans le navigateur, modele fourni par Chrome Nul, aucun telechargement a votre charge Micro-taches texte, hors ligne, donnees sensibles
transformers.js / WebLLM Dans le navigateur, modele telecharge par votre site Bande passante et stockage a votre charge Modele specifique impose par le besoin metier
Ollama en local Serveur HTTP sur la machine de l'utilisateur Nul, mais installation manuelle requise Outils internes, postes de developpeurs
API cloud (OpenAI, Claude, Gemini) Chez le fournisseur Facture au token Raisonnement, contexte long, qualite maximale

La différence majeure avec Ollama ou transformers.js tient en une phrase : ici, ce n'est pas votre application qui distribue le modèle. Aucun téléchargement de plusieurs centaines de mégaoctets au premier chargement de votre page, aucun cache à gérer, aucun worker WebGPU à écrire. Le navigateur s'en charge, et le modèle est déjà là pour l'utilisateur qui a visité un autre site l'ayant déclenché.

Les cas d'usage qui marchent vraiment

Bons candidats :
  • Resume d'un contenu affiche — article, fil de commentaires, description longue, compte rendu
  • Reformulation assistee — rendre un message plus formel, plus court, plus clair avant envoi
  • Traduction a la volee — commentaires ou messages dans une langue etrangere, sans envoyer le contenu a un tiers
  • Classification legere — detecter le ton d'un avis, categoriser un ticket, proposer des tags
  • Extraction de champs — recuperer une date, un montant ou un nom dans un texte libre
  • Aide a la saisie — proposer un titre, une description ou un premier jet de reponse
  • Fonctionnement hors ligne — une PWA qui doit rester utile sans reseau
Le cas d'usage le plus sous-estimé est celui des données que vous ne voulez pas voir sortir : notes médicales, brouillons juridiques, contenu d'un intranet, messages privés. Avec un modèle local, la question « où partent les données de l'utilisateur ? » disparaît du cahier des charges. C'est souvent ce qui débloque un projet bien plus que la performance brute du modèle.

Prerequis materiels et activation

En bref : ces API demandent un navigateur Chromium récent, un poste de bureau, une carte graphique disposant d'au moins 4 Go de mémoire vidéo et une vingtaine de gigaoctets d'espace disque libre. Une part significative de vos utilisateurs ne les aura pas : la détection de disponibilité n'est pas une précaution, c'est une obligation.

Les API IA intégrées se déploient progressivement, API par API et version par version. Au moment où ces lignes sont écrites, les API d'écriture et de traduction — Summarizer, Translator, Language Detector — sont les plus avancées en disponibilité générale, tandis que la Prompt API a d'abord été ouverte aux extensions Chrome avant d'arriver sur le web ouvert. Ce calendrier bouge vite : ne le codez jamais en dur, testez la disponibilité à l'exécution.

Configuration requise

Element Exigence Consequence si absent
Navigateur Chrome ou Edge recent, sur poste de bureau Objets globaux absents, repli obligatoire
Systeme Windows 10/11, macOS 13+, Linux, ChromeOS recent API exposee mais unavailable
GPU Environ 4 Go de memoire video minimum Modele non telechargeable
Disque Une vingtaine de Go libres sur le volume du profil Telechargement refuse ou modele supprime
Reseau Connexion non limitee pour le premier telechargement Etat bloque sur downloadable
Mobile Support tres partiel selon les plateformes Prevoir le repli par defaut

Verifier l'etat reel du modele

Chrome expose une page de diagnostic interne qui indique si le modèle est présent, en cours de téléchargement ou refusé, et pour quelle raison. C'est le premier réflexe quand une API répond obstinément unavailable sur votre poste alors que la configuration semble correcte.

# Etat du modele embarque, criteres materiels et journal de telechargement
chrome://on-device-internals

# Composants installes par Chrome : verifier "Optimization Guide On Device Model"
chrome://components

# Drapeaux experimentaux, quand une API n'est pas encore active par defaut
chrome://flags/#prompt-api-for-gemini-nano
chrome://flags/#optimization-guide-on-device-model

Sur un poste de développement, il est fréquent que le modèle ne se télécharge pas tant qu'aucune page ne l'a réclamé. Le déclencher une première fois manuellement, depuis la console, évite de croire à un bug de son code alors que le modèle n'est simplement pas encore descendu.

Origin trial et deploiement en production

Tant qu'une API n'est pas en disponibilité générale, l'accès depuis une page web publique passe par un origin trial : un jeton lié à votre domaine, généré depuis la console des origin trials de Chrome, qui active l'API pour vos visiteurs sans qu'ils aient à toucher un drapeau. Le jeton se déclare dans le <head> ou dans un en-tête HTTP.

<!-- Jeton d'origin trial : lie a un domaine precis et a une date d'expiration -->
<meta http-equiv="origin-trial" content="VOTRE_JETON_ORIGIN_TRIAL">
# Variante en en-tete HTTP, utile quand le HTML est genere ailleurs
Origin-Trial: VOTRE_JETON_ORIGIN_TRIAL
Un jeton d'origin trial expire, généralement au bout de quelques versions de Chrome. Si une fonctionnalité IA cesse de marcher en production sans qu'aucun déploiement n'ait eu lieu, vérifiez d'abord cette date. Prévoyez une alerte calendaire : c'est la panne la plus bête et la plus fréquente de ce type d'intégration.

Detecter la disponibilite et telecharger le modele

En bref : chaque API expose une méthode statique availability() qui renvoie available, downloadable, downloading ou unavailable. Ces quatre états correspondent à quatre interfaces différentes : réponse immédiate, téléchargement à déclencher, attente avec progression, fonctionnalité masquée.

C'est la partie que tout le monde bâcle, et c'est pourtant celle qui détermine la qualité perçue de la fonctionnalité. Afficher un bouton « Résumer » qui met quarante secondes à répondre la première fois — parce qu'un modèle de plusieurs gigaoctets se télécharge en arrière-plan — produit une expérience bien pire que de ne rien afficher du tout.

/**
 * Detection en deux temps : presence de l'API, puis etat du modele.
 * Le premier test evite un ReferenceError sur Firefox et Safari.
 */
async function getLocalAIStatus() {
  // 1. L'API existe-t-elle dans ce navigateur ?
  if (!('LanguageModel' in self)) {
    return { supported: false, state: 'unsupported' };
  }

  // 2. Le modele est-il utilisable sur cette machine ?
  //    'available'    -> pret, reponse immediate
  //    'downloadable' -> supporte, mais telechargement a declencher
  //    'downloading'  -> telechargement deja en cours
  //    'unavailable'  -> materiel ou OS incompatible
  const state = await LanguageModel.availability();

  return { supported: true, state };
}

const status = await getLocalAIStatus();
console.log(status);
// { supported: true, state: 'downloadable' }

Le cas downloadable mérite un traitement explicite. Le téléchargement ne se déclenche qu'à la première création de session, et il doit être initié par une interaction utilisateur : on ne lance pas plusieurs gigaoctets de transfert au chargement d'une page. La bonne pratique consiste à afficher un bouton d'activation clair, puis une barre de progression pendant le transfert.

/**
 * Creation de session avec suivi du telechargement initial.
 * L'option monitor n'est appelee que lorsqu'un transfert est necessaire.
 */
async function createSessionWithProgress(onProgress) {
  const session = await LanguageModel.create({
    monitor(monitor) {
      monitor.addEventListener('downloadprogress', (event) => {
        // event.loaded : progression normalisee entre 0 et 1
        const percent = Math.round(event.loaded * 100);
        onProgress(percent);
      });
    },
  });

  return session;
}

// Toujours declencher depuis une action utilisateur explicite
document.querySelector('#enable-ai').addEventListener('click', async () => {
  const bar = document.querySelector('#ai-progress');
  bar.hidden = false;

  const session = await createSessionWithProgress((percent) => {
    bar.value = percent;
    bar.textContent = `Telechargement du modele : ${percent} %`;
  });

  bar.hidden = true;
  console.log('Modele pret', session);
});

Une fois la session créée, deux propriétés renseignent sur le budget de contexte disponible. Il est tentant de coder en dur une limite trouvée dans un article de blog : c'est une mauvaise idée, ces valeurs évoluent d'une version de Chrome à l'autre. Lisez-les à l'exécution.

const session = await LanguageModel.create();

// Quota total de la session et consommation actuelle, en unites de tokens
console.log('Quota total   :', session.inputQuota);
console.log('Deja consomme :', session.inputUsage);
console.log('Reste         :', session.inputQuota - session.inputUsage);

// Mesurer le cout d'une entree AVANT de l'envoyer : indispensable
// pour tronquer proprement un texte long plutot que de se faire rejeter
const cost = await session.measureInputUsage('Un texte potentiellement tres long...');
if (session.inputUsage + cost > session.inputQuota) {
  console.warn('Entree trop longue : tronquer ou decouper en morceaux');
}
Les premières versions de ces API vivaient sous un objet window.ai et exposaient une méthode capabilities() renvoyant readily, after-download ou no. Cette forme est obsolète : les objets sont désormais des globales de premier niveau (LanguageModel, Summarizer, Translator) avec availability(). Les tutoriels antérieurs à ce changement ne fonctionnent plus tels quels.

Prompt API : dialoguer avec Gemini Nano

En bref : une session LanguageModel conserve l'historique de la conversation, accepte un prompt système via initialPrompts et répond avec prompt() ou promptStreaming(). Une session consomme de la mémoire vidéo : appelez toujours destroy() quand vous n'en avez plus besoin.

La Prompt API est la plus générale des API intégrées : c'est un accès direct au modèle, à vous d'écrire le prompt. Elle sert de solution de repli pour tout ce que les API spécialisées ne couvrent pas — classification, extraction, transformation métier.

// Session la plus simple : une question, une reponse
const session = await LanguageModel.create();

const answer = await session.prompt(
  'Resume en une phrase : le train de 8h12 est supprime, ' +
  'les voyageurs sont invites a emprunter celui de 8h47.'
);

console.log(answer);
// "Le train de 8h12 est supprime, il faut prendre celui de 8h47."

// Liberer la memoire video des que la session ne sert plus
session.destroy();

Le prompt système passe par initialPrompts, un tableau de messages avec un rôle. C'est ici qu'on cadre le comportement du modèle : contrainte de format, langue de réponse, ton. Avec un petit modèle, cette contrainte doit être plus explicite et plus répétitive qu'avec un modèle cloud — il faut accepter d'écrire des consignes qui paraîtraient insultantes pour GPT-4o.

const session = await LanguageModel.create({
  initialPrompts: [
    {
      role: 'system',
      content:
        'Tu es un assistant de support client. ' +
        'Tu reponds toujours en francais, en deux phrases maximum, ' +
        'sur un ton courtois et factuel. ' +
        'Si la question sort du domaine du support, tu reponds ' +
        'exactement : "Je ne peux pas repondre a cette question."',
    },
    // Few-shot : deux exemples valent mieux qu'un paragraphe de consignes
    { role: 'user', content: 'Ma commande est en retard.' },
    {
      role: 'assistant',
      content:
        'Je comprends votre impatience. Vous pouvez suivre votre colis ' +
        'depuis la page Mes commandes, rubrique Suivi.',
    },
  ],
  // Temperature basse = sorties stables et reproductibles
  temperature: 0.2,
  topK: 3,
});

console.log(await session.prompt('Comment retourner un article ?'));

Sur des réponses de plus de deux ou trois phrases, l'attente devient perceptible. Le streaming est alors indispensable : il fait apparaître le texte au fil de la génération, ce qui divise la latence perçue même à durée totale identique.

const session = await LanguageModel.create();
const output = document.querySelector('#answer');
output.textContent = '';

// promptStreaming renvoie un ReadableStream de fragments de texte
const stream = session.promptStreaming(
  'Explique en trois paragraphes ce qu\'est le rendu cote serveur.'
);

for await (const chunk of stream) {
  // Chaque chunk contient le NOUVEAU fragment, pas le texte complet
  output.textContent += chunk;
}
Attention à un piège historique : dans les toutes premières versions, le stream émettait le texte cumulé à chaque itération, ce qui imposait un output.textContent = chunk. Le comportement a été aligné sur le reste de la plateforme et émet désormais des fragments. Si vous voyez le texte se répéter en escalier, c'est exactement ce décalage.

Sessions, clonage et annulation

Une session accumule l'historique : chaque échange consomme du quota. Quand on veut rejouer plusieurs fois la même configuration de départ sans traîner l'historique, clone() repart de l'état initial sans repayer le coût d'amorçage. Et comme n'importe quelle opération longue, une génération s'annule avec un AbortSignal.

const base = await LanguageModel.create({
  initialPrompts: [
    { role: 'system', content: 'Tu classes des avis clients en positif, neutre ou negatif. Tu ne reponds que par ce seul mot.' },
  ],
  temperature: 0,
});

// Chaque avis part d'un contexte propre, sans historique parasite
async function classify(reviews) {
  const results = [];

  for (const review of reviews) {
    const session = await base.clone();
    try {
      const label = await session.prompt(review);
      results.push({ review, label: label.trim().toLowerCase() });
    } finally {
      session.destroy(); // meme en cas d'erreur
    }
  }

  return results;
}

// Annulation : l'utilisateur ferme le panneau pendant la generation
const controller = new AbortController();
document.querySelector('#close').addEventListener('click', () => controller.abort());

try {
  const text = await base.prompt('Redige un resume detaille...', {
    signal: controller.signal,
  });
  console.log(text);
} catch (error) {
  if (error.name === 'AbortError') {
    console.info('Generation annulee par l\'utilisateur');
  } else {
    throw error;
  }
}

base.destroy();
Regles de survie avec les sessions :
  • Une session par contexte fonctionnel, pas une session globale reutilisee partout
  • Toujours destroy() dans un finally ou au demontage du composant
  • clone() pour les traitements en lot, afin de ne pas saturer le quota
  • Un AbortSignal systematique des que l'utilisateur peut quitter l'ecran
  • Verifier inputUsage avant chaque envoi dans une conversation longue

Sorties structurees avec responseConstraint

En bref : l'option responseConstraint accepte un JSON Schema et contraint le décodage du modèle à ne produire qu'une sortie conforme. C'est la différence entre « le modèle produit souvent du JSON valide » et « la sortie est du JSON valide », ce qui change tout dès qu'on branche le résultat sur du code.

Demander « réponds en JSON » à un petit modèle est une source d'échecs constante : texte d'introduction avant l'objet, blocs de code markdown, virgule finale, clés inventées. La contrainte de schéma élimine ce problème à la racine, puisqu'elle agit au moment de l'échantillonnage des tokens et non après coup.

// Schema decrivant exactement la forme attendue
const schema = {
  type: 'object',
  required: ['sentiment', 'score', 'themes'],
  additionalProperties: false,
  properties: {
    sentiment: { type: 'string', enum: ['positif', 'neutre', 'negatif'] },
    score: { type: 'number', minimum: 0, maximum: 1 },
    themes: {
      type: 'array',
      maxItems: 3,
      items: { type: 'string' },
    },
  },
};

const session = await LanguageModel.create({ temperature: 0 });

const raw = await session.prompt(
  'Analyse cet avis client : "Livraison rapide mais le colis etait abime ' +
  'et le service client ne repond pas."',
  { responseConstraint: schema }
);

// La sortie est garantie conforme au schema : le parse ne peut plus surprendre
const analysis = JSON.parse(raw);
console.log(analysis);
// {
//   sentiment: 'negatif',
//   score: 0.22,
//   themes: ['livraison', 'emballage', 'service client']
// }

session.destroy();

Ce mécanisme transforme le modèle local en extracteur de données utilisable en production. Un formulaire qui devine la ville et le code postal à partir d'une adresse collée, un champ de recherche qui traduit « les factures de mars supérieures à 500 euros » en filtres structurés, un import de texte libre converti en enregistrements : autant de fonctionnalités qui ne justifiaient pas un appel API facturé, et qui deviennent gratuites.

/**
 * Extraction d'entites depuis un texte libre, avec garde-fous.
 * Le schema autorise explicitement null : c'est ce qui evite
 * que le modele invente une valeur pour remplir le champ.
 */
const invoiceSchema = {
  type: 'object',
  required: ['numero', 'montant', 'echeance'],
  additionalProperties: false,
  properties: {
    numero: { type: ['string', 'null'] },
    montant: { type: ['number', 'null'] },
    echeance: { type: ['string', 'null'], description: 'Format AAAA-MM-JJ' },
  },
};

async function extractInvoice(text) {
  const session = await LanguageModel.create({
    initialPrompts: [
      {
        role: 'system',
        content:
          'Tu extrais des donnees de facture. ' +
          'Si une information est absente du texte, tu mets null. ' +
          'Tu n\'inventes jamais de valeur.',
      },
    ],
    temperature: 0,
  });

  try {
    const raw = await session.prompt(text, { responseConstraint: invoiceSchema });
    return JSON.parse(raw);
  } finally {
    session.destroy();
  }
}

console.log(await extractInvoice('Facture FA-2026-118, a regler avant le 15 aout.'));
// { numero: 'FA-2026-118', montant: null, echeance: '2026-08-15' }
Un schéma contraint la forme, jamais l'exactitude. Le modèle produira toujours un objet valide, y compris quand il se trompe complètement de valeur. Pour tout ce qui a une conséquence — un montant, une date d'échéance, une adresse de livraison — le résultat reste une proposition à confirmer par l'utilisateur, jamais une donnée qu'on écrit directement en base.

Summarizer API : resumer sans prompt

En bref : plutôt que d'écrire un prompt de résumé, Summarizer expose des options typées — type, format, length, sharedContext. Le prompt sous-jacent est optimisé par Chrome pour le modèle : à tâche égale, le résultat est plus stable qu'un prompt maison.

C'est le principe général des API de tâche : chaque fois qu'une API dédiée existe, elle bat le prompt libre. Non pas parce que le modèle est différent — c'est le même — mais parce que la formulation, la gestion des textes longs et le post-traitement sont déjà réglés.

// Verifier la disponibilite pour CETTE configuration precise
const availability = await Summarizer.availability();

if (availability === 'unavailable') {
  throw new Error('Resume local indisponible sur cet appareil');
}

const summarizer = await Summarizer.create({
  // type : 'key-points' | 'tldr' | 'teaser' | 'headline'
  type: 'key-points',
  // format : 'markdown' | 'plain-text'
  format: 'markdown',
  // length : 'short' | 'medium' | 'long'
  length: 'short',
  // Contexte partage par tous les resumes produits par cette instance
  sharedContext: 'Fil de commentaires d\'un article technique destine a des developpeurs.',
  monitor(m) {
    m.addEventListener('downloadprogress', (e) => console.log(e.loaded));
  },
});

const summary = await summarizer.summarize(longThread, {
  // Contexte specifique a CE resume
  context: 'Mettre en avant les objections techniques, ignorer les remerciements.',
});

console.log(summary);
// - Deux lecteurs signalent un probleme de compatibilite avec Safari
// - Un commentaire propose une alternative basee sur les Web Workers
// - Le point de desaccord porte sur la gestion du cache

summarizer.destroy();

Les quatre valeurs de type répondent à des besoins d'interface différents, et se tromper de type est la principale cause de déception sur cette API.

Type Produit Usage en interface
key-pointsUne liste a puces des points saillantsPanneau lateral, resume d'un fil de discussion
tldrUn paragraphe court et neutreEncart en tete d'un article long
teaserUne accroche qui donne envie de lireCarte d'apercu dans une liste
headlineUn titre de quelques motsNommer automatiquement une conversation

Pour un texte long, le streaming s'applique de la même façon que sur la Prompt API, avec l'avantage de remplir progressivement un encart de résumé pendant que l'utilisateur lit déjà le début.

const summarizer = await Summarizer.create({ type: 'tldr', length: 'medium' });
const box = document.querySelector('#summary');

const stream = summarizer.summarizeStreaming(articleText);
for await (const chunk of stream) {
  box.textContent += chunk;
}

summarizer.destroy();
La fenêtre de contexte reste la contrainte dominante : un article de 4 000 mots ne rentre pas. La parade classique est le résumé en deux passes — découper le texte en sections, résumer chaque section, puis résumer la concaténation des résumés. Le résultat est correct, mais chaque passe coûte du temps de calcul local : au-delà de deux niveaux, un appel cloud est souvent plus rapide et de meilleure qualité.

Writer et Rewriter : generer et reformuler

En bref : Writer produit un texte à partir d'une consigne, Rewriter transforme un texte existant en jouant sur le ton et la longueur. Ces deux API couvrent la quasi-totalité des besoins d'assistance à la rédaction dans un formulaire, sans écrire une ligne de prompt.

Le cas d'usage typique est le champ de texte augmenté : un bouton « rendre plus formel » à côté d'un message, une aide à la rédaction d'une description de produit, un premier jet de réponse à un avis client. Ce sont exactement les fonctionnalités qu'on renonçait à faire quand elles impliquaient une facture au token pour chaque frappe.

// Generation : un texte a partir d'une consigne et d'un contexte
const writer = await Writer.create({
  tone: 'formal',        // 'formal' | 'neutral' | 'casual'
  format: 'plain-text',  // 'markdown' | 'plain-text'
  length: 'short',       // 'short' | 'medium' | 'long'
  sharedContext: 'Reponses publiques d\'une boutique en ligne aux avis clients.',
});

const draft = await writer.write(
  'Remercier le client pour son avis et proposer un remboursement du port.',
  { context: 'Le client s\'est plaint d\'un retard de livraison de trois jours.' }
);

console.log(draft);
writer.destroy();
// Reformulation : on part d'un texte existant
const rewriter = await Rewriter.create({
  tone: 'more-formal',   // 'as-is' | 'more-formal' | 'more-casual'
  length: 'shorter',     // 'as-is' | 'shorter' | 'longer'
  format: 'as-is',
});

const original = 'Salut, dsl mais votre truc marche pas du tout chez moi, ' +
                 'ca fait 3 jours que j\'essaye, merci de regler ca vite.';

const polished = await rewriter.rewrite(original, {
  context: 'Message destine au support technique d\'un editeur logiciel.',
});

console.log(polished);
// "Bonjour, je rencontre depuis trois jours un dysfonctionnement
//  qui m'empeche d'utiliser le service. Pourriez-vous m'aider ?"

rewriter.destroy();

Branchée sur un formulaire, cette API donne un composant complet en une trentaine de lignes. Le point important de l'exemple suivant est le respect du principe de contrôle : le texte reformulé est proposé, l'utilisateur garde la possibilité de revenir à sa version.

const textarea = document.querySelector('#message');
const button = document.querySelector('#rewrite');
const undoButton = document.querySelector('#undo');
let previousValue = null;

button.addEventListener('click', async () => {
  if (!('Rewriter' in self)) return; // fonctionnalite simplement absente

  button.disabled = true;
  button.textContent = 'Reformulation...';
  previousValue = textarea.value;

  const rewriter = await Rewriter.create({ tone: 'more-formal', length: 'as-is' });

  try {
    textarea.value = '';
    for await (const chunk of rewriter.rewriteStreaming(previousValue)) {
      textarea.value += chunk;
    }
    undoButton.hidden = false; // l'utilisateur peut toujours revenir en arriere
  } catch (error) {
    textarea.value = previousValue; // en cas d'echec, on ne perd rien
    console.error(error);
  } finally {
    rewriter.destroy();
    button.disabled = false;
    button.textContent = 'Reformuler';
  }
});

undoButton.addEventListener('click', () => {
  textarea.value = previousValue;
  undoButton.hidden = true;
});
Regles d'interface pour l'assistance a la redaction :
  • Ne jamais ecraser sans retour possible — une action d'annulation visible, pas un simple Ctrl+Z
  • Ne jamais reformuler automatiquement — l'utilisateur declenche, l'outil propose
  • Masquer le bouton si l'API est absente plutot que d'afficher une erreur
  • Indiquer que le traitement est local — c'est un argument de confiance, il faut le dire
  • Desactiver le bouton pendant la generation pour eviter les sessions concurrentes

Translator et Language Detector

En bref : Translator traduit entre deux langues avec des paquets téléchargés à la demande, et LanguageDetector identifie la langue d'un texte avec un score de confiance. La disponibilité se vérifie par paire de langues, pas globalement.

C'est probablement l'API la plus immédiatement rentable de l'ensemble. Traduire les commentaires d'une communauté internationale, ou le contenu généré par les utilisateurs, coûtait jusqu'ici un abonnement à un service de traduction et l'envoi de ce contenu à un tiers. Ici, la traduction se fait sur l'appareil, gratuitement, et fonctionne hors ligne une fois le paquet de langue téléchargé.

// La disponibilite depend de la PAIRE de langues demandee
const pair = { sourceLanguage: 'en', targetLanguage: 'fr' };
const availability = await Translator.availability(pair);
console.log(availability); // 'available' | 'downloadable' | 'downloading' | 'unavailable'

if (availability === 'unavailable') {
  throw new Error('Paire de langues non supportee localement');
}

const translator = await Translator.create({
  ...pair,
  monitor(m) {
    m.addEventListener('downloadprogress', (e) => {
      console.log(`Paquet de langue : ${Math.round(e.loaded * 100)} %`);
    });
  },
});

console.log(await translator.translate('The build failed on the CI runner.'));
// "La compilation a echoue sur l'agent d'integration continue."

translator.destroy();

En pratique, la langue source est rarement connue à l'avance. La combinaison naturelle consiste à détecter d'abord, puis à traduire seulement si nécessaire — et à ne rien faire quand le texte est déjà dans la langue de l'utilisateur.

/**
 * Traduction conditionnelle d'un contenu utilisateur.
 * Retourne le texte inchange si la detection est incertaine
 * ou si la langue est deja la bonne.
 */
async function translateIfNeeded(text, targetLanguage = 'fr') {
  if (!('LanguageDetector' in self) || !('Translator' in self)) {
    return { text, translated: false, reason: 'unsupported' };
  }

  const detector = await LanguageDetector.create();
  const [best] = await detector.detect(text);
  detector.destroy();

  // Sous 0.6 de confiance, on s'abstient : mieux vaut ne rien faire
  // que d'afficher une traduction absurde d'un texte mal identifie
  if (!best || best.confidence < 0.6) {
    return { text, translated: false, reason: 'low-confidence' };
  }

  if (best.detectedLanguage === targetLanguage) {
    return { text, translated: false, reason: 'same-language' };
  }

  const pair = { sourceLanguage: best.detectedLanguage, targetLanguage };
  if ((await Translator.availability(pair)) === 'unavailable') {
    return { text, translated: false, reason: 'pair-unavailable' };
  }

  const translator = await Translator.create(pair);
  try {
    return {
      text: await translator.translate(text),
      translated: true,
      from: best.detectedLanguage,
      confidence: best.confidence,
    };
  } finally {
    translator.destroy();
  }
}

const result = await translateIfNeeded('Das Update hat mein Problem geloest.');
console.log(result);
// { text: "La mise a jour a resolu mon probleme.", translated: true,
//   from: 'de', confidence: 0.97 }
Le détail qui change tout en interface : affichez toujours la langue détectée et un bouton « voir l'original ». Une traduction automatique silencieuse, qui remplace le texte sans le dire, produit systématiquement de la défiance — surtout quand la détection se trompe sur un message court ou truffé de termes techniques.

Strategie de repli vers le cloud

En bref : encapsulez l'appel derrière une interface unique qui essaie le local, puis bascule sur une API distante. Le repli passe obligatoirement par votre back-end : une clé API dans du JavaScript de navigateur est une clé publiée.

Les API intégrées ne seront jamais disponibles pour tout le monde. Firefox et Safari ne les implémentent pas, une partie du parc de machines n'a pas le GPU requis, et l'utilisateur peut refuser le téléchargement. Concevoir la fonctionnalité comme une amélioration progressive n'est donc pas une élégance d'architecte : c'est la seule façon de la mettre en production.

Trois postures sont défendables, et le choix se fait selon la nature de la fonctionnalité.

Posture Comportement si l'API est absente Quand la choisir
Amelioration progressive La fonctionnalite n'apparait pas Confort optionnel : reformuler, resumer, suggerer
Repli cloud Appel a une API distante via le back-end Fonctionnalite attendue par tous les utilisateurs
Local strict Message expliquant la configuration requise Contrainte de confidentialite : les donnees ne doivent pas sortir
/**
 * Facade unique : local d'abord, cloud ensuite.
 * L'appelant ignore totalement quel moteur a repondu.
 */
export async function summarizeText(text, { allowCloud = true } = {}) {
  // 1. Chemin local, si l'API existe et que le modele est deja pret
  if ('Summarizer' in self) {
    try {
      const availability = await Summarizer.availability();

      // On ne declenche PAS un telechargement de plusieurs Go ici :
      // seul l'etat 'available' donne une reponse immediate
      if (availability === 'available') {
        const summarizer = await Summarizer.create({ type: 'tldr', length: 'short' });
        try {
          return { summary: await summarizer.summarize(text), engine: 'local' };
        } finally {
          summarizer.destroy();
        }
      }
    } catch (error) {
      // Le local echoue : on log et on continue vers le repli
      console.warn('Resume local indisponible, bascule cloud', error);
    }
  }

  // 2. Repli cloud, toujours via NOTRE back-end (la cle reste cote serveur)
  if (!allowCloud) {
    return { summary: null, engine: 'none' };
  }

  const response = await fetch('/api/summarize', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });

  if (!response.ok) {
    throw new Error(`Resume distant en echec : ${response.status}`);
  }

  const { summary } = await response.json();
  return { summary, engine: 'cloud' };
}

Côté serveur, le repli est un simple relais vers l'API de votre choix. L'exemple utilise l'API Gemini pour rester cohérent avec le modèle local, mais n'importe quel fournisseur convient — le point essentiel reste le même : la clé vit dans une variable d'environnement, jamais dans le bundle front-end.

// api/summarize.js — repli serveur (Node.js)
import { GoogleGenAI } from '@google/genai';

// La cle API ne quitte JAMAIS le serveur
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

export default async function handler(req, res) {
  const { text } = req.body ?? {};

  // Validation defensive : ne jamais relayer une entree non bornee
  if (typeof text !== 'string' || text.length === 0 || text.length > 20000) {
    return res.status(400).json({ error: 'Texte invalide ou trop long' });
  }

  try {
    const response = await ai.models.generateContent({
      model: 'gemini-2.5-flash',
      contents: `Resume ce texte en trois phrases maximum, en francais :\n\n${text}`,
    });

    return res.status(200).json({ summary: response.text });
  } catch (error) {
    console.error('Echec du resume distant', error);
    return res.status(502).json({ error: 'Service de resume indisponible' });
  }
}
Notez le choix fait dans la facade : on n'accepte que l'état available pour le chemin local. Déclencher un téléchargement de plusieurs gigaoctets au milieu d'un clic sur « Résumer » donnerait l'impression d'une application figée. Le téléchargement se propose ailleurs, explicitement, dans les préférences ou via un encart dédié — et il ne se déclenche jamais dans le chemin critique d'une interaction.

Integration dans une application Angular

En bref : isolez toute la logique dans un service injectable qui expose des signal() d'état — disponible, en téléchargement, en génération — et détruisez la session dans DestroyRef. Le composant ne connaît que ces signaux, jamais l'API du navigateur.

Ces API sont des globales du navigateur, non typées par TypeScript. Une déclaration minimale suffit à obtenir de l'autocomplétion et à éviter les any disséminés dans le code applicatif.

// src/types/built-in-ai.d.ts
type AIAvailability = 'unavailable' | 'downloadable' | 'downloading' | 'available';

interface AIMonitor {
  addEventListener(type: 'downloadprogress', listener: (event: { loaded: number }) => void): void;
}

interface LanguageModelSession {
  prompt(input: string, options?: { signal?: AbortSignal; responseConstraint?: object }): Promise<string>;
  promptStreaming(input: string, options?: { signal?: AbortSignal }): ReadableStream<string>;
  measureInputUsage(input: string): Promise<number>;
  clone(): Promise<LanguageModelSession>;
  destroy(): void;
  readonly inputUsage: number;
  readonly inputQuota: number;
}

declare const LanguageModel: {
  availability(): Promise<AIAvailability>;
  create(options?: {
    initialPrompts?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
    temperature?: number;
    topK?: number;
    monitor?: (monitor: AIMonitor) => void;
  }): Promise<LanguageModelSession>;
} | undefined;

Le service concentre ensuite tout l'état dans des signals, ce qui rend le composant purement déclaratif.

import { Injectable, signal, computed, DestroyRef, inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class LocalAiService {
  private readonly destroyRef = inject(DestroyRef);
  private session: LanguageModelSession | null = null;

  readonly availability = signal<AIAvailability | 'unsupported'>('unsupported');
  readonly downloadProgress = signal(0);
  readonly generating = signal(false);
  readonly output = signal('');

  // Etats derives consommes directement par le template
  readonly ready = computed(() => this.availability() === 'available');
  readonly needsDownload = computed(() => this.availability() === 'downloadable');

  constructor() {
    this.detect();
    // La session est liberee avec l'injecteur : plus de fuite memoire
    this.destroyRef.onDestroy(() => this.session?.destroy());
  }

  private async detect(): Promise<void> {
    if (typeof LanguageModel === 'undefined') {
      this.availability.set('unsupported');
      return;
    }
    this.availability.set(await LanguageModel.availability());
  }

  /** Declenche le telechargement du modele, sur action utilisateur explicite. */
  async enable(): Promise<void> {
    if (typeof LanguageModel === 'undefined') return;

    this.session = await LanguageModel.create({
      temperature: 0.2,
      monitor: (monitor) => {
        monitor.addEventListener('downloadprogress', (event) => {
          this.downloadProgress.set(Math.round(event.loaded * 100));
        });
      },
    });

    this.availability.set('available');
  }

  /** Genere une reponse en streaming, annulable. */
  async ask(prompt: string, signal?: AbortSignal): Promise<void> {
    if (!this.session) await this.enable();
    if (!this.session) return;

    this.generating.set(true);
    this.output.set('');

    try {
      const stream = this.session.promptStreaming(prompt, { signal });
      for await (const chunk of stream) {
        this.output.update((current) => current + chunk);
      }
    } catch (error) {
      if ((error as Error).name !== 'AbortError') throw error;
    } finally {
      this.generating.set(false);
    }
  }
}

Le composant se réduit alors à de l'affichage conditionnel sur les quatre états possibles, ce qui rend le cas « non supporté » impossible à oublier.

import { Component, ChangeDetectionStrategy, inject, signal } from '@angular/core';
import { LocalAiService } from './local-ai.service';

@Component({
  selector: 'af-local-assistant',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @switch (ai.availability()) {
      @case ('unsupported') {
        <p class="text-muted">Assistant local indisponible dans ce navigateur.</p>
      }
      @case ('downloadable') {
        <button type="button" (click)="ai.enable()">
          Activer l'assistant local (telechargement unique)
        </button>
      }
      @case ('downloading') {
        <progress [value]="ai.downloadProgress()" max="100"></progress>
      }
      @default {
        <textarea [value]="question()" (input)="question.set($any($event.target).value)"></textarea>
        <button type="button" [disabled]="ai.generating()" (click)="send()">
          {{ ai.generating() ? 'Generation...' : 'Demander' }}
        </button>
        <p>{{ ai.output() }}</p>
      }
    }
  `,
})
export class LocalAssistantComponent {
  readonly ai = inject(LocalAiService);
  readonly question = signal('');

  send(): void {
    void this.ai.ask(this.question());
  }
}

Cette organisation reprend exactement celle décrite dans l'assistant local Angular basé sur Ollama, à une différence près : ici, aucun serveur à installer côté utilisateur. Le prix à payer est la disponibilité, bien plus incertaine — d'où l'importance du @switch exhaustif plutôt qu'un simple @if.

Limites, pieges et bonnes pratiques

En bref : les trois causes d'échec récurrentes sont la fenêtre de contexte trop petite, la disponibilité surestimée et les sessions jamais détruites. Aucune ne se voit en développement sur une machine bien dotée : elles apparaissent toutes en production, chez les utilisateurs.

Ce que le modele ne sait pas faire

Limite Manifestation concrete Contournement
Fenetre de contexte reduite Erreur de quota des qu'on depasse quelques milliers de tokens measureInputUsage() puis decoupage, ou repli cloud
Connaissances factuelles faibles Affirmations fausses et confiantes sur des faits precis Fournir les donnees dans le prompt, ne rien demander de memoire
Raisonnement multi-etapes fragile Calculs et deductions en chaine incorrects Une seule transformation par appel, calculs en JavaScript
Qualite variable selon la langue Resultats nettement meilleurs en anglais Tester reellement sur vos contenus francais avant de livrer
Pas de garantie de reproductibilite Sortie differente d'une version de Chrome a l'autre Ne jamais dependre d'un format exact sans responseConstraint
Latence liee au materiel Rapide sur une machine recente, lent sur un portable d'entree de gamme Streaming systematique et bouton d'annulation

Le cout n'est pas nul, il est deplace

« Gratuit » mérite une nuance. Vous ne payez pas de tokens, mais l'utilisateur paie en ressources : mémoire vidéo mobilisée, processeur graphique sollicité, batterie consommée. Sur un ordinateur portable, une génération longue se sent immédiatement — ventilateur, autonomie. C'est un argument de plus pour réserver le local aux tâches courtes et pour ne jamais lancer de traitement en arrière-plan sans que l'utilisateur l'ait demandé.

Un traitement en lot — analyser deux cents commentaires au chargement d'une page d'administration — est précisément ce qu'il ne faut pas faire côté client. Ce genre de charge appartient au serveur, où elle est batchée, mise en cache et mesurée.

Securite et vie privee

Points de vigilance :
  • Le contenu tiers reste du contenu non fiable — un commentaire peut contenir une injection de prompt, meme traite localement
  • Ne jamais executer la sortie du modele — pas d'innerHTML, pas d'eval, pas de requete construite depuis le texte genere
  • Traiter la sortie comme une saisie utilisateur — echappement et validation identiques
  • Annoncer le traitement local — c'est un avantage produit, il doit etre visible dans l'interface
  • Ne pas presumer de la confidentialite du modele lui-meme — le comportement peut evoluer avec les versions de Chrome
  • Prevoir le mode degrade — la fonctionnalite doit rester utilisable sans IA du tout

Le premier point est le plus contre-intuitif. Beaucoup d'équipes considèrent qu'une injection de prompt n'a pas d'importance sur un modèle local, puisqu'il n'a accès à rien. C'est vrai tant que la sortie ne pilote rien. Dès que le résultat alimente un affichage HTML, une navigation ou un appel d'API, la surface d'attaque redevient réelle — les mêmes règles que celles décrites dans l'article sur les guardrails s'appliquent.

Tester ce qui ne se voit pas en developpement

Le poste de développement est le pire environnement de test possible pour cette fonctionnalité : le modèle y est déjà téléchargé, la machine est puissante, le navigateur est à jour. Trois vérifications simples couvrent l'essentiel des régressions réelles.

// 1. Simuler l'absence totale d'API (Firefox, Safari, mobile)
//    a executer dans la console avant de charger la page
delete self.LanguageModel;
delete self.Summarizer;
delete self.Translator;

// 2. Simuler un modele non telecharge
Object.defineProperty(self, 'LanguageModel', {
  value: { availability: async () => 'downloadable' },
  configurable: true,
});

// 3. Simuler une machine incompatible
Object.defineProperty(self, 'LanguageModel', {
  value: { availability: async () => 'unavailable' },
  configurable: true,
});
Ajoutez ces trois scénarios à votre plan de test manuel, ou mieux, à vos tests end-to-end en injectant le stub avant chargement de la page. Une fonctionnalité IA locale qui casse l'interface sur Safari est un bug de disponibilité, pas un bug d'IA — et c'est de très loin le plus fréquent sur ce type d'intégration.

Conclusion

Chrome Built-in AI ne remplace pas les API cloud : il ouvre une catégorie de fonctionnalités qui n'existait pas. Toutes ces micro-tâches textuelles qu'on abandonnait parce qu'un aller-retour serveur facturé au token ne se justifiait pas — reformuler un champ, résumer un fil, traduire un commentaire, deviner un titre — deviennent réalisables en quelques lignes de JavaScript, sans budget et sans données qui sortent de la machine.

Le chemin d'adoption raisonnable est incrémental. Commencez par une fonctionnalité de confort, non critique, avec un repli propre : un bouton « résumer » qui n'apparaît que lorsque le modèle est prêt. Mesurez le taux réel de disponibilité chez vos utilisateurs, qui vous surprendra probablement dans un sens ou dans l'autre. Ce chiffre déterminera ensuite s'il vaut la peine d'investir dans un repli cloud complet ou si l'amélioration progressive suffit.

Reste la limite structurelle, qu'aucune version de Chrome ne lèvera à court terme : un modèle qui tient dans quelques gigaoctets de mémoire vidéo ne raisonnera jamais comme un modèle qui tourne dans un centre de données. La compétence à développer n'est pas de faire faire davantage au modèle local, mais de savoir découper le travail — le local pour ce qui est court, fréquent, privé et sans enjeu ; le cloud pour ce qui est long, rare, complexe et déterminant.

Recapitulatif :
  • availability() avant tout : quatre etats, quatre interfaces distinctes
  • Le telechargement se declenche sur action utilisateur, jamais dans un chemin critique
  • Preferer les API de tache (Summarizer, Writer, Translator) au prompt libre
  • responseConstraint des que la sortie alimente du code
  • destroy() systematique, dans un finally ou au demontage
  • Streaming et AbortSignal pour toute generation de plus de deux phrases
  • Repli cloud toujours via le back-end : aucune cle API dans le navigateur
  • Tester l'absence d'API : c'est le cas le plus frequent en production

Sources et références officielles

Questions fréquentes

Chrome Built-in AI est un ensemble d'API JavaScript natives qui exposent un modèle de langage embarqué dans le navigateur, Gemini Nano. Le modèle est téléchargé une fois puis partagé par toutes les origines : aucune clé API, aucun appel réseau et aucun coût par requête.

Testez d'abord la présence de l'objet global avec if (!("LanguageModel" in self)), puis appelez await LanguageModel.availability(). La méthode renvoie available, downloadable, downloading ou unavailable : seul le premier état permet une réponse immédiate.

Gemini Nano tourne sur la machine de l'utilisateur : latence faible, fonctionnement hors ligne, données qui ne quittent jamais l'appareil, coût nul. En contrepartie sa fenêtre de contexte et sa qualité de raisonnement restent très en dessous des modèles cloud, qui restent nécessaires pour les tâches complexes.

Non. Tout s'exécute côté client en JavaScript, contrairement à Ollama qui expose un serveur HTTP local. Un serveur reste utile uniquement pour le repli cloud quand le navigateur ne supporte pas ces API, afin de ne pas exposer votre clé API dans le front-end.

Partager