Demo: Chat Offline com IA Local no Navegador (Prompt API)
intermediárioChatbot 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.
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
- Página carrega → verifica API, cria sessão, exibe mensagem de boas-vindas
- Usuário digita mensagem e pressiona Enter ou clica enviar
- Mensagem aparece na bolha à direita (usuário)
- Bolha do assistente aparece vazia com indicador de streaming (cursor piscante)
- Texto aparece progressivamente na bolha do assistente via streaming
- Streaming completo → input reabilita, usuário pode enviar nova mensagem
- Contexto se enche → aviso aparece brevemente; mensagens antigas descartadas pelo modelo
- Limpar → destrói sessão, cria nova, reseta interface
Edge Cases e Tratamento de Erros
| Cenário | Tratamento |
|---|---|
| Sem internet | Funciona normalmente (modelo é local) |
| Context window cheio | Evento contextoverflow dispara aviso; modelo auto-descarta mensagens antigas |
| Envio durante streaming | Input desabilitado durante geração |
| Mensagem muito longa | Modelo trata; QuotaExceededError se exceder tudo |
| Usuário limpa chat | session.destroy() + nova sessão |
| Model download pendente | Progress bar via monitor callback |
| Resposta vazia do modelo | Bolha 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
contextUsageecontextWindowsão propriedades da sessão para monitorar uso. session.destroy()é necessário ao limpar para liberar recursos.