Compare commits

...

2 Commits

8 changed files with 517 additions and 734 deletions

View File

@ -1,671 +0,0 @@
# 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).

368
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.101.0",
"axios": "^1.17.0",
@ -2801,6 +2802,373 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.9.tgz",
"integrity": "sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.12",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.0",
"@radix-ui/react-portal": "1.1.11",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.5",
"@radix-ui/react-slot": "1.2.5",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-visually-hidden": "1.2.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz",
"integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz",
"integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.5",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-escape-keydown": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz",
"integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.9",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.5",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz",
"integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.5",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz",
"integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@ -23,6 +23,7 @@
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.101.0",
"axios": "^1.17.0",

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -3,6 +3,8 @@ import { formatDistanceToNow } from "date-fns";
import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useAIChat } from "@/features/binary/hooks/useAIChat";
import { analyzingMessages } from "@/features/binary/analyzingMessages";
import { cn } from "@/lib/utils";
@ -22,6 +24,22 @@ const quickPrompts = [
const remarkPlugins = [remarkGfm];
const codeBlockTheme = {
...oneDark,
'pre[class*="language-"]': {
...oneDark['pre[class*="language-"]'],
background: "transparent",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
},
'code[class*="language-"]': {
...oneDark['code[class*="language-"]'],
background: "transparent",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
},
};
const markdownContainerClass =
"[&_th]:border-border [&_td]:border-border max-w-none break-words [&_ol]:my-2 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_pre]:my-2 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_ul]:my-2";
@ -46,14 +64,25 @@ const markdownComponents: ComponentPropsWithoutRef<typeof ReactMarkdown>["compon
}
return (
<pre className="bg-muted-foreground/10 max-w-full overflow-x-auto rounded-lg p-3 text-xs">
<code className="break-all whitespace-pre-wrap">{codeString}</code>
</pre>
<div
className="max-w-full rounded-lg"
style={{ background: "oklch(0.1 0.01 260)" }}
>
<SyntaxHighlighter
language={match[1]}
style={codeBlockTheme}
PreTag="div"
customStyle={{
margin: 0,
fontSize: "0.75rem",
lineHeight: "1.5",
}}
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
pre({ children }) {
return <pre className="my-2 overflow-x-auto rounded-lg">{children}</pre>;
},
};
function normalizeMarkdown(content: string) {

View File

@ -10,12 +10,15 @@ interface CfgGraphProps {
onHighlightRange: (start: string, end: string) => void;
}
const NODE_W = 220;
const MIN_NODE_W = 180;
const HEADER_H = 18;
const LINE_H = 13;
const LINE_H = 14;
const FOOTER_H = 11;
const PAD_V = 12;
const GRAPH_PAD = 32;
const CHAR_W_10 = 6.0;
const CHAR_W_11 = 6.6;
const H_PAD = 20;
const BLOCK_DEFS: Record<number, { stroke: string; fill: string; label: string }> = {
0: { stroke: "#2563eb", fill: "#bfdbfe", label: "Normal" },
@ -79,8 +82,15 @@ export function CfgGraph({ blocks, assembly, onHighlightRange }: CfgGraphProps)
for (const b of blocks) {
const lines = blockLines.get(b.id ?? -1) ?? [];
const maxLineLen = lines.reduce((max, l) => Math.max(max, l.text.length), 0);
const addrStr = `${b.start ?? ""}-${b.end ?? ""}`;
const w = Math.max(
MIN_NODE_W,
maxLineLen * CHAR_W_10 + H_PAD,
addrStr.length * CHAR_W_11 + H_PAD
);
const h = HEADER_H + lines.length * LINE_H + FOOTER_H + PAD_V;
g.setNode(String(b.id), { width: NODE_W, height: h });
g.setNode(String(b.id), { width: w, height: h });
}
for (const b of blocks) {
for (const t of b.succs ?? []) {

View File

@ -6,6 +6,11 @@ import type { FunctionSummaryResponse, SegmentResponse } from "@/api/generated/a
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface FunctionTreeProps {
functions: FunctionSummaryResponse[];
@ -157,8 +162,8 @@ export function FunctionTree({
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2">
<ScrollArea className="min-h-0 flex-1 min-w-0">
<div className="p-2 overflow-x-hidden">
{nameFiltered.map((func) => {
const funcFlags = parseFlags(func.flags);
const segName = func.address ? segmentByAddress[func.address.toLowerCase()] : undefined;
@ -167,27 +172,42 @@ export function FunctionTree({
key={func.id}
onClick={() => onSelectFunction(func)}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors",
"grid min-w-0 w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors",
"grid-cols-[auto_1fr]",
selectedFunctionId === func.id
? "bg-primary/10 text-primary"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
)}
>
<FunctionSquare className="h-4 w-4 shrink-0" />
<span className="flex min-w-0 items-center gap-1">
{func.resolvedName ? (
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 overflow-hidden">
<span className="block truncate font-mono text-xs font-semibold">
{func.resolvedName}
</span>
<span className="text-muted-foreground block truncate font-mono text-[10px]">
{func.name}
</span>
</div>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-none overflow-visible font-mono text-xs">
<p>{func.resolvedName}</p>
<p className="text-muted-foreground">{func.name}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="flex-1 truncate font-mono text-xs">{func.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{func.name}</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-none overflow-visible font-mono text-xs">
<p>{func.name}</p>
</TooltipContent>
</Tooltip>
)}
{/* Flags in list */}
{funcFlags.length > 0 && (
<span className="flex shrink-0 gap-0.5">
{funcFlags.map((f) => (
@ -203,27 +223,22 @@ export function FunctionTree({
))}
</span>
)}
{/* Segment badge */}
{segName && (
<span className="text-muted-foreground/50 shrink-0 font-mono text-[10px]">
{segName}
</span>
)}
{/* Size */}
{func.size != null && (
<span className="text-muted-foreground/60 w-10 shrink-0 text-right font-mono text-[10px]">
{formatSize(func.size)}
</span>
)}
{/* Address */}
{func.address && (
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">
{func.address}
</span>
)}
</span>
</button>
);
})}

View File

@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
interface ProvidersProps {
children: ReactNode;
@ -9,8 +10,10 @@ interface ProvidersProps {
export const Providers = ({ children }: ProvidersProps) => {
return (
<QueryClientProvider client={new QueryClient()}>
<TooltipProvider delayDuration={1000}>
{children}
<Toaster position="bottom-right" />
</TooltipProvider>
</QueryClientProvider>
);
};