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 recebidoPadrã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 backendSe 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.