IA On-Device à Latence Zéro dans React : Chrome Gemini Nano et Prompt API en Production
Création d'interfaces React ultra-rapides et respectueuses de la vie privée avec les API intégrées de Chrome Gemini Nano, window.LanguageModel et assainissement local des PII.

IA On-Device à Latence Zéro dans React : Chrome Gemini Nano et Prompt API en Production
Chaque frappe au clavier dans une application web moderne suscite une attente de retour immédiat. Lorsque les équipes greffent des modèles de langage sur des champs de texte pour l'autocomplétion, l'ajustement du ton ou la synthèse en temps réel, elles se heurtent à une contrainte physique stricte : les allers-retours réseau.
Un aller-retour entre un navigateur et une passerelle LLM dans le cloud nécessite entre 300 ms et 1500 ms pour le premier token (TTFT). Ce délai est acceptable pour un panneau de discussion distinct, mais il interrompt le flux d'écriture dans un éditeur de texte ou un formulaire. L'inférence sur le cloud engendre également des coûts récurrents par token, des démarrages à froid, des limites de débit et des risques de conformité dès que des données sensibles quittent le client.
L'IA intégrée à Chrome redéfinit cette équation en intégrant Gemini Nano au cœur de l'environnement d'exécution du navigateur. Cet article détaille l'architecture, le cycle de vie des sessions, le filtrage des données sur le client et les schémas d'intégration React pour exploiter ces modèles locaux en production.
+-------------------------------------------------------------------------+
| Browser Context (Main Thread) |
| |
| [ User Input ] ---> [ Local PII Sanitizer ] ---> [ useBrowserAI Hook ] |
| | | |
| v v |
| (Cleaned Prompt) (Streaming Tokens) |
| | |
| v |
| [ <SmartTextArea /> ] |
+------------------------------------------------------------|------------+
| IPC / Mojo
+------------------------------------------------------------v------------+
| Chrome Optimization Guide Process (On-Device Runtime) |
| |
| [ window.LanguageModel / ai ] <---> [ Gemini Nano Neural Weights ] |
| - Sub-20ms TTFT - Zero Network Egress |
| - Local Session Cache - GPU/NPU Silicon (LiteRT/Metal) |
+-------------------------------------------------------------------------+Le Modèle d'Inférence Embarqué dans le Navigateur
Chrome intègre Gemini Nano comme modèle neuronal de base exécuté directement sur l'appareil et géré par le service Optimization Guide du navigateur. Contrairement aux portages WebAssembly ou WebGPU qui imposent le téléchargement de poids de modèle de 1 à 4 Go dès le premier chargement, Chrome orchestre la distribution des binaires via son composant d'arrière-plan Component Updater.
L'inférence s'exécute dans un processus utilitaire isolé des scripts de contenu web. L'environnement d'exécution JavaScript communique avec le modèle via des échanges structurés entre processus (IPC) au travers de l'API standard window.LanguageModel (et l'espace transitoire window.ai.languageModel).
Caractéristiques Architecturales
- Confidentialité Déterministe : Le texte brut de l'invite ne transite jamais par une socket réseau. Pour les secteurs réglementés comme la santé ou la finance, l'inférence locale respecte les politiques de zéro fuite de données.
- TTFT Inférieur à 20 ms : Le modèle résidant en mémoire locale et exploitant les accélérateurs matériels de l'appareil (Apple Silicon Metal, Windows DirectML, Vulkan), la génération de tokens démarre quasi instantanément.
- Coût d'Infrastructure Nul : Les charges de travail s'exécutent sur le processeur du client. Une affluence de cent mille utilisateurs n'engendre aucun coût d'API d'inférence.
- Disponibilité Hors Ligne : Les fonctionnalités restent opérationnelles sur des connexions intermittentes ou en cas de coupure réseau complète.
Comprendre le Cycle de Vie de l'IA Intégrée à Chrome
L'API Prompt n'est pas un simple appel de fonction sans état. Elle repose sur un modèle de session avec état nécessitant des vérifications de capacités, des ajustements de paramètres et une gestion rigoureuse de la mémoire.
// Définitions de types pour la W3C Prompt API
export type AICapabilityAvailability = "readily" | "after-download" | "no";
export interface AICapabilities {
available: AICapabilityAvailability;
defaultTemperature?: number;
maxTemperature?: number;
defaultTopK?: number;
maxTopK?: number;
}
export interface AILanguageModelCreateOptions {
systemPrompt?: string;
initialPrompts?: Array<{ role: "system" | "user" | "assistant"; content: string }>;
temperature?: number;
topK?: number;
signal?: AbortSignal;
monitor?: (monitor: EventTarget) => void;
}
export interface AILanguageModelSession {
prompt(input: string, options?: { signal?: AbortSignal }): Promise<string>;
promptStreaming(input: string, options?: { signal?: AbortSignal }): ReadableStream<string>;
countPromptTokens(input: string): Promise<number>;
maxTokens: number;
tokensSoFar: number;
tokensLeft: number;
topK: number;
temperature: number;
clone(): Promise<AILanguageModelSession>;
destroy(): void;
}
export interface AILanguageModelFactory {
availability?(): Promise<AICapabilityAvailability>;
capabilities?(): Promise<AICapabilities>;
create(options?: AILanguageModelCreateOptions): Promise<AILanguageModelSession>;
}
declare global {
interface Window {
LanguageModel?: AILanguageModelFactory;
ai?: {
languageModel?: AILanguageModelFactory;
};
}
}Trois États de Disponibilité du Modèle
Avant d'initialiser une session, les applications doivent interroger window.LanguageModel (ou window.ai.languageModel). Le navigateur retourne l'un des trois états suivants :
"readily": Le binaire du modèle est présent dans le cache local et disponible pour une instanciation immédiate."after-download": Le matériel respecte les prérequis, mais le modèle est en file d'attente pour un téléchargement en arrière-plan. Les applications peuvent suivre la progression grâce au rappelmonitor."no": L'appareil ne dispose pas du matériel requis (VRAM ou mémoire insuffisante, GPU non pris en charge) ou l'option est désactivée.
Couche de nettoyage des PII côté client
Même avec un modèle fonctionnant sur l'appareil sans sortie réseau, les bonnes pratiques de sécurité imposent de masquer les identifiants sensibles (numéros de carte bancaire, identifiants nationaux, adresses e-mail, jetons d'authentification) avant de composer le prompt. Cela évite toute contamination croisée dans l'historique des sessions partagées.
Voici un filtre de tokenisation léger sans dépendance externe :
// lib/pii-scrubber.ts
interface ScrubRule {
name: string;
pattern: RegExp;
mask: (match: string) => string;
}
const PII_RULES: ScrubRule[] = [
{
name: "CREDIT_CARD",
pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
mask: () => "[REDACTED_CARD]",
},
{
name: "EMAIL",
pattern: /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g,
mask: () => "[REDACTED_EMAIL]",
},
{
name: "PHONE",
pattern: /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
mask: () => "[REDACTED_PHONE]",
},
{
name: "AUTH_TOKEN",
pattern: /\b(ey[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*)|(ghp_[A-Za-z0-9]{36})\b/g,
mask: () => "[REDACTED_TOKEN]",
},
{
name: "SSN",
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
mask: () => "[REDACTED_SSN]",
},
];
export interface ScrubResult {
sanitizedText: string;
redactionCount: number;
detectedTypes: string[];
}
export function sanitizePromptText(input: string): ScrubResult {
let sanitizedText = input;
let redactionCount = 0;
const detectedTypes: string[] = [];
for (const rule of PII_RULES) {
const matches = sanitizedText.match(rule.pattern);
if (matches && matches.length > 0) {
redactionCount += matches.length;
detectedTypes.push(rule.name);
sanitizedText = sanitizedText.replace(rule.pattern, rule.mask);
}
}
return { sanitizedText, redactionCount, detectedTypes };
}Hook React pour la production : useBrowserAI
Gérer manuellement les sessions de modèle dans les composants React peut générer des fuites de mémoire si les sessions ne sont pas libérées lors du démontage. Le hook useBrowserAI structure le cycle de vie, la validation des capacités, l'agrégation de texte en flux continu et l'interruption des calculs.
// hooks/useBrowserAI.ts
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import type {
AICapabilityAvailability,
AILanguageModelSession,
AILanguageModelCreateOptions,
} from "@/types/chrome-ai";
interface UseBrowserAIOptions extends AILanguageModelCreateOptions {
autoInit?: boolean;
}
interface UseBrowserAIReturn {
availability: AICapabilityAvailability | "checking" | "unsupported";
downloadProgress: number | null;
isGenerating: boolean;
error: string | null;
generateText: (promptText: string) => Promise<string>;
streamText: (promptText: string, onChunk: (chunk: string) => void) => Promise<string>;
abort: () => void;
resetSession: () => Promise<void>;
tokensRemaining: number | null;
}
export function useBrowserAI(options: UseBrowserAIOptions = {}): UseBrowserAIReturn {
const { systemPrompt, temperature = 0.7, topK = 3, autoInit = true } = options;
const [availability, setAvailability] = useState<AICapabilityAvailability | "checking" | "unsupported">("checking");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokensRemaining, setTokensRemaining] = useState<number | null>(null);
const sessionRef = useRef<AILanguageModelSession | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const cleanupSession = useCallback(() => {
if (sessionRef.current) {
try {
sessionRef.current.destroy();
} catch (err) {
console.warn("Error destroying previous session:", err);
}
sessionRef.current = null;
}
}, []);
const initSession = useCallback(async () => {
if (typeof window === "undefined") return;
const factory = (window as any).LanguageModel || (window as any).ai?.languageModel;
if (!factory) {
setAvailability("unsupported");
return;
}
try {
let availStatus: AICapabilityAvailability = "readily";
if (typeof factory.availability === "function") {
availStatus = await factory.availability();
} else if (typeof factory.capabilities === "function") {
const caps = await factory.capabilities();
availStatus = caps.available;
}
setAvailability(availStatus);
if (availStatus === "no") {
return;
}
cleanupSession();
const session = await factory.create({
systemPrompt,
temperature,
topK,
monitor(m: EventTarget) {
m.addEventListener("downloadprogress", (e: Event) => {
const customEvent = e as CustomEvent<{ loaded: number; total: number }>;
if (customEvent.detail && customEvent.detail.total > 0) {
const progress = Math.round((customEvent.detail.loaded / customEvent.detail.total) * 100);
setDownloadProgress(progress);
}
});
},
});
sessionRef.current = session;
setTokensRemaining(session.tokensLeft ?? 4096);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to initialize Browser AI";
setError(message);
cleanupSession();
}
}, [systemPrompt, temperature, topK, cleanupSession]);
useEffect(() => {
if (autoInit) {
initSession();
}
return () => {
cleanupSession();
};
}, [autoInit, initSession, cleanupSession]);
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsGenerating(false);
}, []);
const streamText = useCallback(
async (promptText: string, onChunk: (chunk: string) => void): Promise<string> => {
if (!sessionRef.current) {
throw new Error("Session is not initialized");
}
abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsGenerating(true);
setError(null);
let accumulatedResponse = "";
try {
const stream = sessionRef.current.promptStreaming(promptText, {
signal: controller.signal,
});
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
accumulatedResponse = value;
onChunk(value);
}
}
if (sessionRef.current) {
setTokensRemaining(sessionRef.current.tokensLeft);
}
return accumulatedResponse;
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return accumulatedResponse;
}
const errorMsg = err instanceof Error ? err.message : "Generation failed";
setError(errorMsg);
throw err;
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
},
[abort]
);
const generateText = useCallback(
async (promptText: string): Promise<string> => {
if (!sessionRef.current) {
throw new Error("Session is not initialized");
}
abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsGenerating(true);
setError(null);
try {
const result = await sessionRef.current.prompt(promptText, {
signal: controller.signal,
});
if (sessionRef.current) {
setTokensRemaining(sessionRef.current.tokensLeft);
}
return result;
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return "";
}
const errorMsg = err instanceof Error ? err.message : "Generation failed";
setError(errorMsg);
throw err;
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
},
[abort]
);
return {
availability,
downloadProgress,
isGenerating,
error,
generateText,
streamText,
abort,
resetSession: initSession,
tokensRemaining,
};
}Composant en vedette : <SmartTextArea />
Ce composant associe la suggestion de texte fantôme en temps réel à une évaluation du ton et un indicateur de statut hors ligne. Lorsque l'utilisateur fait une pause dans sa saisie, Gemini Nano anticipe la fin de la phrase. L'appui sur la touche Tab accepte la suggestion sans interruption.
// components/SmartTextArea.tsx
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useBrowserAI } from "@/hooks/useBrowserAI";
import { sanitizePromptText } from "@/lib/pii-scrubber";
interface SmartTextAreaProps {
initialValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
debounceMs?: number;
}
type SentimentTone = "positive" | "constructive" | "neutral" | "urgent" | "analyzing";
export function SmartTextArea({
initialValue = "",
placeholder = "Draft your architectural decision record or engineering notes...",
onChange,
debounceMs = 280,
}: SmartTextAreaProps) {
const [text, setText] = useState(initialValue);
const [suggestion, setSuggestion] = useState("");
const [tone, setTone] = useState<SentimentTone>("neutral");
const [isOffline, setIsOffline] = useState(!navigator.onLine);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// Initialize the local completion session with a constrained system prompt
const {
availability,
downloadProgress,
isGenerating,
generateText,
abort,
tokensRemaining,
} = useBrowserAI({
systemPrompt:
"You are an autocompletion engine for software engineers. Provide a short, direct inline continuation (1 to 8 words) for the user's text. Return ONLY the continuation words. Do not repeat the input.",
temperature: 0.2,
topK: 1,
});
// Track browser connectivity
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
// Request completion from local model
const triggerCompletion = useCallback(
async (currentText: string) => {
if (availability !== "readily" || currentText.trim().length < 8) {
setSuggestion("");
return;
}
// Sanitize input to protect sensitive data locally
const { sanitizedText } = sanitizePromptText(currentText);
try {
const rawPrediction = await generateText(
`Text: "${sanitizedText}"\nContinuation:`
);
const cleanPrediction = rawPrediction
.replace(/^["'\s]+|["'\s]+$/g, "")
.trim();
if (cleanPrediction.length > 0) {
setSuggestion(cleanPrediction);
} else {
setSuggestion("");
}
} catch {
setSuggestion("");
}
},
[availability, generateText]
);
// Debounced input handler
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
setText(newText);
setSuggestion("");
abort();
if (onChange) {
onChange(newText);
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
triggerCompletion(newText);
}, debounceMs);
};
// Keyboard navigation for ghost text acceptance
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab" && suggestion.length > 0) {
e.preventDefault();
const mergedText = text.endsWith(" ")
? text + suggestion
: text + " " + suggestion;
setText(mergedText);
setSuggestion("");
if (onChange) onChange(mergedText);
} else if (e.key === "Escape") {
setSuggestion("");
abort();
}
};
return (
<div className="smart-textarea-container" style={{ position: "relative", width: "100%" }}>
{/* Header telemetry and indicators */}
<div
className="telemetry-bar"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
fontSize: "12px",
fontFamily: "monospace",
}}
>
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
color: availability === "readily" ? "#15803d" : "#b45309",
}}
>
<span
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: availability === "readily" ? "#22c55e" : "#f59e0b",
}}
/>
{availability === "readily"
? "Gemini Nano (Local Engine Active)"
: availability === "after-download"
? `Downloading Weights (${downloadProgress || 0}%)`
: "Chrome AI Unavailable (Fallback Mode)"}
</span>
{tokensRemaining !== null && (
<span style={{ color: "#64748b" }}>
Budget: {tokensRemaining} tokens left
</span>
)}
</div>
<div style={{ display: "flex", gap: "8px" }}>
{isOffline && (
<span
style={{
backgroundColor: "#fef3c7",
color: "#92400e",
padding: "2px 8px",
borderRadius: "4px",
}}
>
Offline Mode
</span>
)}
</div>
</div>
{/* Editor overlay stack */}
<div style={{ position: "relative", minHeight: "160px" }}>
{/* Ghost text display layer */}
<div
aria-hidden="true"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: "12px",
fontFamily: "inherit",
fontSize: "14px",
lineHeight: "1.5",
pointerEvents: "none",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "transparent",
border: "1px solid transparent",
}}
>
<span>{text}</span>
{suggestion && (
<span style={{ color: "#94a3b8", opacity: 0.8 }}>
{text.endsWith(" ") ? "" : " "}
{suggestion}
</span>
)}
</div>
{/* User interactive input */}
<textarea
ref={textareaRef}
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Smart Content Editor"
style={{
width: "100%",
minHeight: "160px",
padding: "12px",
fontSize: "14px",
lineHeight: "1.5",
fontFamily: "inherit",
backgroundColor: "transparent",
border: "1px solid #cbd5e1",
borderRadius: "6px",
resize: "vertical",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Footer controls and keyboard hints */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "6px",
fontSize: "12px",
color: "#64748b",
}}
>
<span>
{suggestion ? "Press [Tab] to accept completion, [Esc] to dismiss" : "Type to see inline local completions"}
</span>
{isGenerating && <span>Generating prediction...</span>}
</div>
</div>
);
}Mesures de performance et latence
Pour évaluer les gains réels en matière d'expérience utilisateur, nous avons comparé Gemini Nano sous Chrome à un déploiement API cloud classique (Gemini Flash hébergé dans une région centrale via une route Next.js Edge).
Comparaison empirique de latence et de consommation
| Métrique d'évaluation | Chrome Built-in AI (Gemini Nano) | API Cloud via passerelle Next.js Edge |
|---|---|---|
| Délai du premier token (TTFT) | 12 ms - 24 ms | 420 ms - 980 ms |
| Trafic réseau sortant par requête | 0 Ko (Aucun I/O réseau) | 1,4 Ko - 8,2 Ko |
| Exposition des données PII | Risque nul (Reste en mémoire locale) | Requiert chiffrement TLS et contrat DPA |
| Coût unitaire opérationnel | 0,00 $ / million de requêtes | 0,15 / million de tokens |
| Résilience hors ligne | Pleine disponibilité | Échec immédiat (HTTP 503 / Erreur réseau) |
| Consommation matérielle | ~400 Mo VRAM / RAM | Aucune mémoire consommée côté client |
Pièges en production et garde-fous architecturaux
L'exécution de modèles de fondation sur le matériel client implique des contraintes absentes des microservices côté serveur.
1. Épuisement de la fenêtre de contexte et gestion mémoire
Gemini Nano dispose d'un budget de tokens restreint (généralement 1024 à 4096 tokens par session selon l'appareil). Si une session est réutilisée pour plusieurs requêtes longues sans réinitialisation, session.tokensLeft tombe à zéro et les appels suivants renvoient une erreur InvalidStateError.
Solution : Utilisez session.clone() pour créer des sessions éphémères dédiées aux tâches ponctuelles et exécutez session.destroy() dès réception du résultat final.
// Pattern: Ephemeral session cloning
async function runIsolatedTask(
baseSession: AILanguageModelSession,
taskPrompt: string
): Promise<string> {
const ephemeralSession = await baseSession.clone();
try {
return await ephemeralSession.prompt(taskPrompt);
} finally {
ephemeralSession.destroy(); // Free underlying neural runtime resources
}
}2. Architecture de bascule hybride
Tous les utilisateurs ne disposent pas d'un navigateur avec accélération matérielle active pour les modèles locaux. Une architecture de production doit donc prévoir une amélioration progressive :
- Niveau 1 (Local) : Interroger
window.LanguageModel(ouwindow.ai.languageModel). Si"readily", exécuter localement avec une latence inférieure à 20 ms et sans charge serveur. - Niveau 2 (Bascule Edge) : Si
capabilities.available === "no", diriger la requête vers une Server Action ou Edge Route Next.js exploitant un modèle cloud. - Niveau 3 (Mode dégradé) : En l'absence totale de réseau et sans IA locale, basculer sur des règles heuristiques simples sans interruption bloquante.
Synthèse pratique
Déplacer l'inférence depuis des serveurs distants vers le navigateur élimine les délais d'attente sur les interfaces interactives. En combinant la Prompt API de Chrome avec un filtrage rigoureux des données sensibles, des hooks React bien conçus et des solutions de secours dans l'Edge, les développeurs frontend peuvent concevoir des applications réactives, confidentielles et résilientes partout dans le monde.