Demo: Autocomplete Inteligente com Streaming (Prompt API)

avançado

Sugestões autocomplete geradas por IA local em tempo real enquanto você digita. Debounce + promptStreaming + AbortController em UX de formulário real.

Verificando...

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:

  • promptStreaming para exibir sugestões enquanto o modelo ainda gera
  • Debounce de 300ms para não sobrecarregar o modelo a cada keystroke
  • AbortController para 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

  1. Página carrega → verifica Prompt API, cria sessão com system prompt de autocomplete
  2. Usuário digita → debounce de 300ms evita chamadas excessivas
  3. Após 300ms de pausapromptStreaming inicia geração; request anterior é abortado via AbortController
  4. Chunks chegam → dropdown atualiza incrementalmente (efeito typewriter nas sugestões)
  5. Usuário navega → setas ↑↓ movem seleção, Enter confirma, Escape fecha
  6. Seleciona sugestão → preenche o input, fecha dropdown
  7. Continua digitando → abort no stream atual, novo ciclo debounce

Edge Cases e Tratamento de Erros

CenárioTratamento
Input com menos de 3 caracteresDropdown fechado, sem request
Usuário digita rápido (< 300ms entre teclas)Debounce cancela timers anteriores
Request anterior ainda em andamentoAbortController.abort() cancela stream
Modelo retorna menos de 3 sugestõesRenderiza apenas as que existem
Stream cortado (context overflow)Exibe parcial, sem erro visível
Clique fora do dropdownblur handler fecha com delay de 150ms
Prompt API indisponívelMensagem de status, input funciona sem sugestões
Sugestão não começa com o queryExibe 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; }