mozak.tech Engenharia de IA Corporativa 61%

Parte III — Inteligência Artificial na Experiência do Usuário

3.5 — Chatbot com Streaming: Integração de IA entre Cliente e Servidor (Streaming Chatbot)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Implementar streaming de texto via AsyncGenerator no React (chatbotStream)

Construir busca semântica por keyword como fallback de embeddings reais

Montar system prompt dinâmico com contexto de produtos para o chatbot

Completar os 2 TODOs do componente ChatSuporte

Conectar Firebase AI Logic real como alternativa ao mock

Por que isso importa

Chatbots que mostram a resposta inteira de uma vez após 3-5 segundos têm péssima UX — o usuário não sabe se o sistema está processando. Streaming resolve: o texto aparece palavra por palavra, como um humano digitando, com latência percebida de ~300ms.

A combinação RAG + chatbot é o pattern de suporte ao cliente mais adotado em 2024: busca semântica encontra produtos/artigos relevantes → LLM usa como contexto → resposta precisa e fundamentada.

Conceitos Fundamentais

Streaming com AsyncGenerator

async function* chatbotStream(mensagem: string, historico: Mensagem[], contextoProdutos: ProdutoInfo[]): AsyncGenerator<string> {

  // Cada 'yield' entrega um chunk de texto ao componente React

  yield 'Olá, '

  yield 'tudo bem? '

  yield 'Vou ajudar com sua dúvida.'

}

No React, consumir o stream:

for await (const chunk of chatbotStream(input, mensagens, produtos)) {

  textoCompleto += chunk

  setStreamingText(textoCompleto)  // re-render a cada chunk

}

Cada yield causa um re-render que atualiza o texto na tela. O resultado visual: texto que “digita” em tempo real.

TODO 1: Busca Semântica

async function buscaSemantica(query: string): Promise<ProdutoInfo[]> {

  // Implementação com embeddings reais (Firebase AI / OpenAI):

  // const emb = await model.embedContent(query)

  // const resultados = await firestore.collection('produtos')

  //   .where('embedding', nearestNeighbor(emb)).limit(3).get()

  

  // Fallback por keyword (suficiente para o lab):

  const queryLower = query.toLowerCase()

  const termos = queryLower.split(' ').filter(t => t.length > 3)

  

  const scored = PRODUTOS.map(p => {

    const texto = `${p.nome} ${p.descricao}`.toLowerCase()

    const score = termos.filter(t => texto.includes(t)).length

    return { produto: p, score }

  }).filter(({ score }) => score > 0)

  

  scored.sort((a, b) => b.score - a.score)

  return scored.slice(0, 3).map(({ produto }) => produto)

}

TODO 2: Chatbot com Streaming

async function* chatbotStream(

  mensagem: string,

  historico: Mensagem[],

  contextoProdutos: ProdutoInfo[],

): AsyncGenerator<string> {

  // 1. Montar contexto de produtos

  const contextoProdutosTexto = contextoProdutos.length > 0

    ? `PRODUTOS RELEVANTES:\n${contextoProdutos.map(p => 

        `- ${p.nome} (R$${p.preco}/mês): ${p.descricao}`

      ).join('\n')}`

    : 'Nenhum produto específico encontrado para esta query.'

  

  // 2. System prompt com contexto

  const systemPrompt = `Você é um assistente de suporte ao cliente.

Responda de forma clara e amigável em português.

Use os produtos abaixo para fundamentar suas respostas:



${contextoProdutosTexto}



Se a pergunta não for sobre os produtos, responda de forma geral mas útil.`

  

  // 3. Converter histórico para formato do LLM

  const mensagensFormatadas = historico.map(m => ({

    role: m.role as 'user' | 'assistant',

    content: m.content,

  }))

  mensagensFormatadas.push({ role: 'user', content: mensagem })

  

  // 4. Stream com Anthropic SDK (Firebase AI tem API similar)

  const stream = client.messages.stream({

    model: 'claude-haiku-4-5-20251001',

    max_tokens: 500,

    system: systemPrompt,

    messages: mensagensFormatadas,

  })

  

  // 5. Yield cada chunk de texto

  for await (const event of stream) {

    if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {

      yield event.delta.text

    }

  }

}

Aprofundamento Técnico

Firebase AI Logic Real

Para substituir o mock por Firebase AI real:

import { initializeApp } from 'firebase/app'

import { getAI, getGenerativeModel } from 'firebase/ai'



const app = initializeApp({

  apiKey: process.env.REACT_APP_FIREBASE_API_KEY,

  projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,

  // ...

})



const ai = getAI(app)

const model = getGenerativeModel(ai, { model: 'gemini-2.0-flash' })



async function* chatbotStreamFirebase(

  mensagem: string,

  historico: Mensagem[],

  contextoProdutos: ProdutoInfo[],

): AsyncGenerator<string> {

  const prompt = montarPrompt(mensagem, historico, contextoProdutos)

  const stream = await model.generateContentStream(prompt)

  

  for await (const chunk of stream.stream) {

    const texto = chunk.text()

    if (texto) yield texto

  }

}

A estrutura de AsyncGenerator é idêntica — só o provider muda.

aria-live para Acessibilidade do Streaming

{streamingText && (

  <div

    role="status"           // anuncia ao leitor de tela que é uma região de status

    aria-live="polite"      // anuncia quando o texto mudar sem interromper

    aria-atomic={false}     // false = anuncia incrementalmente (não re-lê tudo)

  >

    {streamingText}<span aria-hidden>▌</span>  {/* cursor visual apenas */}

  </div>

)}

Sem aria-live, usuários de leitor de tela não ouvem o texto sendo digitado.

Exemplos Anotados

Exemplo 1: Todos os TODOs Implementados

import Anthropic from '@anthropic-ai/sdk'



const client = new Anthropic()



// TODO 1: busca semântica com score de relevância

async function buscaSemantica(query: string): Promise<ProdutoInfo[]> {

  const queryLower = query.toLowerCase()

  const termos = queryLower.split(/\s+/).filter(t => t.length > 2)

  

  return PRODUTOS

    .map(p => {

      const texto = `${p.nome} ${p.descricao}`.toLowerCase()

      const score = termos.reduce((acc, t) => acc + (texto.includes(t) ? 1 : 0), 0)

      return { produto: p, score }

    })

    .filter(({ score }) => score > 0)

    .sort((a, b) => b.score - a.score)

    .slice(0, 3)

    .map(({ produto }) => produto)

}



// TODO 2: chatbot com streaming usando Anthropic SDK

async function* chatbotStream(

  mensagem: string,

  historico: Mensagem[],

  contextoProdutos: ProdutoInfo[],

): AsyncGenerator<string> {

  const contextoProdutosTexto = contextoProdutos.length > 0

    ? 'PRODUTOS DISPONÍVEIS:\n' + contextoProdutos.map(p => 

        `• ${p.nome} (R$${p.preco}/mês): ${p.descricao}`

      ).join('\n')

    : 'Não foram encontrados produtos específicos para esta consulta.'

  

  const system = `Você é um assistente de suporte ao cliente prestativo e amigável.

Responda sempre em português do Brasil, de forma clara e concisa.



${contextoProdutosTexto}`

  

  const messages = [

    ...historico.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),

    { role: 'user' as const, content: mensagem },

  ]

  

  const stream = client.messages.stream({

    model: 'claude-haiku-4-5-20251001',

    max_tokens: 500,

    system,

    messages,

  })

  

  for await (const event of stream) {

    if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {

      yield event.delta.text

    }

  }

}

Padrões e Armadilhas

Padrões

Padrão 1: Mock antes de integração real O starter usa mockAI — permite desenvolver e testar o componente sem credenciais Firebase. Substitua por API real apenas quando a UI estiver funcionando.

Padrão 2: System prompt com contexto dinâmico

const system = `Dados relevantes: ${JSON.stringify(contextoProdutos, null, 2)}`

O contexto de produtos muda por query — o system prompt é montado dinamicamente.

Padrão 3: setStreamingText('') após finalizar

setStreamingText('');  // limpa o indicador de streaming

setCarregando(false);

Sem isso, o cursor ▌ fica visível após a resposta completa.

Armadilhas

⚠️ Armadilha 1: for await sem tratamento de erro

try {

  for await (const chunk of chatbotStream(...)) {

    textoCompleto += chunk

    setStreamingText(textoCompleto)

  }

} catch (err) {

  setStreamingText('Erro ao carregar resposta. Tente novamente.')

} finally {

  setCarregando(false)

}

⚠️ Armadilha 2: Histórico muito longo = contexto explode Limite o histórico enviado para o LLM:

const historicoRecente = historico.slice(-10)  // últimas 10 mensagens

⚠️ Armadilha 3: client instanciado dentro do componente React

// ERRADO: nova instância a cada render

export default function Chat() {

  const client = new Anthropic()  // ← re-instancia a cada render!

}



// CORRETO: fora do componente (ou useMemo se depender de variável)

const client = new Anthropic()

export default function Chat() { ... }
⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

2 TODOs no starter:

TODO 1 — buscaSemantica(query):

const queryLower = query.toLowerCase()

return PRODUTOS.filter(p => 

  p.nome.toLowerCase().includes(queryLower) || 

  p.descricao.toLowerCase().includes(queryLower)

)

(versão mínima — expanda para scoring de relevância se quiser)

TODO 2 — chatbotStream(...): Use o Anthropic SDK (já disponível no projeto) com client.messages.stream(). O Exemplo 1 desta aula tem a implementação completa.

Teste com perguntas como “Qual plano tem mais storage?” e verifique que a busca semântica retorna o produto relevante e o chatbot responde usando esse contexto.

Agora você está pronto para o lab.