Compare commits
2 Commits
e059442d53
...
979f804623
| Author | SHA1 | Date | |
|---|---|---|---|
| 979f804623 | |||
| 0464501dd4 |
@ -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 | 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 (`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
368
package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-select": "^2.3.0",
|
"@radix-ui/react-select": "^2.3.0",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"axios": "^1.17.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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-select": "^2.3.0",
|
"@radix-ui/react-select": "^2.3.0",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.17.0",
|
||||||
|
|||||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
@ -3,6 +3,8 @@ import { formatDistanceToNow } from "date-fns";
|
|||||||
import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
|
import { Send, Bot, User, Loader2, Sparkles, Plus, Trash2 } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
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 { useAIChat } from "@/features/binary/hooks/useAIChat";
|
||||||
import { analyzingMessages } from "@/features/binary/analyzingMessages";
|
import { analyzingMessages } from "@/features/binary/analyzingMessages";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -22,6 +24,22 @@ const quickPrompts = [
|
|||||||
|
|
||||||
const remarkPlugins = [remarkGfm];
|
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 =
|
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";
|
"[&_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 (
|
return (
|
||||||
<pre className="bg-muted-foreground/10 max-w-full overflow-x-auto rounded-lg p-3 text-xs">
|
<div
|
||||||
<code className="break-all whitespace-pre-wrap">{codeString}</code>
|
className="max-w-full rounded-lg"
|
||||||
</pre>
|
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) {
|
function normalizeMarkdown(content: string) {
|
||||||
|
|||||||
@ -10,12 +10,15 @@ interface CfgGraphProps {
|
|||||||
onHighlightRange: (start: string, end: string) => void;
|
onHighlightRange: (start: string, end: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NODE_W = 220;
|
const MIN_NODE_W = 180;
|
||||||
const HEADER_H = 18;
|
const HEADER_H = 18;
|
||||||
const LINE_H = 13;
|
const LINE_H = 14;
|
||||||
const FOOTER_H = 11;
|
const FOOTER_H = 11;
|
||||||
const PAD_V = 12;
|
const PAD_V = 12;
|
||||||
const GRAPH_PAD = 32;
|
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 }> = {
|
const BLOCK_DEFS: Record<number, { stroke: string; fill: string; label: string }> = {
|
||||||
0: { stroke: "#2563eb", fill: "#bfdbfe", label: "Normal" },
|
0: { stroke: "#2563eb", fill: "#bfdbfe", label: "Normal" },
|
||||||
@ -79,8 +82,15 @@ export function CfgGraph({ blocks, assembly, onHighlightRange }: CfgGraphProps)
|
|||||||
|
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const lines = blockLines.get(b.id ?? -1) ?? [];
|
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;
|
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 b of blocks) {
|
||||||
for (const t of b.succs ?? []) {
|
for (const t of b.succs ?? []) {
|
||||||
|
|||||||
@ -6,6 +6,11 @@ import type { FunctionSummaryResponse, SegmentResponse } from "@/api/generated/a
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface FunctionTreeProps {
|
interface FunctionTreeProps {
|
||||||
functions: FunctionSummaryResponse[];
|
functions: FunctionSummaryResponse[];
|
||||||
@ -157,8 +162,8 @@ export function FunctionTree({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1 min-w-0">
|
||||||
<div className="p-2">
|
<div className="p-2 overflow-x-hidden">
|
||||||
{nameFiltered.map((func) => {
|
{nameFiltered.map((func) => {
|
||||||
const funcFlags = parseFlags(func.flags);
|
const funcFlags = parseFlags(func.flags);
|
||||||
const segName = func.address ? segmentByAddress[func.address.toLowerCase()] : undefined;
|
const segName = func.address ? segmentByAddress[func.address.toLowerCase()] : undefined;
|
||||||
@ -167,27 +172,42 @@ export function FunctionTree({
|
|||||||
key={func.id}
|
key={func.id}
|
||||||
onClick={() => onSelectFunction(func)}
|
onClick={() => onSelectFunction(func)}
|
||||||
className={cn(
|
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
|
selectedFunctionId === func.id
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FunctionSquare className="h-4 w-4 shrink-0" />
|
<FunctionSquare className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
{func.resolvedName ? (
|
{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">
|
<span className="block truncate font-mono text-xs font-semibold">
|
||||||
{func.resolvedName}
|
{func.resolvedName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground block truncate font-mono text-[10px]">
|
<span className="text-muted-foreground block truncate font-mono text-[10px]">
|
||||||
{func.name}
|
{func.name}
|
||||||
</span>
|
</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 && (
|
{funcFlags.length > 0 && (
|
||||||
<span className="flex shrink-0 gap-0.5">
|
<span className="flex shrink-0 gap-0.5">
|
||||||
{funcFlags.map((f) => (
|
{funcFlags.map((f) => (
|
||||||
@ -203,27 +223,22 @@ export function FunctionTree({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Segment badge */}
|
|
||||||
{segName && (
|
{segName && (
|
||||||
<span className="text-muted-foreground/50 shrink-0 font-mono text-[10px]">
|
<span className="text-muted-foreground/50 shrink-0 font-mono text-[10px]">
|
||||||
{segName}
|
{segName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Size */}
|
|
||||||
{func.size != null && (
|
{func.size != null && (
|
||||||
<span className="text-muted-foreground/60 w-10 shrink-0 text-right font-mono text-[10px]">
|
<span className="text-muted-foreground/60 w-10 shrink-0 text-right font-mono text-[10px]">
|
||||||
{formatSize(func.size)}
|
{formatSize(func.size)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
{func.address && (
|
{func.address && (
|
||||||
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">
|
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">
|
||||||
{func.address}
|
{func.address}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -9,8 +10,10 @@ interface ProvidersProps {
|
|||||||
export const Providers = ({ children }: ProvidersProps) => {
|
export const Providers = ({ children }: ProvidersProps) => {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user