Demo: Chat Offline com IA Local no Navegador (Prompt API)

intermediário

Chatbot 100% offline usando a Prompt API com sessões persistentes, streaming em tempo real e histórico de conversa — sem enviar dados para nenhum servidor.

Verificando...

Visão Geral

Demo de chatbot que funciona 100% offline, com interface estilo WhatsApp/Telegram. As respostas aparecem em streaming nas bolhas de chat. A sessão mantém o contexto da conversa inteira — e isso funciona surpreendentemente bem pro tamanho do modelo.

Pra quem: Desenvolvedores que querem aprender gestão de sessão e streaming num contexto de chat real.

Técnica principal: Sessão persistente com initialPrompts, promptStreaming() pra respostas em tempo real, e gerenciamento de context window.


Wireframe

┌─────────────────────────────────────────────────────┐
│  💬 Chat Offline                      [🗑️ Limpar]   │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌───────────────────────────────────────────┐      │
│  │ 🤖 Olá! Sou seu assistente local.        │      │
│  │ Como posso ajudar?                        │      │
│  └───────────────────────────────────────────┘      │
│                                                     │
│       ┌───────────────────────────────────────┐     │
│       │ O que é a Prompt API?             👤  │     │
│       └───────────────────────────────────────┘     │
│                                                     │
│  ┌───────────────────────────────────────────┐      │
│  │ 🤖 A Prompt API é uma interface do        │      │
│  │ Chrome que permite executar modelos de█    │      │
│  └───────────────────────────────────────────┘      │
│                                                     │
│  ┌─────────────────────────────────┐ ┌────────┐    │
│  │ Digite sua mensagem...          │ │Enviar ↑│    │
│  └─────────────────────────────────┘ └────────┘    │
└─────────────────────────────────────────────────────┘

HTML

<section class="demo-container" id="chat-offline">
  <div class="chat-header">
    <h2>💬 Chat Offline</h2>
    <div class="header-actions">
      <span id="context-usage"></span>
      <button id="btn-clear" title="Limpar conversa">🗑️</button>
    </div>
  </div>

  <div id="status-bar" class="status" hidden>
    <span id="status-message"></span>
  </div>

  <div id="chat-messages" class="messages-container">
    <!-- Mensagens renderizadas aqui -->
  </div>

  <form id="chat-form" class="chat-input-area">
    <input
      id="input-message"
      type="text"
      placeholder="Digite sua mensagem..."
      autocomplete="off"
      disabled
    />
    <button type="submit" id="btn-send" disabled>↑</button>
  </form>
</section>

Código JavaScript

class OfflineChat {
  constructor() {
    this.session = null;
    this.controller = null;
    this.isStreaming = false;

    this.messagesEl = document.getElementById("chat-messages");
    this.formEl = document.getElementById("chat-form");
    this.inputEl = document.getElementById("input-message");
    this.btnSend = document.getElementById("btn-send");
    this.btnClear = document.getElementById("btn-clear");
    this.contextUsage = document.getElementById("context-usage");
    this.statusBar = document.getElementById("status-bar");
    this.statusMessage = document.getElementById("status-message");

    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 indisponível neste dispositivo.", "error");
      return;
    }

    await this.createSession();
    this.setupListeners();
  }

  async createSession() {
    try {
      this.session = await LanguageModel.create({
        expectedInputs: [{ type: "text", languages: ["pt", "en"] }],
        expectedOutputs: [{ type: "text", languages: ["pt"] }],
        initialPrompts: [{
          role: "system",
          content: `Você é um assistente amigável e prestativo que responde em português brasileiro. Seja conciso mas completo. Use formatação simples sem markdown.`
        }],
        monitor(m) {
          m.addEventListener("downloadprogress", (e) => {
            document.getElementById("status-message").textContent =
              `⏳ Baixando modelo... ${Math.round(e.loaded * 100)}%`;
          });
        }
      });

      // Escutar overflow de contexto
      this.session.addEventListener("contextoverflow", () => {
        this.updateContextBar();
        this.showStatus("⚠️ Contexto cheio. Mensagens antigas serão esquecidas.", "error");
        setTimeout(() => this.hideStatus(), 3000);
      });

      this.inputEl.disabled = false;
      this.btnSend.disabled = false;
      this.hideStatus();
      this.addMessage("assistant", "Olá! Sou seu assistente local. Como posso ajudar?");
      this.updateContextBar();
    } catch (err) {
      this.showStatus(`❌ Erro: ${err.message}`, "error");
    }
  }

  setupListeners() {
    this.formEl.addEventListener("submit", (e) => {
      e.preventDefault();
      this.sendMessage();
    });

    this.btnClear.addEventListener("click", () => this.clearChat());
  }

  async sendMessage() {
    const text = this.inputEl.value.trim();
    if (!text || this.isStreaming) return;

    this.inputEl.value = "";
    this.addMessage("user", text);

    this.isStreaming = true;
    this.inputEl.disabled = true;
    this.controller = new AbortController();

    const bubbleEl = this.addMessage("assistant", "");
    bubbleEl.classList.add("streaming");

    try {
      const stream = this.session.promptStreaming(text, {
        signal: this.controller.signal
      });

      for await (const chunk of stream) {
        bubbleEl.querySelector(".message-text").textContent = chunk;
        this.scrollToBottom();
      }
    } catch (err) {
      if (err.name !== "AbortError") {
        bubbleEl.querySelector(".message-text").textContent =
          "❌ Erro ao gerar resposta.";
      }
    } finally {
      bubbleEl.classList.remove("streaming");
      this.isStreaming = false;
      this.inputEl.disabled = false;
      this.inputEl.focus();
      this.updateContextBar();
    }
  }

  addMessage(role, text) {
    const div = document.createElement("div");
    div.className = `message message-${role}`;

    const icon = role === "assistant" ? "🤖" : "👤";
    div.innerHTML = `
      <span class="message-icon">${icon}</span>
      <div class="message-bubble">
        <span class="message-text">${this.escapeHtml(text)}</span>
      </div>
    `;

    this.messagesEl.appendChild(div);
    this.scrollToBottom();
    return div;
  }

  async clearChat() {
    this.messagesEl.innerHTML = "";
    if (this.session) this.session.destroy();
    await this.createSession();
  }

  updateContextBar() {
    if (!this.session) return;
    const used = this.session.contextUsage;
    const total = this.session.contextWindow;
    const pct = Math.round((used / total) * 100);
    this.contextUsage.textContent = `Contexto: ${pct}%`;
    this.contextUsage.style.color = pct > 80 ? "#dc2626" : "#6b7280";
  }

  scrollToBottom() {
    this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
  }

  escapeHtml(str) {
    const el = document.createElement("span");
    el.textContent = str;
    return el.innerHTML;
  }

  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 OfflineChat());

Fluxo UX

  1. Página carrega → verifica API, cria sessão, exibe mensagem de boas-vindas
  2. Usuário digita mensagem e pressiona Enter ou clica enviar
  3. Mensagem aparece na bolha à direita (usuário)
  4. Bolha do assistente aparece vazia com indicador de streaming (cursor piscante)
  5. Texto aparece progressivamente na bolha do assistente via streaming
  6. Streaming completo → input reabilita, usuário pode enviar nova mensagem
  7. Contexto se enche → aviso aparece brevemente; mensagens antigas descartadas pelo modelo
  8. Limpar → destrói sessão, cria nova, reseta interface

Edge Cases e Tratamento de Erros

CenárioTratamento
Sem internetFunciona normalmente (modelo é local)
Context window cheioEvento contextoverflow dispara aviso; modelo auto-descarta mensagens antigas
Envio durante streamingInput desabilitado durante geração
Mensagem muito longaModelo trata; QuotaExceededError se exceder tudo
Usuário limpa chatsession.destroy() + nova sessão
Model download pendenteProgress bar via monitor callback
Resposta vazia do modeloBolha mostra texto vazio (raro)

CSS Essencial

.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.message { display: flex; align-items: flex-start; gap: 0.5rem; }
.message-user { flex-direction: row-reverse; }

.message-bubble {
  max-width: 75%;
  padding: 0.75rem 1rem;
  border-radius: 1rem;
  line-height: 1.5;
}

.message-assistant .message-bubble { background: #f3f4f6; }
.message-user .message-bubble { background: #2563eb; color: white; }

.streaming .message-text::after {
  content: "█";
  animation: blink 0.7s steps(1) infinite;
}

@keyframes blink { 50% { opacity: 0; } }

.chat-input-area {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  border-top: 1px solid #e5e7eb;
}

.chat-input-area input {
  flex: 1;
  padding: 0.75rem;
  border-radius: 1.5rem;
  border: 1px solid #d1d5db;
}

#btn-send {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #2563eb;
  color: white;
  font-size: 1.2rem;
}

Notas de Implementação

  • O session.promptStreaming() mantém o histórico da conversa automaticamente — não precisa reenviar mensagens anteriores.
  • Cada chunk do streaming é a resposta completa acumulada até aquele momento.
  • O contextUsage e contextWindow são propriedades da sessão para monitorar uso.
  • session.destroy() é necessário ao limpar para liberar recursos.