mozak.tech Engenharia de IA Corporativa 97%

Parte IV — Projeto Integrador: Do Conceito ao Produto Entregue

4.4 — Interface e Implantação: Streaming no Frontend e Entrega Contínua (Deploy)

Objetivo da Aula

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

Implementar componente Angular de chat com streaming via SSE

Gerenciar estado de sessão e histórico no frontend (signals ou service)

Configurar CI/CD com GitHub Actions para deploy em Netlify + Railway

Tratar erros específicos de APIs de LLM (rate limit, timeout, context_length)

Implementar UX de “modelo pensando” com indicadores de loading adequados

Por que isso importa

A diferença entre um demo de laboratório e um produto usável está em dois detalhes: streaming real (não “aguardando…”) e tratamento de erros que o usuário entende. Uma mensagem “Erro 429: rate_limit_exceeded” não é UX aceitável — “Muitas mensagens no momento, tente em 30 segundos” é. Streaming muda a percepção de velocidade do produto inteiro.

Conceitos Fundamentais

Componente Angular de Chat com SSE

// chat.service.ts

@Injectable({ providedIn: 'root' })

export class ChatService {

    private baseUrl = environment.apiUrl

    

    // Signal para estado do chat:

    mensagens = signal<Mensagem[]>([])

    carregando = signal(false)

    

    async enviarMensagem(sessaoId: string, conteudo: string): Promise<void> {

        // Adicionar mensagem do usuário imediatamente (UX responsiva):

        this.mensagens.update(msgs => [...msgs, {

            role: 'user', conteudo, timestamp: new Date()

        }])

        

        this.carregando.set(true)

        

        // Placeholder para resposta em streaming:

        const placeholderIdx = this.mensagens().length

        this.mensagens.update(msgs => [...msgs, {

            role: 'assistant', conteudo: '', timestamp: new Date(), carregando: true

        }])

        

        // Conectar ao SSE:

        const eventSource = new EventSource(

            `${this.baseUrl}/api/sessoes/${sessaoId}/mensagens`,

            // EventSource não suporta POST diretamente → usar fetch com ReadableStream

        )

        

        // Alternativa com fetch (suporta POST + streaming):

        const response = await fetch(`${this.baseUrl}/api/sessoes/${sessaoId}/mensagens`, {

            method: 'POST',

            headers: { 'Content-Type': 'application/json' },

            body: JSON.stringify({ conteudo, streaming: true }),

        })

        

        const reader = response.body!.getReader()

        const decoder = new TextDecoder()

        let respostaAcumulada = ''

        

        while (true) {

            const { done, value } = await reader.read()

            if (done) break

            

            const texto = decoder.decode(value)

            const linhas = texto.split('\n')

            

            for (const linha of linhas) {

                if (!linha.startsWith('data: ')) continue

                const dados = JSON.parse(linha.slice(6))

                

                if (dados.delta) {

                    respostaAcumulada += dados.delta

                    // Atualizar o placeholder em tempo real:

                    this.mensagens.update(msgs => {

                        const novas = [...msgs]

                        novas[placeholderIdx] = {

                            ...novas[placeholderIdx],

                            conteudo: respostaAcumulada,

                        }

                        return novas

                    })

                }

                

                if (dados.done) {

                    this.mensagens.update(msgs => {

                        const novas = [...msgs]

                        novas[placeholderIdx] = { ...novas[placeholderIdx], carregando: false }

                        return novas

                    })

                    this.carregando.set(false)

                }

            }

        }

    }

}

Template do Componente de Chat

<!-- chat.component.html -->

<div class="chat-container">

    <!-- Lista de mensagens: -->

    @for (msg of chatService.mensagens(); track msg.timestamp) {

        <div [class]="'mensagem ' + msg.role">

            <div class="conteudo" [innerHTML]="renderMarkdown(msg.conteudo)"></div>

            @if (msg.carregando) {

                <span class="cursor-piscante">|</span>

            }

        </div>

    }

    

    <!-- Input: -->

    <div class="input-area">

        <textarea

            [(ngModel)]="inputUsuario"

            (keydown.enter)="enviar($event)"

            [disabled]="chatService.carregando()"

            placeholder="Digite sua pergunta sobre os documentos...">

        </textarea>

        <button

            (click)="enviar()"

            [disabled]="chatService.carregando() || !inputUsuario.trim()">

            {{ chatService.carregando() ? 'Processando...' : 'Enviar' }}

        </button>

    </div>

</div>

Aprofundamento Técnico

CI/CD com GitHub Actions

# .github/workflows/deploy.yml

name: Deploy



on:

  push:

    branches: [main]



jobs:

  test:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4

        with: { node-version: '20' }

      - run: npm ci

      - run: npm test -- --watch=false --browsers=ChromeHeadless



  deploy-frontend:

    needs: test

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - run: npm ci && npm run build

      - uses: netlify/actions/deploy@master

        with:

          publish-dir: dist/assai-docs

          production-deploy: true

        env:

          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}

          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}



  deploy-backend:

    needs: test

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - run: |

          # Railway deploy via CLI:

          npm install -g @railway/cli

          railway up --service backend

        env:

          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}

Tratamento de Erros de LLM

// Erros específicos de APIs de LLM:

interface LLMError {

    type: 'rate_limit' | 'context_length' | 'api_error' | 'network'

    mensagem: string     // mensagem para o usuário

    retentarEm?: number  // segundos para tentar novamente

}



function interpretarErro(error: any): LLMError {

    if (error.status === 429) {

        return {

            type: 'rate_limit',

            mensagem: 'Muitas mensagens em pouco tempo. Aguarde 30 segundos.',

            retentarEm: 30,

        }

    }

    if (error.message?.includes('context_length')) {

        return {

            type: 'context_length',

            mensagem: 'Histórico de conversa muito longo. Iniciando nova sessão.',

        }

    }

    return {

        type: 'api_error',

        mensagem: 'Erro temporário. Tente novamente em instantes.',

    }

}



// No serviço:

} catch (error) {

    const llmError = interpretarErro(error)

    this.erroAtual.set(llmError.mensagem)

    this.carregando.set(false)

    if (llmError.retentarEm) {

        // Mostrar contador regressivo na UI

        setTimeout(() => this.erroAtual.set(null), llmError.retentarEm * 1000)

    }

}

Exemplos Anotados

Exemplo 1: UX de Streaming

Sem streaming:

  Usuário digita pergunta → botão disabled → spinner 8s → resposta aparece toda de uma vez

  Percepção: "lento, não sei se funcionou"



Com streaming:

  Usuário digita → botão disabled → cursor piscante aparece imediatamente →

  tokens chegam um a um (legível em ~2s) → resposta completa em 8s

  Percepção: "rápido, está funcionando"



A velocidade real é a mesma. A percepção muda completamente.

Padrões e Armadilhas

Padrões

Padrão 1: Mostrar mensagem do usuário ANTES da resposta do LLM

// Adicionar mensagem do usuário PRIMEIRO no array (não esperar a resposta)

// O usuário vê seu próprio input imediatamente → feedback de que foi recebido

Padrão 2: Desabilitar input durante streaming, não esconder

<!-- Desabilitar (não remover) → usuário pode ver o que está chegando -->

<textarea [disabled]="carregando()"></textarea>

Armadilhas

⚠️ Armadilha 1: fetch() e CORS em desenvolvimento

Angular dev server: localhost:4200

Backend dev: localhost:3000

→ CORS bloqueado sem configuração no backend



Solução: proxy Angular:

// proxy.conf.json:

{ "/api": { "target": "http://localhost:3000", "changeOrigin": true } }

⚠️ Armadilha 2: Variáveis de ambiente no Angular

environment.ts → incluído no bundle do frontend → NUNCA colocar chaves de API aqui

Chaves de API ficam APENAS no backend (variáveis de ambiente do Railway)

Frontend só sabe a URL do backend
⚗ 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

Esta unidade guia a Entrega 3 (Frontend + Deploy). Use exercicios.md e o README em entrega-3-frontend/ para: 1. Implementar componente de chat com streaming real 2. Configurar GitHub Actions com deploy em Netlify + Railway 3. Testar com usuário externo (sem instruções técnicas) → golden path do produto

Agora você está pronto para o lab.