20 KiB
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:
- Após upload + análise estática de um binário, embeddings são gerados automaticamente
- O usuário cria uma sessão de chat vinculada ao binário
- Mensagens são enviadas e a resposta da LLM é recebida via Server-Sent Events (SSE)
- 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):
{
"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):
[
{
"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):
[
{
"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:
{
"content": "Quais funções nesse binário lidam com criptografia?"
}
| Campo | Tipo | Validação |
|---|---|---|
content |
String | 1–4000 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 (
doneouerror) - 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.contentpara 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
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)
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
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:
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
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:
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).