Demo: Autocomplete Inteligente com Streaming (Prompt API)
avançadoSugestões autocomplete geradas por IA local em tempo real enquanto você digita. Debounce + promptStreaming + AbortController em UX de formulário real.
Visão Geral
Demo que implementa autocomplete alimentado por IA local — conforme o usuário digita, sugestões são geradas via promptStreaming e exibidas incrementalmente num dropdown estilo Google Suggest. O combo debounce (300ms) + AbortController garante que requests anteriores sejam cancelados quando o input muda, evitando race conditions.
Pra quem: Desenvolvedores que já dominam o básico da Prompt API e querem aplicar streaming em UX real de formulário.
Técnicas principais:
promptStreamingpara exibir sugestões enquanto o modelo ainda gera- Debounce de 300ms para não sobrecarregar o modelo a cada keystroke
AbortControllerpara cancelar a geração anterior quando o usuário continua digitando- Dropdown acessível com navegação por teclado
Wireframe
┌─────────────────────────────────────────────────────┐
│ ⚡ Autocomplete Inteligente │
├─────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────┬───┐ │
│ │ input: "como fazer bolo de" │ ⟳ │ │
│ └───────────────────────────────────────────┴───┘ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ● como fazer bolo de chocolate fácil │ │
│ │ ○ como fazer bolo de cenoura com cobr... │ │
│ │ ○ como fazer bolo de fubá cremoso │ │
│ └───────────────────────────────────────────────┘ │
│ ↑ dropdown com sugestões streaming │
│ │
│ ⚠️ Requer Chrome 148+ com Prompt API habilitada │
└─────────────────────────────────────────────────────┘
HTML
<section class="demo-container" id="autocomplete-demo">
<h2>⚡ Autocomplete Inteligente</h2>
<div id="status-bar" class="status" hidden>
<span id="status-message"></span>
</div>
<div class="input-wrapper" role="combobox" aria-expanded="false" aria-haspopup="listbox">
<input
id="search-input"
type="text"
placeholder="Comece a digitar uma pergunta..."
autocomplete="off"
aria-autocomplete="list"
aria-controls="suggestions-list"
aria-activedescendant=""
/>
<span id="loading-indicator" class="spinner" hidden aria-hidden="true"></span>
</div>
<ul id="suggestions-list" class="suggestions" role="listbox" hidden>
<!-- Sugestões inseridas via JS -->
</ul>
<p class="hint">Sugestões geradas por IA local — sem servidor, sem latência de rede.</p>
</section>
Código JavaScript
class SmartAutocomplete {
constructor() {
this.session = null;
this.abortController = null;
this.debounceTimer = null;
this.inputEl = document.getElementById("search-input");
this.listEl = document.getElementById("suggestions-list");
this.wrapperEl = this.inputEl.closest("[role=combobox]");
this.spinnerEl = document.getElementById("loading-indicator");
this.statusBar = document.getElementById("status-bar");
this.statusMessage = document.getElementById("status-message");
this.activeIndex = -1;
this.suggestions = [];
this.init();
}
async init() {
if (!("LanguageModel" in window)) {
this.showStatus("❌ Prompt API não disponível. Use Chrome 148+.", "error");
return;
}
const availability = await LanguageModel.availability();
if (availability === "unavailable") {
this.showStatus("❌ Modelo não disponível neste dispositivo.", "error");
return;
}
if (availability === "downloading") {
this.showStatus("⏳ Baixando modelo...", "loading");
}
try {
this.session = await LanguageModel.create({
initialPrompts: [{
role: "system",
content: `Você é um motor de autocomplete. Dado o início de uma frase, sugira 3 completions naturais e úteis. Retorne APENAS as 3 sugestões, uma por linha, sem numeração, sem explicação. Cada sugestão deve completar a frase do usuário.`
}],
monitor(m) {
m.addEventListener("downloadprogress", (e) => {
document.getElementById("status-message").textContent =
`⏳ Baixando modelo... ${Math.round(e.loaded * 100)}%`;
});
}
});
this.hideStatus();
} catch (err) {
this.showStatus(`❌ Erro: ${err.message}`, "error");
return;
}
this.inputEl.addEventListener("input", () => this.onInput());
this.inputEl.addEventListener("keydown", (e) => this.onKeydown(e));
this.inputEl.addEventListener("blur", () => {
setTimeout(() => this.hideDropdown(), 150);
});
}
// --- Debounce + Cancelamento ---
onInput() {
const query = this.inputEl.value.trim();
// Cancela request anterior
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
clearTimeout(this.debounceTimer);
if (query.length < 3) {
this.hideDropdown();
return;
}
// Debounce 300ms
this.debounceTimer = setTimeout(() => this.fetchSuggestions(query), 300);
}
// --- Streaming de sugestões ---
async fetchSuggestions(query) {
this.abortController = new AbortController();
const { signal } = this.abortController;
this.showSpinner(true);
this.suggestions = ["", "", ""];
this.showDropdown();
try {
const stream = await this.session.promptStreaming(
`Complete esta frase com 3 sugestões:\n"${query}"`,
{ signal }
);
for await (const chunk of stream) {
if (signal.aborted) return;
// Parseia linhas conforme chegam
const lines = chunk.split("\n").filter(l => l.trim());
for (let i = 0; i < Math.min(lines.length, 3); i++) {
this.suggestions[i] = lines[i].trim();
}
this.renderSuggestions(query);
}
} catch (err) {
if (err.name === "AbortError") return;
this.hideDropdown();
} finally {
this.showSpinner(false);
}
}
// --- Renderização incremental ---
renderSuggestions(query) {
this.listEl.innerHTML = "";
this.suggestions.forEach((text, i) => {
if (!text) return;
const li = document.createElement("li");
li.id = `suggestion-${i}`;
li.role = "option";
li.className = i === this.activeIndex ? "active" : "";
li.setAttribute("aria-selected", i === this.activeIndex);
// Destaca o que o usuário já digitou
if (text.toLowerCase().startsWith(query.toLowerCase())) {
li.innerHTML = `<strong>${text.slice(0, query.length)}</strong>${text.slice(query.length)}`;
} else {
li.textContent = text;
}
li.addEventListener("mousedown", () => this.selectSuggestion(text));
this.listEl.appendChild(li);
});
}
// --- Navegação por teclado ---
onKeydown(e) {
const visible = !this.listEl.hidden;
const count = this.listEl.children.length;
switch (e.key) {
case "ArrowDown":
if (!visible) return;
e.preventDefault();
this.activeIndex = (this.activeIndex + 1) % count;
this.updateActive();
break;
case "ArrowUp":
if (!visible) return;
e.preventDefault();
this.activeIndex = (this.activeIndex - 1 + count) % count;
this.updateActive();
break;
case "Enter":
if (visible && this.activeIndex >= 0) {
e.preventDefault();
this.selectSuggestion(this.suggestions[this.activeIndex]);
}
break;
case "Escape":
this.hideDropdown();
break;
}
}
updateActive() {
[...this.listEl.children].forEach((li, i) => {
li.className = i === this.activeIndex ? "active" : "";
li.setAttribute("aria-selected", i === this.activeIndex);
});
this.inputEl.setAttribute("aria-activedescendant",
this.activeIndex >= 0 ? `suggestion-${this.activeIndex}` : ""
);
}
selectSuggestion(text) {
this.inputEl.value = text;
this.hideDropdown();
this.inputEl.focus();
}
// --- UI helpers ---
showDropdown() {
this.listEl.hidden = false;
this.wrapperEl.setAttribute("aria-expanded", "true");
this.activeIndex = -1;
}
hideDropdown() {
this.listEl.hidden = true;
this.wrapperEl.setAttribute("aria-expanded", "false");
this.activeIndex = -1;
}
showSpinner(show) {
this.spinnerEl.hidden = !show;
}
showStatus(msg, type) {
this.statusBar.hidden = false;
this.statusBar.className = `status status-${type}`;
this.statusMessage.textContent = msg;
}
hideStatus() {
this.statusBar.hidden = true;
}
}
document.addEventListener("DOMContentLoaded", () => new SmartAutocomplete());
Fluxo UX
- Página carrega → verifica Prompt API, cria sessão com system prompt de autocomplete
- Usuário digita → debounce de 300ms evita chamadas excessivas
- Após 300ms de pausa →
promptStreaminginicia geração; request anterior é abortado viaAbortController - Chunks chegam → dropdown atualiza incrementalmente (efeito typewriter nas sugestões)
- Usuário navega → setas ↑↓ movem seleção, Enter confirma, Escape fecha
- Seleciona sugestão → preenche o input, fecha dropdown
- Continua digitando → abort no stream atual, novo ciclo debounce
Edge Cases e Tratamento de Erros
| Cenário | Tratamento |
|---|---|
| Input com menos de 3 caracteres | Dropdown fechado, sem request |
| Usuário digita rápido (< 300ms entre teclas) | Debounce cancela timers anteriores |
| Request anterior ainda em andamento | AbortController.abort() cancela stream |
| Modelo retorna menos de 3 sugestões | Renderiza apenas as que existem |
| Stream cortado (context overflow) | Exibe parcial, sem erro visível |
| Clique fora do dropdown | blur handler fecha com delay de 150ms |
| Prompt API indisponível | Mensagem de status, input funciona sem sugestões |
| Sugestão não começa com o query | Exibe texto completo sem highlight |
CSS Essencial
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
#search-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
transition: border-color 0.2s;
}
#search-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.spinner {
position: absolute;
right: 0.75rem;
width: 16px;
height: 16px;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: 4px 0 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 10;
overflow: hidden;
}
.suggestions li {
padding: 0.625rem 1rem;
cursor: pointer;
transition: background 0.15s;
}
.suggestions li:hover,
.suggestions li.active {
background: #f3f4f6;
}
.suggestions li strong {
font-weight: 600;
}
.hint {
margin-top: 1rem;
font-size: 0.8rem;
color: #6b7280;
}
.status-error { color: #dc2626; }
.status-loading { color: #2563eb; }