On-Device ИИ с нулевой задержкой в React: Chrome Gemini Nano и Prompt API в продакшене
Разработка быстрых и конфиденциальных React-интерфейсов на базе встроенных API Gemini Nano в Chrome, window.LanguageModel, локальной очистки PII и гибридных резервных путей.

On-Device ИИ с нулевой задержкой в React: Chrome Gemini Nano и Prompt API в продакшене
Каждое нажатие клавиши в современном веб-приложении требует мгновенного отклика. Когда команды подключают большие языковые модели к полям ввода для автодополнения текста, корректировки тональности или генерации выжимок в реальном времени, они сталкиваются с непреодолимым физическим ограничением: задержкой сетевого обращения.
Запрос из браузера к шлюзу облачной LLM требует от 300 до 1500 мс для генерации первого токена (TTFT). Такая задержка приемлема для отдельного окна чата, но она нарушает темп ввода в текстовых редакторах и формах. Облачный инференс также влечет регулярные затраты на токены, холодные старты, лимиты запросов и риски нарушения конфиденциальности, когда чувствительные данные пользователя покидают клиентское устройство.
Встроенный ИИ в Chrome меняет эту парадигму, интегрируя Gemini Nano непосредственно в среду выполнения браузера. В этой статье рассматриваются архитектура, жизненный цикл сессий, защита данных на стороне клиента и практические шаблоны интеграции с React для запуска локальных моделей в продакшене.
+-------------------------------------------------------------------------+
| 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) |
+-------------------------------------------------------------------------+Модель инференса внутри браузера
Chrome поставляет Gemini Nano как локальную базовую нейросетевую модель, управляемую службой Optimization Guide браузера. В отличие от реализаций на WebAssembly или WebGPU, требующих скачивания от 1 до 4 ГБ весов при первой загрузке страницы, Chrome загружает бинарный файл в фоновом режиме через свой Component Updater.
Вычисления выполняются в изолированном служебном процессе, отделенном от скриптов веб-страницы. Контекст выполнения JavaScript взаимодействует с моделью через структурированное межпроцессное взаимодействие (IPC) через стандартный глобальный интерфейс window.LanguageModel (и переходное пространство имен window.ai.languageModel).
Архитектурные особенности
- Гарантированная конфиденциальность: Исходный текст промпта никогда не передается через сетевой сокет. В регулируемых отраслях, таких как здравоохранение и финансы, локальный инференс полностью исключает утечку данных.
- TTFT менее 20 мс: Поскольку модель находится в локальной памяти и задействует аппаратные ускорители устройства (Apple Silicon Metal, Windows DirectML, Vulkan), генерация токенов начинается практически мгновенно.
- Отсутствие переменных затрат на инфраструктуру: Нагрузка распределяется по устройствам пользователей. Наплыв сотен тысяч посетителей создает нулевые затраты на сторонние API.
- Автономная работа офлайн: Все функции сохраняют работоспособность при нестабильном мобильном соединении или полном отсутствии интернета.
Жизненный цикл встроенного ИИ в Chrome
Prompt API не является простым вызовом функции без сохранения состояния. Оно опирается на модель сессий с сохранением состояния, требующую явной проверки доступности, настройки параметров и управления памятью.
// Описания типов для 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;
};
}
}Три состояния доступности модели
Перед инициализацией сессии приложение запрашивает window.LanguageModel (или window.ai.languageModel). Браузер возвращает одно из трех состояний:
"readily": Бинарный файл модели сохранен на локальном диске и готов к немедленной работе."after-download": Устройство соответствует требованиям, но файл ожидает загрузки в фоновом режиме. Приложения могут отслеживать прогресс через функцию обратного вызоваmonitor."no": Устройство не поддерживается на аппаратном уровне (недостаточно VRAM/RAM или несовместимый GPU), либо флаг функции отключен.
Уровень очистки PII на стороне клиента
Даже при использовании локальной модели без передачи данных по сети стандарты безопасности требуют маскировать конфиденциальные данные (номера банковских карт, паспортов, адреса электронной почты, токены авторизации) перед формированием промпта. Это предотвращает перекрестное загрязнение в истории сессий.
Ниже приведен легковесный фильтр токенизации без внешних зависимостей:
// 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 };
}Готовый к продакшену React-хук: useBrowserAI
Ручное управление сессиями модели в React-компонентах приводит к утечкам памяти, если ресурсы не освобождаются при размонтировании. Хук useBrowserAI инкапсулирует жизненный цикл, проверку поддержки, потоковую агрегацию текста и обработку сигналов отмены.
// 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,
};
}Практический компонент: <SmartTextArea />
Этот компонент объединяет мгновенное автодополнение текста, оценку тональности и индикатор автономного режима. Когда пользователь делает паузу во время набора текста, Gemini Nano прогнозирует продолжение фразы. Нажатие клавиши Tab подставляет подсказку прямо в поле ввода без потери фокуса.
// 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>
);
}Сравнение производительности и задержки
Для оценки практического улучшения пользовательского опыта мы сравнили работу Gemini Nano в Chrome со стандартным облачным решением (Gemini Flash, размещенный в центральном регионе через Next.js Edge route).
Эмпирическое сравнение задержки и ресурсов
| Критерий оценки | Chrome Built-in AI (Gemini Nano) | Облачный API через Next.js Edge |
|---|---|---|
| Время первого токена (TTFT) | 12 мс - 24 мс | 420 мс - 980 мс |
| Сетевой трафик на запрос | 0 КБ (Без сетевого ввода-вывода) | 1,4 КБ - 8,2 КБ |
| Риск раскрытия данных PII | Нулевой риск (Остается в памяти устройства) | Требует передачи по TLS и соглашения DPA |
| Эксплуатационные затраты | 0,00 $ / миллион запросов | 0,15 / миллион токенов |
| Работа в автономном режиме | Полная функциональность | Запрос прерывается (HTTP 503 / Ошибка сети) |
| Нагрузка на клиент | ~400 МБ VRAM / RAM | Память клиента не расходуется |
Подводные камни в продакшене и архитектурные решения
Запуск базовых моделей на оборудовании пользователей порождает вызовы, нехарактерные для серверных микросервисов.
1. Переполнение контекстного окна и управление памятью
Gemini Nano имеет ограниченный бюджет токенов (обычно от 1024 до 4096 токенов на сессию в зависимости от устройства). Если использовать одну сессию для множества длинных запросов без сброса, session.tokensLeft падает до нуля, и последующие вызовы вызывают InvalidStateError.
Решение: Используйте session.clone() для создания временных рабочих сессий под конкретные задачи и вызывайте session.destroy() сразу после получения ответа.
// 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. Гибридная архитектура резервирования
Не у каждого пользователя в браузере доступно аппаратное ускорение для локальных моделей. Поэтому продакшен-архитектура должна строиться по принципу прогрессивного улучшения:
- Уровень 1 (Локальный): Запрос
window.LanguageModel(илиwindow.ai.languageModel). Если возвращается"readily", выполнение происходит локально с задержкой менее 20 мс и нулевой нагрузкой на сервер. - Уровень 2 (Резерв на Edge): Если
capabilities.available === "no", направить запрос в Next.js Server Action или Edge Route с облачной моделью. - Уровень 3 (Автономный режим): При полном отсутствии сети и локального ИИ переключиться на эвристические правила без критических сбоев.
Итоги
Перенос инференса из удаленных дата-центров в среду браузера устраняет проблему сетевых задержек в интерактивных интерфейсах. Сочетая Prompt API в Chrome с фильтрацией PII, надежными React-хуками и гибридным резервированием, фронтенд-разработчики могут создавать быстрые, конфиденциальные и надежные приложения для любых условий.