frontend/frontend-guide.md

672 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Guia de Implementação Frontend — Chat IA (RAG)
## Visão Geral
Este documento descreve como integrar o frontend com a API de chat IA do Decompile-AI. O fluxo consiste em:
1. Após upload + análise estática de um binário, embeddings são gerados automaticamente
2. O usuário cria uma sessão de chat vinculada ao binário
3. Mensagens são enviadas e a resposta da LLM é recebida via **Server-Sent Events (SSE)**
4. O backend faz RAG automaticamente: busca contexto relevante no binário e injeta no prompt
## Stack Recomendada (Frontend)
| Tecnologia | Propósito |
|---|---|
| **EventSource** ou **fetch + ReadableStream** | Consumir SSE stream |
| **React** (ou framework similar) | UI components |
| **AbortController** | Cancelar stream em andamento |
| **@microsoft/fetch-event-source** | Lib opcional para SSE com POST + cancelamento |
---
## 1. Endpoints da API
### 1.1 Criar Sessão de Chat
```
POST /binaries/{binaryId}/chat/sessions?projectId={projectId}&model={model}
```
**Query Params**:
| Parâmetro | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `projectId` | UUID | SIM | ID do projeto pai |
| `model` | String | Não | Modelo LLM (`gemma4:12b` padrão) |
**Response** (`201 Created`):
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"binaryId": "660e8400-e29b-41d4-a716-446655440001",
"projectId": "770e8400-e29b-41d4-a716-446655440002",
"title": "malware.exe — Chat",
"chatModel": "gemma4:12b",
"messageCount": 0,
"createdAt": "2026-06-08T15:30:00Z",
"updatedAt": "2026-06-08T15:30:00Z"
}
```
**Erros**:
| Código | Significado |
|---|---|
| `404` | Binário/projeto não encontrado |
| `400` | Modelo indisponível (Ollama não está rodando) |
| `400` | `projectId` ausente |
### 1.2 Listar Sessões do Binário
```
GET /binaries/{binaryId}/chat/sessions
```
**Response** (`200 OK`):
```json
[
{
"id": "550e8400-...",
"binaryId": "660e8400-...",
"projectId": "770e8400-...",
"title": "malware.exe — Chat",
"chatModel": "gemma4:12b",
"messageCount": 5,
"createdAt": "2026-06-08T15:30:00Z",
"updatedAt": "2026-06-08T16:00:00Z"
}
]
```
Ordenado por `updatedAt` decrescente (sessão mais recente primeiro).
### 1.3 Ver Detalhes da Sessão
```
GET /chat/sessions/{sessionId}
```
Mesmo formato de resposta que o item 1.1.
### 1.4 Deletar Sessão
```
DELETE /chat/sessions/{sessionId}
```
**Response**: `204 No Content`.
> Deletar a sessão remove **todas** as mensagens associadas (cascade).
### 1.5 Histórico de Mensagens
```
GET /chat/sessions/{sessionId}/messages
```
**Response** (`200 OK`):
```json
[
{
"id": "aa0e8400-...",
"sessionId": "550e8400-...",
"role": "USER",
"content": "Quais funções lidam com rede?",
"tokenCount": 12,
"createdAt": "2026-06-08T15:30:30Z"
},
{
"id": "bb0e8400-...",
"sessionId": "550e8400-...",
"role": "ASSISTANT",
"content": "O binário importa funções de rede da WS2_32.dll...",
"tokenCount": 85,
"createdAt": "2026-06-08T15:30:35Z"
}
]
```
Ordenado por `createdAt` crescente (mais antigo primeiro).
### 1.6 Enviar Mensagem (SSE Streaming)
```
POST /chat/sessions/{sessionId}/messages
Content-Type: application/json
Accept: text/event-stream
```
**Request Body**:
```json
{
"content": "Quais funções nesse binário lidam com criptografia?"
}
```
| Campo | Tipo | Validação |
|---|---|---|
| `content` | String | 14000 caracteres, obrigatório |
**Response**: `200 OK` com `Content-Type: text/event-stream`
---
## 2. Protocolo SSE (Server-Sent Events)
O stream é uma sequência de eventos `data:` enviados ao longo da conexão HTTP.
### 2.1 Tipos de Evento
| `type` | Quando | Campos |
|---|---|---|
| `chunk` | Token de texto gerado pela LLM | `content: String` |
| `done` | Stream concluído com sucesso | `sessionId: UUID` |
| `error` | Erro durante o processamento | `message: String` |
### 2.2 Exemplo de Stream
```
data: {"type":"chunk","content":"O binário"}
data: {"type":"chunk","content":" utiliza "}
data: {"type":"chunk","content":"as seguintes"}
data: {"type":"chunk","content":" funções"}
data: {"type":"chunk","content":" para criptografia:"}
data: {"type":"chunk","content":"\n\n1. **sub_405000**"}
...
data: {"type":"done","sessionId":"550e8400-e29b-..."}
```
### 2.3 Evento de Erro
```
data: {"type":"error","message":"Ollama is not available. Install ollama and pull gemma4:12b."}
```
Após um evento `error`, a conexão é fechada. Nenhum evento `done` é enviado.
### 2.4 Notas sobre SSE
- A conexão é mantida aberta até o stream terminar (`done` ou `error`)
- Tokens podem chegar com latência variável (a LLM gera em tempo real)
- Cada evento `data:` contém uma linha JSON válida
- O frontend deve acumular os `chunk.content` para montar a resposta completa
- O backend **já salva** a resposta completa no banco ao final — o frontend não precisa reenviar
---
## 3. Fluxo de Implementação
### 3.1 Diagrama de Sequência
```
Frontend Backend Ollama
│ │ │
│ POST /chat/sessions (criar) │ │
├────────────────────────────────►│ │
│◄────────────────────────────────┤ sessionId │
│ │ │
│ POST /chat/sessions/{id}/messages (SSE) │
├────────────────────────────────►│ │
│ │ embed query │
│ ├──────────────────────────────►│
│ │◄──────────────────────────────┤
│ │ │
│ │ pgvector search (RAG) │
│ │ build system prompt │
│ │ │
│ │ stream LLM │
│ ├──────────────────────────────►│
│◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤
│◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤
│◄──── data: {"type":"chunk"...}──┤◄──────────────────────────────┤
│◄──── data: {"type":"done"...}───┤ │
│ │ │
│ │ save assistant message │
│ │ │
│ GET /chat/sessions/{id}/messages (refresh) │
├────────────────────────────────►│ │
│◄────────────────────────────────┤ full history │
```
### 3.2 Estrutura de Componentes (React)
```
BinaryDetailPage
├── ChatPanel ← componente principal do chat
│ ├── ChatSessionList ← barra lateral: lista de sessões
│ │ ├── ChatSessionItem ← cada sessão (título, modelo, msg count)
│ │ └── NewSessionButton ← botão "Nova conversa"
│ │
│ └── ChatWindow ← área principal de conversa
│ ├── ChatMessageList ← scroll area com mensagens
│ │ ├── UserMessage ← bolha do usuário
│ │ └── AssistantMessage ← bolha do assistente (com markdown)
│ │
│ ├── StreamingMessage ← mensagem sendo gerada (cursor piscando)
│ └── ChatInput ← textarea + botão enviar
```
### 3.3 Exemplo de Hook: `useChatSession`
```typescript
interface ChatSession {
id: string;
binaryId: string;
projectId: string;
title: string;
chatModel: string;
messageCount: number;
createdAt: string;
updatedAt: string;
}
interface ChatMessage {
id: string;
sessionId: string;
role: 'USER' | 'ASSISTANT';
content: string;
tokenCount: number;
createdAt: string;
}
function useChatSession(binaryId: string, projectId: string) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
// Listar sessões
const loadSessions = async () => {
const res = await fetch(`/binaries/${binaryId}/chat/sessions`);
const data = await res.json();
setSessions(data);
};
// Criar nova sessão
const createSession = async (model: string = 'gemma4:12b') => {
const res = await fetch(
`/binaries/${binaryId}/chat/sessions?projectId=${projectId}&model=${model}`,
{ method: 'POST' }
);
const session = await res.json();
setSessions(prev => [session, ...prev]);
setActiveSessionId(session.id);
setMessages([]);
return session;
};
// Carregar histórico
const loadMessages = async (sessionId: string) => {
const res = await fetch(`/chat/sessions/${sessionId}/messages`);
const data = await res.json();
setMessages(data);
};
// Enviar mensagem (SSE streaming)
const sendMessage = async (content: string) => {
if (!activeSessionId) return;
// Adiciona mensagem do usuário ao estado local (otimista)
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
sessionId: activeSessionId,
role: 'USER',
content,
tokenCount: 0,
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
setStreamingContent('');
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(
`/chat/sessions/${activeSessionId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
body: JSON.stringify({ content }),
signal: controller.signal,
}
);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let fullContent = '';
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'chunk') {
fullContent += event.content;
setStreamingContent(fullContent);
} else if (event.type === 'done') {
setIsStreaming(false);
// Recarrega histórico real para pegar IDs do backend
await loadMessages(activeSessionId);
} else if (event.type === 'error') {
setIsStreaming(false);
throw new Error(event.message);
}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
setIsStreaming(false);
console.error('Chat error:', err);
}
}
};
// Cancelar stream
const cancelStream = () => {
abortRef.current?.abort();
setIsStreaming(false);
};
// Deletar sessão
const deleteSession = async (sessionId: string) => {
await fetch(`/chat/sessions/${sessionId}`, { method: 'DELETE' });
setSessions(prev => prev.filter(s => s.id !== sessionId));
if (activeSessionId === sessionId) {
setActiveSessionId(null);
setMessages([]);
}
};
// Selecionar sessão
const selectSession = async (sessionId: string) => {
setActiveSessionId(sessionId);
await loadMessages(sessionId);
};
return {
sessions,
activeSessionId,
messages,
streamingContent,
isStreaming,
loadSessions,
createSession,
selectSession,
sendMessage,
cancelStream,
deleteSession,
};
}
```
### 3.4 Componente de Chat (React)
```tsx
function ChatWindow({
messages,
streamingContent,
isStreaming,
onSend,
onCancel,
}: {
messages: ChatMessage[];
streamingContent: string;
isStreaming: boolean;
onSend: (content: string) => void;
onCancel: () => void;
}) {
const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
onSend(input.trim());
setInput('');
};
return (
<div className="chat-window">
<div className="messages">
{messages.map(msg => (
<MessageBubble key={msg.id} role={msg.role} content={msg.content} />
))}
{isStreaming && (
<MessageBubble
role="ASSISTANT"
content={streamingContent}
isStreaming
/>
)}
<div ref={scrollRef} />
</div>
<form onSubmit={handleSubmit} className="chat-input">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Pergunte sobre funções, protocolos, estruturas..."
disabled={isStreaming}
rows={2}
/>
{isStreaming ? (
<button type="button" onClick={onCancel}>Parar</button>
) : (
<button type="submit" disabled={!input.trim()}>Enviar</button>
)}
</form>
</div>
);
}
```
---
## 4. Tratamento de Erros
| Cenário | Código | Ação Recomendada |
|---|---|---|
| **Sessão não encontrada** | 404 | Redirecionar para lista de binários |
| **Binário não analisado** | — | Embeddings não gerados → exibir mensagem "Execute a análise estática primeiro" |
| **Ollama offline** | `error` SSE | Exibir "Ollama não está rodando. Inicie com `ollama serve`" |
| **Modelo não baixado** | `error` SSE | Exibir "Modelo não encontrado. Execute `ollama pull gemma4:12b`" |
| **Timeout do stream** | Conexão fechada | Mostrar mensagem parcial + botão "Tentar novamente" |
| **Conteúdo vazio** | 400 | Validar no frontend: mínimo 1 caractere |
| **Conteúdo > 4000 chars** | 400 | Limitar textarea a 4000 caracteres |
### Exemplo de Error Boundary
```typescript
function getErrorMessage(error: string): string {
if (error.includes('Ollama is not available')) {
return 'Servidor LLM local (Ollama) não está rodando. Execute `ollama serve` no terminal.';
}
if (error.includes('Install ollama and pull')) {
return `Modelo LLM não encontrado. Execute no terminal:\nollama pull gemma4:12b`;
}
return `Erro ao processar a resposta: ${error}`;
}
```
---
## 5. Considerações de UX
### 5.1 Estados da UI
| Estado | O que mostrar |
|---|---|
| **Binário sem análise** | "Execute a análise estática antes de usar o chat." |
| **Sem embeddings** | "Aguarde a geração dos embeddings..." (polling do job `GENERATE_EMBEDDINGS`) |
| **Nenhuma sessão** | Botão "Iniciar conversa" proeminente |
| **Streaming** | Bolha do assistente com cursor piscando (`▊`), botão "Parar" |
| **Stream concluído** | Mensagem completa renderizada com markdown |
| **Erro** | Banner de erro com ação sugerida |
### 5.2 Renderização de Markdown
A resposta da LLM frequentemente contém markdown (negrito, listas, blocos de código). Use uma biblioteca como `react-markdown` com syntax highlighting para assembly:
```tsx
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
function MessageBubble({ content, role, isStreaming }: Props) {
return (
<div className={`bubble ${role.toLowerCase()}`}>
<ReactMarkdown
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter language={match[1]} PreTag="div">
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>{children}</code>
);
},
}}
>
{content}
</ReactMarkdown>
{isStreaming && <span className="cursor"></span>}
</div>
);
}
```
### 5.3 Gerenciamento de Scroll
- Scroll automático para o final ao receber novos tokens
- Se o usuário scrollar para cima manualmente, **não** força scroll (respeita leitura)
- Retoma auto-scroll quando usuário scrolla de volta ao final
```typescript
function useAutoScroll(deps: any[]) {
const containerRef = useRef<HTMLDivElement>(null);
const [userScrolledUp, setUserScrolledUp] = useState(false);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleScroll = () => {
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setUserScrolledUp(!isAtBottom);
};
el.addEventListener('scroll', handleScroll);
return () => el.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
if (!userScrolledUp) {
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior: 'smooth' });
}
}, deps);
return containerRef;
}
```
### 5.4 Polling de Status dos Embeddings
Antes de habilitar o chat, verifique se os embeddings foram gerados:
```typescript
async function hasEmbeddings(binaryId: string): Promise<boolean> {
// Verifica se existem chunks para este binário
// Opção 1: endpoint dedicado (a ser adicionado)
// Opção 2: verificar jobs — se existe GENERATE_EMBEDDINGS COMPLETED
const res = await fetch(`/jobs?binaryId=${binaryId}&type=GENERATE_EMBEDDINGS&status=COMPLETED`);
const jobs = await res.json();
return jobs.length > 0;
}
```
---
## 6. Resumo de Integração
```
Preparação:
1. Upload binário → POST /projects/{id}/binaries?engine=IDA5
2. Aguardar análise → polling GET /jobs?binaryId={id}&type=STATIC_ANALYSIS
3. Aguardar embeddings → polling GET /jobs?binaryId={id}&type=GENERATE_EMBEDDINGS
Chat:
4. Criar sessão → POST /binaries/{id}/chat/sessions?projectId={pid}
5. Enviar mensagem → POST /chat/sessions/{sid}/messages (SSE stream)
6. Renderizar tokens → acumular data.type=chunk
7. Finalizar → data.type=done → recarregar histórico
8. Histórico → GET /chat/sessions/{sid}/messages (ao voltar à sessão)
Gerenciamento:
9. Listar sessões → GET /binaries/{id}/chat/sessions
10. Deletar sessão → DELETE /chat/sessions/{sid}
```
---
## 7. Endpoints de Suporte
Estes endpoints **já existem** e são úteis para polling de status:
### Jobs
```
GET /jobs?projectId={projectId}&type=GENERATE_EMBEDDINGS&status=COMPLETED
```
Retorna jobs do tipo `GENERATE_EMBEDDINGS` concluídos. Use para verificar se os embeddings estão prontos.
```
GET /jobs?binaryId={binaryId}&type=GENERATE_EMBEDDINGS
```
Status possíveis: `ENQUEUED`, `STARTED`, `IN_PROGRESS`, `COMPLETED`, `FAILED`.
### Funções (para referência no chat)
```
GET /analysis/{analysisId}/functions?page=0&size=50
GET /functions/{functionId}
GET /functions/{functionId}/callers
GET /functions/{functionId}/callees
```
O frontend pode usar esses endpoints para permitir que o usuário **clique em uma função mencionada na resposta** e veja seus detalhes (assembly, xrefs, labels).