IA On-Device a Latenza Zero in React: Chrome Gemini Nano e Prompt API in Produzione
Costruzione di interfacce React a latenza zero e attente alla privacy con le API integrate di Gemini Nano in Chrome, window.LanguageModel, sanitizzazione locale dei dati PII e fallback ibrido.

IA On-Device a Latenza Zero in React: Chrome Gemini Nano e Prompt API in Produzione
Ogni pressione di tasto in una moderna applicazione web comporta l'aspettativa di una risposta istantanea. Quando i team collegano modelli linguistici di grandi dimensioni ai campi di input per l'autocompletamento, la regolazione del tono o la sintesi in tempo reale, si scontrano con un vincolo fisico invalicabile: i tempi di andata e ritorno sulla rete.
Un tragitto dal browser a un gateway LLM su cloud richiede tra 300 ms e 1500 ms per generare il primo token (TTFT). Questa latenza risulta accettabile in un pannello di chat separato, ma spezza il ritmo di digitazione all'interno di editor e form. L'inferenza in cloud comporta inoltre costi ricorrenti per token, avvii a freddo, limiti di frequenza e rischi di conformità quando dati sensibili lasciano il client.
L'intelligenza artificiale integrata in Chrome trasforma questo scenario incorporando Gemini Nano direttamente nel runtime del browser. Questo articolo esamina l'architettura, il ciclo di vita delle sessioni, la gestione dei dati sul client e i pattern concreti di integrazione in React per eseguire modelli locali in produzione.
+-------------------------------------------------------------------------+
| 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) |
+-------------------------------------------------------------------------+Il modello di inferenza all'interno del browser
Chrome distribuisce Gemini Nano come modello neurale di base eseguito direttamente sul dispositivo e gestito dal servizio Optimization Guide del browser. A differenza dei porting basati su WebAssembly o WebGPU, che richiedono il download di 1 GB fino a 4 GB di pesi al primo caricamento della pagina, Chrome gestisce la distribuzione del binario in background tramite il suo Component Updater.
L'inferenza viene eseguita in un processo di utilità isolato rispetto agli script del contenuto web. Il contesto di esecuzione JavaScript comunica con il modello tramite IPC strutturata attraverso l'API standard window.LanguageModel (e il namespace transitorio window.ai.languageModel).
Caratteristiche architetturali
- Privacy deterministica: Il testo originale del prompt non attraversa alcun socket di rete. Nei settori regolamentati come sanità e finanza, l'inferenza locale soddisfa rigidi requisiti di assenza di uscita dati.
- TTFT inferiore a 20 ms: Poiché il modello risiede nella memoria locale e utilizza gli acceleratori hardware del dispositivo (Apple Silicon Metal, Windows DirectML, Vulkan), la generazione dei token inizia quasi istantaneamente.
- Nessun costo variabile di infrastruttura: I carichi di lavoro vengono elaborati sul silicio del client. Picchi di centinaia di migliaia di utenti comportano un costo di 0 $ in chiamate API di inferenza.
- Funzionamento offline: Tutte le funzionalità restano disponibili anche in presenza di connessioni mobili instabili o assenza totale di rete.
Ciclo di vita dell'IA integrata in Chrome
La Prompt API non è una semplice chiamata di funzione senza stato. Si fonda su un modello di sessione con stato che richiede verifiche esplicite delle funzionalità, ottimizzazione dei parametri e gestione della memoria.
// Definizioni di tipi per 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;
};
}
}Tre stati di disponibilità del modello
Prima di inizializzare una sessione, l'applicazione deve interrogare window.LanguageModel (o window.ai.languageModel). Il browser restituisce uno dei seguenti tre stati:
"readily": Il binario del modello è presente nella cache locale ed è pronto per l'istanziazione immediata."after-download": Il dispositivo soddisfa i requisiti hardware, ma il binario è in coda di download in background. L'applicazione può monitorare l'avanzamento tramite il callbackmonitor."no": Il dispositivo non dispone del supporto hardware necessario (VRAM o RAM insufficienti, architettura GPU non supportata) o il flag della funzionalità è disattivato.
Livello di sanitizzazione PII lato client
Anche quando si utilizza un modello on-device senza trasmissione di rete, le migliori pratiche di sicurezza impongono di mascherare gli identificatori sensibili (carte di credito, documenti d'identità, indirizzi email, token di autenticazione) prima di costruire il prompt. Ciò previene la contaminazione incrociata nella cronologia di sessioni condivise.
Di seguito è riportato un filtro di tokenizzazione leggero e privo di dipendenze esterne:
// 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 pronto per la produzione: useBrowserAI
La gestione manuale delle sessioni del modello all'interno dei componenti React può provocare perdite di memoria se le sessioni non vengono chiuse allo smontaggio. L'hook useBrowserAI coordina il ciclo di vita, la verifica delle capacità, l'aggregazione di testo in streaming e la gestione dei segnali di interruzione.
// 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,
};
}Componente in primo piano: <SmartTextArea />
Questo componente unisce l'autocompletamento in testo fantasma a latenza zero con l'analisi in tempo reale del tono e un indicatore di stato offline. Quando l'utente fa una pausa durante la digitazione, Gemini Nano prevede il completamento della frase. Premendo il tasto Tab, il suggerimento viene inserito immediatamente senza interrompere il focus.
// 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>
);
}Valutazione delle prestazioni e latenza
Per quantificare i benefici concreti per l'esperienza utente, abbiamo confrontato Gemini Nano in esecuzione su Chrome con una classica distribuzione API cloud (Gemini Flash ospitato in una regione centrale tramite una route Next.js Edge).
Confronto empirico tra latenza e consumo di risorse
| Metrica di valutazione | Chrome Built-in AI (Gemini Nano) | API Cloud via gateway Next.js Edge |
|---|---|---|
| Tempo di emissione primo token (TTFT) | 12 ms - 24 ms | 420 ms - 980 ms |
| Dati in uscita sulla rete per richiesta | 0 KB (Nessun I/O di rete) | 1,4 KB - 8,2 KB |
| Rischio di esposizione PII | Nessun rischio (Rimane nella memoria locale) | Richiede trasporto TLS e accordo DPA |
| Costo unitario operativo | 0,00 $ / milione di richieste | 0,15 / milione di token |
| Funzionamento offline | Piena funzionalità | Fallimento immediato (HTTP 503 / Errore di rete) |
| Carico hardware | ~400 MB VRAM / RAM | Nessun impatto sulla memoria del client |
Problematiche in produzione e linee guida architetturali
L'esecuzione di modelli di base sull'hardware del client presenta sfide del tutto assenti nei microservizi lato server.
1. Esaurimento della finestra di contesto e gestione della memoria
Gemini Nano ha un limite di token ristretto (in genere da 1024 a 4096 token per sessione a seconda del dispositivo). Se una sessione viene riutilizzata per più prompt lunghi senza essere ripristinata, session.tokensLeft scende a zero e le chiamate successive generano un InvalidStateError.
Strategia di mitigazione: Utilizzare session.clone() per creare sessioni di lavoro temporanee dedicate a singole operazioni ed eseguire session.destroy() subito dopo aver ottenuto la risposta.
// 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. Architettura di fallback ibrida
Non tutti gli utenti utilizzeranno un browser con accelerazione hardware attiva per i modelli locali. Un'architettura di produzione deve pertanto applicare un modello di miglioramento progressivo:
- Livello 1 (Locale): Interrogare
window.LanguageModel(owindow.ai.languageModel). Se"readily", eseguire localmente con latenza inferiore a 20 ms e nessun carico sul server. - Livello 2 (Fallback su Edge): Se
capabilities.available === "no", indirizzare la richiesta a una Server Action o Edge Route di Next.js collegata a un modello cloud. - Livello 3 (Modalità degradata): In caso di assenza totale di rete e mancanza di IA locale, ricorrere a regole euristiche semplici senza provocare interruzioni fatali.
Riepilogo operativo
Trasferire l'inferenza dai server remoti al runtime del browser elimina la latenza nelle interfacce interattive. Integrando la Prompt API di Chrome con un rigoroso filtraggio dei dati PII, hook React ben strutturati e meccanismi di fallback sull'Edge, i team frontend possono realizzare applicazioni veloci, riservate e affidabili in ogni contesto.