Demo: Resumidor de Textos com Streaming e IA On-Device

iniciante

Resuma artigos e textos longos em 3-5 frases com streaming em tempo real usando a Prompt API. Inferência local via Gemini Nano — sem custo e sem enviar dados.

Verificando...

Visão Geral

Demo que resume textos longos (artigos, emails, documentos) em 3-5 frases, mostrando o resultado em tempo real via streaming. O efeito de “digitação” ao vivo é mais satisfatório do que parece — o usuário sente que algo tá acontecendo.

Pra quem: Desenvolvedores que querem aprender streaming com a Prompt API.

Técnica principal: promptStreaming() retornando ReadableStream com chunks concatenados progressivamente.


Wireframe

┌─────────────────────────────────────────────────────────┐
│  📝 Resumidor de Texto                                  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌─────────────────────────────────────────────────┐    │
│  │ textarea grande                                 │    │
│  │ (placeholder: "Cole aqui o texto para          │    │
│  │  resumir... Artigos, emails, documentos")      │    │
│  │                                                 │    │
│  │                                                 │    │
│  │                                                 │    │
│  └─────────────────────────────────────────────────┘    │
│                                                         │
│  Caracteres: 1.450    [ ✂️ Resumir Texto ]              │
│                                                         │
│  ┌─────────────────────────────────────────────────┐    │
│  │  📋 Resumo:                                     │    │
│  │                                                 │    │
│  │  O texto aborda as mudanças climáticas e seu    │    │
│  │  impacto na agricultura brasileira. Os dados    │    │
│  │  mostram que...█  ← cursor streaming            │    │
│  │                                                 │    │
│  └─────────────────────────────────────────────────┘    │
│                                                         │
│  ⚠️ Requer Chrome 148+ com Prompt API habilitada       │
└─────────────────────────────────────────────────────────┘

HTML

<section class="demo-container" id="resumidor">
  <h2>📝 Resumidor de Texto</h2>

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

  <textarea
    id="input-text"
    placeholder="Cole aqui o texto para resumir... Artigos, emails, documentos."
    rows="10"
    maxlength="10000"
  ></textarea>

  <div class="actions">
    <span id="char-count">0 caracteres</span>
    <button id="btn-summarize" disabled>✂️ Resumir Texto</button>
    <button id="btn-stop" hidden>⏹️ Parar</button>
  </div>

  <div id="result" class="result-box" hidden>
    <h3>📋 Resumo:</h3>
    <p id="summary-output" class="streaming-text"></p>
  </div>
</section>

Código JavaScript

class TextSummarizer {
  constructor() {
    this.session = null;
    this.controller = null;

    this.inputEl = document.getElementById("input-text");
    this.btnEl = document.getElementById("btn-summarize");
    this.btnStop = document.getElementById("btn-stop");
    this.resultEl = document.getElementById("result");
    this.outputEl = document.getElementById("summary-output");
    this.statusBar = document.getElementById("status-bar");
    this.statusMessage = document.getElementById("status-message");
    this.charCount = document.getElementById("char-count");

    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({
        expectedInputs: [{ type: "text", languages: ["pt", "en"] }],
        expectedOutputs: [{ type: "text", languages: ["pt"] }],
        initialPrompts: [{
          role: "system",
          content: `Você é um resumidor especialista. Resuma o texto fornecido em 3 a 5 frases concisas em português brasileiro. Mantenha as informações mais importantes. Não adicione opinião. Não repita o título.`
        }],
        monitor(m) {
          m.addEventListener("downloadprogress", (e) => {
            document.getElementById("status-message").textContent =
              `⏳ Baixando modelo... ${Math.round(e.loaded * 100)}%`;
          });
        }
      });

      this.btnEl.disabled = false;
      this.hideStatus();
    } catch (err) {
      this.showStatus(`❌ Erro: ${err.message}`, "error");
      return;
    }

    this.btnEl.addEventListener("click", () => this.summarize());
    this.btnStop.addEventListener("click", () => this.stop());
    this.inputEl.addEventListener("input", () => this.updateCharCount());
    this.inputEl.addEventListener("keydown", (e) => {
      if (e.key === "Enter" && e.ctrlKey) this.summarize();
    });
  }

  async summarize() {
    const text = this.inputEl.value.trim();
    if (!text) return;
    if (text.length < 50) {
      this.showStatus("⚠️ Texto muito curto para resumir (mínimo 50 caracteres).", "error");
      return;
    }

    this.controller = new AbortController();
    this.btnEl.hidden = true;
    this.btnStop.hidden = false;
    this.resultEl.hidden = false;
    this.outputEl.textContent = "";
    this.outputEl.classList.add("streaming");

    try {
      const stream = this.session.promptStreaming(
        `Resuma o seguinte texto em 3 a 5 frases:\n\n${text}`,
        { signal: this.controller.signal }
      );

      for await (const chunk of stream) {
        this.outputEl.textContent = chunk;
      }
    } catch (err) {
      if (err.name !== "AbortError") {
        this.showStatus(`❌ Erro: ${err.message}`, "error");
      }
    } finally {
      this.outputEl.classList.remove("streaming");
      this.btnEl.hidden = false;
      this.btnStop.hidden = true;
    }
  }

  stop() {
    this.controller?.abort();
  }

  updateCharCount() {
    const len = this.inputEl.value.length;
    this.charCount.textContent = `${len.toLocaleString("pt-BR")} caracteres`;
  }

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

Fluxo UX

  1. Página carrega → verifica Prompt API, cria sessão com system prompt de sumarização
  2. Usuário cola texto → contador mostra quantidade de caracteres
  3. Clica “Resumir” (ou Ctrl+Enter) → botão troca para “Parar”, área de resultado aparece
  4. Streaming inicia → texto aparece progressivamente com cursor piscante via CSS
  5. Streaming completo → cursor desaparece, botão “Resumir” retorna
  6. Clica “Parar” → AbortController cancela, resultado parcial permanece visível

Edge Cases e Tratamento de Erros

CenárioTratamento
Texto com menos de 50 caracteresAviso amigável, não executa
Texto com 10.000+ caracteresmaxlength no HTML impede; se context overflow, session trata
Usuário clica “Parar” durante streamingAbortController cancela; resultado parcial visível
Sessão perde contexto (context overflow)Modelo descarta mensagens antigas automaticamente
Modelo retorna texto em inglêsSystem prompt força PT-BR; expectedOutputs reforça
Múltiplos cliques rápidosBotão hidden durante execução impede duplo-click

CSS Essencial

.streaming-text {
  white-space: pre-wrap;
  line-height: 1.6;
  min-height: 80px;
}

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

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

#btn-stop {
  background: #dc2626;
  color: white;
}

Notas de Implementação

  • O promptStreaming() retorna um ReadableStream onde cada chunk é a resposta completa até aquele ponto (não deltas). Basta atribuir textContent = chunk diretamente.
  • O system prompt é definido uma vez na sessão. Cada chamada a promptStreaming() reutiliza o contexto.
  • Para textos muito grandes que excedem a janela de contexto, considere truncar ou dividir em partes.