chore: expurgo de blobs excedentes e realinhamento de estado
release.yml (push) Invalid workflow file

This commit is contained in:
Evandro Fonseca Junior committed 2026-02-22 14:06:01 -03:00
1 parent 593efd42e0
commit a7cbd1a3b4
114 files changed
+20716 -4258

No files matched your search

@@ -0,0 +1,569 @@
---
name: Integração llama.cpp nativo e download Hugging Face
overview: Implementar download direto de modelos GGUF do Hugging Face, integração com llama.cpp nativo via Rust, listagem de modelos locais e UI experimental para alternar entre Ollama e llama.cpp nativo.
todos:
- id: hf-download-module
content: Criar módulo huggingface_download.rs com download_gguf_model() e streaming de progresso
status: pending
- id: hf-list-models
content: Implementar list_huggingface_gguf_models() com cache de metadados (TTL 1h)
status: pending
- id: hf-validation
content: Adicionar validação SHA256 obrigatória quando disponível, retry logic com resume e rate limiting (429)
status: pending
- id: hf-rate-limiting
content: Implementar tratamento de rate limiting com Retry-After header e backoff exponencial
status: pending
- id: llama-cpp-deps
content: Adicionar llama_cpp 0.4 no Cargo.toml com features condicionais por OS + feature flag dev-no-llama
status: pending
- id: llama-deps-parsing
content: Adicionar dependências gguf 0.1 e lru 0.12 para parsing de metadados e cache LRU
status: pending
- id: llama-state
content: Criar LlamaCppState com LRU cache (limite 3 modelos) para prevenir memory leaks
status: pending
- id: models-dir-structure
content: Implementar get_models_dir() com estrutura hierárquica cross-platform (repo_owner/repo_name/quantization)
status: pending
- id: llama-chat-stream
content: Implementar chat_stream_native() com streaming de tokens via llama-cpp-rs
status: pending
- id: llama-params
content: Adicionar configuração de parâmetros (n_gpu_layers, n_ctx, threads) no settings store
status: pending
- id: local-scan
content: Implementar list_local_gguf_models() com scan recursivo e parsing de metadados via crate gguf
status: pending
- id: local-cache
content: Adicionar cache de metadados de modelos locais para evitar re-scan frequente
status: pending
- id: quantization-regex
content: "Implementar regex corrigido para extração de quantização: [._-](Q\\d+_?[KM]?_?[MS]?|[FI]P?\\d+)[._-]"
status: pending
- id: vram-detection
content: Adicionar detecção de VRAM disponível via sysinfo antes de carregar modelos grandes
status: pending
- id: concurrent-downloads
content: Implementar semáforo para limitar downloads paralelos a 2 simultâneos
status: pending
- id: ui-runtime-toggle
content: Criar toggle de runtime (Ollama/llama.cpp) nas configurações com indicador visual
status: pending
- id: ui-hf-selector
content: Implementar seletor de modelos Hugging Face com busca e filtros de quantização
status: pending
- id: ui-integration
content: Integrar runtime selection no use-chat.ts para alternar entre chat_stream e chat_stream_native
status: pending
isProject: false
---
# Plano de Implementação: Integração llama.cpp e Download Hugging Face
## Contexto Arquitetural
O projeto atualmente usa Ollama como runtime de modelos via HTTP (`http://localhost:11434`). O chat streaming é feito através de `chat_stream` que chama `OllamaClient`. Modelos são baixados via `pull_model` que usa a API do Ollama. Existe suporte básico para instalar modelos GGUF locais via `install_gguf_model`.
**Decisão arquitetural:** Implementar runtime dual (Ollama + llama.cpp nativo) com abstração no backend Rust para permitir alternância transparente no frontend.
## Estrutura de Armazenamento
- **Modelos Ollama:** `~/.ollama/models` (Linux/Mac) ou `%USERPROFILE%\.ollama\models` (Windows)
- **Modelos llama.cpp nativo:** Estrutura hierárquica em `{app_data_dir}/models/gguf/{repo_owner}/{repo_name}/{quantization}/model.gguf`
- Exemplo: `~/.local/share/OllaHub/models/gguf/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/Q4_K_M/mistral-7b-instruct-v0.2.Q4_K_M.gguf`
- **Cache de metadados:** `{app_data_dir}/cache/huggingface_models.json`
- **Cross-platform paths:**
- Windows: `%APPDATA%\Roaming\com.ollahub.app\models\gguf`
- Linux: `~/.local/share/com.ollahub.app/models/gguf`
- macOS: `~/Library/Application Support/com.ollahub.app/models/gguf`
## Fase 1: Backend - Download Direto Hugging Face
### 1.1 Módulo de Download (`src-tauri/src/huggingface_download.rs`)
**Contrato técnico:**
```rust
pub struct HuggingFaceModel {
pub id: String,
pub filename: String,
pub size: u64,
pub quantization: String, // Q4_K_M, Q5_K_S, etc.
pub sha256: Option<String>,
}
pub async fn download_gguf_model(
app_handle: AppHandle,
model_id: String,
filename: String,
quantization_filter: Option<String>,
window: Option<Window>,
) -> Result<PathBuf, String>
```
**Implementação:**
- Usar `reqwest` com streaming (já presente no `Cargo.toml`)
- API Hugging Face: `https://huggingface.co/api/models/{model_id}/tree/main`
- Download direto: `https://huggingface.co/{model_id}/resolve/main/{filename}`
- Emitir eventos `download-progress` (reutilizar estrutura existente de `pull_model_impl_window`)
- **Validação SHA256 obrigatória** quando disponível no Hugging Face (usar `sha2` já presente)
- Retry logic com backoff exponencial (3 tentativas)
- **Rate limiting:** Tratar 429 Too Many Requests com `Retry-After` header
- **Resume de download:** Verificar arquivo parcial e continuar de onde parou
**Arquivos:**
- Criar `src-tauri/src/huggingface_download.rs`
- Adicionar comando `download_gguf_model` em `lib.rs`
### 1.2 Busca de Modelos (`list_huggingface_gguf_models`)
**Contrato:**
```rust
pub async fn list_huggingface_gguf_models(
query: Option<String>,
quantization: Option<String>,
) -> Result<Vec<HuggingFaceModel>, String>
```
**Implementação:**
- Usar API pública do Hugging Face: `https://huggingface.co/api/models?search={query}&filter=gguf`
- Filtrar por arquivos `.gguf` no tree
- Cache local com TTL de 1 hora (evitar requests repetidos)
- **Extrair quantização do filename** (regex corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`)
- Suporta: `Q4_K_M`, `Q5_0`, `F16`, `FP32`, etc.
**Arquivos:**
- Adicionar função em `huggingface_download.rs`
- Comando `list_huggingface_gguf_models` em `lib.rs`
### 1.3 Cache de Metadados
**Estrutura:**
- Arquivo JSON em `{app_data_dir}/cache/huggingface_models.json`
- Estrutura: `{ "models": [...], "last_updated": "ISO8601" }`
- Atualizar cache apenas se `last_updated` > 1 hora
## Fase 2: Backend - Integração llama.cpp
### 2.1 Dependências (`Cargo.toml`)
**Correção crítica:** Usar `llama_cpp` (edgenai) versão 0.4 em vez de `llama-cpp-rs` 0.2
```toml
[features]
default = ["llama-cpp"]
llama-cpp = ["dep:llama_cpp"]
dev-no-llama = [] # Skip llama.cpp em dev builds (builds 30-45 min podem ser lentos)
[dependencies]
llama_cpp = { version = "0.4", optional = true }
# Features condicionais por OS (Metal/CUDA são mutuamente exclusivos)
[target.'cfg(target_os = "macos")'.dependencies]
llama_cpp = { version = "0.4", features = ["metal"], optional = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
# Dependência para parsing de metadados GGUF
gguf = "0.1"
lru = "0.12" # Para LRU cache de modelos carregados
```
**Build considerations:**
- `llama_cpp` compila `llama.cpp` via build script
- **Tempo de build:** 30-45 min em máquinas mais fracas (não 10-15 min)
- Feature flag `dev-no-llama` permite builds rápidos em desenvolvimento
- Requer CMake/Ninja instalados (documentar no README)
### 2.2 Gerenciamento de Estado (`AppState`)
**Estrutura (com LRU cache para prevenir memory leaks):**
```rust
use lru::LruCache;
pub struct LlamaCppState {
models: Arc<Mutex<LruCache<String, LlamaModel>>>, // Limite de 3 modelos
default_params: LlamaParams,
}
impl LlamaCppState {
pub fn new() -> Self {
Self {
models: Arc::new(Mutex::new(LruCache::new(3.try_into().unwrap()))),
default_params: LlamaParams::default(),
}
}
}
pub struct LlamaModel {
path: PathBuf,
context: Option<LlamaContext>, // Lazy loading
metadata: ModelMetadata,
}
```
**Arquivos:**
- Criar `src-tauri/src/llama_cpp_state.rs`
- Registrar state em `main()` via `.manage()`
### 2.3 Comando de Chat Streaming (`chat_stream_native`)
**Contrato:**
```rust
#[command]
async fn chat_stream_native(
window: Window,
app_handle: AppHandle,
session_id: Option<String>,
messages: Vec<Message>,
model_path: String,
system_prompt: Option<String>,
params: Option<LlamaParams>,
) -> Result<String, String>
```
**Implementação:**
- Carregar modelo lazy (verificar se já está em `AppState`)
- Usar `llama-cpp-rs` para streaming de tokens
- Emitir eventos `chat-token` (mesmo formato que `chat_stream` atual)
- Reutilizar lógica de salvamento em DB existente
- Parâmetros: `n_gpu_layers`, `n_ctx`, `threads` (configuráveis via settings)
**Arquivos:**
- Criar `src-tauri/src/llama_cpp_chat.rs`
- Comando em `lib.rs`
- Integrar com `ProcessingState` existente
### 2.4 Configuração de Parâmetros
**Settings store (frontend):**
- Adicionar `llamaCppParams` em `settings-store.ts`:
```typescript
llamaCppParams: {
nGpuLayers: number;
nCtx: number;
threads: number;
}
```
**Backend:**
- Struct `LlamaParams` serializável
- Valores padrão: `n_gpu_layers: 0` (CPU), `n_ctx: 4096`, `threads: auto`
## Fase 3: Backend - Listagem Local
### 3.1 Scan de Arquivos GGUF (`list_local_gguf_models`)
**Contrato:**
```rust
#[command]
fn list_local_gguf_models(app_handle: AppHandle) -> Result<Vec<LocalGgufModel>, String>
```
**Implementação:**
- Scan recursivo em `{app_data_dir}/models/gguf/` (estrutura hierárquica)
- Usar `walkdir` (já presente)
- **Parsing de metadados GGUF usando crate `gguf`:**
```rust
use gguf::GGUFFile;
pub fn extract_gguf_metadata(path: &Path) -> Result<GgufMetadata, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let gguf = GGUFFile::parse(file).map_err(|e| e.to_string())?;
Ok(GgufMetadata {
architecture: gguf.header.metadata.get("general.architecture"),
quantization: gguf.header.metadata.get("general.quantization_version"),
parameter_count: gguf.header.metadata.get("general.parameter_count"),
})
}
```
- Extrair metadados:
- Tamanho do arquivo
- Quantização do filename (regex corrigido)
- **Arquitetura via parsing de header GGUF** (não inferir do filename)
- Cache de metadados para evitar re-scan frequente
**Estrutura de retorno:**
```rust
pub struct LocalGgufModel {
pub path: String,
pub name: String,
pub size: u64,
pub quantization: Option<String>,
pub architecture: Option<String>,
}
```
**Arquivos:**
- Função em `lib.rs` ou módulo separado `src-tauri/src/local_models.rs`
### 3.2 Detecção Automática
- Ao baixar modelo do Hugging Face, adicionar à lista local automaticamente
- Verificar integridade (tamanho, extensão) antes de listar
## Fase 4: Frontend - UI Experimental
### 4.1 Settings Store - Runtime Selection
**Modificações em `store/settings-store.ts`:**
```typescript
runtime: 'ollama' | 'llama-cpp';
llamaCppParams: { nGpuLayers: number; nCtx: number; threads: number };
setRuntime: (runtime: 'ollama' | 'llama-cpp') => void;
setLlamaCppParams: (params: Partial<LlamaCppParams>) => void;
```
### 4.2 Toggle de Runtime (`components/settings/`)
**Componente:**
- Toggle switch "Usar llama.cpp nativo"
- **Indicador visual melhorado** com ícone + tooltip:
```tsx
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'}>
{runtime === 'ollama' ? (
<>🦙 Ollama</>
) : (
<>⚡ llama.cpp (nativo)</>
)}
</Badge>
<Tooltip>
<TooltipContent>
{runtime === 'ollama'
? 'Usando Ollama como runtime (requer servidor externo)'
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
</TooltipContent>
</Tooltip>
```
- Desabilitar se nenhum modelo GGUF local disponível
**Arquivos:**
- Criar ou modificar `components/settings/runtime-settings-panel.tsx`
### 4.3 Seletor de Modelos Hugging Face
**Componente:**
- Input de busca
- Lista de modelos com filtros de quantização (Q4, Q5, Q8)
- Botão de download com progresso
- Integrar com `DownloadContext` existente
**Arquivos:**
- Criar `components/chat/huggingface-model-selector.tsx`
- Integrar em `components/chat/model-selector.tsx`
### 4.4 Página de Comparação
**Componente experimental:**
- Side-by-side: Ollama vs llama.cpp
- Mesmo prompt, comparar latência e qualidade
- Métricas: tokens/s, tempo total, uso de VRAM
**Arquivos:**
- Criar `app/benchmark/page.tsx` (opcional, pode ser feature flag)
### 4.5 Integração no Chat
**Modificações em `hooks/use-chat.ts`:**
- Verificar `runtime` do settings store
- Chamar `chat_stream` ou `chat_stream_native` conforme runtime
- Manter compatibilidade com código existente
**Arquivos:**
- Modificar `hooks/use-chat.ts`
- Atualizar `app/chat/page.tsx` se necessário
## Decisões Técnicas
### Abordagem Ingênua vs Sênior
| Aspecto | Ingênua | Sênior (Escolhida) |
|---------|---------|-------------------|
| Download | Polling de status | Streaming com eventos Tauri |
| Estado de Modelos | Sem cache | **LRU cache com limite de 3 modelos** |
| Integração Chat | Duplicar código | Abstração via enum Runtime |
| Validação | Apenas tamanho | **SHA256 obrigatório quando disponível** + tamanho + **parsing header GGUF** |
| Build llama.cpp | Sempre compilar | **Feature flags condicionais por OS + dev-no-llama** |
| Rate Limiting | Sem tratamento | **Backoff + Retry-After header** |
| Parsing Metadata | Inferir do filename | **Crate `gguf` para parsing correto** |
### Trade-offs
- **Performance:** llama.cpp nativo pode ser 10-30% mais rápido que Ollama (sem overhead HTTP)
- **Manutenção:** Duplicar lógica de chat aumenta complexidade; abstração Runtime reduz duplicação
- **VRAM:** llama.cpp permite controle fino de `n_gpu_layers`; Ollama gerencia automaticamente
### Modos de Falha
1. **Gargalo VRAM:** Modelos grandes podem não carregar; implementar fallback para CPU
- **Solução:** Usar `sysinfo` crate para detectar VRAM disponível antes de carregar
2. **Download interrompido:** Retry logic com resume (verificar se arquivo parcial existe)
3. **Build llama.cpp falha:** Feature flag `dev-no-llama` para desabilitar em desenvolvimento
4. **Latência de rede:** Cache agressivo de metadados do Hugging Face
5. **Rate limiting Hugging Face:** Tratar 429 com `Retry-After` header
6. **Concurrent downloads:** Limitar a 2 downloads paralelos com semáforo
7. **Memory leaks:** LRU cache com limite de 3 modelos previne crescimento indefinido
### Antipadrões a Evitar
- Não fazer polling de status de download (usar eventos)
- Não carregar todos os modelos na inicialização (lazy loading)
- Não duplicar lógica de streaming (abstrair Runtime)
- Não hardcodar paths (usar `app_data_dir`)
## Ordem de Implementação (Reordenada)
1. **Estrutura de diretórios e AppState** (fundação)
- Criar `get_models_dir()` com paths cross-platform
- Implementar `LlamaCppState` com LRU cache
2. **Listagem local de GGUF** (valida estrutura)
- `list_local_gguf_models()` com parsing via crate `gguf`
- Cache de metadados
3. **Download Hugging Face** (adiciona modelos)
- `download_gguf_model()` com SHA256 obrigatório
- Rate limiting e retry logic
4. **Parsing de metadados GGUF** (enriquece listagem)
- Integrar crate `gguf` para extrair arquitetura
5. **Integração llama.cpp** (runtime)
- `chat_stream_native()` com lazy loading
- Detecção de VRAM via `sysinfo`
6. **UI experimental** (camada final)
- Toggle de runtime com indicador melhorado
- Integração no `use-chat.ts`
## Validação
**Comandos de teste (Windows PowerShell):**
```powershell
# Verificar modelos baixados
Get-ChildItem "$env:APPDATA\OllaHub\models\gguf" -Recurse -Filter "*.gguf"
# Testar download (via Tauri command)
# Usar DevTools do app para invocar: download_gguf_model
```
**Comandos de teste (Linux Bash):**
```bash
# Verificar modelos baixados
find ~/.local/share/com.ollahub.app/models/gguf -name "*.gguf"
# Testar build llama.cpp (com feature flag)
cargo build --features llama-cpp # Build completo
cargo build --no-default-features --features dev-no-llama # Build rápido (dev)
# Verificar VRAM disponível (se NVIDIA)
nvidia-smi --query-gpu=memory.free --format=csv
```
## Correções Críticas Aplicadas
### 1. Crate llama.cpp
- ✅ Alterado de `llama-cpp-rs = "0.2"` para `llama_cpp = "0.4"` (edgenai, mais estável)
### 2. Feature Flags
- ✅ Features condicionais por OS (Metal/CUDA mutuamente exclusivos)
- ✅ Feature flag `dev-no-llama` para builds rápidos em desenvolvimento
### 3. Parsing de Metadados
- ✅ Adicionada dependência `gguf = "0.1"` para parsing correto de headers GGUF
- ✅ Removida inferência de arquitetura do filename
### 4. Memory Management
- ✅ Substituído `HashMap` por `LruCache` com limite de 3 modelos
### 5. Validação SHA256
- ✅ Tornada obrigatória quando disponível no Hugging Face
### 6. Rate Limiting
- ✅ Implementado tratamento de 429 com `Retry-After` header
### 7. Estrutura de Diretórios
- ✅ Hierárquica: `{repo_owner}/{repo_name}/{quantization}/model.gguf`
### 8. Regex de Quantização
- ✅ Corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`
### 9. Cross-Platform Paths
- ✅ Validação de `app_data_dir` para Windows/Linux/macOS
### 10. Indicador de Runtime
- ✅ Melhorado com ícone + tooltip explicativo
### 11. Ordem de Implementação
- ✅ Reordenada considerando dependências técnicas
### 12. Riscos Adicionais
- ✅ VRAM detection via `sysinfo`
- ✅ Limite de downloads paralelos (semáforo)
- ✅ Documentação de requisitos (CMake/Ninja)
+56
View File
@@ -96,6 +96,62 @@ jobs:
fi fi
echo "Frontend build successful - 'out' directory exists" echo "Frontend build successful - 'out' directory exists"
<<<<<<< HEAD
- name: Prepare bundle resources (Windows only)
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
# Criar diretórios
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/ollama" | Out-Null
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/models/embeddings" | Out-Null
# Baixar instalador do Ollama (se não existir)
$ollamaPath = "src-tauri/bundle/ollama/OllamaSetup.exe"
if (!(Test-Path $ollamaPath)) {
Write-Host "Downloading Ollama installer..."
Invoke-WebRequest -Uri "https://ollama.com/download/OllamaSetup.exe" -OutFile $ollamaPath -UseBasicParsing
}
# Baixar modelos de embeddings (se não existirem)
$modelPath = "src-tauri/bundle/models/embeddings/all-MiniLM-L6-v2.onnx"
if (!(Test-Path $modelPath)) {
Write-Host "Downloading ONNX embedding model (~90MB, this may take a while)..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx" -OutFile $modelPath -UseBasicParsing
}
$tokenizerPath = "src-tauri/bundle/models/embeddings/tokenizer.json"
if (!(Test-Path $tokenizerPath)) {
Write-Host "Downloading tokenizer..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json" -OutFile $tokenizerPath -UseBasicParsing
}
# Baixar e extrair ONNX Runtime DLL
$ortDllPath = "src-tauri/bundle/models/embeddings/onnxruntime.dll"
if (!(Test-Path $ortDllPath)) {
$ortZipPath = "src-tauri/bundle/models/embeddings/onnxruntime.zip"
if (!(Test-Path $ortZipPath)) {
Write-Host "Downloading ONNX Runtime library (~50MB)..."
Invoke-WebRequest -Uri "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip" -OutFile $ortZipPath -UseBasicParsing
}
Write-Host "Extracting ONNX Runtime DLL..."
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($ortZipPath)
foreach ($entry in $zip.Entries) {
if ($entry.Name -eq "onnxruntime.dll") {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $ortDllPath, $true)
Write-Host "Extracted onnxruntime.dll"
break
}
}
$zip.Dispose()
Remove-Item $ortZipPath -ErrorAction SilentlyContinue
}
Write-Host "Bundle resources prepared successfully"
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
- name: Build Tauri app - name: Build Tauri app
run: | run: |
cd src-tauri cd src-tauri
+11
View File
@@ -40,3 +40,14 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
~/.tauri/ollahub.key ~/.tauri/ollahub.key
# tauri build artifacts
src-tauri/target/
src-tauri/bundle/
*.exe
*.msi
*.AppImage
*.deb
*.dmg
OllamaSetup.exe
src-tauri/bundle/ollama/
+8 -1
View File
@@ -17,4 +17,11 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7. COMMERCIAL LICENSING For usage scenarios that fall outside these terms (e.g., white-labeling, redistribution in paid suites, or custom enterprise agreements), please contact the author for a commercial license: 7. COMMERCIAL LICENSING For usage scenarios that fall outside these terms (e.g., white-labeling, redistribution in paid suites, or custom enterprise agreements), please contact the author for a commercial license:
Website: https://evandro.dev.br Website: https://evandro.dev.br
+312 -124
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, RefreshCw, Cpu } from "lucide-react"; import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, RefreshCw, Cpu } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ModelSelector } from "@/components/chat/model-selector";
import { ChatInput } from "@/components/chat/chat-input"; import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message"; import { ChatMessage } from "@/components/chat/chat-message";
import { ReasoningChain } from "@/components/chat/reasoning-chain"; import { ReasoningChain } from "@/components/chat/reasoning-chain";
@@ -13,6 +14,7 @@ import { TitleBar } from "@/components/titlebar";
import { SidebarList } from "@/components/chat/sidebar-list"; import { SidebarList } from "@/components/chat/sidebar-list";
import { useChat } from "@/hooks/use-chat"; import { useChat } from "@/hooks/use-chat";
import { useLocalModels } from "@/hooks/use-local-models"; import { useLocalModels } from "@/hooks/use-local-models";
import { useModelSelection } from "@/hooks/use-model-selection";
import { useChatStorage } from "@/hooks/use-chat-storage"; import { useChatStorage } from "@/hooks/use-chat-storage";
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model"; import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
import { useWebSearch } from "@/hooks/use-web-search"; import { useWebSearch } from "@/hooks/use-web-search";
@@ -24,9 +26,10 @@ import { useDeepResearch } from "@/hooks/use-deep-research";
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research"; import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
import { useSettingsStore } from "@/store/settings-store"; import { useSettingsStore } from "@/store/settings-store";
import type { ScrapedContent } from "@/services/webSearch"; import type { ScrapedContent } from "@/services/webSearch";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback, startTransition } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { invoke } from "@tauri-apps/api/core";
import { ImperativePanelHandle } from "react-resizable-panels"; import { ImperativePanelHandle } from "react-resizable-panels";
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog"; import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
import { chatLog } from "@/lib/terminal-logger"; import { chatLog } from "@/lib/terminal-logger";
@@ -43,6 +46,7 @@ export default function ChatPage() {
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat(); const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
const { models, refresh } = useLocalModels(); const { models, refresh } = useLocalModels();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { isSetupCompleted } = useSettingsStore();
const { const {
sessions, sessions,
@@ -112,9 +116,68 @@ export default function ChatPage() {
})); }));
}, [setMessages]); }, [setMessages]);
const [selectedModel, setSelectedModel] = useState(""); const { selectedModel, setSelectedModel } = useModelSelection();
// Sincronizar eventos de busca com thinking messages
useEffect(() => {
if (webSearch.activeQueries.length > 0 || webSearch.activeUrls.length > 0) {
updateThinkingMessage('web-research', {
activeQueries: webSearch.activeQueries,
activeUrls: webSearch.activeUrls,
});
}
}, [webSearch.activeQueries, webSearch.activeUrls, updateThinkingMessage]);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const initializedRef = useRef(false); const initializedRef = useRef(false);
// Refs para evitar loops e execuções múltiplas
const hasCheckedIntegrityRef = useRef(false);
const isRedirectingRef = useRef(false);
// Guarda de roteamento: verificar se setup foi completado
useEffect(() => {
if (!isSetupCompleted && !isRedirectingRef.current) {
isRedirectingRef.current = true;
startTransition(() => {
router.replace("/setup"); // Mudar de push para replace para consistência
});
}
}, [isSetupCompleted, router]);
// Guarda de integridade: verificar se Ollama está funcional
// Executa SEMPRE que o componente montar, independente do status do setup
useEffect(() => {
if (hasCheckedIntegrityRef.current) return; // Proteção contra múltiplas execuções
if (isRedirectingRef.current) return; // Evitar redirecionamentos múltiplos
const checkIntegrity = async () => {
try {
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact && !isRedirectingRef.current) {
isRedirectingRef.current = true;
// Ollama não está funcional - redirecionar para setup
console.warn("Integridade do Ollama falhou no chat. Redirecionando para Setup.");
startTransition(() => {
router.replace('/setup');
});
}
} catch (e) {
// Em caso de erro na verificação, redirecionar para setup (fallback seguro)
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
console.error("Falha ao verificar integridade do Ollama no chat:", e);
startTransition(() => {
router.replace('/setup');
});
}
} finally {
hasCheckedIntegrityRef.current = true; // Marcar como executado
}
};
checkIntegrity();
}, [router]); // Remover isSetupCompleted das dependências - executar sempre
// Initialize with default format prompt // Initialize with default format prompt
const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
@@ -137,25 +200,10 @@ export default function ChatPage() {
} }
}, [searchQuery, currentSessionId]); }, [searchQuery, currentSessionId]);
// Auto-select first model
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
useEffect(() => {
if (initializedRef.current) return;
const stored = settings.selectedModel;
const first = models.length > 0 ? models[0].name : "";
const next = stored || first || "";
if (next) {
setSelectedModel(next);
if (!stored) {
settings.setSelectedModel(next);
}
}
initializedRef.current = true;
}, [settings, models]);
// Auto-scroll para o final durante streaming // Auto-scroll para o final durante streaming
useEffect(() => { useEffect(() => {
if (messagesEndRef.current && (isLoading || messages.length > 0)) { if (messagesEndRef.current && (isLoading || messages.length > 0)) {
@@ -425,7 +473,10 @@ export default function ChatPage() {
// Executar busca progressiva para cada query do plano // Executar busca progressiva para cada query do plano
const searchPromises = searchPlan.map(async (query, idx) => { const searchPromises = searchPlan.map(async (query, idx) => {
chatLog.info(` → Starting progressive search ${idx + 1}/${searchPlan.length}: "${query}"`); const queryStartTime = Date.now();
chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1}/${searchPlan.length} ==========`);
chatLog.info(`[WebResearch] Query: "${query}"`);
chatLog.info(`[WebResearch] Timestamp: ${new Date().toISOString()}`);
// Atualizar progresso // Atualizar progresso
updateThinkingMessage('web-research', { updateThinkingMessage('web-research', {
@@ -438,6 +489,22 @@ export default function ChatPage() {
const context = deepResearch.state.context; const context = deepResearch.state.context;
const enrichedQueries = deepResearch.state.enrichedQueries; const enrichedQueries = deepResearch.state.enrichedQueries;
if (context) {
chatLog.info(`[WebResearch] Using contextual analysis:`, {
intent: context.intent,
entities: context.entities.length,
topics: context.topics.length,
});
}
if (enrichedQueries) {
chatLog.info(`[WebResearch] Using enriched queries:`, {
literal: enrichedQueries.literal.length,
semantic: enrichedQueries.semantic.length,
related: enrichedQueries.related.length,
});
}
// Executar busca progressiva com semantic search se disponível // Executar busca progressiva com semantic search se disponível
const fallbackResult = await executeProgressiveSearch( const fallbackResult = await executeProgressiveSearch(
query, query,
@@ -461,22 +528,47 @@ export default function ChatPage() {
} }
); );
// Log do resultado const queryDuration = Date.now() - queryStartTime;
chatLog.info(` ✓ Progressive search ${idx + 1} complete:`);
chatLog.info(` - Success: ${fallbackResult.success}`);
chatLog.info(` - Total results: ${fallbackResult.scrapedSources.length}`);
chatLog.info(` - Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
chatLog.info(` - Attempts: ${fallbackResult.attempts.length}`);
chatLog.info(` - Used fallback: ${fallbackResult.usedFallback}`);
// Log detalhado de cada resultado // Log detalhado do resultado
fallbackResult.scrapedSources.forEach((result, resultIdx) => { chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1} RESULTS ==========`);
chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`); chatLog.info(`[WebResearch] Duration: ${queryDuration}ms`);
chatLog.info(` URL: ${result.url}`); chatLog.info(`[WebResearch] Success: ${fallbackResult.success}`);
const contentLength = result.markdown?.length || 0; chatLog.info(`[WebResearch] Total results: ${fallbackResult.scrapedSources.length}`);
chatLog.info(` Content length: ${contentLength} chars`); chatLog.info(`[WebResearch] Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
chatLog.info(`[WebResearch] Attempts: ${fallbackResult.attempts.length}`);
chatLog.info(`[WebResearch] Used fallback: ${fallbackResult.usedFallback}`);
// Log detalhado de cada tentativa
fallbackResult.attempts.forEach((attempt, attemptIdx) => {
chatLog.info(`\n Attempt ${attemptIdx + 1}:`);
chatLog.info(` Round: ${attempt.round}`);
chatLog.info(` Strategy: ${attempt.strategy || 'N/A'}`);
chatLog.info(` Query: "${attempt.query}"`);
chatLog.info(` Results: ${attempt.results.length}`);
chatLog.info(` Relevance score: ${attempt.relevanceScore.toFixed(3)}`);
chatLog.info(` Duration: ${attempt.duration}ms`);
}); });
// Log detalhado de cada resultado extraído
if (fallbackResult.scrapedSources.length > 0) {
chatLog.info(`\n[WebResearch] Extracted Sources:`);
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
chatLog.info(`\n [${resultIdx + 1}] ${result.title}`);
chatLog.info(` URL: ${result.url}`);
const contentLength = result.markdown?.length || result.content?.length || 0;
const snippetLength = result.snippet?.length || 0;
chatLog.info(` Markdown length: ${contentLength} chars`);
chatLog.info(` Snippet length: ${snippetLength} chars`);
chatLog.info(` Cached: ${result.cached ? 'Yes' : 'No'}`);
if (result.markdown) {
const preview = result.markdown.substring(0, 200);
chatLog.info(` Preview: ${preview}${contentLength > 200 ? '...' : ''}`);
}
});
}
chatLog.info(`[WebResearch] ============================================\n`);
// Adicionar resultados à Knowledge Base // Adicionar resultados à Knowledge Base
if (fallbackResult.scrapedSources.length > 0) { if (fallbackResult.scrapedSources.length > 0) {
deepResearch.addToKnowledgeBase(query, fallbackResult.scrapedSources); deepResearch.addToKnowledgeBase(query, fallbackResult.scrapedSources);
@@ -516,13 +608,14 @@ export default function ChatPage() {
chatLog.info(`All searches complete. Knowledge Base size: ${deepResearch.state.knowledgeBase.length}`); chatLog.info(`All searches complete. Knowledge Base size: ${deepResearch.state.knowledgeBase.length}`);
// Coletar todas as fontes para exibição // Coletar todas as fontes para exibição
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({ scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
url: entry.sourceUrl, url: entry.sourceUrl,
title: entry.title, title: entry.title,
content: entry.content, content: entry.content,
markdown: entry.content, markdown: entry.content,
snippet: entry.content.substring(0, 200) snippet: entry.content.substring(0, 200),
})); cached: false
}));
// Adicionar mensagem: Fontes encontradas // Adicionar mensagem: Fontes encontradas
if (scrapedSources.length > 0) { if (scrapedSources.length > 0) {
@@ -535,56 +628,84 @@ export default function ChatPage() {
if (deepResearch.state.knowledgeBase.length > 0) { if (deepResearch.state.knowledgeBase.length > 0) {
// Passo 3: Validação e Contexto // Passo 3: Validação e Contexto
chatLog.info('\nStep 3: Validation'); chatLog.info('\n[DeepResearch] ========== STEP 3: VALIDATION ==========');
deepResearch.setStep('aggregating'); deepResearch.setStep('aggregating');
// Adicionar mensagem: Processamento // Adicionar mensagem: Processamento
addThinkingMessage('processing', 'Processando contexto...', 'running'); addThinkingMessage('processing', 'Processando contexto...', 'running');
const validationStart = Date.now();
validationReport = await deepResearch.validate(selectedModel, content); validationReport = await deepResearch.validate(selectedModel, content);
chatLog.info(`Validation Report Length: ${validationReport.length} chars`); const validationDuration = Date.now() - validationStart;
chatLog.info(`[DeepResearch] Validation duration: ${validationDuration}ms`);
chatLog.info(`[DeepResearch] Validation report length: ${validationReport.length} chars`);
if (validationReport) {
const preview = validationReport.substring(0, 500);
chatLog.info(`[DeepResearch] Validation preview: ${preview}${validationReport.length > 500 ? '...' : ''}`);
}
// Passo 4: Obter contexto curado (usando versão otimizada) // Passo 4: Obter contexto curado (usando versão otimizada)
chatLog.info('\nStep 4: Getting Curated Context (Optimized)'); chatLog.info('\n[DeepResearch] ========== STEP 4: CONTEXT CONDENSATION ==========');
// Calcular tokens disponíveis dinamicamente // Calcular tokens disponíveis dinamicamente
const { getModelContextInfo } = await import('@/lib/model-context'); const { getModelContextInfo } = await import('@/lib/model-context');
const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages); const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages);
const availableTokens = contextInfo.recommendedContextWindow; const availableTokens = contextInfo.recommendedContextWindow;
chatLog.info(`Model Context Window: ${contextInfo.maxContextWindow} tokens`); chatLog.info(`[DeepResearch] Model: ${selectedModel}`);
chatLog.info(`Available Tokens for KB: ${availableTokens} tokens`); chatLog.info(`[DeepResearch] Max context window: ${contextInfo.maxContextWindow} tokens`);
chatLog.info(`[DeepResearch] Available tokens for KB: ${availableTokens} tokens`);
const condensationStart = Date.now();
const optimizedResult = deepResearch.getCuratedContextOptimized(content, availableTokens); const optimizedResult = deepResearch.getCuratedContextOptimized(content, availableTokens);
const condensationDuration = Date.now() - condensationStart;
knowledgeBaseContext = optimizedResult.context; knowledgeBaseContext = optimizedResult.context;
chatLog.info(`Knowledge Base Context Length: ${knowledgeBaseContext.length} chars`); chatLog.info(`\n[DeepResearch] ========== CONDENSATION SUMMARY ==========`);
chatLog.info(`Condensation Method: ${optimizedResult.result.method}`); chatLog.info(`[DeepResearch] Duration: ${condensationDuration}ms`);
chatLog.info(`Chunks Used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`); chatLog.info(`[DeepResearch] Final context length: ${knowledgeBaseContext.length} chars`);
chatLog.info(`Compression Ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`); chatLog.info(`[DeepResearch] Method: ${optimizedResult.result.method}`);
chatLog.info(`[DeepResearch] Chunks used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
chatLog.info(`[DeepResearch] Compression ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
chatLog.info(`[DeepResearch] Original tokens: ${optimizedResult.result.originalTokens}`);
chatLog.info(`[DeepResearch] Final tokens: ${optimizedResult.result.totalTokens}`);
// Log detalhado por fonte
if (optimizedResult.result.sources.length > 0) {
chatLog.info(`[DeepResearch] Chunks per source:`);
optimizedResult.result.sources.forEach((source, idx) => {
chatLog.info(` [${idx + 1}] ${source.title}: ${source.chunksUsed} chunks`);
chatLog.info(` URL: ${source.url}`);
});
}
// Log preview do contexto se não for muito longo // Log preview do contexto se não for muito longo
if (knowledgeBaseContext.length > 0) { if (knowledgeBaseContext.length > 0) {
chatLog.info(`\n[DeepResearch] Context preview:`);
if (knowledgeBaseContext.length > 2000) { if (knowledgeBaseContext.length > 2000) {
chatLog.info(`Context Preview (first 2000 chars):\n${knowledgeBaseContext.substring(0, 2000)}...`); chatLog.info(`${knowledgeBaseContext.substring(0, 2000)}...`);
chatLog.info(`[DeepResearch] (truncated, ${knowledgeBaseContext.length} chars total)`);
} else { } else {
chatLog.info(`Full Context:\n${knowledgeBaseContext}`); chatLog.info(knowledgeBaseContext);
} }
// Validar contexto // Validar contexto
const { validateCondensedContext } = await import('@/lib/knowledge-base-processor'); const { validateCondensedContext } = await import('@/lib/knowledge-base-processor');
const validation = validateCondensedContext(optimizedResult.result, content); const validation = validateCondensedContext(optimizedResult.result, content);
chatLog.info(`\n[DeepResearch] ========== CONTEXT VALIDATION ==========`);
if (!validation.isValid) { if (!validation.isValid) {
chatLog.error(`Context validation failed: ${validation.warnings.join(', ')}`); chatLog.error(`[DeepResearch] Validation failed: ${validation.warnings.join(', ')}`);
} else if (validation.warnings.length > 0) { } else if (validation.warnings.length > 0) {
validation.warnings.forEach(warning => { validation.warnings.forEach(warning => {
chatLog.warn(`⚠️ ${warning}`); chatLog.warn(`[DeepResearch] ⚠️ ${warning}`);
}); });
} else { } else {
chatLog.info('✓ Context validation passed'); chatLog.info(`[DeepResearch] ✓ Context validation passed`);
} }
chatLog.info(`[DeepResearch] ============================================\n`);
} else { } else {
chatLog.warn('⚠️ Knowledge Base Context is empty!'); chatLog.warn('[DeepResearch] ⚠️ Knowledge Base Context is empty!');
} }
@@ -623,17 +744,21 @@ export default function ChatPage() {
} catch (error) { } catch (error) {
// Tratar erro graciosamente sem quebrar o fluxo // Tratar erro graciosamente sem quebrar o fluxo
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${errorMsg}`); chatLog.warn(`⚠️ Error in Deep Research pipeline (using local model fallback): ${errorMsg}`);
// Atualizar mensagem de processo com aviso (não erro fatal) // Atualizar mensagem de processo com aviso (não erro fatal)
updateThinkingMessage('web-research', { updateThinkingMessage('web-research', {
status: 'completed', // Marcar como completed mesmo com erro parcial status: 'completed', // Marcar como completed mesmo com erro
details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`, details: `Erro na pesquisa web (${errorMsg}). Usando modelo local como fallback.`,
duration: Date.now() - webResearchStart, duration: Date.now() - webResearchStart,
}); });
// Garantir que o estado de erro seja registrado para ativar fallback
// O erro já será detectado pelo hasWebSearchError através do estado do hook
chatLog.info('Web search error registered - will use local model fallback');
// Não definir erro global - permitir que o fluxo continue // Não definir erro global - permitir que o fluxo continue
// O sistema continuará com conhecimento interno se necessário // O sistema continuará com conhecimento interno (fallback automático)
} }
} else { } else {
if (!webSearch.isEnabled) { if (!webSearch.isEnabled) {
@@ -694,8 +819,9 @@ export default function ChatPage() {
let enhancedSystemPrompt = systemPrompt; let enhancedSystemPrompt = systemPrompt;
// Se tivermos uma Knowledge Base, usar o prompt STRICT_GENERATION // Se tivermos uma Knowledge Base, usar o prompt STRICT_GENERATION
// Verificar se houve fallback (nenhum resultado relevante encontrado) // Verificar se houve fallback (nenhum resultado relevante encontrado ou erro na pesquisa)
const usedFallback = knowledgeBaseContext.length === 0 || knowledgeBaseContext.length < 100; const usedFallback = knowledgeBaseContext.length === 0 || knowledgeBaseContext.length < 100;
const hasWebSearchError = webSearch.status === 'error' || webSearch.error !== null;
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) { if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION
@@ -704,25 +830,36 @@ export default function ChatPage() {
.replace('{{userQuery}}', content); .replace('{{userQuery}}', content);
enhancedSystemPrompt = strictPrompt; enhancedSystemPrompt = strictPrompt;
} else if (usedFallback) { } else if (usedFallback || hasWebSearchError) {
// Fallback: usar prompt sem contexto web, mas informando que não encontrou fontes // Fallback: usar prompt sem contexto web, mas informando que não encontrou fontes
// Isso garante que o modelo local sempre gere uma resposta mesmo sem dados web
const errorContext = hasWebSearchError && webSearch.error
? `\n**Motivo:** ${webSearch.error}`
: '';
enhancedSystemPrompt = `${systemPrompt} enhancedSystemPrompt = `${systemPrompt}
## ⚠️ AVISO IMPORTANTE ## ⚠️ FALLBACK PARA CONHECIMENTO INTERNO
Não foi possível encontrar informações relevantes nas fontes web consultadas após múltiplas tentativas de busca. Não foi possível acessar fontes web para esta consulta após múltiplas tentativas de busca.${errorContext}
**INSTRUÇÕES:** **INSTRUÇÕES CRÍTICAS:**
- Responda usando APENAS seu conhecimento interno (treinamento) - Você DEVE responder usando APENAS seu conhecimento interno (treinamento do modelo)
- Seja honesto e diga: "Não encontrei essa informação nas fontes consultadas." - Seja transparente: informe que não foi possível acessar fontes web, mas que você responderá com base no seu conhecimento
- Se tiver conhecimento sobre o tópico, compartilhe, mas deixe claro que não há fontes externas verificadas - Se tiver conhecimento sobre o tópico, compartilhe-o de forma útil e detalhada
- Não invente informações ou cite fontes que não foram consultadas - Seja honesto sobre limitações: se não souber algo, diga claramente
- NÃO invente informações ou cite fontes que não foram consultadas
- NÃO mencione "fontes consultadas" ou "pesquisa realizada" - apenas responda com seu conhecimento
**IMPORTANTE:** Este é um fallback legítimo. O usuário espera uma resposta útil mesmo sem dados web. Use seu conhecimento para ajudar da melhor forma possível.
## DATA E HORA ATUAL DO SISTEMA ## DATA E HORA ATUAL DO SISTEMA
**DATA/HORA FORMATADA:** ${currentDateTime} **DATA/HORA FORMATADA:** ${currentDateTime}
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
**ISO 8601:** ${currentDateISO}
Use esta data exata para referências temporais.`; Use esta data exata para referências temporais quando relevante.`;
} }
// Fallback para lógica antiga se não tiver Knowledge Base // Fallback para lógica antiga se não tiver Knowledge Base
else if (knowledgeBaseContext) { else if (knowledgeBaseContext) {
@@ -792,6 +929,9 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
let finalUserContent = content; let finalUserContent = content;
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) { if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
finalUserContent = `[KNOWLEDGE BASE - ÚNICA FONTE DE VERDADE]\n${knowledgeBaseContext}\n[/KNOWLEDGE BASE]\n\nResponda a pergunta usando APENAS os dados acima. Se a informação não estiver na Knowledge Base, diga "Não encontrei essa informação nas fontes consultadas."\n\nPergunta: ${content}`; finalUserContent = `[KNOWLEDGE BASE - ÚNICA FONTE DE VERDADE]\n${knowledgeBaseContext}\n[/KNOWLEDGE BASE]\n\nResponda a pergunta usando APENAS os dados acima. Se a informação não estiver na Knowledge Base, diga "Não encontrei essa informação nas fontes consultadas."\n\nPergunta: ${content}`;
} else if (usedFallback || hasWebSearchError) {
// Quando usar fallback, garantir que o modelo saiba que deve usar conhecimento interno
finalUserContent = `Pergunta do usuário: ${content}\n\nNota: Não foi possível acessar fontes web para esta consulta. Responda usando seu conhecimento interno de forma útil e detalhada.`;
} }
chatLog.info(`Final User Content Length: ${finalUserContent.length} chars`); chatLog.info(`Final User Content Length: ${finalUserContent.length} chars`);
@@ -822,6 +962,75 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
chatLog.info('✅ Response generation complete'); chatLog.info('✅ Response generation complete');
chatLog.info('========== QUERY COMPLETE ==========\n'); chatLog.info('========== QUERY COMPLETE ==========\n');
<<<<<<< HEAD
// Capturar dados antes do setMessages para garantir que estão no escopo correto
const currentScrapedSources = scrapedSources.length > 0
? scrapedSources
: deepResearch.state.knowledgeBase.map(entry => ({
url: entry.sourceUrl,
title: entry.title,
content: entry.content,
markdown: entry.content,
cached: false,
}));
// Coletar thinking steps das mensagens (apenas os relacionados a esta query)
const allThinkingSteps: ThinkingMessageMetadata[] = [];
const currentMessages = messages; // Capturar referência atual
currentMessages.forEach((msg, idx) => {
// Coletar thinking steps das últimas mensagens (relacionadas a esta query)
if (idx >= currentMessages.length - 10) { // Últimas 10 mensagens
if (msg.metadata && typeof msg.metadata === 'object' && 'type' in msg.metadata && msg.metadata.type === 'thinking') {
allThinkingSteps.push(msg.metadata as ThinkingMessageMetadata);
}
}
});
// Coletar sites pesquisados com detalhes completos
const sitesResearched = currentScrapedSources.map((source, idx) => {
// Tentar encontrar informações de duração e status dos logs
const sourceLog = deepResearch.state.logs.find(log => {
if (!log.parsedOutput || typeof log.parsedOutput !== 'object') return false;
const parsed = log.parsedOutput as Record<string, unknown>;
return 'url' in parsed && parsed.url === source.url;
});
let duration: number | undefined = undefined;
if (sourceLog?.parsedOutput && typeof sourceLog.parsedOutput === 'object') {
const parsed = sourceLog.parsedOutput as Record<string, unknown>;
if ('duration' in parsed && typeof parsed.duration === 'number') {
duration = parsed.duration;
}
}
return {
url: source.url,
title: source.title || 'Sem título',
status: source.cached ? 'cached' : 'scraped',
source: source.cached ? 'cache' : 'nodriver',
contentLength: source.content?.length || source.markdown?.length || 0,
scrapedAt: sourceLog?.timestamp || Date.now(),
duration: duration,
};
});
// Coletar dados de raciocínio dos logs do deep research
const reasoningSteps = deepResearch.state.logs
.filter(log => log.stage === 'generation' || log.stage === 'validation')
.map(log => ({
stage: log.stage,
input: log.input,
output: log.rawOutput || JSON.stringify(log.parsedOutput),
timestamp: log.timestamp,
}));
// Estimar token usage (aproximado)
const estimatedInputTokens = Math.ceil((enhancedSystemPrompt.length + finalUserContent.length) / 4);
const lastAssistantMsg = currentMessages.find(m => m.role === 'assistant');
const estimatedOutputTokens = Math.ceil((lastAssistantMsg?.content?.length || 0) / 4);
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
// Construir dados de debug para a última mensagem do assistente // Construir dados de debug para a última mensagem do assistente
setMessages(prev => { setMessages(prev => {
const newMessages = [...prev]; const newMessages = [...prev];
@@ -834,6 +1043,16 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
latency: Date.now() - responseStart, latency: Date.now() - responseStart,
systemPrompt: enhancedSystemPrompt, systemPrompt: enhancedSystemPrompt,
userQuery: content, userQuery: content,
<<<<<<< HEAD
contextUsed: prev.filter(m => m.role !== 'system' && m.role !== 'assistant'),
webResearch: {
queries: deepResearch.state.plan || [],
enrichedQueries: deepResearch.state.enrichedQueries || undefined,
sources: currentScrapedSources,
sitesResearched: sitesResearched,
logs: deepResearch.state.logs || [],
plan: deepResearch.state.plan || [],
=======
contextUsed: messages.filter(m => m.role !== 'system'), contextUsed: messages.filter(m => m.role !== 'system'),
webResearch: { webResearch: {
queries: deepResearch.state.plan || [], queries: deepResearch.state.plan || [],
@@ -841,15 +1060,39 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
sources: scrapedSources, sources: scrapedSources,
logs: deepResearch.state.logs, logs: deepResearch.state.logs,
plan: deepResearch.state.plan, plan: deepResearch.state.plan,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({ knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
sourceUrl: entry.sourceUrl, sourceUrl: entry.sourceUrl,
title: entry.title, title: entry.title,
content: entry.content, content: entry.content,
})), })),
<<<<<<< HEAD
activeQueries: webSearch.activeQueries.length > 0 ? webSearch.activeQueries : undefined,
activeUrls: webSearch.activeUrls.length > 0 ? webSearch.activeUrls : undefined,
},
deepResearchState: deepResearch.state,
thinkingSteps: allThinkingSteps.length > 0 ? allThinkingSteps : undefined,
reasoning: reasoningSteps.length > 0 ? {
steps: reasoningSteps.map(r => `${r.stage}: ${r.input.substring(0, 100)}...`),
intermediateResults: reasoningSteps.map(r => ({
stage: r.stage,
output: r.output,
timestamp: r.timestamp,
})),
} : undefined,
finalResponse: lastMsg.content || '',
rawResponse: lastMsg.content || '',
tokenUsage: {
input: estimatedInputTokens,
output: estimatedOutputTokens,
total: estimatedInputTokens + estimatedOutputTokens,
},
=======
}, },
deepResearchState: deepResearch.state, deepResearchState: deepResearch.state,
finalResponse: lastMsg.content, finalResponse: lastMsg.content,
rawResponse: lastMsg.content, rawResponse: lastMsg.content,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}; };
// Armazenar debug data usando o índice da mensagem // Armazenar debug data usando o índice da mensagem
@@ -1030,7 +1273,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
<TitleBar /> <TitleBar />
{/* Main Content Area */} {/* Main Content Area */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden pt-8">
<ResizablePanelGroup direction="horizontal" className="flex-1"> <ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel <ResizablePanel
@@ -1137,61 +1380,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0"> <div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<TooltipProvider> <ModelSelector />
<Select
value={selectedModel || undefined}
onValueChange={(v) => {
if (v === "__add_model__") {
setShowDownloadDialog(true);
// Não alterar selectedModel quando abrir dialog
} else {
setSelectedModel(v);
settings.setSelectedModel(v);
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="h-9 min-w-0 max-w-full">
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw] sm:max-w-[420px]">
{models.map(m => (
<Tooltip key={m.name}>
<TooltipTrigger asChild>
<SelectItem value={m.name} className="min-w-0">
<span className="flex items-center justify-between w-full min-w-0 gap-2">
<span className="flex items-center gap-2 truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate">{m.name}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
</span>
</SelectItem>
</TooltipTrigger>
{m.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{m.name}</p>
</TooltipContent>
)}
</Tooltip>
))}
<SelectItem value="__add_model__" className="text-primary font-medium">
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Adicionar modelo...
</span>
</SelectItem>
</SelectContent>
</Select>
</TooltipProvider>
</div> </div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos"> <Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
@@ -1463,4 +1652,3 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
); );
} }
+10
View File
@@ -376,6 +376,16 @@
background: transparent; background: transparent;
} }
/* Utility class para ocultar scrollbar */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* Reasoning Chain animations */ /* Reasoning Chain animations */
@keyframes reasoning-fade-in { @keyframes reasoning-fade-in {
from { from {
+19
View File
@@ -4,6 +4,13 @@ import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AppUpdaterProvider } from "@/components/app-updater-provider"; import { AppUpdaterProvider } from "@/components/app-updater-provider";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
<<<<<<< HEAD
import { DownloadProvider } from "@/contexts/download-context";
import { GlobalDownloadStatusBar } from "@/components/global-download-status-bar";
import { OllamaHealthProvider } from "@/contexts/ollama-health-context";
import { ServiceInterruptionOverlay } from "@/components/ui/service-interruption-overlay";
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -40,9 +47,21 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<<<<<<< HEAD
<OllamaHealthProvider>
<DownloadProvider>
<KeyboardShortcuts />
{children}
<AppUpdaterProvider />
<GlobalDownloadStatusBar />
<ServiceInterruptionOverlay />
</DownloadProvider>
</OllamaHealthProvider>
=======
<KeyboardShortcuts /> <KeyboardShortcuts />
{children} {children}
<AppUpdaterProvider /> <AppUpdaterProvider />
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+130
View File
@@ -1,5 +1,15 @@
'use client'; 'use client';
<<<<<<< HEAD
import { TitleBar } from "@/components/titlebar";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, startTransition, useRef } from "react";
import { useSettingsStore } from "@/store/settings-store";
import { invoke } from "@tauri-apps/api/core";
export default function Home() {
=======
import { useSetupCheck } from "@/hooks/use-setup-check"; import { useSetupCheck } from "@/hooks/use-setup-check";
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { InstallModal } from "@/components/modals/install-modal"; import { InstallModal } from "@/components/modals/install-modal";
@@ -11,8 +21,111 @@ import { useEffect, startTransition } from "react";
export default function Home() { export default function Home() {
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck(); const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
const router = useRouter(); const router = useRouter();
const { setSetupCompleted } = useSettingsStore();
const [isHydrated, setIsHydrated] = useState(false);
const [isChecking, setIsChecking] = useState(true);
// Refs para evitar loops e execuções múltiplas
const hasCheckedRef = useRef(false);
const isRedirectingRef = useRef(false);
<<<<<<< HEAD
// Hydration check: aguardar Zustand carregar do localStorage
useEffect(() => {
setIsHydrated(true);
}, []);
// Guarda de roteamento: verificação de integridade do Ollama + flag persistente
// Prioridade: Integridade do Sistema > Flag de Setup
useEffect(() => {
if (!isHydrated) return;
if (hasCheckedRef.current) return; // Proteção contra múltiplas execuções
const performCheck = async () => {
if (isRedirectingRef.current) return; // Evitar múltiplos redirecionamentos
setIsChecking(true);
try {
// 1. Verificação Real do Sistema (Fonte da Verdade)
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
// Ler isSetupCompleted diretamente do store (não usar como dependência)
const currentSetupStatus = useSettingsStore.getState().isSetupCompleted;
if (!isOllamaIntact) {
// Se o motor não existe, força o setup, não importa o que o storage diga
console.warn("Integridade do Ollama falhou. Redirecionando para Setup.");
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
// Resetar flag de setup se Ollama não está íntegro (ANTES de redirecionar)
setSetupCompleted(false);
// Pequeno delay para garantir que o estado seja persistido no localStorage
await new Promise(resolve => setTimeout(resolve, 100));
startTransition(() => {
router.replace('/setup');
});
}
return;
}
// 2. Verificação de Fluxo de UI (apenas se Ollama está OK)
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
if (currentSetupStatus) {
startTransition(() => {
router.replace('/chat');
});
} else {
startTransition(() => {
router.replace('/setup');
});
}
}
} catch (e) {
console.error("Falha crítica no check de inicialização", e);
// Fallback seguro: sempre ir para setup em caso de erro
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
setSetupCompleted(false);
// Pequeno delay para garantir que o estado seja persistido no localStorage
await new Promise(resolve => setTimeout(resolve, 100));
startTransition(() => {
router.replace('/setup');
});
}
} finally {
setIsChecking(false);
hasCheckedRef.current = true; // Marcar como executado
}
};
performCheck();
}, [isHydrated, router, setSetupCompleted]);
// Enquanto não hidratou ou está verificando, retornar skeleton simples
if (!isHydrated || isChecking) {
return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<div className="flex-1 flex items-center justify-center pt-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">
{!isHydrated ? 'Carregando...' : 'Verificando sistema...'}
</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
</div>
=======
// Redirecionar automaticamente se tudo estiver pronto // Redirecionar automaticamente se tudo estiver pronto
useEffect(() => { useEffect(() => {
if (status === 'ready') { if (status === 'ready') {
@@ -54,13 +167,29 @@ export default function Home() {
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">{checkingStep}</span> <span className="text-sm font-medium text-foreground">{checkingStep}</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span> <span className="text-xs text-muted-foreground">Aguarde um momento...</span>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</div> </div>
</div> </div>
</div> </div>
); );
} }
// Após verificação, mostrar loading de redirecionamento (não deve aparecer por muito tempo)
return ( return (
<<<<<<< HEAD
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<div className="flex-1 flex items-center justify-center pt-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">Redirecionando...</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
</div>
</div>
</div>
</div>
=======
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300"> <main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
{/* Notificação de boas-vindas */} {/* Notificação de boas-vindas */}
<WelcomeNotification /> <WelcomeNotification />
@@ -108,5 +237,6 @@ export default function Home() {
</> </>
)} )}
</main> </main>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
); );
} }
+1550 -1049
View File
File diff suppressed because it is too large. Load diff
+11 -25
View File
@@ -1,5 +1,11 @@
'use client'; 'use client';
<<<<<<< HEAD
import { SetupWizard } from '@/components/setup/setup-wizard';
import { TitleBar } from '@/components/titlebar';
export default function SetupPage() {
=======
import { useHardware } from "@/hooks/use-hardware"; import { useHardware } from "@/hooks/use-hardware";
import { HardwareScan } from "@/components/setup/hardware-scan"; import { HardwareScan } from "@/components/setup/hardware-scan";
import { GpuSelector } from "@/components/setup/gpu-selector"; import { GpuSelector } from "@/components/setup/gpu-selector";
@@ -24,32 +30,12 @@ export default function SetupPage() {
}); });
}; };
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground"> <div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700"> <TitleBar />
<div className="text-center space-y-2"> <SetupWizard />
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1> </div>
<p className="text-muted-foreground">Vamos preparar o ambiente ideal para você.</p>
</div>
<HardwareScan specs={specs} loading={loading} />
{!loading && specs && gpus.length > 0 && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
<GpuSelector gpus={gpus} />
</div>
)}
{!loading && specs && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
<ModelSelector
recommendation={getRecommendation(specs)}
onComplete={handleComplete}
/>
</div>
)}
</div>
</main>
); );
} }
+1 -1
View File
@@ -105,7 +105,7 @@ export default function TasksPage() {
return ( return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col"> <div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar /> <TitleBar />
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto pt-8">
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden"> <div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
<div className="mb-4 sm:mb-6"> <div className="mb-4 sm:mb-6">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
+46
View File
@@ -18,7 +18,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
<<<<<<< HEAD
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
=======
import { Message } from '@/hooks/use-chat'; import { Message } from '@/hooks/use-chat';
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
import type { DeepResearchState } from '@/hooks/use-deep-research'; import type { DeepResearchState } from '@/hooks/use-deep-research';
import type { ScrapedContent } from '@/services/webSearch'; import type { ScrapedContent } from '@/services/webSearch';
@@ -39,6 +43,18 @@ export interface DebugData {
contextual: string[]; contextual: string[];
}; };
sources: ScrapedContent[]; sources: ScrapedContent[];
<<<<<<< HEAD
sitesResearched?: Array<{
url: string;
title: string;
status: string;
source: string;
duration?: number;
contentLength: number;
scrapedAt: number;
}>;
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
logs: Array<{ logs: Array<{
stage: string; stage: string;
timestamp: number; timestamp: number;
@@ -53,6 +69,35 @@ export interface DebugData {
title: string; title: string;
content: string; content: string;
}>; }>;
<<<<<<< HEAD
activeQueries?: Array<{
query: string;
source: string;
round?: number;
startedAt: number;
}>;
activeUrls?: Array<{
url: string;
title?: string;
status: string;
source?: string;
startedAt: number;
duration?: number;
}>;
};
deepResearchState?: DeepResearchState;
thinkingSteps?: ThinkingMessageMetadata[];
reasoning?: {
steps?: string[];
intermediateResults?: any[];
};
finalResponse: string;
rawResponse: string;
tokenUsage?: {
input: number;
output: number;
total: number;
=======
}; };
deepResearchState?: DeepResearchState; deepResearchState?: DeepResearchState;
finalResponse: string; finalResponse: string;
@@ -60,6 +105,7 @@ export interface DebugData {
tokenUsage?: { tokenUsage?: {
input: number; input: number;
output: number; output: number;
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}; };
} }
+23 -2
View File
@@ -18,6 +18,8 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useState } from 'react'; import { useState } from 'react';
import type { ThinkingMessageMetadata } from '@/hooks/use-chat'; import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
import { SearchQueriesMonitor } from './search-queries-monitor';
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
interface MessageStepThinkingProps { interface MessageStepThinkingProps {
metadata: ThinkingMessageMetadata; metadata: ThinkingMessageMetadata;
@@ -105,13 +107,31 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
{/* Conteúdo Expandido */} {/* Conteúdo Expandido */}
{isExpanded && ( {isExpanded && (
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in"> <div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-4 text-sm text-muted-foreground animate-in fade-in">
{metadata.details && ( {metadata.details && (
<div className="whitespace-pre-wrap">{metadata.details}</div> <div className="whitespace-pre-wrap">{metadata.details}</div>
)} )}
{/* Monitoramento em Tempo Real - Queries */}
{metadata.activeQueries && metadata.activeQueries.length > 0 && (
<div className="pt-2 border-t border-muted/30">
<SearchQueriesMonitor queries={metadata.activeQueries} />
</div>
)}
{/* Monitoramento em Tempo Real - URLs */}
{metadata.activeUrls && metadata.activeUrls.length > 0 && (
<div className="pt-2 border-t border-muted/30">
<ScrapingUrlsMonitor urls={metadata.activeUrls} />
</div>
)}
{metadata.sources && metadata.sources.length > 0 && ( {metadata.sources && metadata.sources.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1"> <div className="pt-2 border-t border-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">
Fontes Encontradas ({metadata.sources.length})
</div>
<div className="flex flex-wrap gap-2">
{metadata.sources.map((source, idx) => ( {metadata.sources.map((source, idx) => (
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]"> <div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
<ExternalLink className="w-3 h-3 opacity-50" /> <ExternalLink className="w-3 h-3 opacity-50" />
@@ -121,6 +141,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
</button> </button>
</div> </div>
))} ))}
</div>
</div> </div>
)} )}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, Search, DownloadCloud, Cpu, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useModelSelection } from '@/hooks/use-model-selection';
import { useDownloadContext } from '@/contexts/download-context';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
export function ModelSelector() {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const { myModels, availableModels, selectedModel, setSelectedModel, installedVariantsByFamily, refreshModels } = useModelSelection();
const { startDownload, state: downloadState } = useDownloadContext();
const { toast } = useToast();
// Track selected variant for each model in the catalog
const [selectedVariants, setSelectedVariants] = React.useState<Record<string, string>>({});
const handleDownload = async (e: React.MouseEvent, modelName: string) => {
e.stopPropagation();
try {
await startDownload(modelName);
toast({
title: "Download Iniciado",
description: `Baixando ${modelName}...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
const handleSmartDownload = async () => {
const term = searchQuery.trim();
if (!term) return;
try {
await startDownload(term);
setOpen(false);
setSearchQuery("");
toast({
title: "Download Iniciado",
description: `Baixando ${term} da biblioteca Ollama...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
React.useEffect(() => {
if (downloadState?.isSuccess) {
refreshModels();
}
}, [downloadState?.isSuccess, refreshModels]);
const filteredMyModels = React.useMemo(() => {
if (!searchQuery) return myModels;
return myModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [myModels, searchQuery]);
const filteredAvailableModels = React.useMemo(() => {
if (!searchQuery) return availableModels;
return availableModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [availableModels, searchQuery]);
// Helper to check if a model is currently selected (partial match for variants)
const isSelected = (modelName: string) => {
if (!selectedModel) return false;
return selectedModel.startsWith(modelName) || selectedModel === modelName;
};
// Helper to extract tag/parameters from model name
const getVariantDisplay = (name: string) => {
if (name.includes(':')) {
return name.split(':')[1];
}
return 'latest';
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[260px] justify-between"
>
{selectedModel ? (
<span className="flex items-center gap-2 truncate">
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate">{selectedModel}</span>
</span>
) : (
"Selecione um modelo..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="flex flex-col">
{/* Search Input */}
<div className="flex items-center border-b border-border/50 px-3 bg-muted/30">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Buscar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery && filteredAvailableModels.length === 0) {
handleSmartDownload();
}
}}
/>
</div>
<Tabs defaultValue="installed" className="w-full">
<TabsList className="w-full rounded-none border-b bg-transparent p-0">
<TabsTrigger
value="installed"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Instalados
</TabsTrigger>
<TabsTrigger
value="available"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Catálogo
</TabsTrigger>
</TabsList>
<TabsContent value="installed" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredMyModels.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchQuery ? (
<span>Nenhum modelo instalado encontrado.</span>
) : (
<span>Nenhum modelo instalado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredMyModels.map((modelDef) => {
const family = modelDef.model;
const installedTags = installedVariantsByFamily[family] || [];
const isFamilySelected = selectedModel ? (selectedModel === family || selectedModel.startsWith(family + ":")) : false;
return (
<div
key={family}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate flex items-center gap-2">
{modelDef.model}
{isFamilySelected && (
<Check className="h-3 w-3 text-primary animate-in fade-in zoom-in" />
)}
</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-1">
{installedTags.map((fullTag) => {
const tag = getVariantDisplay(fullTag);
const isExactSelected = selectedModel === fullTag;
// Find variant def for size info if available
const variantDef = modelDef.variants.find(v => v.name === fullTag);
return (
<Badge
key={fullTag}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isExactSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={variantDef ? `Tamanho: ${variantDef.size}` : undefined}
onClick={(e) => {
e.stopPropagation();
setSelectedModel(fullTag);
setOpen(false);
}}
>
{tag}
</Badge>
);
})}
</div>
</div>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="available" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredAvailableModels.length === 0 ? (
<div className="py-6 text-center text-sm">
{searchQuery ? (
<div className="flex flex-col items-center gap-2">
<p className="text-muted-foreground">Modelo não encontrado no catálogo.</p>
<Button
variant="outline"
className="h-auto py-2 px-4 border-dashed border-zinc-500/50 hover:border-primary hover:bg-primary/10"
onClick={handleSmartDownload}
>
<DownloadCloud className="mr-2 h-4 w-4" />
<span>Baixar <span className="font-bold ml-1">{searchQuery}</span></span>
</Button>
</div>
) : (
<span className="text-muted-foreground">Nenhum modelo encontrado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredAvailableModels.map((modelDef) => {
// Determine active variant for this item
const activeVariantName = selectedVariants[modelDef.model] || modelDef.variants[0]?.name;
const activeVariant = modelDef.variants.find(v => v.name === activeVariantName) || modelDef.variants[0];
return (
<div
key={modelDef.model}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate">{modelDef.model}</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => handleDownload(e, activeVariant.name)}
disabled={downloadState.isDownloading && downloadState.currentModel === activeVariant.name}
title={`Baixar ${activeVariant.name}`}
>
{downloadState.isDownloading && downloadState.currentModel === activeVariant.name ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<DownloadCloud className="h-4 w-4" />
)}
</Button>
</div>
{/* Variants Pills */}
<div className="flex flex-wrap gap-1.5 mt-1">
{modelDef.variants.map((variant) => {
const isVariantSelected = activeVariant.name === variant.name;
return (
<Badge
key={variant.name}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isVariantSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={`Tamanho em disco: ${variant.size}`}
onClick={(e) => {
e.stopPropagation();
setSelectedVariants(prev => ({
...prev,
[modelDef.model]: variant.name
}));
}}
>
{getVariantDisplay(variant.name)}
</Badge>
);
})}
</div>
</div>
)})}
</div>
)}
</TabsContent>
</Tabs>
</div>
</PopoverContent>
</Popover>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { Loader2, CheckCircle2, XCircle, ExternalLink, Globe } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ActiveUrl } from '@/hooks/use-web-search-events';
interface ScrapingUrlsMonitorProps {
urls: ActiveUrl[];
className?: string;
}
export function ScrapingUrlsMonitor({ urls, className }: ScrapingUrlsMonitorProps) {
if (urls.length === 0) return null;
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
} catch {
return '';
}
};
const getStatusIcon = (status: ActiveUrl['status']) => {
switch (status) {
case 'started':
return <Loader2 className="w-3 h-3 animate-spin text-primary" />;
case 'completed':
case 'cached':
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
case 'failed':
return <XCircle className="w-3 h-3 text-red-500" />;
default:
return <Globe className="w-3 h-3 text-muted-foreground" />;
}
};
const getSourceBadge = (source?: string) => {
if (!source) return null;
const colors: Record<string, string> = {
static: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
nodriver: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
playwright: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
cached: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
};
return (
<Badge variant="outline" className={cn('text-xs', colors[source] || 'bg-muted')}>
{source}
</Badge>
);
};
return (
<div className={cn('space-y-2', className)}>
<div className="text-xs font-medium text-muted-foreground mb-2">
Sites Sendo Acessados ({urls.length})
</div>
{urls.map((url, idx) => (
<div
key={`${url.url}-${idx}`}
className="flex items-start gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(url.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<img
src={getFaviconUrl(url.url)}
alt=""
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<a
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
>
{url.title || url.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{getSourceBadge(url.source)}
{url.duration && (
<span className="text-xs text-muted-foreground">
{url.duration}ms
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{url.url}
</p>
</div>
</div>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { Loader2, CheckCircle2, Globe, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ActiveQuery } from '@/hooks/use-web-search-events';
interface SearchQueriesMonitorProps {
queries: ActiveQuery[];
className?: string;
}
export function SearchQueriesMonitor({ queries, className }: SearchQueriesMonitorProps) {
if (queries.length === 0) return null;
const getSourceColor = (source: string) => {
switch (source) {
case 'brave':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
case 'searxng':
return 'bg-green-500/10 text-green-600 border-green-500/20';
case 'duckduckgo':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
default:
return 'bg-muted text-muted-foreground border-muted';
}
};
const getSourceLabel = (source: string) => {
switch (source) {
case 'brave':
return 'Brave';
case 'searxng':
return 'SearXNG';
case 'duckduckgo':
return 'DuckDuckGo';
default:
return source;
}
};
return (
<div className={cn('space-y-2', className)}>
<div className="text-xs font-medium text-muted-foreground mb-2">
Consultas Ativas ({queries.length})
</div>
{queries.map((query, idx) => (
<div
key={`${query.query}-${query.source}-${idx}`}
className="flex items-center gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
>
<Loader2 className="w-3 h-3 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs truncate">{query.query}</span>
<Badge variant="outline" className={cn('text-xs', getSourceColor(query.source))}>
{getSourceLabel(query.source)}
</Badge>
{query.round && (
<Badge variant="outline" className="text-xs">
Round {query.round}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useDownloadContext } from '@/contexts/download-context';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export function GlobalDownloadStatusBar() {
const { state, cancelDownload } = useDownloadContext();
if (!state.isDownloading) {
return null;
}
const { progress, currentModel, isCancelling } = state;
const modelDisplayName = currentModel || 'Modelo';
const percent = progress.percent ?? 0;
const hasSizeInfo = progress.totalBytes > 0 && progress.downloadedBytes > 0;
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 z-50',
'bg-background/95 backdrop-blur-sm border-t border-border',
'shadow-lg',
'animate-in slide-in-from-bottom duration-300',
'transition-all'
)}
>
<div className="container mx-auto px-4 py-3">
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-foreground truncate">
{isCancelling ? (
'Cancelando download...'
) : (
`Baixando Modelo: ${modelDisplayName} (${percent}%)`
)}
</span>
{progress.speed && !isCancelling && (
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{progress.speed}
</span>
)}
</div>
<Progress
value={percent}
className="h-1.5 mb-1.5"
/>
{hasSizeInfo && (
<div className="flex justify-between items-center text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
<span>
{progress.downloadedFormatted} / {progress.totalFormatted}
</span>
{progress.status && (
<span className="normal-case">{progress.status}</span>
)}
</div>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={cancelDownload}
disabled={isCancelling}
className="shrink-0 h-8 w-8"
aria-label="Cancelar download"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+33 -8
View File
@@ -87,10 +87,7 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
const handleInstall = async () => { const handleInstall = async () => {
try { try {
await downloadState.handleInstall(); await downloadState.handleInstall();
toast({ // Toast será mostrado automaticamente via eventos de progresso
title: 'Instalador iniciado',
description: 'O instalador foi aberto. Siga as instruções na tela.',
});
} catch (error) { } catch (error) {
toast({ toast({
title: 'Erro ao executar instalador', title: 'Erro ao executar instalador',
@@ -144,8 +141,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
const isActive = activeTab === os; const isActive = activeTab === os;
const isDownloading = downloadState.isDownloading && isActive; const isDownloading = downloadState.isDownloading && isActive;
const hasDownloaded = downloadState.filePath !== null && isActive; const hasDownloaded = downloadState.filePath !== null && isActive;
const isInstalling = downloadState.isInstalling && isActive;
const installStatus = downloadState.installStatus;
if (!isDownloading && !hasDownloaded) { if (!isDownloading && !hasDownloaded && !isInstalling) {
return null; return null;
} }
@@ -162,7 +161,18 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<Progress value={downloadState.downloadProgress} className="h-2" /> <Progress value={downloadState.downloadProgress} className="h-2" />
</> </>
)} )}
{hasDownloaded && !isDownloading && ( {isInstalling && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{installStatus || 'Instalando...'}
</span>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
</div>
<Progress value={undefined} className="h-2" />
</>
)}
{hasDownloaded && !isDownloading && !isInstalling && (
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md"> <div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" /> <CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
<div className="text-sm flex-1"> <div className="text-sm flex-1">
@@ -170,7 +180,20 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
Download concluído Download concluído
</p> </p>
<p className="text-green-700 dark:text-green-300"> <p className="text-green-700 dark:text-green-300">
Clique em "Instalar Ollama" para iniciar a instalação. Clique em "Instalar Ollama" para iniciar a instalação automática.
</p>
</div>
</div>
)}
{downloadState.isInstalled && !isInstalling && (
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
<div className="text-sm flex-1">
<p className="font-medium text-green-900 dark:text-green-100">
Instalação concluída!
</p>
<p className="text-green-700 dark:text-green-300">
{installStatus || 'Ollama foi instalado com sucesso.'}
</p> </p>
</div> </div>
</div> </div>
@@ -183,7 +206,9 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<Dialog open={open}> <Dialog open={open}>
<DialogContent className="sm:max-w-[600px]"> <DialogContent className="sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Ollama não encontrado</DialogTitle> <DialogTitle data-tauri-drag-region className="cursor-move select-none">
Ollama não encontrado
</DialogTitle>
<DialogDescription> <DialogDescription>
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema. Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
Baixe e instale o Ollama usando os passos abaixo. Baixe e instale o Ollama usando os passos abaixo.
@@ -67,3 +67,11 @@ export function WelcomeNotification() {
+8
View File
@@ -269,3 +269,11 @@ export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps)
} }
@@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ExternalLink, Copy, Check, Terminal } from 'lucide-react';
import { ProblemSolution } from '@/lib/docker-problems';
import { useToast } from '@/hooks/use-toast';
import { invoke } from '@tauri-apps/api/core';
import { cn } from '@/lib/utils';
interface DockerCommandsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
problem: ProblemSolution | null;
}
export function DockerCommandsModal({ open, onOpenChange, problem }: DockerCommandsModalProps) {
const { toast } = useToast();
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
if (!problem) return null;
const handleCopyCommand = async (command: string, index: number) => {
try {
await navigator.clipboard.writeText(command);
setCopiedIndex(index);
toast({
title: 'Comando copiado',
description: 'Comando copiado para a área de transferência',
});
setTimeout(() => setCopiedIndex(null), 2000);
} catch (error) {
toast({
title: 'Erro ao copiar',
description: 'Não foi possível copiar o comando',
variant: 'destructive',
});
}
};
const handleOpenLink = async (url: string) => {
try {
await invoke('open_url', { url });
} catch (error) {
console.error('Error opening URL:', error);
// Fallback para window.open se Tauri falhar
window.open(url, '_blank', 'noopener,noreferrer');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-4 sm:p-6">
<DialogHeader className="flex-shrink-0 pb-2 sm:pb-4">
<DialogTitle className="flex items-center gap-2 text-sm sm:text-base md:text-lg">
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="break-words">{problem.problem}</span>
</DialogTitle>
{problem.description && (
<DialogDescription className="text-xs sm:text-sm mt-1 sm:mt-2">
{problem.description}
</DialogDescription>
)}
</DialogHeader>
<div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 min-h-0 pr-1 -mr-1">
{/* Links de Documentação */}
{problem.links.length > 0 && (
<Card>
<CardHeader className="pb-2 sm:pb-3">
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
Documentação Oficial
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Links para documentação oficial sobre este problema
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{problem.links.map((link, idx) => (
<Button
key={idx}
variant="outline"
className="w-full justify-start text-xs sm:text-sm h-9 sm:h-10"
onClick={() => handleOpenLink(link.url)}
>
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4 mr-2 shrink-0" />
<span className="truncate">{link.title}</span>
</Button>
))}
</CardContent>
</Card>
)}
{/* Comandos */}
{problem.commands && problem.commands.length > 0 && (
<Card>
<CardHeader className="pb-2 sm:pb-3">
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
<Terminal className="w-3 h-3 sm:w-4 sm:h-4" />
Comandos
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Execute estes comandos no terminal para resolver o problema
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
{problem.commands.map((command, idx) => (
<div key={idx} className="space-y-2">
<div className="flex items-start justify-between gap-2">
<h4 className="text-xs sm:text-sm font-medium flex-1 break-words">{command.title}</h4>
<Button
size="sm"
variant="ghost"
onClick={() => handleCopyCommand(command.code, idx)}
className="h-7 sm:h-8 shrink-0 text-xs"
>
{copiedIndex === idx ? (
<>
<Check className="w-3 h-3 mr-1 text-green-500" />
<span className="hidden sm:inline">Copiado</span>
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
<span className="hidden sm:inline">Copiar</span>
</>
)}
</Button>
</div>
{command.description && (
<p className="text-xs text-muted-foreground">{command.description}</p>
)}
<div className="relative">
<pre className="bg-muted p-2 sm:p-3 md:p-4 rounded-lg overflow-x-auto text-xs sm:text-sm">
<code className="font-mono whitespace-pre-wrap break-all">{command.code}</code>
</pre>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Mensagem quando não há comandos nem links */}
{problem.links.length === 0 && (!problem.commands || problem.commands.length === 0) && (
<Card>
<CardContent className="py-4 sm:py-6 text-center text-muted-foreground">
<p className="text-xs sm:text-sm">Nenhuma solução disponível para este problema.</p>
<p className="text-xs mt-2">
Consulte a documentação oficial do Docker para mais informações.
</p>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,899 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Progress } from '@/components/ui/progress';
import {
CheckCircle2,
XCircle,
Loader2,
Download,
RefreshCw,
AlertCircle,
ExternalLink,
Settings,
Wrench,
Server,
Cpu,
HardDrive,
MemoryStick,
Shield,
Zap,
Terminal
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { useToast } from '@/hooks/use-toast';
import { SearxngInstallWizard } from './searxng-install-wizard';
import { DockerCommandsModal } from './docker-commands-modal';
import { findProblemSolution, ProblemSolution } from '@/lib/docker-problems';
import { cn } from '@/lib/utils';
interface SystemRequirements {
status: 'pass' | 'fail' | 'warn';
os: string;
arch: string;
ram_mb: number;
disk_gb: number;
cpu_cores: number;
kernel_version?: string;
issues: string[];
warnings: string[];
}
interface InstallProgressEvent {
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
step?: number;
step_name?: string;
total_steps?: number;
progress: number;
message?: string;
log_line?: string;
}
interface FixProgressEvent {
event_type: 'start' | 'progress' | 'complete' | 'error';
message: string;
progress?: number;
}
interface ValidationResult {
status: 'pass' | 'fail' | 'partial';
tests: Array<{
name: string;
status: 'pass' | 'fail' | 'warn';
message?: string;
}>;
docker_version?: string;
}
export function DockerSettingsPanel() {
const { toast } = useToast();
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
const [isFixing, setIsFixing] = useState(false);
const [fixProgress, setFixProgress] = useState<{ message: string; progress: number } | null>(null);
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
currentStep: number;
totalSteps: number;
progress: number;
currentStepName: string;
logs: string[];
}>({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showInstallWizard, setShowInstallWizard] = useState(false);
const [showCommandsModal, setShowCommandsModal] = useState(false);
const [selectedProblem, setSelectedProblem] = useState<ProblemSolution | null>(null);
// Verificar Docker ao montar
useEffect(() => {
checkDocker();
checkRequirements();
}, []);
// Listener de eventos de progresso Docker
useEffect(() => {
let unlistenPromise: Promise<() => void> | null = null;
if (isInstallingDocker) {
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
const data = event.payload;
switch (data.event_type) {
case 'step_start':
setDockerInstallProgress(prev => ({
...prev,
currentStep: data.step || 0,
totalSteps: data.total_steps || prev.totalSteps,
currentStepName: data.step_name || '',
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'log':
if (data.log_line) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.log_line!],
}));
}
break;
case 'step_complete':
setDockerInstallProgress(prev => ({
...prev,
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'error':
toast({
title: 'Erro na instalação',
description: data.message || 'Erro desconhecido',
variant: 'destructive',
});
setIsInstallingDocker(false);
break;
case 'complete':
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
setIsInstallingDocker(false);
toast({
title: 'Docker instalado',
description: 'Docker foi instalado com sucesso!',
});
setTimeout(() => {
validateDocker();
checkDocker();
}, 1000);
break;
}
});
}
return () => {
if (unlistenPromise) {
unlistenPromise.then(fn => fn());
}
};
}, [isInstallingDocker, toast]);
// Listener de eventos de correção
useEffect(() => {
let unlistenPromise: Promise<() => void> | null = null;
if (isFixing) {
unlistenPromise = listen<FixProgressEvent>('docker-fix-progress', (event) => {
const data = event.payload;
switch (data.event_type) {
case 'start':
setFixProgress({ message: data.message, progress: 0 });
break;
case 'progress':
setFixProgress({ message: data.message, progress: data.progress || 0 });
break;
case 'complete':
setFixProgress({ message: data.message, progress: 100 });
setTimeout(() => {
setIsFixing(false);
setFixProgress(null);
checkRequirements();
toast({
title: 'Correção concluída',
description: data.message,
});
}, 1500);
break;
case 'error':
setIsFixing(false);
setFixProgress(null);
toast({
title: 'Erro na correção',
description: data.message,
variant: 'destructive',
});
break;
}
});
}
return () => {
if (unlistenPromise) {
unlistenPromise.then(fn => fn());
}
};
}, [isFixing, toast]);
const checkDocker = async () => {
setIsChecking(true);
try {
const available = await invoke<boolean>('check_docker_available');
setDockerAvailable(available);
if (available) {
const running = await invoke<boolean>('check_docker_running');
setDockerRunning(running);
} else {
setDockerRunning(false);
}
} catch (error) {
console.error('Error checking Docker:', error);
setDockerAvailable(false);
setDockerRunning(false);
} finally {
setIsChecking(false);
}
};
const checkRequirements = async () => {
setIsCheckingRequirements(true);
try {
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
setRequirements(reqs);
} catch (error) {
console.error('Error checking requirements:', error);
setRequirements({
status: 'fail',
os: 'unknown',
arch: 'unknown',
ram_mb: 0,
disk_gb: 0,
cpu_cores: 0,
issues: ['Erro ao verificar requisitos do sistema'],
warnings: [],
});
} finally {
setIsCheckingRequirements(false);
}
};
const handleFixRequirements = async () => {
if (!requirements || requirements.issues.length === 0) {
toast({
title: 'Nada para corrigir',
description: 'Não há problemas encontrados que possam ser corrigidos automaticamente',
});
return;
}
setIsFixing(true);
setFixProgress({ message: 'Preparando correção...', progress: 0 });
try {
await invoke('fix_docker_requirements', {
os: requirements.os,
issues: requirements.issues,
});
} catch (error) {
console.error('Error fixing requirements:', error);
setIsFixing(false);
setFixProgress(null);
toast({
title: 'Erro na correção',
description: error instanceof Error ? error.message : 'Erro desconhecido',
variant: 'destructive',
});
}
};
const handleInstallDocker = async () => {
setIsInstallingDocker(true);
setDockerInstallProgress({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
try {
await invoke('install_docker');
} catch (error) {
console.error('Docker installation error:', error);
toast({
title: 'Erro na instalação',
description: error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker',
variant: 'destructive',
});
setIsInstallingDocker(false);
}
};
const validateDocker = async () => {
setIsValidating(true);
setValidationResult(null);
try {
const result = await invoke<ValidationResult>('validate_docker_installation');
setValidationResult(result);
if (result.status === 'pass' || result.status === 'partial') {
setDockerAvailable(true);
setDockerRunning(true);
toast({
title: 'Docker validado',
description: 'Docker está funcionando corretamente',
});
} else {
toast({
title: 'Validação falhou',
description: 'Alguns testes de validação falharam',
variant: 'destructive',
});
}
} catch (error) {
console.error('Validation error:', error);
toast({
title: 'Erro na validação',
description: error instanceof Error ? error.message : 'Erro ao validar Docker',
variant: 'destructive',
});
} finally {
setIsValidating(false);
}
};
const getStatusIcon = (status: 'pass' | 'fail' | 'warn') => {
switch (status) {
case 'pass':
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
case 'fail':
return <XCircle className="w-5 h-5 text-red-500" />;
case 'warn':
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
}
};
return (
<div className="space-y-4">
{/* Status do Docker */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<CardTitle className="text-lg">Status do Docker</CardTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={checkDocker}
disabled={isChecking}
>
<RefreshCw className={cn("w-4 h-4 mr-1", isChecking && "animate-spin")} />
Atualizar
</Button>
</div>
<CardDescription>
Verifique se Docker está instalado e rodando
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isChecking ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
<span className="text-sm text-muted-foreground">Verificando...</span>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<div className={cn(
"p-4 rounded-lg border-2 transition-colors",
dockerAvailable
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
)}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-foreground">Instalação</span>
{dockerAvailable ? (
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
) : (
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
)}
</div>
<Badge
variant="outline"
className={cn(
dockerAvailable
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
)}
>
{dockerAvailable ? 'Instalado' : 'Não Instalado'}
</Badge>
</div>
<div className={cn(
"p-4 rounded-lg border-2 transition-colors",
dockerRunning
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
)}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-foreground">Serviço</span>
{dockerRunning ? (
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
) : (
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
)}
</div>
<Badge
variant="outline"
className={cn(
dockerRunning
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
)}
>
{dockerRunning ? 'Rodando' : 'Parado'}
</Badge>
</div>
</div>
{dockerAvailable === false && (
<Alert variant="destructive">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
Docker não está instalado. Instale-o para usar SearXNG.
</AlertDescription>
</Alert>
)}
{dockerAvailable && !dockerRunning && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
</AlertDescription>
</Alert>
)}
</>
)}
</CardContent>
</Card>
{/* Requisitos do Sistema */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" />
<CardTitle className="text-lg">Requisitos do Sistema</CardTitle>
</div>
<div className="flex gap-2">
{requirements && requirements.issues.length > 0 && (
<Button
variant="default"
size="sm"
onClick={handleFixRequirements}
disabled={isFixing || isCheckingRequirements}
>
<Wrench className="w-4 h-4 mr-1" />
Corrigir
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={checkRequirements}
disabled={isCheckingRequirements}
>
<RefreshCw className={cn("w-4 h-4 mr-1", isCheckingRequirements && "animate-spin")} />
Verificar
</Button>
</div>
</div>
<CardDescription>
Verifique se seu sistema atende aos requisitos para Docker
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isCheckingRequirements ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
<span className="text-sm text-muted-foreground">Verificando requisitos...</span>
</div>
) : requirements ? (
<>
{/* Status Geral */}
<div className={cn(
"p-4 rounded-lg border-2 flex items-center justify-between",
requirements.status === 'pass' && "border-green-500/50 bg-green-500/10 dark:bg-green-500/20",
requirements.status === 'warn' && "border-yellow-500/50 bg-yellow-500/10 dark:bg-yellow-500/20",
requirements.status === 'fail' && "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
)}>
<div className="flex items-center gap-3">
{getStatusIcon(requirements.status)}
<div>
<p className="font-semibold text-foreground">Status Geral</p>
<p className="text-xs text-muted-foreground">
{requirements.status === 'pass' && 'Sistema compatível'}
{requirements.status === 'warn' && 'Avisos encontrados'}
{requirements.status === 'fail' && 'Problemas encontrados'}
</p>
</div>
</div>
<Badge className={cn(
requirements.status === 'pass' && "bg-green-500",
requirements.status === 'warn' && "bg-yellow-500",
requirements.status === 'fail' && "bg-red-500"
)}>
{requirements.status === 'pass' && 'Compatível'}
{requirements.status === 'warn' && 'Avisos'}
{requirements.status === 'fail' && 'Incompatível'}
</Badge>
</div>
{/* Especificações do Sistema */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-1">
<MemoryStick className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">RAM</span>
</div>
<p className="text-sm font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
</div>
<div className="p-3 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Disco Livre</span>
</div>
<p className="text-sm font-medium">{requirements.disk_gb} GB</p>
</div>
<div className="p-3 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-1">
<Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">CPU Cores</span>
</div>
<p className="text-sm font-medium">{requirements.cpu_cores}</p>
</div>
<div className="p-3 rounded-lg border bg-muted/30">
<div className="flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Sistema</span>
</div>
<p className="text-sm font-medium">{requirements.os} ({requirements.arch})</p>
</div>
</div>
{/* Progresso de Correção */}
{isFixing && fixProgress && (
<div className="space-y-2 p-4 rounded-lg border bg-blue-50 border-blue-200">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{fixProgress.message}</span>
<span>{fixProgress.progress}%</span>
</div>
<Progress value={fixProgress.progress} className="h-2" />
</div>
)}
{/* Problemas */}
{requirements.issues.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Problemas Encontrados:</p>
<ul className="space-y-2 text-sm">
{requirements.issues.map((issue, idx) => {
const solution = findProblemSolution(issue, requirements.os);
return (
<li key={idx} className="flex items-start justify-between gap-2">
<span className="flex-1">{issue}</span>
{solution && (
<div className="flex gap-1 shrink-0">
{solution.commands && solution.commands.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedProblem(solution);
setShowCommandsModal(true);
}}
className="h-7 text-xs"
>
<Terminal className="w-3 h-3 mr-1" />
Comandos
</Button>
)}
{solution.links.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={async () => {
if (solution.links[0]) {
try {
await invoke('open_url', { url: solution.links[0].url });
} catch (error) {
console.error('Error opening URL:', error);
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
}
}
}}
className="h-7 text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
Docs
</Button>
)}
</div>
)}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
)}
{/* Avisos */}
{requirements.warnings.length > 0 && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Avisos:</p>
<ul className="space-y-2 text-sm">
{requirements.warnings.map((warning, idx) => {
const solution = findProblemSolution(warning, requirements.os);
return (
<li key={idx} className="flex items-start justify-between gap-2">
<span className="flex-1">{warning}</span>
{solution && (
<div className="flex gap-1 shrink-0">
{solution.commands && solution.commands.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedProblem(solution);
setShowCommandsModal(true);
}}
className="h-7 text-xs"
>
<Terminal className="w-3 h-3 mr-1" />
Comandos
</Button>
)}
{solution.links.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={async () => {
if (solution.links[0]) {
try {
await invoke('open_url', { url: solution.links[0].url });
} catch (error) {
console.error('Error opening URL:', error);
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
}
}
}}
className="h-7 text-xs"
>
<ExternalLink className="w-3 h-3 mr-1" />
Docs
</Button>
)}
</div>
)}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
)}
</>
) : null}
</CardContent>
</Card>
{/* Instalação Docker */}
{dockerAvailable === false && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary" />
<CardTitle className="text-lg">Instalar Docker</CardTitle>
</div>
<CardDescription>
Instale Docker automaticamente ou manualmente
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
window.open('https://www.docker.com/products/docker-desktop', '_blank');
}}
className="flex-1"
>
<ExternalLink className="w-4 h-4 mr-2" />
Instalação Manual
</Button>
<Button
variant="default"
onClick={handleInstallDocker}
disabled={isInstallingDocker || requirements?.status === 'fail'}
className="flex-1"
>
{isInstallingDocker ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Instalando...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Instalar Agora
</>
)}
</Button>
</div>
{isInstallingDocker && (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
<span className="text-muted-foreground">{dockerInstallProgress.progress}%</span>
</div>
<Progress value={dockerInstallProgress.progress} className="h-2" />
</div>
{dockerInstallProgress.logs.length > 0 && (
<div className="bg-black text-green-400 font-mono text-xs p-3 rounded-lg h-40 overflow-y-auto">
{dockerInstallProgress.logs.map((log, idx) => (
<div key={idx}>{log}</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Validação */}
{dockerAvailable && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-primary" />
<CardTitle className="text-lg">Validação</CardTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={validateDocker}
disabled={isValidating}
>
{isValidating ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
Validando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-1" />
Validar
</>
)}
</Button>
</div>
<CardDescription>
Execute testes para verificar se Docker está funcionando corretamente
</CardDescription>
</CardHeader>
<CardContent>
{validationResult ? (
<div className="space-y-2">
{validationResult.tests.map((test, idx) => (
<div
key={idx}
className={cn(
"flex items-center justify-between p-3 rounded-lg border",
test.status === 'pass' && "bg-green-50 border-green-200",
test.status === 'warn' && "bg-yellow-50 border-yellow-200",
test.status === 'fail' && "bg-red-50 border-red-200"
)}
>
<div className="flex-1">
<p className="text-sm font-medium">{test.name}</p>
{test.message && (
<p className="text-xs text-muted-foreground mt-1">{test.message}</p>
)}
</div>
{test.status === 'pass' ? (
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
) : test.status === 'warn' ? (
<AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0" />
) : (
<XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
)}
</div>
))}
{validationResult.docker_version && (
<p className="text-xs text-muted-foreground mt-2">
Docker {validationResult.docker_version} instalado
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
Clique em "Validar" para executar os testes
</p>
)}
</CardContent>
</Card>
)}
{/* Botão para configurar SearXNG */}
{dockerAvailable && dockerRunning && (
<Card>
<CardContent className="pt-6">
<Button
variant="default"
onClick={() => setShowInstallWizard(true)}
className="w-full"
size="lg"
>
<Settings className="w-5 h-5 mr-2" />
Configurar SearXNG
</Button>
</CardContent>
</Card>
)}
{showInstallWizard && (
<SearxngInstallWizard
onComplete={() => {
setShowInstallWizard(false);
checkDocker();
toast({
title: 'SearXNG configurado',
description: 'SearXNG foi instalado e configurado com sucesso!',
});
}}
onCancel={() => setShowInstallWizard(false)}
initialUrl="http://localhost:8080"
/>
)}
<DockerCommandsModal
open={showCommandsModal}
onOpenChange={setShowCommandsModal}
problem={selectedProblem}
/>
</div>
);
}
@@ -0,0 +1,244 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { useSettingsStore } from '@/store/settings-store';
import { invoke } from '@tauri-apps/api/core';
import { Zap, Cpu } from 'lucide-react';
interface LocalGgufModel {
path: string;
name: string;
size: number;
quantization?: string;
architecture?: string;
}
export function RuntimeSettingsPanel() {
const { runtime, llamaCppParams, setRuntime, setLlamaCppParams } = useSettingsStore();
const [localModels, setLocalModels] = useState<LocalGgufModel[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadLocalModels();
}, []);
const loadLocalModels = async () => {
try {
setLoading(true);
const models = await invoke<LocalGgufModel[]>('list_local_gguf_models');
setLocalModels(models);
} catch (error) {
console.error('Failed to load local GGUF models:', error);
} finally {
setLoading(false);
}
};
const hasLocalModels = localModels.length > 0;
const isLlamaCppAvailable = hasLocalModels;
return (
<Card>
<CardHeader>
<CardTitle>Runtime de Modelos</CardTitle>
<CardDescription>
Escolha entre Ollama (servidor externo) ou llama.cpp nativo (mais rápido, sem servidor)
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Toggle de Runtime */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="runtime-toggle">Usar llama.cpp nativo</Label>
<p className="text-sm text-muted-foreground">
{runtime === 'ollama'
? 'Usando Ollama como runtime (requer servidor externo)'
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
</p>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'} className="gap-1">
{runtime === 'ollama' ? (
<>
<span>🦙</span> Ollama
</>
) : (
<>
<Zap className="h-3 w-3" />
llama.cpp
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent>
{runtime === 'ollama'
? 'Usando Ollama como runtime (requer servidor externo)'
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
id="runtime-toggle"
checked={runtime === 'llama-cpp'}
onCheckedChange={(checked) => setRuntime(checked ? 'llama-cpp' : 'ollama')}
disabled={!isLlamaCppAvailable}
/>
</div>
</div>
{!isLlamaCppAvailable && (
<div className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
<p>
Nenhum modelo GGUF local encontrado. Baixe modelos do Hugging Face para usar llama.cpp nativo.
</p>
</div>
)}
{loading && (
<div className="text-sm text-muted-foreground">Carregando modelos locais...</div>
)}
{hasLocalModels && (
<div className="rounded-md bg-muted p-3">
<p className="text-sm font-medium mb-2">
Modelos GGUF locais encontrados: {localModels.length}
</p>
<div className="space-y-1">
{localModels.slice(0, 5).map((model, idx) => (
<div key={idx} className="text-xs text-muted-foreground">
• {model.name} {model.quantization && `(${model.quantization})`}
</div>
))}
{localModels.length > 5 && (
<div className="text-xs text-muted-foreground">
... e mais {localModels.length - 5} modelo(s)
</div>
)}
</div>
</div>
)}
{/* Configurações do llama.cpp */}
{runtime === 'llama-cpp' && (
<div className="space-y-4 pt-4 border-t">
<div>
<Label>Camadas GPU (n_gpu_layers)</Label>
<p className="text-sm text-muted-foreground mb-2">
Número de camadas para carregar na GPU. 0 = CPU apenas
</p>
<div className="flex items-center gap-4">
<Slider
value={[llamaCppParams.nGpuLayers]}
onValueChange={([value]) =>
setLlamaCppParams({ nGpuLayers: value })
}
min={0}
max={100}
step={1}
className="flex-1"
/>
<Input
type="number"
value={llamaCppParams.nGpuLayers}
onChange={(e) =>
setLlamaCppParams({ nGpuLayers: parseInt(e.target.value) || 0 })
}
className="w-20"
min={0}
max={100}
/>
</div>
</div>
<div>
<Label>Tamanho do Contexto (n_ctx)</Label>
<p className="text-sm text-muted-foreground mb-2">
Tamanho máximo do contexto em tokens
</p>
<div className="flex items-center gap-4">
<Slider
value={[llamaCppParams.nCtx]}
onValueChange={([value]) =>
setLlamaCppParams({ nCtx: value })
}
min={512}
max={32768}
step={512}
className="flex-1"
/>
<Input
type="number"
value={llamaCppParams.nCtx}
onChange={(e) =>
setLlamaCppParams({ nCtx: parseInt(e.target.value) || 4096 })
}
className="w-24"
min={512}
max={32768}
step={512}
/>
</div>
</div>
<div>
<Label>Threads</Label>
<p className="text-sm text-muted-foreground mb-2">
Número de threads (0 = automático)
</p>
<Input
type="number"
value={llamaCppParams.threads || 0}
onChange={(e) =>
setLlamaCppParams({ threads: parseInt(e.target.value) || 0 })
}
className="w-32"
min={0}
max={32}
/>
</div>
<div>
<Label>Temperatura</Label>
<p className="text-sm text-muted-foreground mb-2">
Controla a aleatoriedade das respostas (0.0 - 2.0)
</p>
<div className="flex items-center gap-4">
<Slider
value={[llamaCppParams.temperature]}
onValueChange={([value]) =>
setLlamaCppParams({ temperature: value })
}
min={0}
max={2}
step={0.1}
className="flex-1"
/>
<Input
type="number"
value={llamaCppParams.temperature}
onChange={(e) =>
setLlamaCppParams({ temperature: parseFloat(e.target.value) || 0.7 })
}
className="w-20"
min={0}
max={2}
step={0.1}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,209 @@
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { Loader2, Save, Search, Shield } from 'lucide-react';
interface SearchConfig {
region: string;
safe_search: string;
max_results: number;
enabled: boolean;
log_search_content: boolean;
retention_days: number;
}
export function SearchSettingsPanel() {
const { toast } = useToast();
const [config, setConfig] = useState<SearchConfig | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setIsLoading(true);
try {
const loadedConfig = await invoke<SearchConfig>('load_search_config_command');
setConfig(loadedConfig);
} catch (error) {
console.error('Failed to load Search config:', error);
toast({
title: 'Error loading configuration',
description: String(error),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
if (!config) return;
setIsSaving(true);
try {
await invoke('save_search_config_command', { config });
toast({
title: 'Configuration saved',
description: 'Search settings have been updated successfully.',
});
} catch (error) {
console.error('Failed to save Search config:', error);
toast({
title: 'Error saving configuration',
description: String(error),
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!config) {
return (
<div className="text-center p-8 text-muted-foreground">
Failed to load configuration.
<Button variant="outline" className="ml-4" onClick={loadConfig}>Retry</Button>
</div>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
DuckDuckGo Search Integration
</CardTitle>
<CardDescription>
Configure anonymous web search settings provided by DuckDuckGo.
</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={config.enabled}
onCheckedChange={(checked) => setConfig({ ...config, enabled: checked })}
id="search-enabled"
/>
<Label htmlFor="search-enabled">Enabled</Label>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="region">Region</Label>
<Select
value={config.region}
onValueChange={(val) => setConfig({ ...config, region: val })}
>
<SelectTrigger id="region">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
<SelectItem value="wt-wt">Global (wt-wt)</SelectItem>
<SelectItem value="us-en">United States (us-en)</SelectItem>
<SelectItem value="uk-en">United Kingdom (uk-en)</SelectItem>
<SelectItem value="br-pt">Brazil (br-pt)</SelectItem>
<SelectItem value="de-de">Germany (de-de)</SelectItem>
<SelectItem value="fr-fr">France (fr-fr)</SelectItem>
<SelectItem value="es-es">Spain (es-es)</SelectItem>
<SelectItem value="it-it">Italy (it-it)</SelectItem>
<SelectItem value="jp-jp">Japan (jp-jp)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="safe-search" className="flex items-center gap-2">
<Shield className="h-3 w-3" /> Safe Search
</Label>
<Select
value={config.safe_search}
onValueChange={(val) => setConfig({ ...config, safe_search: val })}
>
<SelectTrigger id="safe-search">
<SelectValue placeholder="Select safe search level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="on">Strict (On)</SelectItem>
<SelectItem value="moderate">Moderate</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="max-results">Max Results</Label>
<Input
id="max-results"
type="number"
value={config.max_results}
onChange={(e) => setConfig({ ...config, max_results: parseInt(e.target.value) || 10 })}
min={1}
max={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retention">Log Retention (days)</Label>
<Input
id="retention"
type="number"
value={config.retention_days}
onChange={(e) => setConfig({ ...config, retention_days: parseInt(e.target.value) || 30 })}
min={1}
max={365}
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t">
<h3 className="text-sm font-medium">Privacy</h3>
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<Label className="text-sm">Log Search Content</Label>
<p className="text-xs text-muted-foreground">
Store query text in local history
</p>
</div>
<Switch
checked={config.log_search_content}
onCheckedChange={(checked) => setConfig({ ...config, log_search_content: checked })}
/>
</div>
</div>
<div className="flex justify-end pt-4">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Save Settings
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,881 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
CheckCircle2,
XCircle,
Loader2,
ArrowLeft,
ArrowRight,
AlertCircle,
ExternalLink,
Download,
Settings,
Play,
Square,
X
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { cn } from '@/lib/utils';
interface SearxngInstallWizardProps {
onComplete: () => void;
onCancel: () => void;
initialUrl?: string;
}
type Step = 0 | 1 | 2 | 3 | 4 | 5;
interface SystemRequirements {
status: 'pass' | 'fail' | 'warn';
os: string;
arch: string;
ram_mb: number;
disk_gb: number;
cpu_cores: number;
kernel_version?: string;
issues: string[];
warnings: string[];
}
interface InstallProgressEvent {
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
step?: number;
step_name?: string;
total_steps?: number;
progress: number;
message?: string;
log_line?: string;
}
interface ValidationResult {
status: 'pass' | 'fail' | 'partial';
tests: Array<{
name: string;
status: 'pass' | 'fail' | 'warn';
message?: string;
}>;
docker_version?: string;
}
export function SearxngInstallWizard({ onComplete, onCancel, initialUrl = 'http://localhost:8080' }: SearxngInstallWizardProps) {
const [currentStep, setCurrentStep] = useState<Step>(0);
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [url, setUrl] = useState(initialUrl);
const [port, setPort] = useState('8080');
const [autoStart, setAutoStart] = useState(true);
const [installProgress, setInstallProgress] = useState<string>('');
const [isInstalling, setIsInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
currentStep: number;
totalSteps: number;
progress: number;
currentStepName: string;
logs: string[];
}>({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [verificationStatus, setVerificationStatus] = useState<'checking' | 'success' | 'error' | null>(null);
const [verificationError, setVerificationError] = useState<string | null>(null);
// Step 0: Verificar Requisitos
useEffect(() => {
if (currentStep === 0) {
checkRequirements();
}
}, [currentStep]);
// Step 1: Verificar Docker
useEffect(() => {
if (currentStep === 1) {
checkDocker();
}
}, [currentStep]);
// Listener de eventos de progresso Docker
useEffect(() => {
let unlistenPromise: Promise<() => void> | null = null;
if (isInstallingDocker) {
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
const data = event.payload;
switch (data.event_type) {
case 'step_start':
setDockerInstallProgress(prev => ({
...prev,
currentStep: data.step || 0,
totalSteps: data.total_steps || prev.totalSteps,
currentStepName: data.step_name || '',
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'log':
if (data.log_line) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.log_line!],
}));
}
break;
case 'step_complete':
setDockerInstallProgress(prev => ({
...prev,
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'error':
setInstallError(data.message || 'Erro desconhecido');
setIsInstallingDocker(false);
break;
case 'complete':
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
setIsInstallingDocker(false);
// Validar instalação após conclusão
setTimeout(() => {
validateDocker();
}, 1000);
break;
}
});
}
return () => {
if (unlistenPromise) {
unlistenPromise.then(fn => fn());
}
};
}, [isInstallingDocker]);
const checkRequirements = async () => {
setIsCheckingRequirements(true);
try {
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
setRequirements(reqs);
} catch (error) {
console.error('Error checking requirements:', error);
setRequirements({
status: 'fail',
os: 'unknown',
arch: 'unknown',
ram_mb: 0,
disk_gb: 0,
cpu_cores: 0,
issues: ['Erro ao verificar requisitos do sistema'],
warnings: [],
});
} finally {
setIsCheckingRequirements(false);
}
};
const checkDocker = async () => {
setIsChecking(true);
try {
const available = await invoke<boolean>('check_docker_available');
setDockerAvailable(available);
if (available) {
const running = await invoke<boolean>('check_docker_running');
setDockerRunning(running);
}
} catch (error) {
console.error('Error checking Docker:', error);
setDockerAvailable(false);
setDockerRunning(false);
} finally {
setIsChecking(false);
}
};
const handleNext = () => {
if (currentStep < 5) {
setCurrentStep((prev) => (prev + 1) as Step);
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep((prev) => (prev - 1) as Step);
}
};
const handleInstallDocker = async () => {
setIsInstallingDocker(true);
setInstallError(null);
setDockerInstallProgress({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
try {
await invoke('install_docker');
} catch (error) {
console.error('Docker installation error:', error);
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker');
setIsInstallingDocker(false);
}
};
const validateDocker = async () => {
setIsValidating(true);
setValidationResult(null);
try {
const result = await invoke<ValidationResult>('validate_docker_installation');
setValidationResult(result);
if (result.status === 'pass' || result.status === 'partial') {
// Docker validado, atualizar estado
setDockerAvailable(true);
setDockerRunning(true);
}
} catch (error) {
console.error('Validation error:', error);
setValidationResult({
status: 'fail',
tests: [{
name: 'Validação',
status: 'fail',
message: error instanceof Error ? error.message : 'Erro ao validar Docker',
}],
});
} finally {
setIsValidating(false);
}
};
const handleInstall = async () => {
setIsInstalling(true);
setInstallError(null);
setInstallProgress('Iniciando instalação...');
try {
setInstallProgress('Baixando imagem SearXNG...');
await invoke('install_searxng', { url, port: parseInt(port, 10) });
setInstallProgress('Criando container...');
// Installation is handled by install_searxng command
setInstallProgress('Iniciando serviços...');
await invoke('start_searxng');
setInstallProgress('Instalação concluída!');
setTimeout(() => {
handleNext();
}, 1000);
} catch (error) {
console.error('Installation error:', error);
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação');
} finally {
setIsInstalling(false);
}
};
const handleVerify = async () => {
setVerificationStatus('checking');
setVerificationError(null);
try {
const isAvailable = await invoke<boolean>('check_searxng_status', { url });
if (isAvailable) {
setVerificationStatus('success');
setTimeout(() => {
onComplete();
}, 1500);
} else {
setVerificationStatus('error');
setVerificationError('SearXNG não está respondendo. Verifique se o container está rodando.');
}
} catch (error) {
setVerificationStatus('error');
setVerificationError(error instanceof Error ? error.message : 'Erro ao verificar SearXNG');
}
};
const renderStep = () => {
switch (currentStep) {
case 0:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação de Requisitos do Sistema</h3>
<p className="text-sm text-muted-foreground">
Verificando se seu sistema atende aos requisitos para instalação do Docker.
</p>
</div>
{isCheckingRequirements ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground">Verificando requisitos...</span>
</div>
) : requirements ? (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-4">
<span className="font-medium text-lg">Status Geral</span>
{requirements.status === 'pass' ? (
<Badge className="bg-green-500">Compatível</Badge>
) : requirements.status === 'warn' ? (
<Badge className="bg-yellow-500">Avisos</Badge>
) : (
<Badge className="bg-red-500">Incompatível</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<p className="text-xs text-muted-foreground">Sistema Operacional</p>
<p className="font-medium">{requirements.os} ({requirements.arch})</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RAM</p>
<p className="font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Disco Livre</p>
<p className="font-medium">{requirements.disk_gb} GB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">CPU Cores</p>
<p className="font-medium">{requirements.cpu_cores}</p>
</div>
</div>
{requirements.kernel_version && (
<div className="mt-4">
<p className="text-xs text-muted-foreground">Versão do Kernel</p>
<p className="font-medium">{requirements.kernel_version}</p>
</div>
)}
</div>
{requirements.issues.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">Problemas Encontrados:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{requirements.issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
</div>
</AlertDescription>
</Alert>
)}
{requirements.warnings.length > 0 && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">Avisos:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{requirements.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</div>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={checkRequirements} disabled={isCheckingRequirements}>
Verificar Novamente
</Button>
<Button
onClick={handleNext}
disabled={requirements.status === 'fail'}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
) : null}
</div>
);
case 1:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação de Docker</h3>
<p className="text-sm text-muted-foreground">
SearXNG requer Docker para funcionar. Vamos verificar se está instalado e rodando.
</p>
</div>
{isChecking ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground">Verificando Docker...</span>
</div>
) : (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Docker Instalado</span>
{dockerAvailable === null ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : dockerAvailable ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div>
{dockerAvailable === false && (
<p className="text-xs text-muted-foreground mt-2">
Docker não está instalado. Você precisa instalá-lo primeiro.
</p>
)}
</div>
{dockerAvailable && (
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Docker Rodando</span>
{dockerRunning === null ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : dockerRunning ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div>
{dockerRunning === false && (
<p className="text-xs text-muted-foreground mt-2">
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
</p>
)}
</div>
)}
{dockerAvailable === false && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p>Docker não está instalado. Você pode instalá-lo agora ou manualmente.</p>
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={handleInstallDocker}
disabled={isInstallingDocker}
>
{isInstallingDocker ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Instalando...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Instalar Docker Agora
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
window.open('https://www.docker.com/products/docker-desktop', '_blank');
}}
>
<ExternalLink className="w-4 h-4 mr-2" />
Instalar Manualmente
</Button>
</div>
</div>
</AlertDescription>
</Alert>
)}
{isInstallingDocker && (
<div className="space-y-4 mt-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
<span>{dockerInstallProgress.progress}%</span>
</div>
<Progress value={dockerInstallProgress.progress} />
</div>
{dockerInstallProgress.logs.length > 0 && (
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto">
{dockerInstallProgress.logs.map((log, idx) => (
<div key={idx}>{log}</div>
))}
</div>
)}
</div>
)}
{validationResult && (
<div className="space-y-2 mt-4">
<h4 className="font-medium">Resultados da Validação:</h4>
{validationResult.tests.map((test, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded border bg-muted/30">
<span className="text-sm">{test.name}</span>
{test.status === 'pass' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : test.status === 'warn' ? (
<AlertCircle className="w-4 h-4 text-yellow-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
</div>
))}
{validationResult.docker_version && (
<p className="text-xs text-muted-foreground">
Docker {validationResult.docker_version} instalado
</p>
)}
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={checkDocker} disabled={isChecking}>
Verificar Novamente
</Button>
{validationResult && (validationResult.status === 'pass' || validationResult.status === 'partial') && (
<Button
onClick={handleNext}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{!validationResult && dockerAvailable && dockerRunning && (
<Button
onClick={handleNext}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{!dockerAvailable && !isInstallingDocker && (
<Button
onClick={handleNext}
variant="outline"
className="ml-auto"
>
Pular Instalação Docker
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>
</div>
)}
</div>
);
case 2:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Configuração</h3>
<p className="text-sm text-muted-foreground">
Configure a URL e porta onde SearXNG será acessível.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="searxng-url">URL do SearXNG</Label>
<Input
id="searxng-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:8080"
/>
<p className="text-xs text-muted-foreground">
URL completa onde o SearXNG estará acessível
</p>
</div>
<div className="space-y-2">
<Label htmlFor="searxng-port">Porta</Label>
<Input
id="searxng-port"
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="8080"
min="1024"
max="65535"
/>
<p className="text-xs text-muted-foreground">
Porta que será usada pelo container (padrão: 8080)
</p>
</div>
<div className="flex items-center space-x-2 p-4 rounded-lg border bg-muted/30">
<Checkbox
id="auto-start"
checked={autoStart}
onCheckedChange={(checked) => setAutoStart(checked === true)}
/>
<Label htmlFor="auto-start" className="cursor-pointer">
Iniciar automaticamente com o OllaHub
</Label>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
<Button onClick={handleNext} className="ml-auto">
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Instalação do SearXNG</h3>
<p className="text-sm text-muted-foreground">
Instalando e configurando SearXNG. Isso pode levar alguns minutos.
</p>
</div>
{isInstalling ? (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">{installProgress}</p>
<div className="mt-2 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary animate-pulse" style={{ width: '60%' }} />
</div>
</div>
</div>
</div>
</div>
) : installError ? (
<Alert variant="destructive">
<XCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Erro durante instalação</p>
<p className="text-sm">{installError}</p>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="mt-3"
>
Tentar Novamente
</Button>
</AlertDescription>
</Alert>
) : (
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium">Instalação concluída!</p>
<p className="text-sm text-muted-foreground">
SearXNG foi instalado com sucesso.
</p>
</div>
</div>
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack} disabled={isInstalling}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
{!isInstalling && !installError && (
<Button onClick={handleNext} className="ml-auto">
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{isInstalling && (
<Button variant="outline" onClick={onCancel} className="ml-auto">
Cancelar
</Button>
)}
</div>
</div>
);
case 5:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação</h3>
<p className="text-sm text-muted-foreground">
Verificando se SearXNG está funcionando corretamente.
</p>
</div>
{verificationStatus === null && (
<div className="p-4 rounded-lg border bg-muted/30 text-center">
<p className="text-sm text-muted-foreground mb-4">
Clique em "Verificar" para testar a conexão com SearXNG.
</p>
<Button onClick={handleVerify} size="lg">
<Play className="w-4 h-4 mr-2" />
Verificar
</Button>
</div>
)}
{verificationStatus === 'checking' && (
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<span>Verificando conexão...</span>
</div>
</div>
)}
{verificationStatus === 'success' && (
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium">SearXNG está disponível!</p>
<p className="text-sm text-muted-foreground">
A conexão foi estabelecida com sucesso.
</p>
</div>
</div>
</div>
)}
{verificationStatus === 'error' && (
<Alert variant="destructive">
<XCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Falha na verificação</p>
<p className="text-sm mb-3">{verificationError || 'SearXNG não está respondendo'}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleVerify}>
Tentar Novamente
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke('start_searxng');
setTimeout(handleVerify, 2000);
} catch (error) {
console.error('Error starting SearXNG:', error);
}
}}
>
<Play className="w-4 h-4 mr-2" />
Iniciar Container
</Button>
</div>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack} disabled={verificationStatus === 'checking'}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
{verificationStatus === 'success' && (
<Button onClick={onComplete} className="ml-auto">
Concluir
<CheckCircle2 className="w-4 h-4 ml-2" />
</Button>
)}
</div>
</div>
);
default:
return null;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-2xl relative">
<Button
variant="ghost"
size="icon"
onClick={onCancel}
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
aria-label="Fechar"
disabled={isInstalling || isInstallingDocker}
>
<X className="w-4 h-4" />
</Button>
<CardHeader>
<CardTitle>SearXNG - Instalação</CardTitle>
<CardDescription>
Passo {currentStep + 1} de 6
</CardDescription>
</CardHeader>
<CardContent>
{renderStep()}
</CardContent>
</Card>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
'use client';
import { CheckCircle2, Shield, Infinity, Globe, Settings, ArrowRight, X, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface SearxngOnboardingProps {
onInstall: () => void;
onSkip: () => void;
onLearnMore?: () => void;
}
export function SearxngOnboarding({ onInstall, onSkip, onLearnMore }: SearxngOnboardingProps) {
const benefits = [
{
icon: Infinity,
title: 'Sem limites de queries',
description: 'Enquanto Brave API oferece 2000 queries/mês, SearXNG é ilimitado',
color: 'text-green-500',
},
{
icon: Shield,
title: 'Privacidade total',
description: 'Sem rastreamento, sem cookies, sem logs. Seus dados ficam privados',
color: 'text-blue-500',
},
{
icon: Globe,
title: 'Agrega múltiplos motores',
description: 'Combina resultados de Google, Bing, DuckDuckGo e outros em uma única busca',
color: 'text-purple-500',
},
{
icon: Settings,
title: 'Controle total',
description: 'Configure motores, filtros e personalizações conforme sua necessidade',
color: 'text-orange-500',
},
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto relative">
<Button
variant="ghost"
size="icon"
onClick={onSkip}
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
aria-label="Fechar"
>
<X className="w-4 h-4" />
</Button>
<CardHeader className="text-center space-y-4 pb-6">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Globe className="w-8 h-8 text-primary" />
</div>
<CardTitle className="text-2xl sm:text-3xl">SearXNG Self-Hosted</CardTitle>
<CardDescription className="text-base">
Busca Ilimitada e Privada
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Por que SearXNG? */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Por que SearXNG?
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{benefits.map((benefit, idx) => {
const Icon = benefit.icon;
return (
<div
key={idx}
className="p-4 rounded-lg border border-muted bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<Icon className={cn('w-5 h-5 mt-0.5 flex-shrink-0', benefit.color)} />
<div className="space-y-1 flex-1">
<h4 className="font-medium text-sm">{benefit.title}</h4>
<p className="text-xs text-muted-foreground">{benefit.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Como Funciona */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
Como Funciona
</h3>
<div className="p-4 rounded-lg border border-muted bg-muted/30">
<div className="flex items-center justify-center gap-2 sm:gap-4 flex-wrap">
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-blue-500/10 border border-blue-500/20 text-sm font-medium">
Brave API
</div>
<span className="text-xs text-muted-foreground">Tier 1</span>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground" />
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-green-500/10 border border-green-500/20 text-sm font-medium">
SearXNG
</div>
<span className="text-xs text-muted-foreground">Tier 2</span>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground" />
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-sm font-medium">
DuckDuckGo
</div>
<span className="text-xs text-muted-foreground">Tier 3</span>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4 text-center">
SearXNG é usado como fallback quando Brave API esgota sua quota mensal (2000 queries).
É completamente opcional e não afeta o uso normal do sistema.
</p>
</div>
</div>
{/* Requisitos */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Settings className="w-5 h-5 text-orange-500" />
Requisitos
</h3>
<div className="space-y-2">
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Docker instalado e rodando</p>
<p className="text-xs text-muted-foreground">
O SearXNG roda em um container Docker. Se não tiver Docker, você pode instalá-lo durante o processo.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">~200MB de RAM</p>
<p className="text-xs text-muted-foreground">
O container SearXNG consome aproximadamente 200MB de memória RAM.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Porta 8080 disponível</p>
<p className="text-xs text-muted-foreground">
Por padrão, SearXNG usa a porta 8080. Você pode configurar uma porta diferente se necessário.
</p>
</div>
</div>
</div>
</div>
{/* Ações */}
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t">
<Button onClick={onInstall} className="flex-1" size="lg">
<ArrowRight className="w-4 h-4 mr-2" />
Instalar Agora
</Button>
{onLearnMore && (
<Button onClick={onLearnMore} variant="outline" className="flex-1" size="lg">
<ExternalLink className="w-4 h-4 mr-2" />
Saiba Mais
</Button>
)}
<Button onClick={onSkip} variant="ghost" className="flex-1" size="lg">
<X className="w-4 h-4 mr-2" />
Pular por Enquanto
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { Progress } from '@/components/ui/progress';
interface LogLine {
line: string;
type: 'stdout' | 'stderr';
progress?: number;
}
interface InstallationTerminalProps {
onComplete?: () => void;
onError?: (error: string) => void;
}
export function InstallationTerminal({ onComplete, onError }: InstallationTerminalProps) {
const [logs, setLogs] = useState<LogLine[]>([]);
const [progress, setProgress] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const lastProgressRef = useRef<number>(0);
// Auto-scroll para última linha
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
// Escutar eventos de log
useEffect(() => {
const unlistenPromise = listen<LogLine>('install-log', (event) => {
const logLine = event.payload;
setLogs(prev => [...prev, logLine]);
// Atualizar progresso se disponível
if (logLine.progress !== undefined) {
setProgress(logLine.progress);
lastProgressRef.current = logLine.progress;
} else {
// Tentar extrair progresso da linha de texto
const extractedProgress = extractProgressFromText(logLine.line);
if (extractedProgress !== null) {
setProgress(extractedProgress);
lastProgressRef.current = extractedProgress;
}
}
// Verificar se instalação foi concluída
const lineLower = logLine.line.toLowerCase();
if (lineLower.includes('success') || lineLower.includes('completed') || lineLower.includes('finished')) {
setProgress(100);
if (onComplete) {
setTimeout(() => onComplete(), 500);
}
}
// Verificar se houve erro
if (logLine.type === 'stderr' && (lineLower.includes('error') || lineLower.includes('failed'))) {
if (onError) {
onError(logLine.line);
}
}
});
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, [onComplete, onError]);
return (
<div className="space-y-2 sm:space-y-3">
<div className="bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden max-h-[50vh] flex flex-col">
<div className="px-2 sm:px-3 py-1.5 sm:py-2 bg-zinc-900 border-b border-zinc-800 flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-red-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-yellow-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-green-500"></div>
<span className="text-[10px] sm:text-xs text-zinc-400 ml-1 sm:ml-2">Terminal</span>
</div>
{progress > 0 && (
<span className="text-[10px] sm:text-xs text-zinc-400 font-mono shrink-0">{progress}%</span>
)}
</div>
<div
ref={scrollRef}
className="h-48 sm:h-64 md:h-80 flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 font-mono text-[10px] sm:text-xs"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#3f3f46 #18181b',
scrollBehavior: 'smooth'
}}
>
{logs.length === 0 ? (
<div className="text-zinc-500 text-[10px] sm:text-xs">Aguardando logs...</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={`
${log.type === 'stderr' ? 'text-red-400' : 'text-green-400'}
whitespace-pre-wrap
break-words
`}
>
{log.line}
</div>
))
)}
</div>
</div>
{progress > 0 && (
<div className="space-y-1">
<Progress value={progress} className="h-1.5 sm:h-2" />
<div className="text-[10px] sm:text-xs text-muted-foreground text-center">
{progress < 100 ? `Instalando... ${progress}%` : 'Instalação concluída'}
</div>
</div>
)}
</div>
);
}
/// Extrai progresso percentual de uma linha de texto
function extractProgressFromText(line: string): number | null {
// Procurar por padrão "Progress: n%" ou "n%"
const percentMatch = line.match(/(\d+)%/);
if (percentMatch) {
const num = parseInt(percentMatch[1], 10);
if (num >= 0 && num <= 100) {
return num;
}
}
// Mapear keywords para progresso aproximado
const lineLower = line.toLowerCase();
if (lineLower.includes('downloading')) {
return 25;
} else if (lineLower.includes('installing')) {
return 50;
} else if (lineLower.includes('shim') || lineLower.includes('creating')) {
return 75;
} else if (lineLower.includes('finished') || lineLower.includes('completed') || lineLower.includes('success')) {
return 100;
}
return null;
}
+425
View File
@@ -0,0 +1,425 @@
'use client';
import { useEffect, useMemo, useState, useCallback, startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useDownloadContext } from '@/contexts/download-context';
import { Search, Download, CheckCircle2, AlertCircle, Loader2, Sparkles, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ModelInfo {
id: string;
name: string;
size: string;
minRam: number;
description?: string;
}
// Lista expandida de modelos populares
const POPULAR_MODELS: ModelInfo[] = [
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '1.3GB', minRam: 4, description: 'Leve e rápido, ideal para iniciantes' },
{ id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2.0GB', minRam: 8, description: 'Equilíbrio perfeito entre velocidade e qualidade' },
{ id: 'llama3.1:8b', name: 'Llama 3.1 8B', size: '4.7GB', minRam: 16, description: 'Padrão da indústria, alta capacidade' },
{ id: 'mistral:latest', name: 'Mistral 7B', size: '4.1GB', minRam: 16, description: 'Excelente para raciocínio e código' },
{ id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5.4GB', minRam: 12, description: 'Modelo avançado do Google' },
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', size: '4.5GB', minRam: 16, description: 'Multilíngue e versátil' },
{ id: 'phi3:mini', name: 'Phi-3 Mini', size: '2.3GB', minRam: 8, description: 'Compacto e eficiente da Microsoft' },
{ id: 'deepseek-coder:6.7b', name: 'DeepSeek Coder 6.7B', size: '4.2GB', minRam: 16, description: 'Especializado em programação' },
];
interface ModelDownloadStepProps {
onNext: () => void;
}
export function ModelDownloadStep({ onNext }: ModelDownloadStepProps) {
const { state, checkSetupState } = useSetupWizard();
const { state: downloadState, startDownload } = useDownloadContext();
const [searchQuery, setSearchQuery] = useState('');
const [customModelInput, setCustomModelInput] = useState('');
const [activeModelTag, setActiveModelTag] = useState<string | null>(null);
const [installedModels, setInstalledModels] = useState<Set<string>>(new Set());
const [customModelInstalled, setCustomModelInstalled] = useState(false);
// Verificar modelos instalados ao montar
useEffect(() => {
const checkAllModels = async () => {
const installed = new Set<string>();
// Verificar todos os modelos em paralelo
const checks = POPULAR_MODELS.map(async (model) => {
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: model.id });
if (isInstalled) {
installed.add(model.id);
}
} catch (error) {
console.error(`Erro ao verificar ${model.id}:`, error);
}
});
await Promise.all(checks);
startTransition(() => {
setInstalledModels(installed);
});
};
checkAllModels();
}, []);
// Verificar modelo customizado quando o usuário digita
useEffect(() => {
const checkCustomModel = async () => {
const modelTag = customModelInput.trim();
if (!modelTag) {
setCustomModelInstalled(false);
return;
}
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: modelTag });
setCustomModelInstalled(isInstalled);
} catch (error) {
console.error(`Erro ao verificar modelo customizado ${modelTag}:`, error);
setCustomModelInstalled(false);
}
};
// Debounce: aguardar 500ms após o usuário parar de digitar
const timer = setTimeout(checkCustomModel, 500);
return () => clearTimeout(timer);
}, [customModelInput]);
// Filtrar modelos baseado na pesquisa
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return POPULAR_MODELS;
const query = searchQuery.toLowerCase();
return POPULAR_MODELS.filter(
model =>
model.name.toLowerCase().includes(query) ||
model.id.toLowerCase().includes(query) ||
model.description?.toLowerCase().includes(query)
);
}, [searchQuery]);
// Progresso do download ativo
const activeProgress = useMemo(() => {
if (!downloadState.isDownloading || downloadState.currentModel !== activeModelTag) {
return null;
}
return {
percent: downloadState.progress.percent,
downloaded: downloadState.progress.downloadedBytes,
total: downloadState.progress.totalBytes,
downloadedFormatted: downloadState.progress.downloadedFormatted,
totalFormatted: downloadState.progress.totalFormatted,
status: downloadState.progress.status,
speed: downloadState.progress.speed,
};
}, [downloadState, activeModelTag]);
// Atualizar lista de instalados e limpar activeModelTag quando download completar
useEffect(() => {
if (downloadState.isSuccess && activeModelTag) {
const modelTag = activeModelTag;
// Adicionar modelo à lista de instalados usando startTransition para evitar cascading renders
startTransition(() => {
setInstalledModels(prev => new Set([...prev, modelTag]));
// Verificar também o modelo customizado se for o caso
if (modelTag === customModelInput.trim()) {
setCustomModelInstalled(true);
}
});
// Limpar activeModelTag após um pequeno delay para garantir que o estado foi atualizado
const timer = setTimeout(() => {
setActiveModelTag(null);
}, 100);
return () => clearTimeout(timer);
}
}, [downloadState.isSuccess, activeModelTag, customModelInput]);
// Verificar estado do setup quando download completar
useEffect(() => {
if (downloadState.isSuccess || (!downloadState.isDownloading && downloadState.progress.percent === 100 && downloadState.progress.status === 'success')) {
checkSetupState().catch(console.error);
}
}, [downloadState.isSuccess, downloadState.isDownloading, downloadState.progress.percent, downloadState.progress.status, checkSetupState]);
const handleModelDownload = useCallback(async (modelTag: string) => {
if (downloadState.isDownloading) {
console.warn('Download já em andamento');
return;
}
setActiveModelTag(modelTag);
try {
await startDownload(modelTag);
} catch (error) {
console.error('Erro ao iniciar download:', error);
setActiveModelTag(null);
}
}, [downloadState.isDownloading, startDownload]);
const handleCustomDownload = useCallback(async () => {
const modelTag = customModelInput.trim();
if (!modelTag) return;
await handleModelDownload(modelTag);
setCustomModelInput('');
}, [customModelInput, handleModelDownload]);
if (state.modelDownloaded) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Modelo Instalado</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Modelo {state.modelName || 'padrão'} pronto para uso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Download concluído com sucesso!</p>
<p className="text-xs text-emerald-300 mt-1">Avançando para o próximo passo...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-6xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl max-h-[calc(100vh-20rem)] flex flex-col">
<CardHeader className="p-6 sm:p-8 flex-shrink-0">
<CardTitle className="text-xl font-bold text-white flex items-center gap-2 mb-1">
<Download className="w-6 h-6 shrink-0" />
<span>Download do Modelo</span>
</CardTitle>
<CardDescription className="text-zinc-400 text-xs">
Escolha um modelo e/ou informe uma tag custom para baixar.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-4 sm:pb-6 pt-0 space-y-4 flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col md:flex-row gap-3 mb-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Pesquisar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10 text-sm"
/>
</div>
<div className="flex gap-2 md:w-1/3">
<Input
type="text"
placeholder="Tag custom (ex: deepseek-coder:6.7b)"
value={customModelInput}
onChange={(e) => setCustomModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCustomDownload();
}
}}
disabled={downloadState.isDownloading}
className="flex-1 h-10 text-sm"
/>
<Button
onClick={handleCustomDownload}
disabled={!customModelInput.trim() || downloadState.isDownloading || customModelInstalled}
variant={customModelInstalled ? 'outline' : 'default'}
className={cn(
"gap-2 h-10 px-4 text-sm",
customModelInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
)}
>
{downloadState.isDownloading && activeModelTag === customModelInput.trim() ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Baixando
</>
) : customModelInstalled ? (
<>
<CheckCircle2 className="w-4 h-4" />
Já Instalado
</>
) : (
<>
<Download className="w-4 h-4" />
Baixar
</>
)}
</Button>
</div>
</div>
{/* Lista de Modelos Recomendados */}
<div className="space-y-4">
<div className="flex items-center gap-2 mt-2 mb-2">
<Sparkles className="w-4 h-4 text-primary" />
<h3 className="text-xs font-semibold">Modelos Recomendados</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 min-h-[300px] max-h-[450px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-zinc-800 custom-scrollbar">
{filteredModels.length === 0 ? (
<div className="col-span-1 sm:col-span-2 text-center py-12 text-sm text-muted-foreground">
Nenhum modelo encontrado para &ldquo;{searchQuery}&rdquo;
</div>
) : (
filteredModels.map((model) => {
const isActive = activeModelTag === model.id;
const isDownloading = downloadState.isDownloading && isActive;
const progress = isActive ? activeProgress : null;
const isInstalled = installedModels.has(model.id);
return (
<Card
key={model.id}
className={cn(
'relative border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900 transition-all',
isDownloading && 'border-emerald-500/50',
isInstalled && !isDownloading && 'border-emerald-500/30 bg-emerald-500/5'
)}
>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold text-sm truncate">{model.name}</h4>
{isInstalled && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-[10px] px-1.5 py-0">
<CheckCircle2 className="w-3 h-3 mr-1" />
Instalado
</Badge>
)}
</div>
<p className="hidden md:block text-[11px] text-muted-foreground mt-1 line-clamp-1">
{model.description || ''}
</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px] sm:text-xs">
{model.size}
</Badge>
</div>
<div className="flex items-center justify-between text-[10px] sm:text-xs text-muted-foreground gap-2">
<span className="truncate">Mín. {model.minRam}GB RAM</span>
<code className="text-[9px] sm:text-[10px] bg-muted px-1 sm:px-1.5 py-0.5 rounded shrink-0 truncate max-w-[120px] sm:max-w-none">
{model.id}
</code>
</div>
{isDownloading && progress && (
<div className="space-y-1 pt-2 border-t border-zinc-800/50">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{progress.status || 'Baixando...'}
</span>
{progress.speed && (
<span className="font-mono text-[10px] text-muted-foreground">
{progress.speed}
</span>
)}
</div>
<Progress
value={progress.total > 0 && progress.percent !== null ? progress.percent : undefined}
className="h-1"
/>
{progress.total > 0 && (
<div className="flex justify-between text-[10px] text-muted-foreground/70">
<span>
{progress.downloadedFormatted} / {progress.totalFormatted}
</span>
<span>{progress.percent ?? 0}%</span>
</div>
)}
</div>
)}
<Button
onClick={() => handleModelDownload(model.id)}
disabled={downloadState.isDownloading || isInstalled}
variant={isInstalled ? 'outline' : isDownloading ? 'outline' : 'default'}
className={cn(
"w-full gap-2 h-8 text-xs",
isInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
)}
>
{isDownloading ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="hidden sm:inline">Baixando...</span>
</>
) : isInstalled ? (
<>
<CheckCircle2 className="w-3 h-3" />
<span className="hidden sm:inline">Já Instalado</span>
</>
) : (
<>
<Download className="w-3 h-3" />
<span className="hidden sm:inline">Baixar</span>
</>
)}
</Button>
</div>
</Card>
);
})
)}
</div>
</div>
{/* Mensagens de Erro */}
{state.lastError && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900/50">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1 break-words">{state.lastError}</p>
</div>
</div>
)}
{/* SLIM FOOTER NAVIGATION */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 mt-4 border-t border-zinc-800 bg-zinc-950/50 backdrop-blur-sm flex-shrink-0">
<div className="text-xs text-zinc-500">
<span>Selecione um modelo ou pule para configurar depois.</span>
</div>
<div className="flex gap-3 w-full sm:w-auto">
<Button
variant="ghost"
onClick={onNext}
className="text-zinc-400 hover:text-white hover:bg-zinc-800 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Pular etapa
</Button>
<Button
onClick={onNext}
className="bg-white text-black hover:bg-zinc-200 px-8 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</CardContent>
<CardFooter className="p-6 sm:p-8 pt-0 border-t-0 flex-shrink-0 hidden">
{/* Footer vazio - mantido para compatibilidade, mas conteúdo movido para CardContent */}
</CardFooter>
</Card>
);
}
+638
View File
@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useOllamaDownload } from '@/hooks/use-ollama-download';
import { useOllamaCheck } from '@/hooks/use-ollama-check';
import { useOperatingSystem } from '@/hooks/use-operating-system';
import { InstallationTerminal } from './installation-terminal';
import { Loader2, CheckCircle2, AlertCircle, Download, RefreshCw, Shield, Clock, WifiOff, ArrowRight } from 'lucide-react';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function formatTime(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
type InstallErrorType = 'ERR_NO_PRIVILEGE' | 'ERR_DOWNLOAD_FAIL' | 'ERR_TIMEOUT' | 'ERR_INSTALL_FAILED';
function parseInstallError(error: string): InstallErrorType {
const errorLower = error.toLowerCase();
// Verificar privilégios/permissões
if (
errorLower.includes('privilege') ||
errorLower.includes('permission') ||
errorLower.includes('elevated') ||
errorLower.includes('uac') ||
errorLower.includes('administrator') ||
errorLower.includes('access denied') ||
errorLower.includes('acesso negado')
) {
return 'ERR_NO_PRIVILEGE';
}
// Verificar erros de download/rede
if (
errorLower.includes('download') ||
errorLower.includes('network') ||
errorLower.includes('connection') ||
errorLower.includes('failed to download') ||
errorLower.includes('falha ao baixar') ||
errorLower.includes('conexão') ||
errorLower.includes('timeout') && (errorLower.includes('download') || errorLower.includes('conexão'))
) {
return 'ERR_DOWNLOAD_FAIL';
}
// Verificar timeout
if (
errorLower.includes('timeout') ||
errorLower.includes('90 segundos') ||
errorLower.includes('timed out') ||
errorLower.includes('tempo esgotado')
) {
return 'ERR_TIMEOUT';
}
return 'ERR_INSTALL_FAILED';
}
interface ErrorDisplayProps {
errorType: InstallErrorType;
errorMessage: string;
onRetry: () => void;
isRetrying: boolean;
}
function ErrorDisplay({ errorType, errorMessage, onRetry, isRetrying }: ErrorDisplayProps) {
switch (errorType) {
case 'ERR_NO_PRIVILEGE':
return (
<Card className="border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-amber-900 dark:text-amber-100">
Permissões Administrativas Necessárias
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-amber-700 dark:text-amber-300 mt-1 sm:mt-2">
O Windows solicitou permissões de administrador para instalar o Ollama.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<p className="text-xs sm:text-sm font-medium text-foreground mb-2">O que fazer:</p>
<ol className="list-decimal list-inside space-y-1 text-xs sm:text-sm text-muted-foreground">
<li>Verifique se apareceu um prompt do Windows pedindo permissões</li>
<li>Clique em &quot;Sim&quot; ou &quot;Allow&quot; no prompt de UAC (User Account Control)</li>
<li>Aguarde alguns segundos para a instalação continuar</li>
<li>Se o prompt não apareceu, tente executar o OllaHub como administrador</li>
</ol>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
<p className="text-xs text-muted-foreground text-center">
{errorMessage}
</p>
</CardContent>
</Card>
);
case 'ERR_DOWNLOAD_FAIL':
return (
<Card className="border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-red-900 dark:text-red-100">
Erro ao Baixar Instalador
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 sm:mt-2">
Não foi possível baixar o instalador do Ollama. Verifique sua conexão com a internet.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Tentando Novamente...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Tentar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
case 'ERR_TIMEOUT':
return (
<Card className="border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600 dark:text-orange-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-orange-900 dark:text-orange-100">
Instalação Demorando Mais do Que o Esperado
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-orange-700 dark:text-orange-300 mt-1 sm:mt-2">
A instalação pode ainda estar em andamento em segundo plano.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-orange-200 dark:border-orange-800">
<p className="text-xs sm:text-sm text-muted-foreground mb-2 break-words">
O processo de instalação pode levar alguns minutos. Se o Ollama estiver sendo instalado,
você pode verificar o status manualmente.
</p>
<p className="text-[10px] sm:text-xs text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
default:
return (
<div className="flex items-start gap-2 p-3 sm:p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-2 sm:space-y-3 min-w-0">
<div>
<p className="text-sm sm:text-base font-medium text-red-900 dark:text-red-100">Erro na instalação</p>
<p className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 break-words">{errorMessage}</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</div>
</div>
);
}
}
type InstallStepState = 'IDLE' | 'INSTALLING' | 'VERIFYING' | 'SUCCESS';
interface OllamaInstallStepProps {
onNext: () => void;
}
export function OllamaInstallStep({ onNext }: OllamaInstallStepProps) {
const { state, installOllama, checkChocolatey, installChocolatey, installOllamaViaChoco, checkSetupState } = useSetupWizard();
const { os: detectedOS } = useOperatingSystem();
const downloadState = useOllamaDownload();
const ollamaCheck = useOllamaCheck();
const [stepState, setStepState] = useState<InstallStepState>('IDLE');
const [useChocolatey, setUseChocolatey] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const installationInProgressRef = useRef(false);
const hasCheckedInitialRef = useRef(false);
// Função de polling ativo para aguardar serviço ficar disponível
const waitForService = async (maxAttempts = 15): Promise<boolean> => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
return true;
}
} catch (error) {
console.warn(`Tentativa ${attempt}/${maxAttempts} falhou:`, error);
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
throw new Error('Ollama não ficou disponível após 30 segundos');
};
// Verificação inicial no mount: se Ollama já estiver instalado, ir direto para SUCCESS
useEffect(() => {
if (hasCheckedInitialRef.current) return;
const checkInitial = async () => {
if (stepState === 'IDLE') {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
// Se a integridade está OK, atualizar estado do wizard e ir para SUCCESS
await checkSetupState();
setStepState('SUCCESS');
}
} catch {
// Silenciosamente ignorar - usuário ainda não instalou
}
}
hasCheckedInitialRef.current = true;
};
checkInitial();
}, [stepState, checkSetupState]);
// Sincronizar estado do polling com wizard state e atualizar FSM
useEffect(() => {
if (ollamaCheck.status === 'running' && !state.ollamaRunning) {
// Polling detectou que Ollama está rodando, atualizar wizard state
checkSetupState().catch(console.error);
}
// Atualizar FSM baseado no status
if (stepState === 'VERIFYING' && ollamaCheck.status === 'running' && state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
ollamaCheck.stopPolling();
}
}, [ollamaCheck.status, state.ollamaRunning, state.ollamaInstalled, checkSetupState, stepState, ollamaCheck]);
const handleManualRetry = async () => {
setIsRetrying(true);
try {
await checkSetupState();
// Se já estiver instalado, ir para SUCCESS
if (state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
} else {
// Iniciar verificação manual
setStepState('VERIFYING');
ollamaCheck.startPolling();
}
} catch (error) {
console.error('Erro ao verificar novamente:', error);
} finally {
setIsRetrying(false);
}
};
const handleCheckExisting = async () => {
setStepState('VERIFYING');
ollamaCheck.startPolling();
await checkSetupState();
};
const handleInstall = async () => {
if (installationInProgressRef.current) {
return; // Evitar múltiplas instalações simultâneas
}
installationInProgressRef.current = true;
setStepState('INSTALLING');
setShowTerminal(true);
try {
// No Windows, verificar se Chocolatey está disponível
if (detectedOS === 'windows') {
const hasChoco = await checkChocolatey();
if (hasChoco) {
setUseChocolatey(true);
// Instalar Ollama via Chocolatey
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} else {
// Tentar instalar Chocolatey primeiro
try {
await installChocolatey();
setUseChocolatey(true);
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} catch (error) {
console.warn('Falha ao usar Chocolatey, usando instalação direta:', error);
setUseChocolatey(false);
setShowTerminal(false);
}
}
}
// Fallback: instalação direta
if (!downloadState.filePath && detectedOS) {
await downloadState.handleDownload(detectedOS);
}
if (downloadState.filePath) {
await installOllama(downloadState.filePath);
} else {
await installOllama();
}
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
} catch (error) {
console.error('Erro ao aguardar serviço:', error);
setStepState('IDLE');
} finally {
installationInProgressRef.current = false;
}
};
// Estado SUCCESS: mostrar mensagem de sucesso e botão Continuar
if (stepState === 'SUCCESS' || (state.ollamaInstalled && state.ollamaRunning)) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Instalação Concluída</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Ollama está instalado e rodando com sucesso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Ollama Service is Running</p>
<p className="text-xs text-emerald-300 mt-1">Pronto para continuar com o download de modelos.</p>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={onNext} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardFooter>
</Card>
);
}
// Estado IDLE: mostrar card informativo
if (stepState === 'IDLE') {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<Download className="w-6 h-6 shrink-0" />
<span>Ollama Engine</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
O OllaHub requer o motor Ollama para executar IAs localmente.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<p className="text-sm text-zinc-400 leading-relaxed">
O Ollama é uma ferramenta open-source que permite executar modelos de linguagem grandes (LLMs) diretamente no seu computador, sem necessidade de conexão com a internet.
</p>
</CardContent>
<CardFooter className="flex flex-col sm:flex-row justify-end gap-4 p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={handleCheckExisting} variant="outline" className="w-full sm:w-auto h-12 border-zinc-700 text-zinc-300 hover:bg-zinc-800">
Já tenho o Ollama instalado
</Button>
<Button onClick={handleInstall} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
<Download className="w-4 h-4 mr-2" />
Instalar Ollama
</Button>
</CardFooter>
</Card>
);
}
const errorType = state.lastError ? parseInstallError(state.lastError) : null;
const showRetryButton = state.lastError || state.progressStatus === 'error';
const showManualRetry = showRetryButton && state.lastError;
const hasTerminal = showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey);
return (
<Card className={`w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl ${hasTerminal ? 'max-h-[calc(100vh-16rem)] flex flex-col' : ''}`}>
<CardHeader className={`p-6 sm:p-8 ${hasTerminal ? 'flex-shrink-0' : ''}`}>
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{stepState === 'INSTALLING' && <Loader2 className="w-6 h-6 animate-spin" />}
{stepState === 'VERIFYING' && <Loader2 className="w-6 h-6 animate-spin" />}
{(state.lastError || state.progressStatus === 'error') && <AlertCircle className="w-6 h-6 text-red-500" />}
<span>
{stepState === 'INSTALLING' ? 'Instalando Ollama...' :
stepState === 'VERIFYING' ? 'Verificando Instalação...' :
'Instalação em Progresso'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Acompanhe o progresso da configuração do ambiente.
</CardDescription>
</CardHeader>
<CardContent className={`px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6 ${hasTerminal ? 'flex-1 min-h-0 overflow-y-auto' : ''}`}>
{/* Terminal integrado para instalação via Chocolatey */}
{(showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey)) && (
<InstallationTerminal
onComplete={() => {
setShowTerminal(false);
}}
onError={(error) => {
console.error('Erro na instalação:', error);
}}
/>
)}
{/* Progresso para instalação direta (fallback) */}
{state.progressMessage && !showTerminal && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{state.progressMessage}</span>
{ollamaCheck.isChecking && (
<span className="text-xs text-muted-foreground">
Verificando... {formatTime(ollamaCheck.elapsedTime)}
</span>
)}
</div>
{state.progressStatus === 'installing_ollama' && (
<Progress value={undefined} className="h-2" />
)}
</div>
)}
{/* Feedback do polling em background (estado VERIFYING) */}
{stepState === 'VERIFYING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">
Instalação concluída. Aguardando inicialização do serviço...
</span>
</div>
)}
{/* Display de erro granular */}
{state.lastError && errorType && (
<ErrorDisplay
errorType={errorType}
errorMessage={state.lastError}
onRetry={handleManualRetry}
isRetrying={isRetrying}
/>
)}
{/* Botão de retry manual quando não há erro específico */}
{showRetryButton && !showManualRetry && (
<Button
onClick={handleManualRetry}
disabled={isRetrying || stepState === 'INSTALLING'}
variant="outline"
className="w-full"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
)}
{downloadState.isDownloading && (
<div className="space-y-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-2">
{downloadState.status === 'verifying' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
{downloadState.status === 'verifying'
? 'Verificando integridade...'
: 'Baixando...'}
</span>
<span className="font-mono">{downloadState.speed || '0 MB/s'}</span>
</div>
<Progress
value={downloadState.downloadProgress}
className="h-2 w-full transition-all duration-300"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
<span>
{formatBytes(downloadState.downloadedBytes)} / {formatBytes(downloadState.totalBytes)}
</span>
<span>{downloadState.downloadProgress.toFixed(1)}%</span>
</div>
{downloadState.eta > 0 && downloadState.status === 'downloading' && (
<div className="text-xs text-muted-foreground text-center">
Tempo restante: {formatTime(downloadState.eta)}
</div>
)}
</div>
)}
{/* Estado INSTALLING: mostrar progresso */}
{stepState === 'INSTALLING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">Instalando Ollama... Isso pode levar alguns minutos.</span>
</div>
)}
</CardContent>
{/* Footer opcional se necessário */}
</Card>
);
}
+173
View File
@@ -0,0 +1,173 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useSettingsStore } from '@/store/settings-store';
import { useRouter } from 'next/navigation';
import { CheckCircle2, ArrowRight, AlertCircle, Sparkles, Loader2 } from 'lucide-react';
import { startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
export function SetupCompleteStep() {
const { state, goToStep } = useSetupWizard();
const { setSetupCompleted } = useSettingsStore();
const router = useRouter();
const [integrityError, setIntegrityError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(true);
useEffect(() => {
const verifyAndComplete = async () => {
try {
setIsVerifying(true);
setIntegrityError(null);
// Verificar integridade ANTES de marcar como completo
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar silenciosamente para o passo de instalação do Ollama
setIsVerifying(false);
goToStep(2);
return;
}
// Só definir flag e redirecionar se Ollama estiver íntegro
setSetupCompleted(true);
setIsVerifying(false);
const timer = setTimeout(() => {
startTransition(() => {
router.replace('/chat');
});
}, 2000);
return () => clearTimeout(timer);
} catch {
// Em caso de erro na verificação, redirecionar para instalação
setIsVerifying(false);
goToStep(2);
}
};
verifyAndComplete();
}, [setSetupCompleted, router, goToStep]);
const handleGoToChat = async () => {
try {
// Verificar integridade antes de redirecionar manualmente também
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar para o passo de instalação do Ollama
goToStep(2);
return;
}
setSetupCompleted(true);
startTransition(() => {
router.replace('/chat');
});
} catch {
// Em caso de erro na verificação, redirecionar para instalação
goToStep(2);
}
};
const handleGoToInstallation = () => {
goToStep(2);
};
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{isVerifying ? (
<Loader2 className="w-6 h-6 animate-spin shrink-0" />
) : integrityError ? (
<AlertCircle className="w-6 h-6 text-red-500 shrink-0" />
) : (
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
)}
<span>
{isVerifying ? 'Verificando Integridade' :
integrityError ? 'Erro na Configuração' :
'Configuração Completa'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
{isVerifying ? 'Validando instalação do Ollama...' :
integrityError ? 'Encontramos um problema.' :
'Tudo pronto para começar a usar o OllaHub.'}
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
{isVerifying ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-zinc-900/50 flex items-center justify-center border border-zinc-800">
<CheckCircle2 className="w-8 h-8 text-zinc-700 animate-pulse" />
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Estamos verificando se o serviço Ollama está respondendo corretamente antes de liberar o acesso.
</p>
</div>
) : integrityError ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-red-950/20 flex items-center justify-center border border-red-900/50">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<div className="text-center space-y-2 max-w-md">
<h3 className="font-semibold text-red-400">Falha na Verificação</h3>
<p className="text-sm text-zinc-400">
{integrityError}
</p>
</div>
</div>
) : (
<>
<div className="flex items-start gap-4 p-6 bg-emerald-950/10 rounded-xl border border-emerald-900/20">
<div className="p-3 bg-emerald-500/10 rounded-lg shrink-0">
<Sparkles className="w-6 h-6 text-emerald-500" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-emerald-400 text-lg">Ambiente Configurado</h3>
<p className="text-emerald-200/70 text-sm leading-relaxed">
Seu assistente de IA local está pronto. Você pode começar a conversar, gerar códigos ou analisar documentos agora mesmo.
</p>
</div>
</div>
<div className="space-y-3 p-4 bg-zinc-900/50 rounded-xl border border-zinc-800/50">
<p className="text-sm font-medium text-zinc-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
Resumo da Instalação:
</p>
<ul className="text-sm text-zinc-500 space-y-2 pl-6 list-disc marker:text-zinc-700">
<li>Hardware compatível detectado</li>
{state.ollamaInstalled && <li>Ollama Engine instalado e ativo</li>}
{state.modelDownloaded && (
<li>Modelo de IA <strong>{state.modelName || 'padrão'}</strong> baixado</li>
)}
</ul>
</div>
</>
)}
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button
onClick={integrityError ? handleGoToInstallation : handleGoToChat}
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
disabled={isVerifying}
>
{isVerifying ? 'Verificando...' : integrityError ? 'Instalar Ollama' : 'Ir para o Chat'}
{!isVerifying && !integrityError && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</CardFooter>
</Card>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { useEffect } from 'react';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { SystemCheckStep } from './system-check-step';
import { OllamaInstallStep } from './ollama-install-step';
import { ModelDownloadStep } from './model-download-step';
import { SetupCompleteStep } from './setup-complete-step';
import { CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const steps = [
{ id: 1, title: 'Verificação do Sistema', description: 'Analisando hardware' },
{ id: 2, title: 'Instalação do Ollama', description: 'Configurando engine' },
{ id: 3, title: 'Download do Modelo', description: 'Baixando modelo padrão' },
{ id: 4, title: 'Conclusão', description: 'Tudo pronto!' },
];
export function SetupWizard() {
const { state, checkSetupState, goToStep, nextStep } = useSetupWizard();
useEffect(() => {
checkSetupState();
}, [checkSetupState]);
const handleNext = () => {
nextStep();
};
const renderStep = () => {
switch (state.currentStep) {
case 1:
return <SystemCheckStep onNext={handleNext} />;
case 2:
return <OllamaInstallStep onNext={handleNext} />;
case 3:
return <ModelDownloadStep onNext={handleNext} />;
case 4:
return <SetupCompleteStep />;
default:
return <SystemCheckStep onNext={handleNext} />;
}
};
const containerWidth = state.currentStep === 3 ? 'max-w-6xl' : 'max-w-2xl';
return (
<div className="h-screen max-h-screen w-full bg-background flex flex-col items-center overflow-y-auto p-4 sm:p-6 lg:p-8">
<div className={`w-full ${containerWidth} flex-shrink-0 transition-all duration-500 ease-in-out space-y-4 sm:space-y-6 lg:space-y-8 py-4`}>
<div className="text-center space-y-2 flex-shrink-0">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
<p className="text-sm sm:text-base text-muted-foreground">
Vamos preparar o ambiente ideal para você começar a usar o OllaHub.
</p>
</div>
<div className={`w-full ${containerWidth} mx-auto mb-20 sm:mb-24 md:mb-28 px-2 sm:px-0 transition-all duration-500 ease-in-out flex-shrink-0 relative z-0`}>
<div className="flex items-center justify-between relative">
{steps.map((step, index) => {
const isCompleted = state.completedSteps.includes(step.id);
const isCurrent = state.currentStep === step.id;
const isFuture = !isCompleted && !isCurrent;
const canNavigate = step.id <= state.currentStep;
const maxReachableStep = Math.max(state.currentStep, ...state.completedSteps);
const isClickable = canNavigate || step.id === maxReachableStep + 1;
const isLineActive = state.currentStep > index + 1 || state.completedSteps.includes(step.id);
return (
<div key={step.id} className={cn("contents")}>
{/* STEP CIRCLE & LABEL */}
<div className="flex flex-col items-center relative z-0 group pb-0 md:pb-0">
<div
onClick={() => isClickable && step.id !== state.currentStep && goToStep(step.id)}
className={cn(
"w-10 h-10 sm:w-12 sm:h-12 rounded-full flex items-center justify-center border-2 transition-all duration-300 relative z-0",
// Estética dos Círculos
isCompleted && "bg-emerald-500 border-emerald-500 text-white",
isCurrent && "bg-zinc-900 border-white text-white shadow-[0_0_10px_rgba(255,255,255,0.5)]",
isFuture && "bg-zinc-900 border-zinc-800 text-zinc-500",
// Interatividade
isClickable ? "cursor-pointer" : "cursor-default"
)}
>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 sm:w-6 sm:h-6" />
) : (
<span className="text-sm sm:text-base font-bold">{step.id}</span>
)}
</div>
{/* Rótulos (Labels) - Desktop Only */}
<div className="hidden md:flex absolute top-14 flex-col items-center w-32 text-center z-10">
<span className={cn(
"text-sm font-medium transition-colors duration-300",
isCurrent ? "text-white" : "text-muted-foreground"
)}>
{step.title}
</span>
</div>
</div>
{/* LINHA CONECTORA (Entre passos) */}
{index < steps.length - 1 && (
<div className={cn(
"flex-1 h-[2px] mx-2 sm:mx-4 rounded transition-colors duration-500 relative z-0",
isLineActive ? "bg-emerald-500" : "bg-zinc-800"
)} />
)}
</div>
);
})}
</div>
</div>
<div className="w-full flex-shrink-0 relative z-10">
{renderStep()}
</div>
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { motion } from 'framer-motion';
import { CheckCircle2, Cpu, HardDrive, Zap, AlertCircle, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useHardware } from '@/hooks/use-hardware';
import { cn } from '@/lib/utils';
// Componente Skeleton simples para loading
function Skeleton({ className }: { className?: string }) {
return (
<div className={cn('animate-pulse bg-zinc-900 rounded-xl', className)} />
);
}
interface SystemCheckStepProps {
onNext: () => void;
}
export function SystemCheckStep({ onNext }: SystemCheckStepProps) {
const { specs, loading } = useHardware();
const status = loading ? 'checking' : (specs ? 'success' : 'error');
const ramGB = specs ? (specs.total_memory / (1024 * 1024 * 1024)).toFixed(1) : 0;
const isCompatible = specs ? specs.total_memory >= 4 * 1024 * 1024 * 1024 : false; // Mínimo 4GB
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<Zap className="w-6 h-6 text-emerald-500 shrink-0" />
<span className="truncate">Verificação de Hardware</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Analisando compatibilidade para execução de IA local.
</CardDescription>
</div>
{status === 'success' && isCompatible && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 px-2 py-1 text-xs shrink-0">
Compatível
</Badge>
)}
</div>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
{/* Estado de Carregamento ou Grid de Specs */}
{status === 'checking' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : specs ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
>
{/* Card RAM */}
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400 shrink-0">
<HardDrive className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-400">Memória RAM</p>
<h3 className="text-2xl font-bold text-white mt-1">
{ramGB} <span className="text-sm font-normal text-zinc-500">GB</span>
</h3>
<p className="text-xs text-zinc-500 mt-1">Mínimo recomendado: 16GB</p>
</div>
</div>
{/* Card CPU */}
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400 shrink-0">
<Cpu className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-400">Processador</p>
<h3 className="text-2xl font-bold text-white mt-1">
{specs.cpu_count} <span className="text-sm font-normal text-zinc-500">Núcleos</span>
</h3>
<p className="text-xs text-zinc-500 mt-1">Alta capacidade de threads</p>
</div>
</div>
</motion.div>
) : null}
{/* Feedback Visual */}
<div className="pt-6">
{status === 'checking' && (
<p className="text-sm text-zinc-500 flex items-center gap-2 animate-pulse">
<span className="w-2 h-2 rounded-full bg-zinc-500 block" />
Escaneando componentes...
</p>
)}
{status === 'success' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-start sm:items-center gap-3 text-sm text-emerald-400 bg-emerald-950/30 p-4 rounded-lg border border-emerald-900/50"
>
<CheckCircle2 className="w-5 h-5 shrink-0" />
<span>Hardware detectado com sucesso. Pronto para instalar o Ollama.</span>
</motion.div>
)}
{status === 'error' && (
<div className="flex items-start sm:items-center gap-3 text-sm text-red-400 bg-red-950/30 p-4 rounded-lg border border-red-900/50">
<AlertCircle className="w-5 h-5 shrink-0" />
<span>Não foi possível detectar o hardware. Verifique as permissões.</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button
onClick={onNext}
disabled={status !== 'success'}
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
>
{status === 'checking' ? 'Verificando...' : 'Continuar'}
{status === 'success' && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</CardFooter>
</Card>
);
}
+5 -1
View File
@@ -80,8 +80,12 @@ export function TitleBar() {
return ( return (
<div <div
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0" className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-[9999] shrink-0 fixed top-0 left-0 right-0"
data-tauri-drag-region data-tauri-drag-region
style={{
pointerEvents: 'auto',
WebkitAppRegion: 'drag'
} as React.CSSProperties & { WebkitAppRegion?: string }}
> >
{/* Área de arrasto com logo/título */} {/* Área de arrasto com logo/título */}
<div <div
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant = "default", ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertDescription }
+8 -2
View File
@@ -38,9 +38,14 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-8 left-0 right-0 bottom-0 z-50 bg-black/50",
className className
)} )}
style={{
// Garantir que o overlay não bloqueie eventos na área do TitleBar (topo 32px)
clipPath: 'inset(0 0 0 0)',
pointerEvents: 'auto'
}}
{...props} {...props}
/> />
) )
@@ -60,9 +65,10 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[calc(50%+16px)] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className
)} )}
style={{ pointerEvents: 'auto' }}
{...props} {...props}
> >
{children} {children}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { useOllamaHealth } from '@/contexts/ollama-health-context';
import { useSettingsStore } from '@/store/settings-store';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw, Power } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
export function ServiceInterruptionOverlay() {
const { isOllamaRunning, isChecking, checkNow } = useOllamaHealth();
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
const [starting, setStarting] = useState(false);
const tryStartOllama = async () => {
setStarting(true);
try {
await invoke<boolean>('auto_start_ollama');
await checkNow();
} catch {}
setStarting(false);
};
if (isOllamaRunning || !isSetupCompleted) return null;
return (
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-6">
<div className="max-w-md w-full border rounded-lg bg-zinc-900 text-white p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-yellow-400" />
<h2 className="text-lg font-semibold">Conexão com Ollama Perdida</h2>
</div>
<p className="text-sm text-zinc-300 mb-4">
O serviço Ollama não está respondendo. Verifique se ele está aberto.
</p>
<div className="flex items-center gap-2 mb-6">
{isChecking ? (
<span className="text-xs text-zinc-400 flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" /> Tentando reconectar...
</span>
) : (
<Button variant="ghost" size="sm" onClick={checkNow}>
<RefreshCw className="w-4 h-4" /> Verificar novamente
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button onClick={tryStartOllama} disabled={starting} className="flex-1">
{starting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" /> Iniciando Ollama...
</>
) : (
<>
<Power className="w-4 h-4" /> Tentar abrir Ollama
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={checkNow}>
Testar novamente
</Button>
</div>
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+346
View File
@@ -0,0 +1,346 @@
'use client';
import { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
// Helper para formatar bytes em formato legível (KB, MB, GB)
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
// Helper para formatar velocidade em formato legível
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
return `${parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Helper para parsear string formatada (ex: "552 MB") para bytes
function parseFormattedBytes(formatted: string): number {
if (!formatted || typeof formatted !== 'string') return 0;
const match = formatted.match(/([\d.]+)\s*(B|KB|MB|GB)/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multiplier =
unit === 'GB' ? 1024 * 1024 * 1024 :
unit === 'MB' ? 1024 * 1024 :
unit === 'KB' ? 1024 : 1;
return value * multiplier;
}
interface DownloadProgress {
percent: number | null; // null = indeterminado
downloadedBytes: number; // em bytes
totalBytes: number; // em bytes
downloadedFormatted: string; // "552 MB"
totalFormatted: string; // "1.2 GB"
speed: string; // "25 MB/s" (calculado no frontend)
status: string;
}
interface DownloadState {
isDownloading: boolean;
isSuccess: boolean; // NOVO: indica se download foi concluído com sucesso
progress: DownloadProgress;
currentModel: string | null;
error: string | null;
isCancelling: boolean;
}
interface DownloadContextType {
state: DownloadState;
startDownload: (modelName: string) => Promise<void>;
cancelDownload: () => void;
clearError: () => void;
}
const DownloadContext = createContext<DownloadContextType | undefined>(undefined);
export function DownloadProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DownloadState>({
isDownloading: false,
isSuccess: false,
progress: {
percent: null,
downloadedBytes: 0,
totalBytes: 0,
downloadedFormatted: '0 B',
totalFormatted: '0 B',
speed: '0 B/s',
status: '',
},
currentModel: null,
error: null,
isCancelling: false,
});
const abortControllerRef = useRef<AbortController | null>(null);
const unlistenRef = useRef<(() => void) | null>(null);
const isCancellingRef = useRef(false);
const lastDownloadedBytesRef = useRef<number>(0);
const lastUpdateTimeRef = useRef<number>(0);
// Sincronizar ref com estado
useEffect(() => {
isCancellingRef.current = state.isCancelling;
}, [state.isCancelling]);
// Escutar eventos de progresso do download
useEffect(() => {
if (!state.isDownloading) {
// Limpar listener quando não estiver baixando
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
return;
}
const setupListener = async () => {
const unlisten = await listen<string>('download-progress', (event) => {
if (isCancellingRef.current) return;
try {
const data = JSON.parse(event.payload);
const status = data.status || '';
// Usar percent do backend diretamente (já calculado corretamente)
// Se não estiver disponível e tivermos completed/total, calcular
let percent: number | null = data.percent !== undefined ? data.percent : null;
// Parsear valores formatados do backend para bytes
let downloadedBytes = 0;
let totalBytes = 0;
if (data.downloaded) {
downloadedBytes = typeof data.downloaded === 'string'
? parseFormattedBytes(data.downloaded)
: data.downloaded;
}
if (data.total) {
totalBytes = typeof data.total === 'string'
? parseFormattedBytes(data.total)
: data.total;
}
// Se percent não veio do backend mas temos valores brutos, calcular
if (percent === null && totalBytes > 0 && downloadedBytes > 0) {
percent = Math.round((downloadedBytes / totalBytes) * 100);
}
// Se total for 0 ou indefinido, percent fica null (indeterminado)
if (totalBytes === 0) {
percent = null;
}
// Calcular velocidade em tempo real usando refs
const now = Date.now();
let calculatedSpeed = '0 B/s';
if (downloadedBytes > 0) {
// Primeira atualização: inicializar timestamp
if (lastUpdateTimeRef.current === 0) {
lastUpdateTimeRef.current = now;
lastDownloadedBytesRef.current = downloadedBytes;
// Usar velocidade do backend se disponível, senão 0
calculatedSpeed = data.speed || '0 B/s';
} else {
const deltaTime = (now - lastUpdateTimeRef.current) / 1000; // em segundos
if (deltaTime > 0 && downloadedBytes > lastDownloadedBytesRef.current) {
const deltaBytes = downloadedBytes - lastDownloadedBytesRef.current;
const bytesPerSec = deltaBytes / deltaTime;
calculatedSpeed = formatSpeed(bytesPerSec);
} else {
// Sem mudança ou tempo inválido, manter velocidade anterior ou do backend
calculatedSpeed = data.speed || '0 B/s';
}
// Atualizar refs para próximo cálculo
lastDownloadedBytesRef.current = downloadedBytes;
lastUpdateTimeRef.current = now;
}
}
// Formatar valores para exibição
const downloadedFormatted = downloadedBytes > 0 ? formatBytes(downloadedBytes) : (data.downloaded || '0 B');
const totalFormatted = totalBytes > 0 ? formatBytes(totalBytes) : (data.total || '0 B');
// Determinar se é sucesso
const isSuccess = status === 'success' || percent === 100;
setState(prev => ({
...prev,
isSuccess: isSuccess,
isDownloading: !isSuccess,
progress: {
percent: percent !== null ? percent : prev.progress.percent,
downloadedBytes: downloadedBytes || prev.progress.downloadedBytes,
totalBytes: totalBytes || prev.progress.totalBytes,
downloadedFormatted: downloadedFormatted,
totalFormatted: totalFormatted,
speed: calculatedSpeed,
status: status || prev.progress.status,
},
isCancelling: false,
}));
if (isSuccess) {
// Não resetar progresso - manter em 100% para feedback visual
setState(prev => ({
...prev,
isDownloading: false,
isSuccess: true,
progress: {
...prev.progress,
percent: 100,
},
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
}
} catch (error) {
console.error('Erro ao processar evento de progresso:', error);
}
});
unlistenRef.current = unlisten;
};
setupListener();
return () => {
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
};
}, [state.isDownloading, state.isCancelling]);
const startDownload = useCallback(async (modelName: string) => {
if (state.isDownloading) {
console.warn('Download já em andamento');
return;
}
// Resetar refs para cálculo de velocidade
lastDownloadedBytesRef.current = 0;
lastUpdateTimeRef.current = Date.now();
setState({
isDownloading: true,
isSuccess: false,
progress: {
percent: null,
downloadedBytes: 0,
totalBytes: 0,
downloadedFormatted: '0 B',
totalFormatted: '0 B',
speed: '0 B/s',
status: 'Iniciando download...',
},
currentModel: modelName,
error: null,
isCancelling: false,
});
abortControllerRef.current = new AbortController();
try {
await invoke('download_default_model_command', { modelName });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Erro desconhecido durante download';
setState(prev => ({
...prev,
isDownloading: false,
isSuccess: false,
error: errorMsg,
isCancelling: false,
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
}
}, [state.isDownloading]);
const cancelDownload = useCallback(async () => {
if (!state.isDownloading) return;
setState(prev => ({
...prev,
isCancelling: true,
}));
try {
await invoke('cancel_download');
} catch (error) {
console.error('Falha ao cancelar no backend:', error);
}
setState(prev => ({
...prev,
isDownloading: false,
isCancelling: false,
progress: {
...prev.progress,
status: 'Cancelado',
},
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
// Limpar AbortController local
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}, [state.isDownloading]);
const clearError = useCallback(() => {
setState(prev => ({
...prev,
error: null,
}));
}, []);
return (
<DownloadContext.Provider
value={{
state,
startDownload,
cancelDownload,
clearError,
}}
>
{children}
</DownloadContext.Provider>
);
}
export function useDownloadContext() {
const context = useContext(DownloadContext);
if (context === undefined) {
throw new Error('useDownloadContext deve ser usado dentro de DownloadProvider');
}
return context;
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { usePathname } from 'next/navigation';
import { useSettingsStore } from '@/store/settings-store';
interface OllamaHealthContextType {
isOllamaRunning: boolean;
lastCheckedAt: number | null;
isChecking: boolean;
checkNow: () => Promise<void>;
}
const OllamaHealthContext = createContext<OllamaHealthContextType | undefined>(undefined);
export function OllamaHealthProvider({ children }: { children: ReactNode }) {
const [isOllamaRunning, setIsOllamaRunning] = useState<boolean>(true);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
const [isChecking, setIsChecking] = useState<boolean>(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const mountedRef = useRef<boolean>(false);
const pathname = usePathname();
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
const shouldPoll = useMemo(() => {
if (!isSetupCompleted) return false;
if (!pathname) return true;
return !pathname.startsWith('/setup');
}, [pathname, isSetupCompleted]);
const checkNow = async () => {
try {
if (!mountedRef.current) return;
setIsChecking(true);
const ok = await invoke<boolean>('check_ollama_heartbeat');
if (!mountedRef.current) return;
setIsOllamaRunning(!!ok);
setLastCheckedAt(Date.now());
} catch {
if (!mountedRef.current) return;
setIsOllamaRunning(false);
setLastCheckedAt(Date.now());
} finally {
if (!mountedRef.current) return;
setIsChecking(false);
}
};
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
useEffect(() => {
if (!shouldPoll) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
// primeira verificação imediata após mount
setTimeout(() => { checkNow(); }, 0);
// polling
intervalRef.current = setInterval(() => { if (mountedRef.current) { checkNow(); } }, 3000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [shouldPoll]);
return (
<OllamaHealthContext.Provider value={{ isOllamaRunning, lastCheckedAt, isChecking, checkNow }}>
{children}
</OllamaHealthContext.Provider>
);
}
export function useOllamaHealth() {
const ctx = useContext(OllamaHealthContext);
if (!ctx) throw new Error('useOllamaHealth deve ser usado dentro de OllamaHealthProvider');
return ctx;
}
+31
View File
@@ -0,0 +1,31 @@
version: "3.7"
services:
searxng:
image: searxng/searxng:latest
container_name: ollaHub_searxng
ports:
- "8080:8080"
volumes:
- searxng_data:/etc/searxng:rw
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
restart: unless-stopped
networks:
- searxng_network
redis:
image: redis:alpine
container_name: ollaHub_searxng_redis
command: redis-server --save "" --appendonly "no"
networks:
- searxng_network
tmpfs:
- /var/lib/redis
volumes:
searxng_data:
networks:
searxng_network:
driver: bridge
+16
View File
@@ -49,6 +49,14 @@ export function useAppUpdater(): AppUpdaterState {
// Verificar atualizações // Verificar atualizações
const checkUpdate = useCallback(async () => { const checkUpdate = useCallback(async () => {
<<<<<<< HEAD
// Ignorar verificação de updates em ambiente de desenvolvimento
if (process.env.NODE_ENV === 'development') {
return;
}
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
setIsChecking(true); setIsChecking(true);
setError(null); setError(null);
@@ -104,6 +112,14 @@ export function useAppUpdater(): AppUpdaterState {
// Verificação automática periódica (a cada 6 horas) // Verificação automática periódica (a cada 6 horas)
useEffect(() => { useEffect(() => {
<<<<<<< HEAD
// Ignorar verificação automática em desenvolvimento
if (process.env.NODE_ENV === 'development') {
return;
}
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
if (!autoCheckEnabled) return; if (!autoCheckEnabled) return;
// Verificar imediatamente ao montar (com delay para não interferir no carregamento) // Verificar imediatamente ao montar (com delay para não interferir no carregamento)
+21 -1
View File
@@ -15,7 +15,8 @@ export function useAutoLabelingModel() {
const checkAndDownload = async () => { const checkAndDownload = async () => {
try { try {
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL }); // O comando Tauri é check_if_model_installed_command, mas o Tauri remove o sufixo _command
const installed = await invoke<boolean>('check_if_model_installed_command', { name: TARGET_MODEL });
if (installed) { if (installed) {
setIsReady(true); setIsReady(true);
return; return;
@@ -49,6 +50,25 @@ export function useAutoLabelingModel() {
setIsDownloading(false); setIsDownloading(false);
setIsReady(true); setIsReady(true);
} catch (error) { } catch (error) {
// Detectar erros de conexão/rede e tratá-los silenciosamente
const errorMessage = error instanceof Error ? error.message : String(error);
const isConnectionError =
errorMessage.toLowerCase().includes('connection') ||
errorMessage.toLowerCase().includes('network') ||
errorMessage.toLowerCase().includes('econnrefused') ||
errorMessage.toLowerCase().includes('timeout') ||
errorMessage.toLowerCase().includes('failed to connect') ||
errorMessage.toLowerCase().includes('connection refused');
if (isConnectionError) {
// Erro de conexão: Ollama está offline - tratar silenciosamente
// Não logar console.error para não poluir o console
setIsDownloading(false);
setIsReady(false);
return;
}
// Outros erros: manter log atual para debug
console.error("Failed to setup auto-labeling model:", error); console.error("Failed to setup auto-labeling model:", error);
setIsDownloading(false); setIsDownloading(false);
} }
+9 -16
View File
@@ -37,24 +37,17 @@ export function useBatchVerify() {
const callLLM = async (model: string, prompt: string, json = false) => { const callLLM = async (model: string, prompt: string, json = false) => {
try { try {
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const response = await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt,
model, options: {
prompt, temperature: 0.3,
stream: false, num_predict: 1024,
format: json ? 'json' : undefined, format: json ? 'json' : undefined,
options: { },
temperature: 0.3,
num_predict: 1024,
},
}),
}); });
return response;
if (!response.ok) throw new Error('Failed to call LLM');
const data = await response.json();
return data.response;
} catch (e) { } catch (e) {
console.error('LLM Call Error:', e); console.error('LLM Call Error:', e);
throw e; throw e;
+16 -43
View File
@@ -254,29 +254,15 @@ export function useChatStorage() {
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => { const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
// Generate title immediately from user's first message // Generate title immediately from user's first message
try { try {
const response = await fetch('http://localhost:11434/api/chat', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', let title = await invoke<string>('generate_chat_completion', {
headers: { 'Content-Type': 'application/json' }, model: TITLE_MODEL,
body: JSON.stringify({ messages: [
model: TITLE_MODEL, { role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
messages: [ { role: 'user', content: userMessage }
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' }, ],
{ role: 'user', content: userMessage } options: undefined,
],
stream: false,
}),
}); });
if (!response.ok) {
// If model not available, use fallback
if (response.status === 404 || response.status >= 500) {
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
}
throw new Error(`Title generation failed with status: ${response.status}`);
}
const data = await response.json();
let title = data.message?.content?.trim() || "";
// If empty from start, use fallback // If empty from start, use fallback
if (!title || title.length === 0) { if (!title || title.length === 0) {
@@ -320,30 +306,17 @@ export function useChatStorage() {
content: removeMetadataNoise(m.content), content: removeMetadataNoise(m.content),
})); }));
const response = await fetch('http://localhost:11434/api/chat', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const rawTitle = await invoke<string>('generate_chat_completion', {
headers: { 'Content-Type': 'application/json' }, model: TITLE_MODEL,
body: JSON.stringify({ messages: [
model: TITLE_MODEL, { role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
messages: [
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
...sanitizedMessages ...sanitizedMessages
], ],
stream: false, options: undefined,
}),
}); });
if (!response.ok) {
// If model not found or unavailable, throw to trigger fallback
if (response.status === 404 || response.status >= 500) {
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
}
// For other errors, use fallback
throw new Error(`Title generation failed with status: ${response.status}`);
}
const data = await response.json(); let title = removeMetadataNoise(rawTitle?.trim() || "");
let title = removeMetadataNoise(data.message?.content?.trim() || "");
// If empty from start, throw to trigger fallback // If empty from start, throw to trigger fallback
if (!title || title.length === 0) { if (!title || title.length === 0) {
+16 -3
View File
@@ -26,6 +26,8 @@ export interface ThinkingMessageMetadata {
error?: string; error?: string;
timestamp: number; timestamp: number;
duration?: number; duration?: number;
activeQueries?: Array<{ query: string; source: string; round?: number; startedAt: number }>;
activeUrls?: Array<{ url: string; title?: string; status: string; source?: string; startedAt: number; duration?: number }>;
} }
export interface Message { export interface Message {
@@ -528,10 +530,21 @@ export function useChat() {
} }
}; };
const stop = () => { const stop = async () => {
if (abortControllerRef.current) { try {
abortControllerRef.current.abort(); // Cancelar processamento de scraping (prioridade alta)
try {
await invoke('cancel_scraping');
} catch (e) {
console.error('Erro ao cancelar scraping:', e);
}
if (currentSessionIdRef.current) {
await invoke('stop_processing', { sessionId: currentSessionIdRef.current });
}
} finally {
setIsLoading(false); setIsLoading(false);
abortControllerRef.current = null;
} }
}; };
+95 -70
View File
@@ -23,7 +23,7 @@ export interface DeepResearchLog {
timestamp: number; timestamp: number;
input: string; input: string;
rawOutput?: string; rawOutput?: string;
parsedOutput?: any; parsedOutput?: unknown;
error?: string; error?: string;
} }
@@ -47,7 +47,7 @@ function logStep(message: string) {
log.info(message); log.info(message);
} }
function logData(label: string, data: any) { function logData(label: string, data: unknown) {
if (typeof data === 'string' && data.length > 500) { if (typeof data === 'string' && data.length > 500) {
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`); log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
} else if (typeof data === 'object') { } else if (typeof data === 'object') {
@@ -57,7 +57,7 @@ function logData(label: string, data: any) {
} }
} }
function logError(message: string, error?: any) { function logError(message: string, error?: unknown) {
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`); log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
} }
@@ -75,17 +75,17 @@ function extractQueriesFromResponse(raw: string): string[] {
logData('Raw response', raw); logData('Raw response', raw);
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```) // 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
let cleaned = raw const cleaned = raw
.replace(/```json\s*/gi, '') .replace(/```json\s*/gi, '')
.replace(/```\s*/g, '') .replace(/```\s*/g, '')
.trim(); .trim();
// 2. Try to parse as JSON // 2. Try to parse as JSON
let parsed: any; let parsed: unknown;
try { try {
parsed = JSON.parse(cleaned); parsed = JSON.parse(cleaned);
logStep('Direct JSON parse successful'); logStep('Direct JSON parse successful');
} catch (e) { } catch {
logStep('Direct JSON parse failed, trying to extract JSON from string...'); logStep('Direct JSON parse failed, trying to extract JSON from string...');
// If JSON parse fails, try to find JSON-like content in the string // If JSON parse fails, try to find JSON-like content in the string
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/); const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
@@ -93,7 +93,7 @@ function extractQueriesFromResponse(raw: string): string[] {
try { try {
parsed = JSON.parse(jsonMatch[0]); parsed = JSON.parse(jsonMatch[0]);
logStep('Extracted JSON from string successfully'); logStep('Extracted JSON from string successfully');
} catch (e2) { } catch {
logError('Could not extract JSON from response'); logError('Could not extract JSON from response');
throw new Error('Could not extract JSON from response'); throw new Error('Could not extract JSON from response');
} }
@@ -105,7 +105,7 @@ function extractQueriesFromResponse(raw: string): string[] {
// 3. Handle different formats // 3. Handle different formats
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0); const result = (parsed as unknown[]).filter(item => typeof item === 'string' && (item as string).trim().length > 0) as string[];
logSuccess(`Parsed as plain array: ${result.length} queries`); logSuccess(`Parsed as plain array: ${result.length} queries`);
return result; return result;
} }
@@ -113,17 +113,19 @@ function extractQueriesFromResponse(raw: string): string[] {
// 4. Check common object keys used by different models // 4. Check common object keys used by different models
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions']; const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
for (const key of commonKeys) { for (const key of commonKeys) {
if (parsed[key] && Array.isArray(parsed[key])) { const obj = parsed as Record<string, unknown>;
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0); const val = obj[key];
if (Array.isArray(val)) {
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
logSuccess(`Found queries under key "${key}": ${result.length} queries`); logSuccess(`Found queries under key "${key}": ${result.length} queries`);
return result; return result;
} }
} }
// 5. Fallback: find first array value in the object // 5. Fallback: find first array value in the object
for (const val of Object.values(parsed)) { for (const val of Object.values(parsed as Record<string, unknown>)) {
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') { if (Array.isArray(val) && val.length > 0 && typeof (val as unknown[])[0] === 'string') {
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0); const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
logSuccess(`Found queries in object value: ${result.length} queries`); logSuccess(`Found queries in object value: ${result.length} queries`);
return result; return result;
} }
@@ -151,29 +153,19 @@ export function useDeepResearch() {
logData('Prompt length', `${prompt.length} chars`); logData('Prompt length', `${prompt.length} chars`);
try { try {
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const response = await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt,
model, options: {
prompt, temperature: 0.2,
stream: false, num_predict: 2048,
format: json ? 'json' : undefined, format: json ? 'json' : undefined,
options: { },
temperature: 0.2,
num_predict: 2048,
},
}),
}); });
if (!response.ok) {
logError(`LLM API returned status ${response.status}`);
throw new Error('Failed to call LLM');
}
const data = await response.json(); logSuccess(`LLM response received (${response?.length || 0} chars)`);
logSuccess(`LLM response received (${data.response?.length || 0} chars)`); return response;
return data.response;
} catch (e) { } catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e); const errorMsg = e instanceof Error ? e.message : String(e);
logError('LLM Call Error', errorMsg); logError('LLM Call Error', errorMsg);
@@ -255,40 +247,41 @@ export function useDeepResearch() {
plan = [query]; plan = [query];
} }
// Safety Check
logStep('--- Safety Check ---'); logStep('--- Safety Check ---');
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3); const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
logData('Keywords from query', keywords); logData('Keywords from query', keywords);
const isSmallModel = /(?:270m|0\.5b|1b|1\.7b|2b|mini|small|abliterated|uncensored|qwen3|llama2)/i.test(model);
const isRelevant = plan.some(q => if (isSmallModel) {
keywords.some(k => q.toLowerCase().includes(k)) logStep('Safety Check bypassed for small/experimental model');
);
logData('Plan is relevant', isRelevant);
if (!isRelevant && keywords.length > 0) {
logError('Safety Check Failed: Plan is irrelevant to query');
logData('Generated plan', plan);
logStep('Falling back to original query');
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: 'Safety Check Failed: Plan irrelevant to query'
});
plan = [query];
} else { } else {
logSuccess(`Decomposition complete: ${plan.length} search queries generated`); const isRelevant = plan.some(q =>
addLog({ keywords.some(k => q.toLowerCase().includes(k))
stage: 'decomposition', );
timestamp: Date.now(), logData('Plan is relevant', isRelevant);
input: prompt, if (!isRelevant && keywords.length > 0) {
rawOutput: response, logError('Safety Check Failed: Plan is irrelevant to query');
parsedOutput: plan, logData('Generated plan', plan);
error: parseError logStep('Falling back to original query');
}); addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: 'Safety Check Failed: Plan irrelevant to query'
});
plan = [query];
}
} }
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: parseError
});
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
setState(s => ({ ...s, plan })); setState(s => ({ ...s, plan }));
return plan; return plan;
@@ -303,31 +296,63 @@ export function useDeepResearch() {
// --- ADD TO KNOWLEDGE BASE --- // --- ADD TO KNOWLEDGE BASE ---
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => { const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
logStep(`--- Adding to Knowledge Base ---`); logSection('ADDING TO KNOWLEDGE BASE');
logData('Query', query); logData('Query', query);
logData('Sources count', sources.length); logData('Sources count', sources.length);
logData('Timestamp', new Date().toISOString());
const entries: ResearchEntry[] = sources.map((src, idx) => ({ const entries: ResearchEntry[] = sources.map((src, idx) => ({
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`, id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
query, query,
sourceUrl: src.url, sourceUrl: src.url,
title: src.title, title: src.title,
content: src.markdown, content: src.markdown || src.content || '',
timestamp: Date.now(), timestamp: Date.now(),
})); }));
// Log detalhado de cada fonte
sources.forEach((src, idx) => { sources.forEach((src, idx) => {
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`); logStep(`\n--- Source ${idx + 1}/${sources.length} ---`);
const contentLength = src.markdown?.length || 0; logData('Title', src.title);
logData(` Content length`, `${contentLength} chars`); logData('URL', src.url);
const contentLength = src.markdown?.length || src.content?.length || 0;
const snippetLength = src.snippet?.length || 0;
logData('Content length (markdown)', `${contentLength} chars`);
logData('Snippet length', `${snippetLength} chars`);
logData('Cached', src.cached ? 'Yes' : 'No');
if (src.markdown && contentLength > 0) { if (src.markdown && contentLength > 0) {
const preview = src.markdown.substring(0, 300); // Log preview mais detalhado
logData(` Preview`, preview + (contentLength > 300 ? '...' : '')); const preview = src.markdown.substring(0, 500);
logData('Content preview', preview + (contentLength > 500 ? '...' : ''));
// Estatísticas do conteúdo
const wordCount = src.markdown.split(/\s+/).length;
const paragraphCount = src.markdown.split(/\n\n/).length;
logData('Word count', wordCount);
logData('Paragraph count', paragraphCount);
} else if (src.content && src.content.length > 0) {
const preview = src.content.substring(0, 500);
logData('Content preview (raw)', preview + (src.content.length > 500 ? '...' : ''));
} else { } else {
logStep(` ⚠️ No content extracted`); logError('No content extracted from this source');
}
// Log snippet se disponível
if (src.snippet) {
logData('Snippet', src.snippet.substring(0, 200) + (snippetLength > 200 ? '...' : ''));
} }
}); });
// Estatísticas agregadas
const totalContentLength = entries.reduce((sum, e) => sum + e.content.length, 0);
const avgContentLength = entries.length > 0 ? totalContentLength / entries.length : 0;
logStep('\n--- Aggregated Statistics ---');
logData('Total entries', entries.length);
logData('Total content length', `${totalContentLength} chars`);
logData('Average content length', `${Math.round(avgContentLength)} chars`);
setState(s => ({ setState(s => ({
...s, ...s,
+102
View File
@@ -0,0 +1,102 @@
import { useState, useMemo } from 'react';
import { useLocalModels, LocalModel } from '@/hooks/use-local-models';
import { MODEL_REGISTRY, ModelDefinition } from '@/lib/model-registry';
const STORAGE_KEY = 'last_used_model';
export interface UseModelSelectionResult {
myModels: ModelDefinition[];
availableModels: ModelDefinition[];
selectedModel: string | null;
setSelectedModel: (model: string) => void;
isLoading: boolean;
installedVariantsByFamily: Record<string, string[]>;
refreshModels: () => Promise<void>;
}
export function useModelSelection(): UseModelSelectionResult {
const { models: installedModels, loading: isLoadingModels, refresh: refreshLocalModels } = useLocalModels();
const [selectedModel, setSelectedModelState] = useState<string | null>(null);
const { myModels, availableModels } = useMemo(() => {
const installedNames = new Set(installedModels.map(m => m.name));
const my: ModelDefinition[] = [];
const available: ModelDefinition[] = [];
const registryByModel = new Map<string, ModelDefinition>();
for (const def of MODEL_REGISTRY) {
registryByModel.set(def.model, def);
}
for (const def of MODEL_REGISTRY) {
const isInstalled = def.variants.some(v => installedNames.has(v.name));
if (isInstalled) {
my.push(def);
} else {
available.push(def);
}
}
const installedByBase = new Map<string, LocalModel[]>();
for (const m of installedModels) {
const base = m.name.split(':')[0];
const list = installedByBase.get(base) || [];
list.push(m);
installedByBase.set(base, list);
}
for (const [base, models] of installedByBase.entries()) {
if (!registryByModel.has(base)) {
const variants = models.map((m) => ({
name: m.name,
size: m.size,
context: 'Unknown',
inputType: 'Text',
}));
my.push({
model: base,
url: '',
description: 'Modelo instalado localmente',
variantsCount: variants.length,
variants,
});
}
}
return { myModels: my, availableModels: available };
}, [installedModels]);
const installedVariantsByFamily = useMemo(() => {
const map: Record<string, string[]> = {};
for (const m of installedModels) {
const base = m.name.split(':')[0];
if (!map[base]) map[base] = [];
map[base].push(m.name);
}
return map;
}, [installedModels]);
const defaultSelectedModel = useMemo(() => {
if (isLoadingModels) return null;
const lastUsed = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
const installedNames = installedModels.map(m => m.name);
if (lastUsed && installedNames.includes(lastUsed)) return lastUsed;
if (installedModels.length > 0) return installedModels[0].name;
return null;
}, [installedModels, isLoadingModels]);
// Persistence wrapper
const setSelectedModel = (model: string) => {
setSelectedModelState(model);
localStorage.setItem(STORAGE_KEY, model);
};
return {
myModels,
availableModels,
selectedModel: selectedModel ?? defaultSelectedModel,
setSelectedModel,
isLoading: isLoadingModels,
installedVariantsByFamily,
refreshModels: refreshLocalModels,
};
}
+190 -16
View File
@@ -1,50 +1,224 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running'; export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
export function useOllamaCheck() { interface UseOllamaCheckReturn {
const [status, setStatus] = useState<OllamaStatus>('checking'); status: OllamaStatus;
isChecking: boolean;
check: () => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
reset: () => void;
elapsedTime: number;
}
const check = async () => { const INITIAL_INTERVAL = 2000; // 2 segundos
setStatus('checking'); const MAX_INTERVAL = 30000; // 30 segundos
const BACKOFF_MULTIPLIER = 1.5;
const TOTAL_TIMEOUT = 300000; // 5 minutos
export function useOllamaCheck(): UseOllamaCheckReturn {
const [status, setStatus] = useState<OllamaStatus>('not_installed');
const [isChecking, setIsChecking] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number | null>(null);
const currentIntervalRef = useRef<number>(INITIAL_INTERVAL);
const attemptCountRef = useRef<number>(0);
const isPollingActiveRef = useRef<boolean>(false);
const elapsedTimerRef = useRef<NodeJS.Timeout | null>(null);
const performCheck = useCallback(async (): Promise<boolean> => {
try { try {
// Usar verificação completa que checa instalação E execução
const result = await invoke<{ const result = await invoke<{
installed: boolean; installed: boolean;
running: boolean; running: boolean;
status: string; status: string;
}>('check_ollama_full'); }>('check_ollama_full');
setStatus(result.status as OllamaStatus); const newStatus = result.status as OllamaStatus;
setStatus(newStatus);
// Se Ollama está rodando, polling deve parar
if (newStatus === 'running' && result.installed && result.running) {
return true; // Sucesso, parar polling
}
return false; // Continua polling
} catch (error) { } catch (error) {
console.error('Failed to check ollama:', error); console.error('Failed to check ollama:', error);
// Se falhar, tentar verificação individual como fallback // Se falhar, tentar verificação robusta como fallback
try { try {
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
if (isInstalledAndRunning) {
setStatus('running');
return true; // Sucesso, parar polling
}
// Se verify_ollama_integrity retornou false, pode ser instalado mas não rodando
// ou não instalado. Verificar separadamente para determinar status exato
const installed = await invoke<boolean>('check_ollama_installed'); const installed = await invoke<boolean>('check_ollama_installed');
if (!installed) { if (!installed) {
setStatus('not_installed'); setStatus('not_installed');
return; return false; // Continua polling (não instalado ainda)
} }
const running = await invoke<boolean>('check_ollama_running'); const running = await invoke<boolean>('check_ollama_running');
if (!running) { if (!running) {
setStatus('installed_stopped'); setStatus('installed_stopped');
return; return false; // Continua polling (instalado mas não rodando)
} }
setStatus('running'); setStatus('running');
return true; // Sucesso, parar polling
} catch (fallbackError) { } catch (fallbackError) {
console.error('Fallback check also failed:', fallbackError); console.error('Fallback check also failed:', fallbackError);
setStatus('not_installed'); setStatus('not_installed');
return false; // Continua polling
} }
} }
};
useEffect(() => {
check();
}, []); }, []);
return { status, check }; const startPolling = useCallback(() => {
} if (isPollingActiveRef.current) {
return; // Já está rodando
}
isPollingActiveRef.current = true;
setIsChecking(true);
startTimeRef.current = Date.now();
attemptCountRef.current = 0;
currentIntervalRef.current = INITIAL_INTERVAL;
// Atualizar tempo decorrido a cada segundo
elapsedTimerRef.current = setInterval(() => {
if (startTimeRef.current) {
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
setElapsedTime(elapsed);
// Verificar timeout total
if (elapsed * 1000 >= TOTAL_TIMEOUT) {
setIsChecking(false);
isPollingActiveRef.current = false;
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
elapsedTimerRef.current = null;
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
}
}, 1000);
// Primeira verificação imediata
performCheck().then((shouldStop) => {
if (shouldStop) {
stopPolling();
return;
}
// Iniciar polling com exponential backoff
const scheduleNext = () => {
if (!isPollingActiveRef.current) {
return;
}
attemptCountRef.current += 1;
// Calcular próximo intervalo com exponential backoff
const nextInterval = Math.min(
INITIAL_INTERVAL * Math.pow(BACKOFF_MULTIPLIER, attemptCountRef.current - 1),
MAX_INTERVAL
);
currentIntervalRef.current = nextInterval;
intervalRef.current = setTimeout(async () => {
if (!isPollingActiveRef.current) {
return;
}
const shouldStop = await performCheck();
if (shouldStop) {
stopPolling();
} else {
scheduleNext();
}
}, nextInterval);
};
scheduleNext();
});
// Timeout total
timeoutRef.current = setTimeout(() => {
if (isPollingActiveRef.current) {
console.warn('Ollama check polling timeout após 5 minutos');
stopPolling();
}
}, TOTAL_TIMEOUT);
}, [performCheck]);
const stopPolling = useCallback(() => {
isPollingActiveRef.current = false;
setIsChecking(false);
if (intervalRef.current) {
clearTimeout(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current);
elapsedTimerRef.current = null;
}
}, []);
const check = useCallback(async () => {
// Forçar verificação manual
await performCheck();
}, [performCheck]);
const reset = useCallback(() => {
stopPolling();
currentIntervalRef.current = INITIAL_INTERVAL;
attemptCountRef.current = 0;
setElapsedTime(0);
startTimeRef.current = null;
startPolling();
}, [stopPolling, startPolling]);
// Limpar polling ao desmontar
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);
// Parar polling automaticamente quando status for 'running'
useEffect(() => {
if (status === 'running' && isPollingActiveRef.current) {
stopPolling();
}
}, [status, stopPolling]);
return {
status,
isChecking,
check,
startPolling,
stopPolling,
reset,
elapsedTime,
};
}
+158 -11
View File
@@ -12,6 +12,13 @@ interface DownloadState {
isInstalled: boolean; isInstalled: boolean;
isChecking: boolean; isChecking: boolean;
checkStatus: string; checkStatus: string;
installStatus: string | null;
isInstalling: boolean;
speed: string;
downloadedBytes: number;
totalBytes: number;
eta: number;
status: 'downloading' | 'verifying' | 'completed';
} }
export function useOllamaDownload() { export function useOllamaDownload() {
@@ -24,6 +31,13 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
}); });
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -31,17 +45,71 @@ export function useOllamaDownload() {
// Escutar eventos de progresso do download // Escutar eventos de progresso do download
useEffect(() => { useEffect(() => {
const unlistenPromise = listen('installer-download-progress', (event: any) => { const unlistenDownloadPromise = listen('installer-download-progress', (event: any) => {
const data = event.payload; const data = event.payload;
const statusText = data.status || '';
const isVerifying = statusText.includes('Verificando');
const isCompleted = data.progress === 100 && !isVerifying;
setState(prev => ({ setState(prev => ({
...prev, ...prev,
downloadProgress: data.progress || 0, downloadProgress: data.progress || 0,
downloadStatus: data.status || '', downloadStatus: statusText,
speed: data.speed_formatted || '0 MB/s',
downloadedBytes: data.downloaded || 0,
totalBytes: data.total || 0,
eta: data.eta_seconds || 0,
status: isVerifying ? 'verifying' : isCompleted ? 'completed' : 'downloading',
isDownloading: !isCompleted,
})); }));
}); });
// Escutar eventos de progresso da instalação
const unlistenInstallPromise = listen('installer-progress', (event: any) => {
const data = event.payload;
const status = data.status || 'unknown';
const message = data.message || 'Processando...';
setState(prev => ({
...prev,
isInstalling: status !== 'success' && status !== 'error' && status !== 'partial',
installStatus: message,
isInstalled: status === 'success',
downloadError: status === 'error' ? message : prev.downloadError,
}));
// Se a instalação foi bem-sucedida, iniciar verificação automática
if (status === 'success') {
// Usar setTimeout para evitar dependência circular
setTimeout(() => {
// startAutoCheck será chamado via closure do componente
// Por enquanto, apenas atualizar o estado
setState(prev => ({
...prev,
isChecking: true,
checkStatus: 'Verificando instalação...',
}));
}, 100);
}
});
// Escutar evento de Ollama pronto
const unlistenReadyPromise = listen('ollama-ready', () => {
setState(prev => ({
...prev,
isInstalling: false,
installStatus: 'Ollama instalado e rodando!',
isInstalled: true,
isChecking: false,
checkStatus: 'Ollama encontrado e rodando!',
}));
window.dispatchEvent(new CustomEvent('ollama-installed'));
});
return () => { return () => {
unlistenPromise.then(unlisten => unlisten()); unlistenDownloadPromise.then(unlisten => unlisten());
unlistenInstallPromise.then(unlisten => unlisten());
unlistenReadyPromise.then(unlisten => unlisten());
}; };
}, []); }, []);
@@ -55,6 +123,16 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
try { try {
@@ -70,6 +148,16 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'completed',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
return; return;
} }
@@ -85,6 +173,16 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'completed',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
} catch (error) { } catch (error) {
setState({ setState({
@@ -96,6 +194,16 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
} }
}; };
@@ -109,6 +217,24 @@ export function useOllamaDownload() {
return; return;
} }
<<<<<<< HEAD
// Atualizar estado para mostrar que a instalação está começando
setState(prev => ({
...prev,
isInstalling: true,
installStatus: 'Iniciando instalação silenciosa...',
downloadError: null,
}));
// Chamar Tauri em background (não bloquear)
runInstaller(state.filePath)
.then(() => {
// Após iniciar a instalação, começar a verificação automática após um delay
setTimeout(() => {
startAutoCheck();
}, 2000);
})
=======
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri // Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
setState(prev => ({ setState(prev => ({
...prev, ...prev,
@@ -120,27 +246,40 @@ export function useOllamaDownload() {
// Chamar Tauri em background (não bloquear) // Chamar Tauri em background (não bloquear)
runInstaller(state.filePath) runInstaller(state.filePath)
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
.catch((error) => { .catch((error) => {
setState(prev => ({ setState(prev => ({
...prev, ...prev,
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador', downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
<<<<<<< HEAD
isInstalling: false,
installStatus: null,
=======
isInstalled: false, // Reverter se falhar isInstalled: false, // Reverter se falhar
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
})); }));
}); });
}; };
const checkOllama = async (): Promise<boolean> => { const checkOllama = async (): Promise<boolean> => {
try { try {
const installed = await invoke<boolean>('check_ollama_installed'); // Usar verificação robusta multi-camada (PATH, caminhos absolutos, HTTP)
if (!installed) { const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
return false; return isInstalledAndRunning;
}
const running = await invoke<boolean>('check_ollama_running');
return running;
} catch (error) { } catch (error) {
console.error('Erro ao verificar Ollama:', error); console.error('Erro ao verificar Ollama:', error);
return false; // Fallback para verificação simples se o comando robusto falhar
try {
const installed = await invoke<boolean>('check_ollama_installed');
if (!installed) {
return false;
}
const running = await invoke<boolean>('check_ollama_running');
return running;
} catch (fallbackError) {
console.error('Fallback check also failed:', fallbackError);
return false;
}
} }
}; };
@@ -237,6 +376,7 @@ export function useOllamaDownload() {
filePath: existingPath, filePath: existingPath,
downloadProgress: 100, downloadProgress: 100,
downloadStatus: 'Download já concluído', downloadStatus: 'Download já concluído',
status: 'completed',
})); }));
} }
}; };
@@ -252,6 +392,13 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
}); });
}; };
+8
View File
@@ -56,3 +56,11 @@ export function useOperatingSystem() {
+9 -15
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
// Hardcoded for now to avoid FS complexity in v1, but ideally read from file // Hardcoded for now to avoid FS complexity in v1, but ideally read from file
const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade. const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade.
@@ -40,23 +41,16 @@ export function usePromptGenerator() {
const generatePrompt = async (userGoal: string, model: string): Promise<string> => { const generatePrompt = async (userGoal: string, model: string): Promise<string> => {
setIsGenerating(true); setIsGenerating(true);
try { try {
const response = await fetch('http://localhost:11434/api/chat', { const response = await invoke<string>('generate_chat_completion', {
method: 'POST', model,
headers: { 'Content-Type': 'application/json' }, messages: [
body: JSON.stringify({ { role: 'system', content: PROMPT_GEN_SYSTEM },
model, { role: 'user', content: userGoal }
messages: [ ],
{ role: 'system', content: PROMPT_GEN_SYSTEM }, options: undefined,
{ role: 'user', content: userGoal }
],
stream: false, // We want the full prompt at once for this utility
}),
}); });
if (!response.ok) throw new Error('Failed to generate prompt');
const data = await response.json(); return response || "";
return data.message?.content || "";
} catch (error) { } catch (error) {
console.error("Prompt generation failed:", error); console.error("Prompt generation failed:", error);
throw error; throw error;
+8 -19
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
/** /**
* Hook para gerar query de busca otimizada usando a LLM * Hook para gerar query de busca otimizada usando a LLM
@@ -33,27 +34,15 @@ Pergunta do usuário: "${userInput}"
Query de busca (ou NO_SEARCH):`; Query de busca (ou NO_SEARCH):`;
const response = await fetch('http://localhost:11434/api/generate', { const generatedText = await invoke<string>('generate_completion', {
method: 'POST', model,
headers: { 'Content-Type': 'application/json' }, prompt,
body: JSON.stringify({ options: {
model, temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
prompt, num_predict: 50, // Limitar tokens para resposta rápida
stream: false, },
options: {
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
num_predict: 50, // Limitar tokens para resposta rápida
},
}),
}); });
if (!response.ok) {
throw new Error('Falha ao gerar query');
}
const data = await response.json();
const generatedText = (data.response || '').trim();
// Limpar resposta (remover aspas, pontos finais, etc) // Limpar resposta (remover aspas, pontos finais, etc)
let query = generatedText let query = generatedText
.replace(/^["']|["']$/g, '') // Remover aspas .replace(/^["']|["']$/g, '') // Remover aspas
+19
View File
@@ -43,12 +43,31 @@ export function useSetupCheck() {
// Aguardar um pouco para o servidor iniciar // Aguardar um pouco para o servidor iniciar
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
<<<<<<< HEAD
// Verificar integridade completa (instalação + serviço rodando)
try {
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
if (!isInstalledAndRunning) {
// Ainda não está rodando ou instalação está corrompida
return 'needs_setup';
}
} catch (verifyError) {
// Fallback para verificação simples se o comando robusto falhar
console.warn('verify_ollama_integrity failed, using fallback:', verifyError);
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
return 'needs_setup';
}
}
=======
// Verificar novamente se está rodando // Verificar novamente se está rodando
const running = await invoke<boolean>('check_ollama_running'); const running = await invoke<boolean>('check_ollama_running');
if (!running) { if (!running) {
// Ainda não está rodando, precisa de intervenção do usuário // Ainda não está rodando, precisa de intervenção do usuário
return 'needs_setup'; return 'needs_setup';
} }
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
// Agora está rodando, verificar modelos // Agora está rodando, verificar modelos
const hasAnyModels = await checkModels(); const hasAnyModels = await checkModels();
return hasAnyModels ? 'ready' : 'needs_setup'; return hasAnyModels ? 'ready' : 'needs_setup';
+366
View File
@@ -0,0 +1,366 @@
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
export interface SetupWizardState {
currentStep: number;
completedSteps: number[];
ollamaInstalled: boolean;
ollamaRunning: boolean;
modelDownloaded: boolean;
modelName: string | null;
lastError: string | null;
progressMessage: string | null;
progressStatus: string | null;
}
const CHECKPOINT_KEY = 'ollahub_setup_wizard_checkpoint';
const DEFAULT_MODEL = 'llama3.2:1b';
const MAX_STEPS = 4;
const MIN_STEP = 1;
function getDefaultState(): SetupWizardState {
return {
currentStep: 1,
completedSteps: [],
ollamaInstalled: false,
ollamaRunning: false,
modelDownloaded: false,
modelName: null,
lastError: null,
progressMessage: null,
progressStatus: null,
};
}
function validateWizardState(state: unknown): SetupWizardState | null {
// Verificar se é um objeto
if (!state || typeof state !== 'object' || Array.isArray(state)) {
return null;
}
const stateObj = state as Record<string, unknown>;
// Validar currentStep
const currentStep = typeof stateObj.currentStep === 'number'
? Math.max(MIN_STEP, Math.min(MAX_STEPS, Math.round(stateObj.currentStep)))
: MIN_STEP;
// Validar completedSteps
const completedSteps = Array.isArray(stateObj.completedSteps)
? stateObj.completedSteps
.filter((step: unknown) => typeof step === 'number' && step >= MIN_STEP && step <= MAX_STEPS)
.map((step: unknown) => Math.round(step as number))
.filter((step: number, index: number, arr: number[]) => arr.indexOf(step) === index) // Remover duplicatas
: [];
// Validar tipos dos campos booleanos
const ollamaInstalled = typeof stateObj.ollamaInstalled === 'boolean' ? stateObj.ollamaInstalled : false;
const ollamaRunning = typeof stateObj.ollamaRunning === 'boolean' ? stateObj.ollamaRunning : false;
const modelDownloaded = typeof stateObj.modelDownloaded === 'boolean' ? stateObj.modelDownloaded : false;
// Validar modelName (pode ser null ou string)
const modelName = stateObj.modelName === null || typeof stateObj.modelName === 'string'
? stateObj.modelName as string | null
: null;
// Validar lastError (pode ser null ou string)
const lastError = stateObj.lastError === null || typeof stateObj.lastError === 'string'
? stateObj.lastError as string | null
: null;
// Validar progressMessage (pode ser null ou string)
const progressMessage = stateObj.progressMessage === null || typeof stateObj.progressMessage === 'string'
? stateObj.progressMessage as string | null
: null;
// Validar progressStatus (pode ser null ou string)
const progressStatus = stateObj.progressStatus === null || typeof stateObj.progressStatus === 'string'
? stateObj.progressStatus as string | null
: null;
return {
currentStep,
completedSteps,
ollamaInstalled,
ollamaRunning,
modelDownloaded,
modelName,
lastError,
progressMessage,
progressStatus,
};
}
export function useSetupWizard() {
const [state, setState] = useState<SetupWizardState>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(CHECKPOINT_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
const validated = validateWizardState(parsed);
if (validated) {
return validated;
} else {
// Estado inválido, remover do localStorage e usar estado inicial
console.warn('Estado do wizard inválido detectado, resetando para estado inicial');
localStorage.removeItem(CHECKPOINT_KEY);
}
} catch (error) {
// Se falhar ao fazer parse, remover item corrompido
console.error('Erro ao fazer parse do estado do wizard:', error);
localStorage.removeItem(CHECKPOINT_KEY);
}
}
}
return getDefaultState();
});
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem(CHECKPOINT_KEY, JSON.stringify(state));
}
}, [state]);
useEffect(() => {
const unlistenPromise = listen<{ status: string; message: string }>('setup-progress', (event) => {
const { status, message } = event.payload;
setState(prev => ({
...prev,
progressStatus: status,
progressMessage: message,
}));
});
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, []);
const checkSetupState = useCallback(async () => {
try {
const setupState = await invoke<{
ollama_installed: boolean;
ollama_running: boolean;
default_model_downloaded: boolean;
default_model_name: string | null;
}>('get_setup_state_command');
setState(prev => ({
...prev,
ollamaInstalled: setupState.ollama_installed,
ollamaRunning: setupState.ollama_running,
modelDownloaded: setupState.default_model_downloaded,
modelName: setupState.default_model_name || DEFAULT_MODEL,
}));
let initialStep = 1;
if (setupState.ollama_installed && setupState.ollama_running) {
initialStep = 2;
if (setupState.default_model_downloaded) {
initialStep = 4;
} else {
initialStep = 3;
}
}
setState(prev => ({
...prev,
currentStep: Math.max(prev.currentStep, initialStep),
}));
return setupState;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
setState(prev => ({
...prev,
lastError: errorMsg,
}));
throw error;
}
}, []);
const nextStep = useCallback(() => {
setState(prev => {
const next = prev.currentStep + 1;
return {
...prev,
currentStep: next,
completedSteps: [...prev.completedSteps, prev.currentStep],
lastError: null,
};
});
}, []);
const previousStep = useCallback(() => {
setState(prev => ({
...prev,
currentStep: Math.max(1, prev.currentStep - 1),
lastError: null,
}));
}, []);
const goToStep = useCallback((step: number) => {
setState(prev => ({
...prev,
currentStep: Math.max(1, Math.min(4, step)),
lastError: null,
}));
}, []);
const checkChocolatey = useCallback(async (): Promise<boolean> => {
try {
return await invoke<boolean>('check_chocolatey_installed_command');
} catch (error) {
console.error('Erro ao verificar Chocolatey:', error);
return false;
}
}, []);
const installChocolatey = useCallback(async (): Promise<void> => {
try {
setState(prev => ({
...prev,
progressMessage: 'Instalando Chocolatey...',
progressStatus: 'installing_chocolatey',
lastError: null,
}));
await invoke('install_chocolatey_command');
setState(prev => ({
...prev,
progressMessage: 'Chocolatey instalado com sucesso',
progressStatus: null,
}));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Erro ao instalar Chocolatey';
setState(prev => ({
...prev,
lastError: errorMsg,
progressStatus: null,
}));
throw error;
}
}, []);
const installOllamaViaChoco = useCallback(async (): Promise<void> => {
try {
setState(prev => ({
...prev,
progressMessage: 'Instalando Ollama via Chocolatey...',
progressStatus: 'installing_ollama',
lastError: null,
}));
await invoke('install_ollama_choco_command');
setState(prev => ({
...prev,
progressMessage: 'Ollama instalado com sucesso',
progressStatus: null,
}));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Erro ao instalar Ollama via Chocolatey';
setState(prev => ({
...prev,
lastError: errorMsg,
progressStatus: null,
}));
throw error;
}
}, []);
const installOllama = useCallback(async (installerPath?: string) => {
try {
setState(prev => ({
...prev,
lastError: null,
progressMessage: 'Iniciando instalação do Ollama...',
progressStatus: 'installing_ollama',
}));
await invoke('install_ollama_silently_command', {
installerPath: installerPath || null,
});
await checkSetupState();
setState(prev => ({
...prev,
ollamaInstalled: true,
ollamaRunning: true,
progressMessage: 'Ollama instalado e rodando!',
progressStatus: 'ollama_ready',
}));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
setState(prev => ({
...prev,
lastError: errorMsg,
progressMessage: `Erro: ${errorMsg}`,
progressStatus: 'error',
}));
throw error;
}
}, [checkSetupState]);
const downloadModel = useCallback(async (modelName: string = DEFAULT_MODEL) => {
try {
setState(prev => ({
...prev,
lastError: null,
progressMessage: `Baixando modelo ${modelName}...`,
progressStatus: 'downloading_model',
}));
await invoke('download_default_model_command', { modelName });
setState(prev => ({
...prev,
modelDownloaded: true,
modelName,
progressMessage: `Modelo ${modelName} baixado com sucesso!`,
progressStatus: 'model_ready',
}));
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
setState(prev => ({
...prev,
lastError: errorMsg,
progressMessage: `Erro ao baixar modelo: ${errorMsg}`,
progressStatus: 'error',
}));
throw error;
}
}, []);
const resetWizard = useCallback(() => {
if (typeof window !== 'undefined') {
localStorage.removeItem(CHECKPOINT_KEY);
}
setState(getDefaultState());
}, []);
const isStepComplete = useCallback((step: number) => {
return state.completedSteps.includes(step);
}, [state.completedSteps]);
return {
state,
checkSetupState,
nextStep,
previousStep,
goToStep,
checkChocolatey,
installChocolatey,
installOllamaViaChoco,
installOllama,
downloadModel,
resetWizard,
isStepComplete,
};
}
+3
View File
@@ -34,7 +34,10 @@ export function useSystemHealth() {
title: 'Modo de Economia Ativado', title: 'Modo de Economia Ativado',
description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.', description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.',
variant: 'destructive', variant: 'destructive',
<<<<<<< HEAD
=======
duration: 5000, duration: 5000,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
setHasShownCriticalToast(true); setHasShownCriticalToast(true);
} else if (newHealth.status !== 'critical') { } else if (newHealth.status !== 'critical') {
+161
View File
@@ -0,0 +1,161 @@
import { useState, useEffect, useCallback } from 'react';
import { listen } from '@tauri-apps/api/event';
export interface ActiveQuery {
query: string;
source: string;
round?: number;
startedAt: number;
}
export interface ActiveUrl {
url: string;
title?: string;
status: 'started' | 'completed' | 'failed' | 'cached';
source?: string;
startedAt: number;
duration?: number;
}
interface SearchQueryEvent {
query: string;
source: string;
round?: number;
timestamp: number;
}
interface SearchQueryCompletedEvent {
query: string;
source: string;
results_count: number;
success: boolean;
timestamp: number;
}
interface SearchSourceSwitchEvent {
from: string;
to: string;
reason: string;
timestamp: number;
}
interface ScrapingUrlEvent {
url: string;
title?: string;
status: string;
duration_ms?: number;
source?: string;
timestamp: number;
}
export function useWebSearchEvents() {
const [activeQueries, setActiveQueries] = useState<ActiveQuery[]>([]);
const [activeUrls, setActiveUrls] = useState<ActiveUrl[]>([]);
useEffect(() => {
let unlistenQueries: (() => void) | null = null;
let unlistenCompleted: (() => void) | null = null;
let unlistenSwitched: (() => void) | null = null;
let unlistenScraping: (() => void) | null = null;
const setupListeners = async () => {
// Listener para query iniciada
unlistenQueries = await listen<SearchQueryEvent>('search-query-started', (event) => {
const { query, source, round, timestamp } = event.payload;
setActiveQueries((prev) => {
// Remover query anterior se existir (mesma query)
const filtered = prev.filter((q) => q.query !== query);
return [
...filtered,
{
query,
source,
round,
startedAt: timestamp,
},
];
});
});
// Listener para query completada
unlistenCompleted = await listen<SearchQueryCompletedEvent>('search-query-completed', (event) => {
const { query, source } = event.payload;
setActiveQueries((prev) => prev.filter((q) => !(q.query === query && q.source === source)));
});
// Listener para mudança de fonte
unlistenSwitched = await listen<SearchSourceSwitchEvent>('search-source-switched', (event) => {
const { from, to, reason } = event.payload;
console.log(`[WebSearchEvents] Source switched: ${from} → ${to} (${reason})`);
// Atualizar query ativa para nova fonte
setActiveQueries((prev) =>
prev.map((q) => (q.source === from ? { ...q, source: to } : q))
);
});
// Listener para eventos de scraping
unlistenScraping = await listen<ScrapingUrlEvent>('scraping-url-event', (event) => {
const { url, title, status, duration_ms, source, timestamp } = event.payload;
setActiveUrls((prev) => {
const existingIndex = prev.findIndex((u) => u.url === url);
const newUrl: ActiveUrl = {
url,
title,
status: status as ActiveUrl['status'],
source,
startedAt: timestamp,
duration: duration_ms,
};
if (existingIndex >= 0) {
// Atualizar URL existente
const updated = [...prev];
updated[existingIndex] = newUrl;
return updated;
} else {
// Adicionar nova URL
return [...prev, newUrl];
}
});
// Remover URLs completadas/falhadas após 5 segundos
if (status === 'completed' || status === 'failed' || status === 'cached') {
setTimeout(() => {
setActiveUrls((prev) => prev.filter((u) => u.url !== url));
}, 5000);
}
});
};
setupListeners();
return () => {
if (unlistenQueries) unlistenQueries();
if (unlistenCompleted) unlistenCompleted();
if (unlistenSwitched) unlistenSwitched();
if (unlistenScraping) unlistenScraping();
};
}, []);
const clearActiveQueries = useCallback(() => {
setActiveQueries([]);
}, []);
const clearActiveUrls = useCallback(() => {
setActiveUrls([]);
}, []);
const clearAll = useCallback(() => {
setActiveQueries([]);
setActiveUrls([]);
}, []);
return {
activeQueries,
activeUrls,
clearActiveQueries,
clearActiveUrls,
clearAll,
};
}
+42 -3
View File
@@ -1,8 +1,10 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch'; import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
import { useSettingsStore } from '@/store/settings-store'; import { useSettingsStore } from '@/store/settings-store';
import { useWebSearchEvents, type ActiveQuery, type ActiveUrl } from './use-web-search-events';
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error'; export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error' | 'cancelled';
export interface WebSearchState { export interface WebSearchState {
isEnabled: boolean; isEnabled: boolean;
@@ -10,6 +12,8 @@ export interface WebSearchState {
currentQuery: string; currentQuery: string;
scrapedSources: ScrapedContent[]; scrapedSources: ScrapedContent[];
error: string | null; error: string | null;
activeQueries: ActiveQuery[];
activeUrls: ActiveUrl[];
} }
const DEFAULT_STATE: WebSearchState = { const DEFAULT_STATE: WebSearchState = {
@@ -18,12 +22,16 @@ const DEFAULT_STATE: WebSearchState = {
currentQuery: '', currentQuery: '',
scrapedSources: [], scrapedSources: [],
error: null, error: null,
activeQueries: [],
activeUrls: [],
}; };
/** /**
* Hook para gerenciar estado do Web Search * Hook para gerenciar estado do Web Search
*/ */
export function useWebSearch() { export function useWebSearch() {
const events = useWebSearchEvents();
const [state, setState] = useState<WebSearchState>(() => { const [state, setState] = useState<WebSearchState>(() => {
// Carregar preferência do localStorage // Carregar preferência do localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -36,6 +44,15 @@ export function useWebSearch() {
return DEFAULT_STATE; return DEFAULT_STATE;
}); });
// Sincronizar eventos com estado usando useEffect
useEffect(() => {
setState((prev) => ({
...prev,
activeQueries: events.activeQueries,
activeUrls: events.activeUrls,
}));
}, [events.activeQueries, events.activeUrls]);
/** /**
* Ativa/desativa Web Search * Ativa/desativa Web Search
*/ */
@@ -175,7 +192,7 @@ export function useWebSearch() {
} catch (error) { } catch (error) {
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo) // Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag'; const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage); console.warn('Erro em smartSearchRag (retornando vazio para usar fallback de modelo local):', errorMessage);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
@@ -185,6 +202,7 @@ export function useWebSearch() {
})); }));
// Retornar vazio em vez de throw para não quebrar o fluxo // Retornar vazio em vez de throw para não quebrar o fluxo
// O sistema continuará usando o modelo local como fallback
return []; return [];
} }
}, []); }, []);
@@ -218,6 +236,22 @@ export function useWebSearch() {
} }
}, []); }, []);
/**
* Cancela a busca atual
*/
const cancel = useCallback(async () => {
try {
await invoke('cancel_scraping');
setState(prev => ({
...prev,
status: 'cancelled',
error: null
}));
} catch (error) {
console.error('Erro ao cancelar busca:', error);
}
}, []);
/** /**
* Reseta o estado * Reseta o estado
*/ */
@@ -235,14 +269,19 @@ export function useWebSearch() {
webSearchService.clearCache(); webSearchService.clearCache();
}, []); }, []);
return { return {
...state, ...state,
activeQueries: events.activeQueries,
activeUrls: events.activeUrls,
setEnabled, setEnabled,
search, search,
smartSearchRag, smartSearchRag,
extractUrl, extractUrl,
cancel,
reset, reset,
clearCache, clearCache,
setState, // Expor setState para permitir atualização externa em caso de erro
}; };
} }
+8 -19
View File
@@ -108,27 +108,16 @@ export async function extractKeyFacts(
.replace('{{url}}', entry.sourceUrl) .replace('{{url}}', entry.sourceUrl)
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho .replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const rawResponse = await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt,
model, options: {
prompt, temperature: 0.3,
stream: false, num_predict: 600,
options: { },
temperature: 0.3,
num_predict: 600,
},
}),
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar e extrair JSON // Limpar e extrair JSON
let jsonText = rawResponse let jsonText = rawResponse
.replace(/^```json\s*/i, '') .replace(/^```json\s*/i, '')
+52 -20
View File
@@ -154,27 +154,17 @@ export async function analyzeQueryContext(
try { try {
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query); const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const rawResponse = await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt,
model, options: {
prompt, temperature: 0.3,
stream: false, num_predict: 800,
options: { format: 'json',
temperature: 0.3, // Baixa temperatura para análise mais determinística },
num_predict: 800, // Espaço suficiente para JSON completo
},
}),
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar resposta (remover markdown code blocks se houver) // Limpar resposta (remover markdown code blocks se houver)
let jsonText = rawResponse let jsonText = rawResponse
.replace(/^```json\s*/i, '') .replace(/^```json\s*/i, '')
@@ -188,7 +178,7 @@ export async function analyzeQueryContext(
jsonText = jsonMatch[0]; jsonText = jsonMatch[0];
} }
const analysis = JSON.parse(jsonText) as Partial<QueryContext>; const analysis = tryParseJsonWithRepair(jsonText) as Partial<QueryContext>;
// Validar e preencher campos obrigatórios // Validar e preencher campos obrigatórios
const context: QueryContext = { const context: QueryContext = {
@@ -274,3 +264,45 @@ function createFallbackContext(query: string): QueryContext {
}; };
} }
function repairJson(text: string): string {
let s = text.trim();
s = s.replace(/,\s*([}\]])/g, '$1');
let inString = false;
let escape = false;
let braceCount = 0;
let bracketCount = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (escape) { escape = false; continue; }
if (c === '\\') { escape = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (!inString) {
if (c === '{') braceCount++;
else if (c === '}') braceCount--;
else if (c === '[') bracketCount++;
else if (c === ']') bracketCount--;
}
}
if (braceCount > 0) s += '}'.repeat(braceCount);
if (bracketCount > 0) s += ']'.repeat(bracketCount);
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
if (lastClose > 0) {
const after = s.slice(lastClose + 1);
if (/\S/.test(after)) {
s = s.slice(0, lastClose + 1);
}
}
return s;
}
function tryParseJsonWithRepair(text: string): unknown {
try { return JSON.parse(text); } catch {}
const repaired = repairJson(text);
try { return JSON.parse(repaired); } catch {}
const lastClose = Math.max(repaired.lastIndexOf('}'), repaired.lastIndexOf(']'));
if (lastClose > 0) {
const truncated = repaired.slice(0, lastClose + 1);
return JSON.parse(truncated);
}
throw new Error('Invalid JSON');
}
+626
View File
@@ -0,0 +1,626 @@
export interface ProblemSolution {
id: string;
problem: string; // Texto exato ou padrão para matching
os: 'windows' | 'linux' | 'macos' | 'all';
links: Array<{
title: string;
url: string;
}>;
commands?: {
title: string;
code: string;
description?: string;
}[];
description?: string;
}
export const PROBLEM_SOLUTIONS: ProblemSolution[] = [
// ========== WINDOWS ==========
{
id: 'windows-build-old',
problem: 'Windows build',
os: 'windows',
description: 'Versão do Windows muito antiga. É necessário Windows 10 20H1 (build 19041) ou superior, ou Windows 11.',
links: [
{
title: 'Instalar Atualizações do Windows',
url: 'https://support.microsoft.com/windows/install-windows-updates-3c5ae7fc-9fb6-9af1-1984-b5e0412c556a'
},
{
title: 'Baixar Windows 10',
url: 'https://www.microsoft.com/software-download/windows10'
},
{
title: 'Baixar Windows 11',
url: 'https://www.microsoft.com/software-download/windows11'
}
],
commands: [
{
title: 'Verificar build atual',
code: '[System.Environment]::OSVersion.Version',
description: 'Execute no PowerShell para verificar a versão atual do Windows'
}
]
},
{
id: 'windows-vm-platform',
problem: 'VirtualMachinePlatform não habilitado',
os: 'windows',
description: 'A plataforma de máquina virtual precisa estar habilitada para suportar WSL 2.',
links: [
{
title: 'Microsoft Learn - WSL Manual Install',
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
}
],
commands: [
{
title: 'Habilitar VirtualMachinePlatform (PowerShell como Administrador)',
code: 'dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart',
description: 'Execute no PowerShell como Administrador. Reinicie o sistema após a execução.'
}
]
},
{
id: 'windows-wsl-not-enabled',
problem: 'WSL não habilitado',
os: 'windows',
description: 'Windows Subsystem for Linux precisa estar habilitado para executar Docker no Windows.',
links: [
{
title: 'Microsoft Learn - Instalar WSL',
url: 'https://learn.microsoft.com/windows/wsl/install'
},
{
title: 'Microsoft Learn - WSL Manual',
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
}
],
commands: [
{
title: 'Habilitar WSL (PowerShell como Administrador)',
code: 'dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart',
description: 'Execute no PowerShell como Administrador. Reinicie o sistema após a execução.'
}
]
},
{
id: 'windows-wsl2-not-configured',
problem: 'WSL 2 não configurado',
os: 'windows',
description: 'WSL 2 precisa ser configurado como versão padrão do WSL.',
links: [
{
title: 'Microsoft Learn - Instalar WSL',
url: 'https://learn.microsoft.com/windows/wsl/install'
},
{
title: 'Microsoft Learn - WSL Manual',
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
}
],
commands: [
{
title: 'Configurar WSL 2 como padrão',
code: 'wsl --set-default-version 2',
description: 'Execute após habilitar WSL e VirtualMachinePlatform e reiniciar o sistema.'
},
{
title: 'Comandos completos (PowerShell como Administrador)',
code: `# Habilitar WSL
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
# Habilitar Virtual Machine Platform
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
# Reiniciar e depois definir WSL 2 como padrão
wsl --set-default-version 2`,
description: 'Execute todos os comandos em sequência. Reinicie após os dois primeiros comandos.'
}
]
},
{
id: 'windows-ram-insufficient',
problem: 'RAM insuficiente',
os: 'windows',
description: 'Sistema requer no mínimo 4GB de RAM para executar Docker no Windows.',
links: [
{
title: 'Adicionar mais memória ao computador',
url: 'https://support.microsoft.com/windows/add-more-memory-to-your-computer-0f7e2d8a-d2c9-c858-5dca-8d3b3759a0ff'
}
],
commands: [
{
title: 'Verificar processos consumindo mais memória',
code: 'Get-Process | Sort-Object -Property WS -Descending | Select-Object -First 10',
description: 'Lista os 10 processos que mais consomem memória. Feche aplicativos desnecessários.'
}
]
},
{
id: 'windows-disk-insufficient',
problem: 'Espaço em disco insuficiente',
os: 'windows',
description: 'É necessário pelo menos 10GB de espaço livre em disco para instalar e executar Docker.',
links: [
{
title: 'Liberar espaço em disco no Windows',
url: 'https://support.microsoft.com/windows/free-up-drive-space-in-windows-85529ccb-c365-490d-b548-831022bc9b32'
}
],
commands: [
{
title: 'Abrir Limpeza de Disco',
code: 'cleanmgr.exe',
description: 'Abre a ferramenta de limpeza de disco do Windows.'
}
]
},
{
id: 'windows-cpu-cores',
problem: 'CPU cores insuficientes',
os: 'windows',
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
commands: [
{
title: 'Verificar número de cores lógicos',
code: '(Get-WmiObject Win32_Processor).NumberOfLogicalProcessors',
description: 'Mostra o número de processadores lógicos disponíveis.'
}
]
},
// ========== LINUX ==========
{
id: 'linux-kernel-old',
problem: 'Kernel',
os: 'linux',
description: 'Kernel Linux muito antigo. É necessário kernel 3.10 ou superior para executar Docker.',
links: [
{
title: 'Ubuntu/Debian - Gerenciamento de Pacotes',
url: 'https://ubuntu.com/server/docs/package-management'
},
{
title: 'Arch/Manjaro - Kernel',
url: 'https://wiki.archlinux.org/title/Kernel'
},
{
title: 'Fedora/RHEL - Kernel',
url: 'https://docs.fedoraproject.org/en-US/quick-docs/kernel/'
}
],
commands: [
{
title: 'Ubuntu/Debian - Atualizar sistema e kernel',
code: `sudo apt update && sudo apt upgrade -y
sudo apt install linux-generic`,
description: 'Atualiza todos os pacotes e instala o kernel genérico mais recente.'
},
{
title: 'Arch/Manjaro - Atualizar kernel',
code: 'sudo pacman -Syu linux',
description: 'Atualiza o sistema e o kernel.'
},
{
title: 'Fedora - Atualizar kernel',
code: 'sudo dnf upgrade kernel',
description: 'Atualiza o kernel para a versão mais recente.'
}
]
},
{
id: 'linux-curl-missing',
problem: 'Pacote curl não instalado',
os: 'linux',
description: 'O pacote curl é necessário para baixar arquivos e chaves GPG durante a instalação do Docker.',
links: [
{
title: 'curl.se - Download oficial',
url: 'https://curl.se/download.html'
},
{
title: 'Ubuntu packages - curl',
url: 'https://packages.ubuntu.com/search?keywords=curl'
}
],
commands: [
{
title: 'Ubuntu/Debian',
code: 'sudo apt update && sudo apt install curl',
description: 'Instala curl no Ubuntu ou Debian.'
},
{
title: 'Arch/Manjaro',
code: 'sudo pacman -S curl',
description: 'Instala curl no Arch Linux ou Manjaro.'
},
{
title: 'Fedora/RHEL',
code: 'sudo dnf install curl',
description: 'Instala curl no Fedora ou RHEL.'
},
{
title: 'openSUSE',
code: 'sudo zypper install curl',
description: 'Instala curl no openSUSE.'
}
]
},
{
id: 'linux-ca-certificates-missing',
problem: 'Pacote ca-certificates não instalado',
os: 'linux',
description: 'Certificados CA são necessários para verificar conexões HTTPS seguras.',
links: [
{
title: 'Ubuntu packages - ca-certificates',
url: 'https://packages.ubuntu.com/search?keywords=ca-certificates'
},
{
title: 'Debian wiki - ca-certificates',
url: 'https://wiki.debian.org/ca-certificates'
}
],
commands: [
{
title: 'Ubuntu/Debian',
code: 'sudo apt update && sudo apt install ca-certificates',
description: 'Instala certificados CA no Ubuntu ou Debian.'
},
{
title: 'Arch/Manjaro',
code: 'sudo pacman -S ca-certificates',
description: 'Instala certificados CA no Arch Linux ou Manjaro.'
},
{
title: 'Fedora/RHEL',
code: 'sudo dnf install ca-certificates',
description: 'Instala certificados CA no Fedora ou RHEL.'
},
{
title: 'openSUSE',
code: 'sudo zypper install ca-certificates',
description: 'Instala certificados CA no openSUSE.'
}
]
},
{
id: 'linux-gnupg-missing',
problem: 'Pacote gnupg não instalado',
os: 'linux',
description: 'GnuPG é necessário para verificar chaves GPG durante a instalação do Docker.',
links: [
{
title: 'GnuPG oficial - Download',
url: 'https://gnupg.org/download/'
},
{
title: 'Ubuntu packages - gnupg',
url: 'https://packages.ubuntu.com/search?keywords=gnupg'
}
],
commands: [
{
title: 'Ubuntu/Debian',
code: 'sudo apt update && sudo apt install gnupg',
description: 'Instala GnuPG no Ubuntu ou Debian.'
},
{
title: 'Arch/Manjaro',
code: 'sudo pacman -S gnupg',
description: 'Instala GnuPG no Arch Linux ou Manjaro.'
},
{
title: 'Fedora/RHEL',
code: 'sudo dnf install gnupg',
description: 'Instala GnuPG no Fedora ou RHEL.'
},
{
title: 'openSUSE',
code: 'sudo zypper install gpg2',
description: 'Instala GnuPG no openSUSE.'
}
]
},
{
id: 'linux-sudo-missing',
problem: 'Privilégios sudo necessários',
os: 'linux',
description: 'Privilégios de administrador são necessários para instalar pacotes e configurar o sistema.',
links: [
{
title: 'Ubuntu - RootSudo',
url: 'https://help.ubuntu.com/community/RootSudo'
},
{
title: 'Arch Linux - Sudo',
url: 'https://wiki.archlinux.org/title/Sudo'
}
],
commands: [
{
title: 'Adicionar usuário ao grupo sudo (Ubuntu/Debian)',
code: 'sudo usermod -aG sudo $USER',
description: 'Adiciona o usuário atual ao grupo sudo. Faça logout e login novamente para aplicar.'
},
{
title: 'Adicionar ao grupo wheel (Arch/Fedora)',
code: 'sudo usermod -aG wheel $USER',
description: 'Adiciona o usuário atual ao grupo wheel. Faça logout e login novamente para aplicar.'
}
]
},
{
id: 'linux-ram-insufficient',
problem: 'RAM insuficiente',
os: 'linux',
description: 'Sistema requer no mínimo 2GB de RAM para executar Docker.',
commands: [
{
title: 'Verificar processos consumindo mais memória',
code: 'top',
description: 'Mostra processos em tempo real. Pressione "q" para sair.'
},
{
title: 'Verificar processos (htop - se instalado)',
code: 'htop',
description: 'Interface mais amigável. Instale com: sudo apt install htop (Ubuntu/Debian)'
},
{
title: 'Verificar uso de memória',
code: 'free -h',
description: 'Mostra uso de memória em formato legível.'
}
]
},
{
id: 'linux-disk-insufficient',
problem: 'Espaço em disco insuficiente',
os: 'linux',
description: 'É necessário pelo menos 10GB de espaço livre em disco.',
commands: [
{
title: 'Verificar espaço em disco',
code: 'df -h',
description: 'Mostra espaço usado e disponível em todos os sistemas de arquivos.'
},
{
title: 'Verificar diretórios ocupando mais espaço',
code: 'du -h --max-depth=1 / | sort -hr | head -10',
description: 'Lista os 10 diretórios que mais ocupam espaço (execute com sudo para resultados completos).'
},
{
title: 'Limpar cache de pacotes (Ubuntu/Debian)',
code: 'sudo apt clean && sudo apt autoremove',
description: 'Remove pacotes antigos e cache para liberar espaço.'
}
]
},
{
id: 'linux-cpu-cores',
problem: 'CPU cores insuficientes',
os: 'linux',
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
commands: [
{
title: 'Verificar número de cores',
code: 'nproc',
description: 'Mostra o número de processadores disponíveis.'
},
{
title: 'Informações detalhadas da CPU',
code: 'lscpu | grep "^CPU(s):"',
description: 'Mostra informações detalhadas sobre a CPU.'
}
]
},
// ========== macOS ==========
{
id: 'macos-version-old',
problem: 'macOS',
os: 'macos',
description: 'Versão do macOS muito antiga. Recomenda-se macOS 11 (Big Sur) ou superior.',
links: [
{
title: 'Apple - Atualizar macOS',
url: 'https://support.apple.com/HT201541'
},
{
title: 'Apple - Como atualizar',
url: 'https://support.apple.com/guide/mac-help/update-macos-mchlpx1065'
}
]
},
{
id: 'macos-homebrew-missing',
problem: 'Homebrew não instalado',
os: 'macos',
description: 'Homebrew é recomendado para facilitar a instalação do Docker no macOS.',
links: [
{
title: 'Homebrew oficial',
url: 'https://brew.sh/'
}
],
commands: [
{
title: 'Instalar Homebrew (Intel e Apple Silicon)',
code: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
description: 'Instala Homebrew. Siga as instruções na tela.'
},
{
title: 'Configurar PATH para Apple Silicon (após instalação)',
code: `echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"`,
description: 'Adiciona Homebrew ao PATH em Macs com Apple Silicon (M1/M2/M3).'
}
]
},
{
id: 'macos-rosetta-missing',
problem: 'Rosetta 2 não instalado',
os: 'macos',
description: 'Rosetta 2 é necessário para executar aplicativos x86_64 em Macs com Apple Silicon.',
links: [
{
title: 'Apple - Rosetta 2',
url: 'https://support.apple.com/HT211861'
},
{
title: 'Apple Developer - Rosetta',
url: 'https://developer.apple.com/documentation/apple-silicon/about-the-rosetta-translation-environment'
}
],
commands: [
{
title: 'Instalar Rosetta 2',
code: 'softwareupdate --install-rosetta --agree-to-license',
description: 'Instala Rosetta 2. Pode solicitar confirmação.'
},
{
title: 'Verificar se Rosetta está instalado',
code: 'ls /Library/Apple/usr/share/rosetta',
description: 'Verifica se Rosetta 2 está instalado. Se o diretório existir, está instalado.'
},
{
title: 'Testar execução x86_64',
code: 'arch -x86_64 uname -m',
description: 'Deve retornar "x86_64" se Rosetta estiver funcionando.'
}
]
},
{
id: 'macos-ram-insufficient',
problem: 'RAM insuficiente',
os: 'macos',
description: 'Sistema requer no mínimo 4GB de RAM para executar Docker no macOS.',
links: [
{
title: 'Apple - Memória Mac',
url: 'https://support.apple.com/mac/memory'
}
],
commands: [
{
title: 'Verificar processos consumindo mais memória',
code: 'top -o MEM',
description: 'Mostra processos ordenados por uso de memória. Pressione "q" para sair.'
},
{
title: 'Abrir Monitor de Atividade',
code: 'open -a "Activity Monitor"',
description: 'Abre o Monitor de Atividade do macOS.'
}
]
},
{
id: 'macos-disk-insufficient',
problem: 'Espaço em disco insuficiente',
os: 'macos',
description: 'É necessário pelo menos 4GB de espaço livre em disco.',
links: [
{
title: 'Apple - Liberar espaço de armazenamento',
url: 'https://support.apple.com/guide/mac-help/free-up-storage-space-mh31934'
}
],
commands: [
{
title: 'Abrir Gerenciamento de Armazenamento',
code: 'open "x-apple.systempreferences:com.apple.preference.storage"',
description: 'Abre as preferências de armazenamento do macOS.'
}
]
},
{
id: 'macos-cpu-cores',
problem: 'CPU cores insuficientes',
os: 'macos',
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
commands: [
{
title: 'Verificar número de cores',
code: 'sysctl -n hw.ncpu',
description: 'Mostra o número de cores de CPU disponíveis.'
}
]
},
// ========== GERAL ==========
{
id: 'os-not-supported',
problem: 'Sistema operacional não suportado',
os: 'all',
description: 'Este sistema operacional não é suportado. Docker requer Windows, Linux ou macOS.',
links: [
{
title: 'Docker - Requisitos do Sistema',
url: 'https://docs.docker.com/desktop/supported-platforms/'
}
]
}
];
/**
* Encontra a solução correspondente para um problema
* @param problemText Texto do problema retornado pela verificação
* @param os Sistema operacional atual
* @returns Solução correspondente ou null se não encontrada
*/
export function findProblemSolution(problemText: string, os: string): ProblemSolution | null {
const normalizedProblem = problemText.toLowerCase();
const normalizedOs = os.toLowerCase();
// Buscar por matching exato primeiro
let solution = PROBLEM_SOLUTIONS.find(s => {
const matchesOs = s.os === 'all' || s.os === normalizedOs ||
(normalizedOs === 'mac' && s.os === 'macos') ||
(normalizedOs === 'windows' && s.os === 'windows') ||
(normalizedOs === 'linux' && s.os === 'linux');
if (!matchesOs) return false;
const problemLower = s.problem.toLowerCase();
return normalizedProblem.includes(problemLower) || problemLower.includes(normalizedProblem);
});
// Se não encontrou, tentar matching parcial por palavras-chave
if (!solution) {
const keywords: { [key: string]: string } = {
'windows build': 'windows-build-old',
'virtualmachineplatform': 'windows-vm-platform',
'wsl não habilitado': 'windows-wsl-not-enabled',
'wsl 2 não configurado': 'windows-wsl2-not-configured',
'ram insuficiente': normalizedOs === 'windows' ? 'windows-ram-insufficient' :
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-ram-insufficient' :
'linux-ram-insufficient',
'espaço em disco': normalizedOs === 'windows' ? 'windows-disk-insufficient' :
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-disk-insufficient' :
'linux-disk-insufficient',
'cpu cores': normalizedOs === 'windows' ? 'windows-cpu-cores' :
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-cpu-cores' :
'linux-cpu-cores',
'kernel': 'linux-kernel-old',
'pacote curl': 'linux-curl-missing',
'pacote ca-certificates': 'linux-ca-certificates-missing',
'pacote gnupg': 'linux-gnupg-missing',
'privilégios sudo': 'linux-sudo-missing',
'homebrew': 'macos-homebrew-missing',
'rosetta': 'macos-rosetta-missing',
'sistema operacional não suportado': 'os-not-supported'
};
for (const [keyword, id] of Object.entries(keywords)) {
if (normalizedProblem.includes(keyword)) {
solution = PROBLEM_SOLUTIONS.find(s => s.id === id);
if (solution) break;
}
}
}
return solution || null;
}
+8 -4
View File
@@ -53,14 +53,15 @@ export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
* Faz download do instalador (oficial ou fallback) * Faz download do instalador (oficial ou fallback)
* Retorna o caminho do arquivo baixado * Retorna o caminho do arquivo baixado
*/ */
export async function downloadInstaller(os: OS): Promise<string> { export async function downloadInstaller(os: OS, expectedSha256?: string): Promise<string> {
const url = OLLAMA_DOWNLOAD_URLS[os]; const url = OLLAMA_DOWNLOAD_URLS[os];
const filename = LOCAL_INSTALLER_FILES[os]; const filename = LOCAL_INSTALLER_FILES[os];
try { try {
const filePath = await invoke<string>('download_installer', { const filePath = await invoke<string>('download_installer', {
url, url,
filename filename,
expected_sha256: expectedSha256 || null
}); });
return filePath; return filePath;
} catch (error) { } catch (error) {
@@ -87,9 +88,12 @@ export async function getDownloadedInstallerPath(os: OS): Promise<string | null>
/** /**
* Executa o instalador baixado * Executa o instalador baixado
*/ */
export async function runInstaller(filePath: string): Promise<void> { export async function runInstaller(filePath: string, expectedSha256?: string): Promise<void> {
try { try {
await invoke('run_installer', { filePath }); await invoke('run_installer', {
file_path: filePath,
expected_sha256: expectedSha256 || null
});
} catch (error) { } catch (error) {
throw new Error(`Falha ao executar instalador: ${error}`); throw new Error(`Falha ao executar instalador: ${error}`);
} }
+77
View File
@@ -3,6 +3,7 @@ import type { QueryContext } from './contextual-analyzer';
import { processContent, type Chunk, type ProcessedContent } from './content-condenser'; import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
import { scoreAndSelectChunks } from './relevance-scorer'; import { scoreAndSelectChunks } from './relevance-scorer';
import { summarizeMarkdown } from './content-condenser'; import { summarizeMarkdown } from './content-condenser';
import { chatLog } from './terminal-logger';
export interface CondensationResult { export interface CondensationResult {
context: string; context: string;
@@ -79,9 +80,21 @@ export function condenseKnowledgeBase(
query: string, query: string,
options: CondensationOptions = {} options: CondensationOptions = {}
): CondensationResult { ): CondensationResult {
const condensationStart = Date.now();
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION START ==========`);
chatLog.info(`[KnowledgeBase] Query: "${query}"`);
chatLog.info(`[KnowledgeBase] Entries: ${entries.length}`);
chatLog.info(`[KnowledgeBase] Options:`, {
maxTokens: options.maxTokens || DEFAULT_OPTIONS.maxTokens,
minRelevanceScore: options.minRelevanceScore || DEFAULT_OPTIONS.minRelevanceScore,
autoSummarize: options.autoSummarize ?? DEFAULT_OPTIONS.autoSummarize,
});
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
if (entries.length === 0) { if (entries.length === 0) {
chatLog.warn(`[KnowledgeBase] ⚠️ No entries to condense`);
return { return {
context: '', context: '',
totalTokens: 0, totalTokens: 0,
@@ -97,6 +110,9 @@ export function condenseKnowledgeBase(
// Calcular tokens originais // Calcular tokens originais
const originalContent = entries.map(e => e.content).join('\n\n'); const originalContent = entries.map(e => e.content).join('\n\n');
const originalTokens = estimateTokens(originalContent); const originalTokens = estimateTokens(originalContent);
chatLog.info(`[KnowledgeBase] Original content: ${originalContent.length} chars`);
chatLog.info(`[KnowledgeBase] Original tokens (estimated): ${originalTokens}`);
// Processar cada entrada // Processar cada entrada
const allChunks: Chunk[] = []; const allChunks: Chunk[] = [];
@@ -106,7 +122,15 @@ export function condenseKnowledgeBase(
chunks: Chunk[]; chunks: Chunk[];
}> = []; }> = [];
chatLog.info(`[KnowledgeBase] Processing ${entries.length} entries into chunks...`);
entries.forEach((entry, entryIndex) => { entries.forEach((entry, entryIndex) => {
const entryStart = Date.now();
chatLog.info(`\n[KnowledgeBase] --- Processing Entry ${entryIndex + 1}/${entries.length} ---`);
chatLog.info(`[KnowledgeBase] Title: ${entry.title}`);
chatLog.info(`[KnowledgeBase] URL: ${entry.sourceUrl}`);
chatLog.info(`[KnowledgeBase] Content length: ${entry.content.length} chars`);
const processed = processContent(entry.content, query, { const processed = processContent(entry.content, query, {
summarizeThreshold: opts.summarizeThreshold, summarizeThreshold: opts.summarizeThreshold,
}); });
@@ -119,6 +143,19 @@ export function condenseKnowledgeBase(
sourceTitle: entry.title, sourceTitle: entry.title,
})); }));
const entryDuration = Date.now() - entryStart;
chatLog.info(`[KnowledgeBase] Generated ${chunksWithSource.length} chunks in ${entryDuration}ms`);
// Log detalhes dos chunks
if (chunksWithSource.length > 0) {
chunksWithSource.slice(0, 3).forEach((chunk, chunkIdx) => {
chatLog.info(` Chunk ${chunkIdx + 1}: ${chunk.content.length} chars, relevance: ${chunk.relevanceScore?.toFixed(3) || 'N/A'}`);
});
if (chunksWithSource.length > 3) {
chatLog.info(` ... and ${chunksWithSource.length - 3} more chunks`);
}
}
processedEntries.push({ processedEntries.push({
entry, entry,
processed, processed,
@@ -127,11 +164,18 @@ export function condenseKnowledgeBase(
allChunks.push(...chunksWithSource); allChunks.push(...chunksWithSource);
}); });
chatLog.info(`[KnowledgeBase] Total chunks generated: ${allChunks.length}`);
// Calcular tokens disponíveis (reservar 20% para margem) // Calcular tokens disponíveis (reservar 20% para margem)
const availableTokens = Math.floor(opts.maxTokens * 0.8); const availableTokens = Math.floor(opts.maxTokens * 0.8);
chatLog.info(`[KnowledgeBase] Available tokens: ${availableTokens} (max: ${opts.maxTokens}, reserved 20%)`);
chatLog.info(`[KnowledgeBase] Min relevance score: ${opts.minRelevanceScore}`);
// Selecionar chunks mais relevantes (usando contexto se disponível) // Selecionar chunks mais relevantes (usando contexto se disponível)
const selectionStart = Date.now();
chatLog.info(`[KnowledgeBase] Selecting most relevant chunks...`);
let selectedChunks = scoreAndSelectChunks( let selectedChunks = scoreAndSelectChunks(
allChunks, allChunks,
query, query,
@@ -139,6 +183,19 @@ export function condenseKnowledgeBase(
opts.minRelevanceScore, opts.minRelevanceScore,
opts.context opts.context
); );
const selectionDuration = Date.now() - selectionStart;
chatLog.info(`[KnowledgeBase] Selected ${selectedChunks.length} chunks from ${allChunks.length} total in ${selectionDuration}ms`);
// Log top chunks selecionados
if (selectedChunks.length > 0) {
chatLog.info(`[KnowledgeBase] Top 5 selected chunks:`);
selectedChunks.slice(0, 5).forEach((chunk, idx) => {
chatLog.info(` [${idx + 1}] Score: ${chunk.relevanceScore?.toFixed(3) || 'N/A'}, Length: ${chunk.content.length} chars`);
chatLog.info(` Source: ${chunk.sourceTitle || 'Unknown'}`);
chatLog.info(` Preview: ${chunk.content.substring(0, 100)}...`);
});
}
// Se não selecionou chunks suficientes ou método de fallback está ativo // Se não selecionou chunks suficientes ou método de fallback está ativo
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks'; let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
@@ -196,6 +253,26 @@ export function condenseKnowledgeBase(
const totalTokens = estimateTokens(context); const totalTokens = estimateTokens(context);
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0; const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
const condensationDuration = Date.now() - condensationStart;
// Log resultado final
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION RESULTS ==========`);
chatLog.info(`[KnowledgeBase] Method: ${method}`);
chatLog.info(`[KnowledgeBase] Duration: ${condensationDuration}ms`);
chatLog.info(`[KnowledgeBase] Original tokens: ${originalTokens}`);
chatLog.info(`[KnowledgeBase] Final tokens: ${totalTokens}`);
chatLog.info(`[KnowledgeBase] Compression ratio: ${(compressionRatio * 100).toFixed(1)}%`);
chatLog.info(`[KnowledgeBase] Chunks used: ${selectedChunks.length} / ${allChunks.length}`);
chatLog.info(`[KnowledgeBase] Final context length: ${context.length} chars`);
// Log estatísticas por fonte
chatLog.info(`[KnowledgeBase] Chunks per source:`);
entries.forEach((entry, idx) => {
const chunksUsed = selectedChunks.filter(c => c.sourceIndex === idx).length;
chatLog.info(` ${entry.title}: ${chunksUsed} chunks`);
});
chatLog.info(`[KnowledgeBase] ============================================\n`);
return { return {
context: context.trim(), context: context.trim(),
+4 -8
View File
@@ -29,16 +29,12 @@ export async function detectModelContextWindow(modelName: string): Promise<numbe
try { try {
// Tentar obter informações do modelo via Ollama API // Tentar obter informações do modelo via Ollama API
const response = await fetch('http://localhost:11434/api/show', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const data = await invoke<any>('get_ollama_model_info', {
headers: { 'Content-Type': 'application/json' }, modelName,
body: JSON.stringify({
name: modelName,
}),
}); });
if (response.ok) { if (data) {
const data = await response.json();
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto // Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
// Mapear tamanhos conhecidos de modelos // Mapear tamanhos conhecidos de modelos
+142
View File
@@ -0,0 +1,142 @@
import { LocalModel } from '@/hooks/use-local-models';
// Interfaces based on the JSON structure
export interface ModelVariant {
name: string;
size: string;
context: string;
inputType: string;
}
export interface ModelDefinition {
model: string;
url: string;
description: string;
variantsCount: number;
variants: ModelVariant[];
}
// Import model definitions
// We use a static list as dynamic imports of JSONs are not trivial in client-side bundles without API routes
import aya from '@/data/models/aya.json';
import codellama from '@/data/models/codellama.json';
import commandR from '@/data/models/command-r.json';
import commandRPlus from '@/data/models/command-r-plus.json';
import deepseekCoder from '@/data/models/deepseek-coder.json';
import deepseekLLM from '@/data/models/deepseek-llm.json';
import deepseekV2 from '@/data/models/deepseek-v2.json';
import deepseekV25 from '@/data/models/deepseek-v2.5.json';
import deepseekV3 from '@/data/models/deepseek-v3.json';
import falcon from '@/data/models/falcon.json';
import gemma from '@/data/models/gemma.json';
import gemma2 from '@/data/models/gemma2.json';
import llama2 from '@/data/models/llama2.json';
import llama2Uncensored from '@/data/models/llama2-uncensored.json';
import llama3 from '@/data/models/llama3.json';
import llama31 from '@/data/models/llama3.1.json';
import llama32 from '@/data/models/llama3.2.json';
import llama32Vision from '@/data/models/llama3.2-vision.json';
import llama33 from '@/data/models/llama3.3.json';
import llava from '@/data/models/llava.json';
import mistral from '@/data/models/mistral.json';
import mistralNemo from '@/data/models/mistral-nemo.json';
import mixtral from '@/data/models/mixtral.json';
import moondream from '@/data/models/moondream.json';
import neuralChat from '@/data/models/neural-chat.json';
import openhermes from '@/data/models/openhermes.json';
import orcaMini from '@/data/models/orca-mini.json';
import phi from '@/data/models/phi.json';
import phi3 from '@/data/models/phi3.json';
import phi35 from '@/data/models/phi3.5.json';
import phi4 from '@/data/models/phi4.json';
import qwen from '@/data/models/qwen.json';
import qwen2 from '@/data/models/qwen2.json';
import qwen25 from '@/data/models/qwen2.5.json';
import qwen25Coder from '@/data/models/qwen2.5-coder.json';
import starlingLM from '@/data/models/starling-lm.json';
import tinyllama from '@/data/models/tinyllama.json';
import vicuna from '@/data/models/vicuna.json';
import wizardVicunaUncensored from '@/data/models/wizard-vicuna-uncensored.json';
import yi from '@/data/models/yi.json';
import zephyr from '@/data/models/zephyr.json';
// Aggregate into a registry
export const MODEL_REGISTRY: ModelDefinition[] = [
aya,
codellama,
commandR,
commandRPlus,
deepseekCoder,
deepseekLLM,
deepseekV2,
deepseekV25,
deepseekV3,
falcon,
gemma,
gemma2,
llama2,
llama2Uncensored,
llama3,
llama31,
llama32,
llama32Vision,
llama33,
llava,
mistral,
mistralNemo,
mixtral,
moondream,
neuralChat,
openhermes,
orcaMini,
phi,
phi3,
phi35,
phi4,
qwen,
qwen2,
qwen25,
qwen25Coder,
starlingLM,
tinyllama,
vicuna,
wizardVicunaUncensored,
yi,
zephyr,
] as ModelDefinition[];
// Helper to find a model definition by one of its variant names
export function findModelDefinition(variantName: string): ModelDefinition | undefined {
// Remove tag to match base model name roughly?
// Or search all variants
const cleanName = variantName.split(':')[0];
// First try to find by exact match in variants
const exactMatch = MODEL_REGISTRY.find(def =>
def.variants.some(v => v.name === variantName)
);
if (exactMatch) return exactMatch;
// Then try to match by model ID/name
return MODEL_REGISTRY.find(def => def.model === cleanName);
}
export function getInstalledModels(
registry: ModelDefinition[],
installed: LocalModel[]
): ModelDefinition[] {
const installedNames = new Set(installed.map(m => m.name));
return registry.filter(def =>
def.variants.some(v => installedNames.has(v.name))
);
}
export function getAvailableModels(
registry: ModelDefinition[],
installed: LocalModel[]
): ModelDefinition[] {
const installedNames = new Set(installed.map(m => m.name));
return registry.filter(def =>
!def.variants.some(v => installedNames.has(v.name))
);
}
+8 -19
View File
@@ -118,27 +118,16 @@ export async function enrichQueries(
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum') .replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
.replace('{{domain}}', context.domain || 'geral'); .replace('{{domain}}', context.domain || 'geral');
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const rawResponse = await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt,
model, options: {
prompt, temperature: 0.5, // Temperatura média para criatividade controlada
stream: false, num_predict: 1000, // Espaço suficiente para múltiplas queries
options: { },
temperature: 0.5, // Temperatura média para criatividade controlada
num_predict: 1000, // Espaço suficiente para múltiplas queries
},
}),
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const rawResponse = (data.response || '').trim();
// Limpar resposta (remover markdown code blocks se houver) // Limpar resposta (remover markdown code blocks se houver)
let jsonText = rawResponse let jsonText = rawResponse
.replace(/^```json\s*/i, '') .replace(/^```json\s*/i, '')
+8
View File
@@ -211,3 +211,11 @@ export function formatSearchLog(log: SearchLog): string {
return lines.join('\n'); return lines.join('\n');
} }
+8
View File
@@ -38,3 +38,11 @@ export const deepResearchLog = {
+31
View File
@@ -13,3 +13,34 @@ export interface SourcesConfig {
last_updated: string; last_updated: string;
} }
export interface SearxngConfig {
searxng_url: string;
api_format: string;
default_engines: string[];
default_category: string;
timeout_seconds: number;
verify_ssl: boolean;
enabled: boolean;
log_search_content: boolean;
anonymize_identifiers: boolean;
encrypt_logs: boolean;
retention_days: number;
use_public_instances: boolean;
search_via_scraping: boolean;
}
export interface SearchLog {
id: string;
chat_id?: string;
user_id?: string;
timestamp: string;
searxng_instance?: string;
query: string;
engines?: string[];
filters?: string;
response_time_ms?: number;
total_results?: number;
results_per_engine?: string;
error?: string;
}
+41 -39
View File
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: FallbackConfig = {
maxRounds: 4, maxRounds: 4,
maxResultsPerRound: 10, maxResultsPerRound: 10,
maxTotalResults: 40, maxTotalResults: 40,
minRelevanceScore: 0.3, minRelevanceScore: 0.1,
enableQueryExpansion: true, enableQueryExpansion: true,
initialTimeout: TIMEOUT_CONFIG.initialTimeout, initialTimeout: TIMEOUT_CONFIG.initialTimeout,
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery, maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
@@ -100,37 +100,33 @@ Instruções:
Query expandida:`; Query expandida:`;
const response = await fetch('http://localhost:11434/api/generate', { const { invoke } = await import('@tauri-apps/api/core');
method: 'POST', const expanded = (await invoke<string>('generate_completion', {
headers: { 'Content-Type': 'application/json' }, model,
body: JSON.stringify({ prompt: expansionPrompt,
model, options: {
prompt: expansionPrompt, temperature: 0.5,
stream: false, num_predict: 100,
options: { },
temperature: 0.5, }))
num_predict: 100, .replace(/^["']|["']$/g, '')
}, .replace(/\.$/, '')
}), .trim();
});
if (!response.ok) { const sanitized = expanded
chatLog.warn('Failed to expand query, using original'); .replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/^\s*```[a-zA-Z]*\s*/g, '')
.replace(/\s*```\s*$/g, '')
.replace(/^(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*/i, '')
.trim()
.replace(/[\r\n]+/g, ' ');
if (sanitized.length < 3 || sanitized.length > 150 || /[<>]/.test(sanitized)) {
return originalQuery; return originalQuery;
} }
const data = await response.json(); chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
const expanded = (data.response || '').trim() return sanitized;
.replace(/^["']|["']$/g, '')
.replace(/\.$/, '')
.trim();
if (expanded.length < 3) {
return originalQuery;
}
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
return expanded;
} catch (error) { } catch (error) {
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`); chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
return originalQuery; return originalQuery;
@@ -155,7 +151,7 @@ export function calculateRelevanceScoreForFallback(
// Contar matches de palavras-chave // Contar matches de palavras-chave
let matches = 0; let matches = 0;
let totalWords = queryWords.length; const totalWords = queryWords.length;
for (const word of queryWords) { for (const word of queryWords) {
if (combinedText.includes(word)) { if (combinedText.includes(word)) {
@@ -283,7 +279,7 @@ export async function executeProgressiveSearch(
const finalConfig = { ...DEFAULT_CONFIG, ...config }; const finalConfig = { ...DEFAULT_CONFIG, ...config };
const attempts: FallbackAttempt[] = []; const attempts: FallbackAttempt[] = [];
let totalResultsAnalyzed = 0; let totalResultsAnalyzed = 0;
let allResults: ScrapedContent[] = []; const allResults: ScrapedContent[] = [];
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
const queryStartTime = Date.now(); const queryStartTime = Date.now();
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery; const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
@@ -337,8 +333,8 @@ export async function executeProgressiveSearch(
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`); chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
const roundStartTime = Date.now(); const roundStartTime = Date.now();
let roundResults: ScrapedContent[] = []; const roundResults: ScrapedContent[] = [];
let roundQueries: string[] = []; const roundQueries: string[] = [];
// Executar todas as queries do round em paralelo com timeout por round // Executar todas as queries do round em paralelo com timeout por round
const queryPromises = roundConfig.queries.map(async (query) => { const queryPromises = roundConfig.queries.map(async (query) => {
@@ -351,8 +347,13 @@ export async function executeProgressiveSearch(
// Passar round para searchFn para timeout adaptativo // Passar round para searchFn para timeout adaptativo
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round); const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
return { query: finalQuery, results }; return { query: finalQuery, results };
<<<<<<< HEAD
} catch (err) {
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${err instanceof Error ? err.message : String(err)}`);
=======
} catch (error) { } catch (error) {
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`); chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return { query: finalQuery, results: [] }; return { query: finalQuery, results: [] };
} }
}); });
@@ -367,7 +368,7 @@ export async function executeProgressiveSearch(
let queryResults: QueryResult[]; let queryResults: QueryResult[];
try { try {
queryResults = await Promise.race([roundPromise, timeoutPromise]); queryResults = await Promise.race([roundPromise, timeoutPromise]);
} catch (error) { } catch (_err) {
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`); chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
// Tentar obter resultados parciais das promises que já completaram // Tentar obter resultados parciais das promises que já completaram
queryResults = await Promise.allSettled(queryPromises).then(results => queryResults = await Promise.allSettled(queryPromises).then(results =>
@@ -406,13 +407,15 @@ export async function executeProgressiveSearch(
} }
// Calcular relevância usando contexto se disponível // Calcular relevância usando contexto se disponível
let semanticSimilarity: ((ctx: QueryContext, text: string) => number) | null = null;
if (finalConfig.context) {
const mod = await import('./relevance-scorer');
semanticSimilarity = mod.calculateSemanticSimilarity;
}
const relevanceScore = roundResults.length > 0 const relevanceScore = roundResults.length > 0
? roundResults.reduce((sum, r) => { ? roundResults.reduce((sum, r) => {
// Usar contexto para cálculo de relevância se disponível if (semanticSimilarity && finalConfig.context) {
if (finalConfig.context) { return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title);
// Importar função dinamicamente para evitar circular dependency
const { calculateSemanticSimilarity } = require('./relevance-scorer');
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
} }
return sum + calculateRelevanceScoreForFallback(r, originalQuery); return sum + calculateRelevanceScoreForFallback(r, originalQuery);
}, 0) / roundResults.length }, 0) / roundResults.length
@@ -479,4 +482,3 @@ export async function executeProgressiveSearch(
usedFallback: true, usedFallback: true,
}; };
} }
+36
View File
@@ -5,7 +5,19 @@ const nextConfig: NextConfig = {
images: { images: {
unoptimized: true, unoptimized: true,
}, },
<<<<<<< HEAD
webpack: (config, { isServer, webpack }) => {
// Garantir que config.module e config.module.rules existam
if (!config.module) {
config.module = { rules: [] };
}
if (!config.module.rules) {
config.module.rules = [];
}
=======
webpack: (config) => { webpack: (config) => {
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
// Adiciona suporte para raw-loader para arquivos .md // Adiciona suporte para raw-loader para arquivos .md
config.module.rules.push({ config.module.rules.push({
test: /\.md$/, test: /\.md$/,
@@ -16,6 +28,30 @@ const nextConfig: NextConfig = {
}, },
}, },
}); });
// Desabilitar source maps completamente para evitar problemas com stack traces
// O erro "ignore-listed frames" está relacionado ao processamento de source maps
config.devtool = false;
// Configurar ignoreWarnings para evitar erros relacionados a stack traces
if (!config.ignoreWarnings) {
config.ignoreWarnings = [];
}
// Ignorar avisos relacionados a módulos não encontrados ou problemas de parsing
config.ignoreWarnings.push(
/Failed to parse source map/,
/Can't resolve/,
/ignore-listed/,
);
// Configurar stats para reduzir verbosidade e evitar problemas com stack traces
if (!config.stats) {
config.stats = {};
}
config.stats.errorDetails = false;
config.stats.errorStack = false;
return config; return config;
}, },
// Configuração Turbopack: objeto vazio silencia o erro // Configuração Turbopack: objeto vazio silencia o erro
+7 -2
View File
@@ -32,7 +32,7 @@
"emoji-regex": "^10.6.0", "emoji-regex": "^10.6.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"next": "16.0.3", "next": "16.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
@@ -51,10 +51,15 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.3", "eslint-config-next": "16.1.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
},
"pnpm": {
"overrides": {
"mdast-util-to-hast": ">=13.2.1"
}
} }
} }
+59 -55
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
mdast-util-to-hast: '>=13.2.1'
importers: importers:
.: .:
@@ -72,8 +75,8 @@ importers:
specifier: ^0.554.0 specifier: ^0.554.0
version: 0.554.0([email protected]) version: 0.554.0([email protected])
next: next:
specifier: 16.0.3 specifier: 16.1.1
version: 16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected]) version: 16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected])
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6([email protected]([email protected]))([email protected]) version: 0.4.6([email protected]([email protected]))([email protected])
@@ -124,8 +127,8 @@ importers:
specifier: ^9 specifier: ^9
version: 9.39.1([email protected]) version: 9.39.1([email protected])
eslint-config-next: eslint-config-next:
specifier: 16.0.3 specifier: 16.1.1
version: 16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]) version: 16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
raw-loader: raw-loader:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.2([email protected]) version: 4.0.2([email protected])
@@ -453,56 +456,56 @@ packages:
'@napi-rs/[email protected]': '@napi-rs/[email protected]':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.3': '@next/env@16.1.1':
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==} resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
'@next/eslint-plugin-next@16.0.3': '@next/eslint-plugin-next@16.1.1':
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==} resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==}
'@next/swc-darwin-arm64@16.0.3': '@next/swc-darwin-arm64@16.1.1':
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==} resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.0.3': '@next/swc-darwin-x64@16.1.1':
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==} resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.3': '@next/swc-linux-arm64-gnu@16.1.1':
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==} resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-arm64-musl@16.0.3': '@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==} resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-x64-gnu@16.0.3': '@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==} resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-linux-x64-musl@16.0.3': '@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==} resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-win32-arm64-msvc@16.0.3': '@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==} resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.0.3': '@next/swc-win32-x64-msvc@16.1.1':
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==} resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1770,8 +1773,8 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'} engines: {node: '>=12'}
eslint-config-next@16.0.3: eslint-config-next@16.1.1:
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==} resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==}
peerDependencies: peerDependencies:
eslint: '>=9.0.0' eslint: '>=9.0.0'
typescript: '>=3.3.1' typescript: '>=3.3.1'
@@ -2467,8 +2470,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
[email protected].0: [email protected].1:
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@@ -2620,8 +2623,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.0.3: next@16.1.1:
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==} resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3599,34 +3602,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@next/env@16.0.3': {} '@next/env@16.1.1': {}
'@next/eslint-plugin-next@16.0.3': '@next/eslint-plugin-next@16.1.1':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.3': '@next/swc-darwin-arm64@16.1.1':
optional: true optional: true
'@next/swc-darwin-x64@16.0.3': '@next/swc-darwin-x64@16.1.1':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.0.3': '@next/swc-linux-arm64-gnu@16.1.1':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.0.3': '@next/swc-linux-arm64-musl@16.1.1':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.0.3': '@next/swc-linux-x64-gnu@16.1.1':
optional: true optional: true
'@next/swc-linux-x64-musl@16.0.3': '@next/swc-linux-x64-musl@16.1.1':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.0.3': '@next/swc-win32-arm64-msvc@16.1.1':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.0.3': '@next/swc-win32-x64-msvc@16.1.1':
optional: true optional: true
'@nodelib/[email protected]': '@nodelib/[email protected]':
@@ -4948,9 +4951,9 @@ snapshots:
[email protected]: {} [email protected]: {}
eslint-config-next@16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]): eslint-config-next@16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/eslint-plugin-next': 16.0.3 '@next/eslint-plugin-next': 16.1.1
eslint: 9.39.1([email protected]) eslint: 9.39.1([email protected])
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected])) eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
@@ -5803,7 +5806,7 @@ snapshots:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
unist-util-is: 6.0.1 unist-util-is: 6.0.1
[email protected].0: [email protected].1:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -6068,24 +6071,25 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0([email protected]) react-dom: 19.2.0([email protected])
next@16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected]): next@16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.0.3 '@next/env': 16.1.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.8.29
caniuse-lite: 1.0.30001755 caniuse-lite: 1.0.30001755
postcss: 8.4.31 postcss: 8.4.31
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0([email protected]) react-dom: 19.2.0([email protected])
styled-jsx: 5.1.6(@babel/[email protected])([email protected]) styled-jsx: 5.1.6(@babel/[email protected])([email protected])
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.0.3 '@next/swc-darwin-arm64': 16.1.1
'@next/swc-darwin-x64': 16.0.3 '@next/swc-darwin-x64': 16.1.1
'@next/swc-linux-arm64-gnu': 16.0.3 '@next/swc-linux-arm64-gnu': 16.1.1
'@next/swc-linux-arm64-musl': 16.0.3 '@next/swc-linux-arm64-musl': 16.1.1
'@next/swc-linux-x64-gnu': 16.0.3 '@next/swc-linux-x64-gnu': 16.1.1
'@next/swc-linux-x64-musl': 16.0.3 '@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.0.3 '@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.0.3 '@next/swc-win32-x64-msvc': 16.1.1
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -6239,7 +6243,7 @@ snapshots:
devlop: 1.1.0 devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6 hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1 html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.0 mdast-util-to-hast: 13.2.1
react: 19.2.0 react: 19.2.0
remark-parse: 11.0.0 remark-parse: 11.0.0
remark-rehype: 11.1.2 remark-rehype: 11.1.2
@@ -6344,7 +6348,7 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
mdast-util-to-hast: 13.2.0 mdast-util-to-hast: 13.2.1
unified: 11.0.5 unified: 11.0.5
vfile: 6.0.3 vfile: 6.0.3
+67
View File
@@ -0,0 +1,67 @@
# OllaHub Recommended SearXNG Configuration (2025)
# Optimized for resilience against 403 Forbidden and Rate Limiting
# General Settings
use_default_settings: true
general:
debug: false
instance_name: "OllaHub Search"
# Search Engine Settings
search:
safe_search: 0
autocomplete: ""
# CRITICAL: Adjust ban times to avoid long lockouts
ban_time_on_fail: 5
max_ban_time_on_fail: 120
suspended_times:
SearxEngineAccessDenied: 0 # Disable suspension on 403 (Access Denied)
SearxEngineCaptcha: 0 # Disable suspension on Captcha
SearxEngineTooManyRequests: 3600 # Keep suspension for 429
# Rate Limiter (Disable for self-hosted single user)
limiter:
enabled: false
# Server Configuration
server:
port: 8080
bind_address: "0.0.0.0"
secret_key: "ollahub-secret-key-change-me" # Change this!
# Proxy configuration (Crucial for proper IP forwarding)
method: "GET"
image_proxy: true
# Engine Configuration
engines:
- name: google
engine: google
shortcut: g
disabled: false
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
disabled: false
- name: bing
engine: bing
shortcut: b
disabled: false
- name: wikipedia
engine: wikipedia
shortcut: w
disabled: false
# Outgoing Requests
outgoing:
# Request timeout
request_timeout: 10.0
# Max request timeout
max_request_timeout: 20.0
# User Agent (SearXNG default rotates, but we can enforce generic if needed)
# useragent_suffix: ""
# Proxy Support (Optional - Uncomment if using proxies)
# proxies:
# all:
# - http://user:pass@proxy_ip:port
# - http://user:pass@proxy_ip2:port
-191
View File
@@ -1,191 +0,0 @@
/**
* Circuit Breaker para motores de busca
* Desabilita motores que estão falhando consistentemente
*/
export interface EngineHealth {
engine: string;
successCount: number;
failureCount: number;
totalAttempts: number;
avgLatency: number; // ms
lastFailure: number | null; // timestamp
lastSuccess: number | null; // timestamp
isOpen: boolean; // circuit breaker aberto?
openedAt: number | null; // quando foi aberto
}
export interface CircuitBreakerConfig {
failureThreshold: number; // Taxa de falha para abrir (0-1, padrão: 0.5 = 50%)
cooldownPeriod: number; // Tempo em ms antes de tentar novamente (padrão: 5 minutos)
trackingWindow: number; // Número de tentativas para rastrear (padrão: 10)
minAttempts: number; // Mínimo de tentativas antes de considerar (padrão: 3)
}
const DEFAULT_CONFIG: CircuitBreakerConfig = {
failureThreshold: 0.5, // 50% de falha
cooldownPeriod: 5 * 60 * 1000, // 5 minutos
trackingWindow: 10,
minAttempts: 3,
};
export class EngineCircuitBreaker {
private health: Map<string, EngineHealth> = new Map();
private config: CircuitBreakerConfig;
constructor(config: Partial<CircuitBreakerConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Registra uma tentativa de busca
*/
recordAttempt(engine: string, success: boolean, latency: number): void {
const health = this.getOrCreateHealth(engine);
health.totalAttempts++;
health.avgLatency = (health.avgLatency * (health.totalAttempts - 1) + latency) / health.totalAttempts;
if (success) {
health.successCount++;
health.lastSuccess = Date.now();
// Se teve sucesso e circuit estava aberto há tempo suficiente, tentar fechar
if (health.isOpen && health.openedAt) {
const timeSinceOpen = Date.now() - health.openedAt;
if (timeSinceOpen >= this.config.cooldownPeriod) {
health.isOpen = false;
health.openedAt = null;
console.log(`[CircuitBreaker] ${engine}: Circuit fechado após sucesso (${timeSinceOpen}ms desde abertura)`);
}
}
} else {
health.failureCount++;
health.lastFailure = Date.now();
}
// Limitar histórico às últimas N tentativas
if (health.totalAttempts > this.config.trackingWindow) {
// Remover tentativas mais antigas (simplificado: resetar contadores)
const successRate = health.successCount / health.totalAttempts;
health.successCount = Math.floor(successRate * this.config.trackingWindow);
health.failureCount = this.config.trackingWindow - health.successCount;
health.totalAttempts = this.config.trackingWindow;
}
// Verificar se deve abrir circuit breaker
if (!health.isOpen && health.totalAttempts >= this.config.minAttempts) {
const failureRate = health.failureCount / health.totalAttempts;
if (failureRate >= this.config.failureThreshold) {
health.isOpen = true;
health.openedAt = Date.now();
console.warn(`[CircuitBreaker] ${engine}: Circuit aberto (taxa de falha: ${(failureRate * 100).toFixed(1)}%)`);
}
}
this.health.set(engine, health);
}
/**
* Verifica se um motor está disponível (circuit não está aberto)
*/
isAvailable(engine: string): boolean {
const health = this.getOrCreateHealth(engine);
if (!health.isOpen) {
return true;
}
// Se está aberto, verificar se passou tempo suficiente para tentar novamente
if (health.openedAt) {
const timeSinceOpen = Date.now() - health.openedAt;
if (timeSinceOpen >= this.config.cooldownPeriod) {
// Tentar fechar (half-open state)
health.isOpen = false;
this.health.set(engine, health);
console.log(`[CircuitBreaker] ${engine}: Tentando fechar circuit (half-open state)`);
return true;
}
}
return false;
}
/**
* Prioriza motores baseado em saúde
*/
prioritizeEngines(engines: string[]): string[] {
return [...engines].sort((a, b) => {
const healthA = this.getOrCreateHealth(a);
const healthB = this.getOrCreateHealth(b);
// Motores não disponíveis vão para o final
if (healthA.isOpen && !healthB.isOpen) return 1;
if (!healthA.isOpen && healthB.isOpen) return -1;
// Comparar taxa de sucesso
const successRateA = healthA.totalAttempts > 0
? healthA.successCount / healthA.totalAttempts
: 0.5;
const successRateB = healthB.totalAttempts > 0
? healthB.successCount / healthB.totalAttempts
: 0.5;
if (Math.abs(successRateA - successRateB) > 0.1) {
return successRateB - successRateA; // Maior taxa de sucesso primeiro
}
// Se taxa similar, comparar latência
return healthA.avgLatency - healthB.avgLatency; // Menor latência primeiro
});
}
/**
* Obtém estatísticas de um motor
*/
getHealth(engine: string): EngineHealth | null {
return this.health.get(engine) || null;
}
/**
* Obtém ou cria health para um motor
*/
private getOrCreateHealth(engine: string): EngineHealth {
if (!this.health.has(engine)) {
this.health.set(engine, {
engine,
successCount: 0,
failureCount: 0,
totalAttempts: 0,
avgLatency: 0,
lastFailure: null,
lastSuccess: null,
isOpen: false,
openedAt: null,
});
}
return this.health.get(engine)!;
}
/**
* Reseta estatísticas de um motor
*/
reset(engine: string): void {
this.health.delete(engine);
}
/**
* Reseta todas as estatísticas
*/
resetAll(): void {
this.health.clear();
}
/**
* Obtém todos os motores disponíveis
*/
getAvailableEngines(engines: string[]): string[] {
return engines.filter(engine => this.isAvailable(engine));
}
}
+103 -63
View File
@@ -4,11 +4,9 @@ import {
retryWithBackoff, retryWithBackoff,
withTimeout, withTimeout,
isRetryableError, isRetryableError,
type RetryConfig,
calculateEngineTimeout, calculateEngineTimeout,
TIMEOUT_CONFIG TIMEOUT_CONFIG
} from '@/lib/retry-utils'; } from '@/lib/retry-utils';
import { EngineCircuitBreaker } from './engine-circuit-breaker';
import { FailureCache } from './failure-cache'; import { FailureCache } from './failure-cache';
export interface ScrapedContent { export interface ScrapedContent {
@@ -16,7 +14,7 @@ export interface ScrapedContent {
url: string; url: string;
content: string; content: string;
markdown: string; markdown: string;
cached?: boolean; cached: boolean;
} }
export interface SearchConfig { export interface SearchConfig {
@@ -58,7 +56,6 @@ class WebSearchService {
private lastSearchTime = 0; private lastSearchTime = 0;
private pendingRequest: PendingRequest | null = null; private pendingRequest: PendingRequest | null = null;
private rateLimitQueue: PendingRequest[] = []; private rateLimitQueue: PendingRequest[] = [];
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
private failureCache: FailureCache = new FailureCache(); private failureCache: FailureCache = new FailureCache();
constructor() { constructor() {
@@ -200,8 +197,9 @@ class WebSearchService {
): Promise<ScrapedContent[]> { ): Promise<ScrapedContent[]> {
try { try {
// Se SearchConfig foi fornecido, converter para formato Rust // Se SearchConfig foi fornecido, converter para formato Rust
let rustConfig: any = undefined; let rustConfig: Record<string, unknown> | undefined = undefined;
if (searchConfig) { if (searchConfig) {
const settings = useSettingsStore.getState();
rustConfig = { rustConfig = {
max_concurrent_tabs: searchConfig.maxConcurrentTabs, max_concurrent_tabs: searchConfig.maxConcurrentTabs,
total_sources_limit: searchConfig.totalSourcesLimit, total_sources_limit: searchConfig.totalSourcesLimit,
@@ -213,6 +211,10 @@ class WebSearchService {
})), })),
user_custom_sites: searchConfig.userCustomSites, user_custom_sites: searchConfig.userCustomSites,
excluded_domains: searchConfig.excludedDomains, excluded_domains: searchConfig.excludedDomains,
searxng: {
enabled: settings.webSearch.searxng.enabled,
url: settings.webSearch.searxng.url,
},
}; };
} }
@@ -222,7 +224,7 @@ class WebSearchService {
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined), excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
search_config: rustConfig, search_config: rustConfig,
}); });
console.log('Raw scraped results from backend:', results?.length ?? 0);
return results || []; return results || [];
} catch (error) { } catch (error) {
console.error('Erro ao buscar conteúdo:', error); console.error('Erro ao buscar conteúdo:', error);
@@ -255,51 +257,44 @@ class WebSearchService {
// Se não há resultados parciais, retornar vazio imediatamente (sem retry) // Se não há resultados parciais, retornar vazio imediatamente (sem retry)
return []; return [];
} }
// Obter configurações de motores do store
const settings = useSettingsStore.getState();
const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
let engineOrder = settings.webSearch.engineOrder || defaultEngines;
// Priorizar motores usando circuit breaker let rustConfig: Record<string, unknown> | undefined = undefined;
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder); if (searchConfig) {
const settings = useSettingsStore.getState();
// Filtrar motores não disponíveis (circuit breaker aberto) rustConfig = {
const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder); max_concurrent_tabs: searchConfig.maxConcurrentTabs,
total_sources_limit: searchConfig.totalSourcesLimit,
if (availableEngines.length === 0) { categories: searchConfig.categories.map(cat => ({
console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`); id: cat.id,
// Se todos estão abertos, tentar mesmo assim (pode ser temporário) name: cat.name,
engineOrder = engineOrder; base_sites: cat.baseSites,
} else { enabled: cat.enabled,
engineOrder = availableEngines; })),
if (availableEngines.length < engineOrder.length) { user_custom_sites: searchConfig.userCustomSites,
console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`); excluded_domains: searchConfig.excludedDomains,
searxng: {
enabled: settings.webSearch.searxng.enabled,
url: settings.webSearch.searxng.url,
},
};
} }
}
let rustConfig: any = undefined;
if (searchConfig) {
rustConfig = {
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
total_sources_limit: searchConfig.totalSourcesLimit,
categories: searchConfig.categories.map(cat => ({
id: cat.id,
name: cat.name,
base_sites: cat.baseSites,
enabled: cat.enabled,
})),
user_custom_sites: searchConfig.userCustomSites,
excluded_domains: searchConfig.excludedDomains,
};
}
// Calcular timeout adaptativo baseado na tentativa // Calcular timeout adaptativo baseado na tentativa
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs); const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
// Log da tentativa // Log detalhado da tentativa
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`); const searchStartTime = Date.now();
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`); console.log(`\n[WebSearch] ========== METADATA SEARCH START ==========`);
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`); console.log(`[WebSearch] Query: "${query}"`);
console.log(`[WebSearch] Limit: ${limit}`);
console.log(`[WebSearch] Timeout: ${adaptiveTimeout}ms`);
console.log(`[WebSearch] Attempt: ${attempt}`);
console.log(`[WebSearch] Config:`, searchConfig ? {
maxConcurrentTabs: searchConfig.maxConcurrentTabs,
totalSourcesLimit: searchConfig.totalSourcesLimit,
categories: searchConfig.categories.map(c => c.name),
excludedDomains: searchConfig.excludedDomains.length,
} : 'default');
// Executar com retry e timeout adaptativo // Executar com retry e timeout adaptativo
let retryAttempt = 0; let retryAttempt = 0;
@@ -315,7 +310,7 @@ class WebSearchService {
query: query.trim(), query: query.trim(),
limit, limit,
search_config: rustConfig, search_config: rustConfig,
engineOrder: engineOrder, // Passar ordem de motores engine_order: undefined, // Não usado mais, mantido para compatibilidade
}); });
const result = await withTimeout( const result = await withTimeout(
@@ -323,16 +318,25 @@ class WebSearchService {
retryTimeout, retryTimeout,
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})` `Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
); );
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
// Registrar sucesso no circuit breaker para cada motor usado // Log detalhado dos resultados
// (assumindo que o primeiro motor disponível foi usado) console.log(`\n[WebSearch] ========== METADATA SEARCH RESULTS ==========`);
if (engineOrder.length > 0) { console.log(`[WebSearch] Duration: ${duration}ms`);
const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem console.log(`[WebSearch] Results count: ${result?.length ?? 0}`);
this.circuitBreaker.recordAttempt(usedEngine, true, duration);
if (result && result.length > 0) {
console.log(`[WebSearch] Top ${Math.min(5, result.length)} results:`);
result.slice(0, 5).forEach((meta, idx) => {
console.log(` [${idx + 1}] ${meta.title}`);
console.log(` URL: ${meta.url}`);
console.log(` Snippet: ${meta.snippet?.substring(0, 100) || 'N/A'}...`);
console.log(` Engine: ${meta.engine || 'unknown'}`);
});
} else {
console.warn(`[WebSearch] ⚠️ No metadata results returned`);
} }
console.log(`[WebSearch] ============================================\n`);
return result; return result;
} catch (error) { } catch (error) {
@@ -340,12 +344,6 @@ class WebSearchService {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg); console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
// Registrar falha no circuit breaker para cada motor tentado
// (assumindo que tentou todos os motores na ordem)
for (const engine of engineOrder) {
this.circuitBreaker.recordAttempt(engine, false, duration);
}
throw error; throw error;
} }
}, },
@@ -360,7 +358,7 @@ class WebSearchService {
const results = retryResult.result || []; const results = retryResult.result || [];
if (results.length === 0) { if (results.length === 0) {
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`); console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`); console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}`);
} }
return results; return results;
} }
@@ -384,9 +382,8 @@ class WebSearchService {
errorMsg errorMsg
); );
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`); console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`); console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
// Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores // Retornar vazio mas não quebrar o fluxo
return []; return [];
} else { } else {
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio // Para erros não recuperáveis, logar detalhadamente mas retornar vazio
@@ -395,7 +392,6 @@ class WebSearchService {
errorMsg errorMsg
); );
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`); console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
return []; return [];
} }
} }
@@ -501,6 +497,13 @@ class WebSearchService {
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils'); const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs); const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
const smartSearchStart = Date.now();
console.log(`\n[WebSearch] ========== SMART SEARCH RAG START ==========`);
console.log(`[WebSearch] Query: "${query}"`);
console.log(`[WebSearch] Limit: ${limit}`);
console.log(`[WebSearch] Round: ${round}`);
console.log(`[WebSearch] Timeout: ${roundTimeout}ms`);
// Buscar metadados com retry e timeout adaptativo // Buscar metadados com retry e timeout adaptativo
const metas = await this.executeMetadataSearch( const metas = await this.executeMetadataSearch(
query, query,
@@ -513,7 +516,14 @@ class WebSearchService {
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente) // Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
const topUrls = metas.map(m => m.url).slice(0, limit); const topUrls = metas.map(m => m.url).slice(0, limit);
console.log(`[WebSearch] Selected ${topUrls.length} URLs for scraping:`);
topUrls.forEach((url, idx) => {
console.log(` [${idx + 1}] ${url}`);
});
if (topUrls.length === 0) { if (topUrls.length === 0) {
console.warn(`[WebSearch] ⚠️ No URLs to scrape, returning metadata only`);
console.log(`[WebSearch] ============================================\n`);
// Retornar resultados parciais (metadados vazios mas sem erro) // Retornar resultados parciais (metadados vazios mas sem erro)
return { metadata: metas, contents: [] }; return { metadata: metas, contents: [] };
} }
@@ -521,6 +531,8 @@ class WebSearchService {
// Tentar fazer scraping mesmo se metadados foram parciais // Tentar fazer scraping mesmo se metadados foram parciais
// Usar timeout reduzido para scraping (50% do timeout de busca) // Usar timeout reduzido para scraping (50% do timeout de busca)
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000); const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
console.log(`[WebSearch] Starting scraping with timeout: ${scrapingTimeout}ms`);
const scrapingStart = Date.now();
try { try {
const contents = await withTimeout( const contents = await withTimeout(
@@ -528,6 +540,34 @@ class WebSearchService {
scrapingTimeout, scrapingTimeout,
`Timeout ao fazer scraping para "${query}"` `Timeout ao fazer scraping para "${query}"`
); );
const scrapingDuration = Date.now() - scrapingStart;
const totalDuration = Date.now() - smartSearchStart;
// Log detalhado do scraping
console.log(`\n[WebSearch] ========== SCRAPING RESULTS ==========`);
console.log(`[WebSearch] Scraping duration: ${scrapingDuration}ms`);
console.log(`[WebSearch] Total duration: ${totalDuration}ms`);
console.log(`[WebSearch] Contents extracted: ${contents?.length ?? 0}`);
if (contents && contents.length > 0) {
contents.forEach((content, idx) => {
console.log(`\n [${idx + 1}] ${content.title}`);
console.log(` URL: ${content.url}`);
const contentLength = content.markdown?.length || 0;
const snippetLength = content.snippet?.length || 0;
console.log(` Markdown length: ${contentLength} chars`);
console.log(` Snippet length: ${snippetLength} chars`);
if (content.markdown) {
const preview = content.markdown.substring(0, 200);
console.log(` Preview: ${preview}${contentLength > 200 ? '...' : ''}`);
}
});
} else {
console.warn(`[WebSearch] ⚠️ No content extracted from URLs`);
}
console.log(`[WebSearch] ============================================\n`);
return { metadata: metas, contents: contents || [] }; return { metadata: metas, contents: contents || [] };
} catch (error) { } catch (error) {
// Logar erro mas retornar metadados disponíveis (resultado parcial) // Logar erro mas retornar metadados disponíveis (resultado parcial)
+490 -317
View File
File diff suppressed because it is too large. Load diff
+35 -3
View File
@@ -16,6 +16,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.5.1", features = [] } tauri-build = { version = "2.5.1", features = [] }
zip = { version = "0.6", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"] }
sha2 = "0.10"
aes-gcm = "0.10"
base64 = "0.21"
[features]
default = [] # Por padrão, não incluir llama-cpp (requer CUDA/libclang)
llama-cpp = ["dep:llama_cpp"]
dev-no-llama = [] # Skip llama.cpp em dev builds (builds 30-45 min podem ser lentos)
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
@@ -30,10 +40,7 @@ sysinfo = "0.36.1"
uuid = { version = "1.10", features = ["v4", "serde"] } uuid = { version = "1.10", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
headless_chrome = "1.0"
scraper = "0.18" scraper = "0.18"
readability = "0.3"
html2text = "0.5"
anyhow = "1.0" anyhow = "1.0"
url = "2.5" url = "2.5"
urlencoding = "2.1" urlencoding = "2.1"
@@ -48,6 +55,11 @@ tauri-plugin-updater = "2"
dirs = "5.0" dirs = "5.0"
rfd = "0.14" rfd = "0.14"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
<<<<<<< HEAD
aes-gcm = "0.10"
base64 = "0.21"
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
# ONNX Runtime para embeddings locais (ranking de relevância) # ONNX Runtime para embeddings locais (ranking de relevância)
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows # Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
@@ -55,3 +67,23 @@ rusqlite = { version = "0.31", features = ["bundled"] }
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] } ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
ndarray = "0.16" ndarray = "0.16"
tokenizers = "0.21" tokenizers = "0.21"
<<<<<<< HEAD
sha2 = "0.10"
# Dependências para llama.cpp e parsing GGUF
# Nota: llama_cpp 0.3.x é a versão mais recente disponível
llama_cpp = { version = "0.3", optional = true }
gguf = "0.1"
lru = "0.12"
# Features condicionais por OS (Metal/CUDA são mutuamente exclusivos)
[target.'cfg(target_os = "macos")'.dependencies]
llama_cpp = { version = "0.3", features = ["metal"], optional = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
llama_cpp = { version = "0.3", features = ["cuda"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
llama_cpp = { version = "0.3", features = ["cuda"], optional = true }
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
+444 -1
View File
@@ -1,3 +1,446 @@
use std::path::Path;
use std::process::Command;
use std::fs::File;
use std::io::Read;
use std::io::Write;
use std::time::Duration;
// Constantes para download e validação do OllamaSetup.exe
const OLLAMA_SETUP_URL: &str = "https://ollama.com/download/OllamaSetup.exe";
const MAX_RETRIES: u32 = 3;
const DOWNLOAD_TIMEOUT_SECS: u64 = 300;
// Hash SHA256 esperado do OllamaSetup.exe
// Pode ser sobrescrito via variável de ambiente OLLAMA_SETUP_EXE_SHA256
// IMPORTANTE: Atualizar este valor com o hash SHA256 oficial do instalador
const DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256: &str = "AC9C74B41A8303E5566C5AD3ED6EF0E0123D58AEBC87D3109973F02A38D3FF61";
/// Calcula o hash SHA256 de um arquivo
/// Usa chunks de 8KB para leitura eficiente (padrão do projeto)
fn calculate_sha256(file_path: &Path) -> Result<String, String> {
use sha2::{Sha256, Digest};
let mut file = File::open(file_path)
.map_err(|e| format!("Failed to open file for hash calculation: {}", e))?;
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 8192]; // Buffer de 8KB para leitura em chunks
loop {
let bytes_read = file.read(&mut buffer)
.map_err(|e| format!("Failed to read file for hash: {}", e))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = hasher.finalize();
Ok(format!("{:x}", hash))
}
/// Faz download de um arquivo com retry logic e backoff exponencial
fn download_with_retry(url: &str, dest_path: &Path) -> Result<(), String> {
use reqwest::blocking::Client;
// Limpar arquivo parcial se existir antes de iniciar
if dest_path.exists() {
std::fs::remove_file(dest_path)
.map_err(|e| format!("Failed to remove existing file before download: {}", e))?;
}
let client = Client::builder()
.timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let mut last_error = None;
for attempt in 1..=MAX_RETRIES {
println!("cargo:warning=Download attempt {}/{}: {}", attempt, MAX_RETRIES, url);
// Limpar arquivo parcial antes de cada tentativa (exceto na primeira, já limpo acima)
if attempt > 1 && dest_path.exists() {
std::fs::remove_file(dest_path)
.map_err(|e| format!("Failed to remove partial file before retry: {}", e))?;
}
// Garantir que o diretório pai existe
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
match client.get(url).send() {
Ok(response) => {
if !response.status().is_success() {
let err_msg = format!("HTTP error: {}", response.status());
println!("cargo:warning={}", err_msg);
last_error = Some(err_msg);
if attempt < MAX_RETRIES {
let backoff_secs = 2_u64.pow(attempt - 1); // 1s, 2s, 4s
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
std::thread::sleep(Duration::from_secs(backoff_secs));
}
continue;
}
// Obter bytes do response
match response.bytes() {
Ok(bytes) => {
// Criar arquivo e escrever conteúdo
match File::create(dest_path) {
Ok(mut dest_file) => {
match dest_file.write_all(&bytes) {
Ok(_) => {
drop(dest_file); // Fechar arquivo antes de validar
println!("cargo:warning=Download completed successfully");
return Ok(());
}
Err(e) => {
let err_msg = format!("Failed to write file: {}", e);
println!("cargo:warning={}", err_msg);
last_error = Some(err_msg);
// Limpar arquivo parcial
let _ = std::fs::remove_file(dest_path);
if attempt < MAX_RETRIES {
let backoff_secs = 2_u64.pow(attempt - 1);
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
std::thread::sleep(Duration::from_secs(backoff_secs));
}
continue;
}
}
}
Err(e) => {
let err_msg = format!("Failed to create destination file: {}", e);
println!("cargo:warning={}", err_msg);
last_error = Some(err_msg);
if attempt < MAX_RETRIES {
let backoff_secs = 2_u64.pow(attempt - 1);
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
std::thread::sleep(Duration::from_secs(backoff_secs));
}
continue;
}
}
}
Err(e) => {
let err_msg = format!("Failed to read response bytes: {}", e);
println!("cargo:warning={}", err_msg);
last_error = Some(err_msg);
// Limpar arquivo parcial
let _ = std::fs::remove_file(dest_path);
if attempt < MAX_RETRIES {
let backoff_secs = 2_u64.pow(attempt - 1);
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
std::thread::sleep(Duration::from_secs(backoff_secs));
}
continue;
}
}
}
Err(e) => {
let err_msg = format!("Download failed: {}", e);
println!("cargo:warning={}", err_msg);
last_error = Some(err_msg);
if attempt < MAX_RETRIES {
let backoff_secs = 2_u64.pow(attempt - 1);
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
std::thread::sleep(Duration::from_secs(backoff_secs));
}
}
}
}
Err(format!(
"Failed to download {} after {} attempts. Last error: {}",
url,
MAX_RETRIES,
last_error.unwrap_or_else(|| "Unknown error".to_string())
))
}
/// Valida o hash SHA256 de um arquivo contra o hash esperado
fn validate_sha256(file_path: &Path, expected_hash: &str) -> Result<(), String> {
println!("cargo:warning=Validating SHA256 hash...");
let calculated_hash = calculate_sha256(file_path)?;
let calculated_lower = calculated_hash.to_lowercase();
let expected_lower = expected_hash.to_lowercase();
if calculated_lower != expected_lower {
// Remover arquivo corrompido
let _ = std::fs::remove_file(file_path);
return Err(format!(
"SHA256 hash mismatch! Expected: {}, Got: {}. Corrupted file removed.",
expected_hash,
calculated_hash
));
}
println!("cargo:warning=SHA256 hash validated successfully");
Ok(())
}
/// Obtém o hash SHA256 esperado (variável de ambiente ou constante padrão)
fn get_expected_sha256() -> String {
std::env::var("OLLAMA_SETUP_EXE_SHA256")
.unwrap_or_else(|_| DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256.to_string())
}
/// Baixa e valida o instalador do Ollama
fn download_and_validate_ollama_setup(ollama_path: &Path) -> Result<(), String> {
let expected_hash = get_expected_sha256();
// Se o hash esperado está vazio, falhar imediatamente
if expected_hash.is_empty() {
panic!(
"OLLAMA_SETUP_EXE_SHA256 não configurado! \
Configure a variável de ambiente OLLAMA_SETUP_EXE_SHA256 ou \
atualize DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256 no build.rs"
);
}
// Verificar se arquivo já existe
if ollama_path.exists() {
println!("cargo:warning=OllamaSetup.exe already exists, validating hash...");
// Validar hash do arquivo existente
match validate_sha256(ollama_path, &expected_hash) {
Ok(_) => {
println!("cargo:warning=Existing OllamaSetup.exe is valid, skipping download");
return Ok(());
}
Err(e) => {
println!("cargo:warning=Existing file failed validation: {}", e);
println!("cargo:warning=Re-downloading...");
// Continuar para baixar novamente
}
}
}
// Fazer download com retry
println!("cargo:warning=Downloading Ollama installer...");
download_with_retry(OLLAMA_SETUP_URL, ollama_path)?;
// Validar hash após download
validate_sha256(ollama_path, &expected_hash)?;
println!("cargo:warning=Ollama installer downloaded and validated successfully");
Ok(())
}
fn main() { fn main() {
tauri_build::build() // Criar estrutura de diretórios para bundle
let bundle_dir = Path::new("bundle");
let ollama_dir = bundle_dir.join("ollama");
let embeddings_dir = bundle_dir.join("models/embeddings");
std::fs::create_dir_all(&ollama_dir).ok();
std::fs::create_dir_all(&embeddings_dir).ok();
#[cfg(target_os = "windows")]
{
println!("cargo:warning=Preparing bundle resources for Windows...");
// 1. Baixar e validar instalador do Ollama com SHA256
let ollama_path = ollama_dir.join("OllamaSetup.exe");
match download_and_validate_ollama_setup(&ollama_path) {
Ok(_) => {}
Err(e) => {
panic!("cargo:error=Failed to download and validate OllamaSetup.exe: {}", e);
}
}
// 2. Baixar modelo de embeddings ONNX (apenas se não existir)
let model_url = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
let model_path = embeddings_dir.join("all-MiniLM-L6-v2.onnx");
if !model_path.exists() {
println!("cargo:warning=Downloading ONNX embedding model (this may take a while, ~90MB)...");
let status = Command::new("powershell")
.args(&[
"-Command",
&format!(
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
model_path.display(),
model_url,
model_path.display()
)
])
.status();
if let Ok(exit_status) = status {
if exit_status.success() {
println!("cargo:warning=ONNX model downloaded successfully");
} else {
println!("cargo:warning=Failed to download ONNX model (non-zero exit code)");
}
} else {
println!("cargo:warning=Failed to download ONNX model (command failed)");
}
} else {
println!("cargo:warning=ONNX model already exists, skipping download");
}
// 3. Baixar tokenizer
let tokenizer_url = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
let tokenizer_path = embeddings_dir.join("tokenizer.json");
if !tokenizer_path.exists() {
println!("cargo:warning=Downloading tokenizer...");
let status = Command::new("powershell")
.args(&[
"-Command",
&format!(
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
tokenizer_path.display(),
tokenizer_url,
tokenizer_path.display()
)
])
.status();
if let Ok(exit_status) = status {
if exit_status.success() {
println!("cargo:warning=Tokenizer downloaded successfully");
} else {
println!("cargo:warning=Failed to download tokenizer (non-zero exit code)");
}
} else {
println!("cargo:warning=Failed to download tokenizer (command failed)");
}
} else {
println!("cargo:warning=Tokenizer already exists, skipping download");
}
// 4. Baixar e extrair ONNX Runtime DLL
let ort_url = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip";
let ort_zip_path = embeddings_dir.join("onnxruntime.zip");
let ort_dll_path = embeddings_dir.join("onnxruntime.dll");
if !ort_dll_path.exists() {
if !ort_zip_path.exists() {
println!("cargo:warning=Downloading ONNX Runtime library (~50MB)...");
let status = Command::new("powershell")
.args(&[
"-Command",
&format!(
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
ort_zip_path.display(),
ort_url,
ort_zip_path.display()
)
])
.status();
if let Ok(exit_status) = status {
if !exit_status.success() {
println!("cargo:warning=Failed to download ONNX Runtime (non-zero exit code)");
} else {
// Aguardar um pouco para garantir que o download foi concluído
std::thread::sleep(std::time::Duration::from_secs(1));
}
} else {
println!("cargo:warning=Failed to download ONNX Runtime (command failed)");
}
}
// Extrair DLL do zip se o zip existe e está completo
if ort_zip_path.exists() && !ort_dll_path.exists() {
// Verificar se o arquivo zip não está vazio e tem tamanho mínimo razoável (~40MB)
let min_zip_size = 40 * 1024 * 1024; // 40MB
let zip_valid = if let Ok(metadata) = std::fs::metadata(&ort_zip_path) {
let size = metadata.len();
if size < 1000 {
println!("cargo:warning=ONNX Runtime zip file appears to be incomplete (too small: {} bytes), will be downloaded at runtime", size);
false
} else if size < min_zip_size {
println!("cargo:warning=ONNX Runtime zip file appears to be incomplete ({} bytes, expected at least {} bytes), will be re-downloaded", size, min_zip_size);
// Tentar remover ZIP corrompido para forçar re-download
let _ = std::fs::remove_file(&ort_zip_path);
false
} else {
true
}
} else {
println!("cargo:warning=Failed to get metadata for ONNX Runtime zip file");
false
};
if zip_valid {
println!("cargo:warning=Extracting ONNX Runtime DLL from zip...");
// Usar zip crate para extrair (mais confiável que PowerShell)
match extract_onnx_dll_from_zip(&ort_zip_path, &ort_dll_path) {
Ok(_) => {
println!("cargo:warning=ONNX Runtime DLL extracted successfully");
// Remover zip após extração bem-sucedida
let _ = std::fs::remove_file(&ort_zip_path);
}
Err(e) => {
println!("cargo:warning=Failed to extract ONNX Runtime DLL: {}. ZIP may be corrupted, will be downloaded at runtime", e);
// Remover ZIP corrompido para forçar re-download em runtime
let _ = std::fs::remove_file(&ort_zip_path);
// Não falhar o build se a extração falhar - será baixado em runtime se necessário
}
}
}
}
} else {
println!("cargo:warning=ONNX Runtime DLL already exists, skipping download");
}
}
tauri_build::build()
}
/// Extrai onnxruntime.dll do arquivo zip
fn extract_onnx_dll_from_zip(zip_path: &Path, dll_path: &Path) -> Result<(), String> {
use zip::ZipArchive;
let file = File::open(zip_path)
.map_err(|e| format!("Failed to open zip file: {}", e))?;
let mut archive = ZipArchive::new(file)
.map_err(|e| format!("Failed to read zip archive: {}", e))?;
// Procurar pela DLL no zip
for i in 0..archive.len() {
let mut file = archive.by_index(i)
.map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?;
let name = file.name().to_string();
// Procurar por onnxruntime.dll (pode estar em subdiretórios)
if name.ends_with("onnxruntime.dll") {
// Criar diretório de destino se não existir
if let Some(parent) = dll_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create destination directory: {}", e))?;
}
// Extrair arquivo
let mut outfile = File::create(dll_path)
.map_err(|e| format!("Failed to create output file: {}", e))?;
std::io::copy(&mut file, &mut outfile)
.map_err(|e| format!("Failed to copy file: {}", e))?;
return Ok(());
}
}
Err("onnxruntime.dll not found in zip archive".to_string())
} }
+4
View File
@@ -2,3 +2,7 @@ curl_cffi>=0.5.10
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
lxml>=4.9.0 lxml>=4.9.0
html2text>=2024.1.0 html2text>=2024.1.0
playwright>=1.40.0
fake-useragent>=1.4.0
duckduckgo-search>=4.0.0
nodriver>=0.33
+334 -466
View File
@@ -1,499 +1,367 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Script Python para scraping web com curl_cffi + BeautifulSoup Micro-serviço Python para scraping e busca
Pipeline: curl_cffi (Request HTTP) -> BeautifulSoup (Parsing) -> html2text (Formatação) Pipeline:
Comunica via JSON stdin/stdout com o backend Rust 1. Scraping: curl_cffi / Playwright Stealth
2. Busca: duckduckgo-search
""" """
import sys import sys
import json import json
import time
import logging import logging
import traceback import traceback
from typing import List, Dict, Optional
from urllib.parse import urljoin, urlparse, quote
import random import random
from concurrent.futures import ThreadPoolExecutor, as_completed import time
import os
import asyncio
from itertools import cycle
from typing import Dict, Optional, Union, List
from urllib.parse import urljoin
# Bibliotecas de Scraping e Parsing
from bs4 import BeautifulSoup
from curl_cffi import requests
import html2text
# DuckDuckGo Search
try:
from duckduckgo_search import DDGS
HAS_DDG = True
except ImportError:
HAS_DDG = False
# Fallback libs
try:
import requests as pyrequests
HAS_PYREQUESTS = True
except ImportError:
HAS_PYREQUESTS = False
try:
import httpx
HAS_HTTPX = True
except ImportError:
HAS_HTTPX = False
# Playwright & Anti-Detect
try:
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
HAS_PLAYWRIGHT = True
except ImportError:
HAS_PLAYWRIGHT = False
try:
from fake_useragent import UserAgent
ua_generator = UserAgent()
HAS_FAKE_UA = True
except ImportError:
HAS_FAKE_UA = False
ua_generator = None
# --- CONFIGURAÇÃO DO AMBIENTE ---
# Configuração CRÍTICA de Encoding para Windows
try: try:
sys.stdout.reconfigure(encoding='utf-8') sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8') sys.stderr.reconfigure(encoding='utf-8')
except (AttributeError, Exception): except (AttributeError, Exception):
pass pass
# Configuração de Logging para STDERR (Não polui o STDOUT)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='[PYTHON LOG] %(message)s', format='[PYTHON SCRAPER] %(levelname)s: %(message)s',
stream=sys.stderr stream=sys.stderr
) )
def print_json(data): # --- CONSTANTES E POOLS ---
"""Função auxiliar para garantir output limpo em JSON"""
UA_POOL_FALLBACK = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
]
ua_cycle = cycle(UA_POOL_FALLBACK)
REQUEST_TIMEOUT = 20
MAX_RETRIES = 2
PROXY_POOL = None
env_proxies = os.environ.get('SCRAPER_PROXIES') or os.environ.get('SCRAPER_HTTP_PROXIES')
if env_proxies:
parts = [p.strip() for p in env_proxies.split(',') if p.strip()]
if parts:
PROXY_POOL = cycle(parts)
# --- FUNÇÕES AUXILIARES ---
def get_random_ua() -> str:
if HAS_FAKE_UA:
try:
return ua_generator.random
except Exception:
pass
return next(ua_cycle)
def generate_consistent_headers(user_agent: str) -> Dict[str, str]:
base_headers = {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Cache-Control': 'max-age=0'
}
if 'Chrome' in user_agent:
try:
version = user_agent.split('Chrome/')[1].split('.')[0]
except IndexError:
version = "120"
base_headers.update({
'sec-ch-ua': f'"Chromium";v="{version}", "Google Chrome";v="{version}", "Not-A.Brand";v="99"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"' if 'Windows' in user_agent else ('"macOS"' if 'Macintosh' in user_agent else '"Linux"')
})
return base_headers
def get_proxy_dict() -> Optional[Dict[str, str]]:
if PROXY_POOL:
proxy = next(PROXY_POOL)
return {"http": proxy, "https": proxy}
return None
def random_jitter(min_sec=0.5, max_sec=2.0):
time.sleep(random.uniform(min_sec, max_sec))
# --- LIMPEZA E EXTRAÇÃO ---
def clean_html(soup: BeautifulSoup) -> BeautifulSoup:
for tag in soup(["script", "style", "meta", "link", "noscript", "iframe", "object", "embed", "applet", "canvas", "svg"]):
tag.decompose()
for tag in soup.find_all(["nav", "header", "footer", "aside", "menu", "dialog"]):
tag.decompose()
ad_keywords = ["ads", "advertisement", "banner", "ad-", "popup", "modal", "cookie", "subscription", "newsletter", "share", "social", "sidebar", "promo", "sponsor"]
selectors_to_remove = ['.ad', '.advertisement', '[class*="ad-"]', '[id*="ad-"]', '[id*="cookie"]', '[class*="cookie"]', '.share-buttons', '.social-media', '.comments-section', '#comments']
for selector in selectors_to_remove:
for tag in soup.select(selector):
tag.decompose()
def is_ad(tag):
if not tag.name: return False
attrs = str(tag.attrs).lower()
return any(k in attrs for k in ad_keywords)
for tag in soup.find_all(is_ad):
tag.decompose()
return soup
def extract_main_content(soup: BeautifulSoup) -> BeautifulSoup:
selectors = ['article', 'main', '[role="main"]', '.post-content', '.article-body', '#content', '.content', '.entry-content', '.page-content']
for selector in selectors:
found = soup.select_one(selector)
if found and len(found.get_text(strip=True)) > 200:
return found
body = soup.find('body')
return body if body else soup
def html_to_markdown(html_content: str) -> str:
try: try:
print(json.dumps(data, ensure_ascii=False)) h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = False
h.images_to_alt = True
h.body_width = 0
h.protect_links = True
return h.handle(html_content)
except Exception as e:
logging.warning(f"Erro na conversão Markdown: {e}")
return html_content
# --- ESTRATÉGIAS DE SCRAPING ---
async def scrape_with_playwright(url: str) -> Dict:
if not HAS_PLAYWRIGHT:
return {"error": "Playwright not installed"}
logging.info(f"[PLAYWRIGHT] Iniciando scraping stealth para: {url}")
async with async_playwright() as p:
args = ['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', '--disable-infobars', '--window-size=1920,1080', '--start-maximized']
try:
browser = await p.chromium.launch(headless=True, args=args)
ua = get_random_ua()
context = await browser.new_context(user_agent=ua, viewport={'width': 1920, 'height': 1080}, locale='pt-BR', timezone_id='America/Sao_Paulo', java_script_enabled=True, ignore_https_errors=True)
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, 'languages', { get: () => ['pt-BR', 'pt', 'en-US', 'en'] });
""")
page = await context.new_page()
try:
await page.goto(url, wait_until='domcontentloaded', timeout=25000)
await page.wait_for_timeout(2000)
except PlaywrightTimeoutError:
logging.warning(f"[PLAYWRIGHT] Timeout carregando {url}")
content = await page.content()
title = await page.title()
await browser.close()
return {"content": content, "title": title, "method": "playwright"}
except Exception as e:
logging.error(f"[PLAYWRIGHT] Erro fatal: {e}")
return {"error": str(e)}
def scrape_with_curl_cffi(url: str) -> Dict:
ua = get_random_ua()
headers = generate_consistent_headers(ua)
proxies = get_proxy_dict()
try:
response = requests.get(url, headers=headers, proxies=proxies, timeout=15, impersonate="chrome120", allow_redirects=True)
response.raise_for_status()
if response.status_code in [403, 429, 503]:
raise requests.RequestsError(f"Blocked with status {response.status_code}")
return {"content": response.text, "title": "", "method": "curl_cffi", "headers": response.headers}
except Exception as e:
return {"error": str(e)}
async def scrape_url_smart(url: str) -> Dict:
random_jitter()
logging.info(f"Tentativa 1: curl_cffi -> {url}")
result = scrape_with_curl_cffi(url)
html_content = None
title = ""
if "error" not in result:
html_content = result["content"]
if "challenge-platform" in html_content or "Cloudflare" in html_content and len(html_content) < 5000:
logging.warning("Detectado desafio Cloudflare/WAF no curl_cffi. Acionando fallback.")
html_content = None
if not html_content:
if HAS_PLAYWRIGHT:
logging.info(f"Tentativa 2: Playwright Stealth -> {url}")
pw_result = await scrape_with_playwright(url)
if "error" not in pw_result:
html_content = pw_result["content"]
title = pw_result.get("title", "")
else:
logging.error(f"Playwright falhou: {pw_result['error']}")
else:
logging.warning("Playwright não disponível para fallback.")
if html_content:
try:
soup = BeautifulSoup(html_content, 'html.parser')
if not title:
title = soup.title.string.strip() if soup.title else url
soup = clean_html(soup)
main_content = extract_main_content(soup)
cleaned_html = str(main_content)
markdown_text = html_to_markdown(cleaned_html)
final_markdown = '\n'.join([line.strip() for line in markdown_text.split('\n') if line.strip()])
if len(final_markdown) < 50:
return {"response_type": "error", "message": "Conteúdo insuficiente após processamento."}
return {"response_type": "success", "url": url, "title": title, "markdown": final_markdown, "content": final_markdown}
except Exception as e:
logging.error(f"Erro no processamento HTML: {traceback.format_exc()}")
return {"response_type": "error", "message": f"Processing error: {str(e)}"}
return {"response_type": "error", "message": f"Falha em todos os métodos de scraping. Erro inicial: {result.get('error', 'Unknown')}"}
# --- DUCKDUCKGO SEARCH LOGIC ---
async def search_duckduckgo(keywords: str, region: str = "wt-wt", safesearch: str = "moderate", max_results: int = 10) -> Dict:
if not HAS_DDG:
return {"response_type": "error", "message": "duckduckgo-search module not installed"}
logging.info(f"Searching DuckDuckGo: {keywords} (region={region}, safe={safesearch})")
try:
results_list = []
# DDGS is synchronous, run in thread executor to avoid blocking async loop if needed
# But since this script main loop is simple, direct call is fine, or wrap in to_thread
with DDGS() as ddgs:
# ddgs.text() is a generator
ddg_gen = ddgs.text(
keywords=keywords,
region=region,
safesearch=safesearch,
max_results=max_results
)
for r in ddg_gen:
results_list.append({
"title": r.get("title", ""),
"url": r.get("href", ""),
"content": r.get("body", ""),
"engine": "duckduckgo",
"score": 1.0
})
return {
"response_type": "success",
"query": keywords,
"results": results_list,
"number_of_results": len(results_list)
}
except Exception as e:
logging.error(f"DuckDuckGo Search Error: {e}")
return {"response_type": "error", "message": str(e)}
# --- MAIN ---
def write_json_response(data: Dict):
try:
json_line = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
print(json_line)
sys.stdout.flush() sys.stdout.flush()
except Exception as e: except Exception as e:
try: logging.error(f"Erro enviando resposta JSON: {e}")
print(json.dumps({"type": "error", "message": f"Failed to format output: {str(e)}"}))
sys.stdout.flush()
except:
pass
# Tentar importar dependências
CURL_CFFI_AVAILABLE = False
BS4_AVAILABLE = False
HTML2TEXT_AVAILABLE = False
try:
from curl_cffi import requests
CURL_CFFI_AVAILABLE = True
except ImportError as e:
logging.error(f"curl_cffi não disponível: {e}")
try:
from bs4 import BeautifulSoup
BS4_AVAILABLE = True
except ImportError as e:
logging.error(f"BeautifulSoup4 não disponível: {e}")
try:
import html2text
HTML2TEXT_AVAILABLE = True
except ImportError as e:
logging.error(f"html2text não disponível: {e}")
# Pool de User Agents para parecer mais humano
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
]
def get_random_user_agent():
"""Retorna um user agent aleatório"""
return random.choice(USER_AGENTS)
def clean_url(url: str) -> str:
"""Remove fragmentos e parâmetros de rastreamento da URL"""
try:
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
except:
return url
def extract_domain(url: str) -> str:
"""Extrai o domínio de uma URL"""
try:
parsed = urlparse(url)
return parsed.netloc
except:
return ""
def is_ad_or_tracker_url(url: str) -> bool:
"""Verifica se uma URL é de anúncio ou rastreador"""
trackers = ['analytics', 'google-analytics', 'doubleclick', 'facebook.com/tr', 'ads.', 'banner', 'tracking']
return any(tracker in url.lower() for tracker in trackers)
def is_domain_blocked(url: str, excluded_domains: List[str]) -> bool:
"""Verifica se o domínio da URL está na lista de exclusão"""
domain = extract_domain(url)
return any(excluded in domain for excluded in excluded_domains)
def scrape_url(url: str, visible: bool = False) -> Dict:
"""Função principal de scraping com curl_cffi + BeautifulSoup"""
# Verificar dependências
if not CURL_CFFI_AVAILABLE:
return {
"type": "error",
"message": "curl_cffi não está instalado"
}
if not BS4_AVAILABLE:
return {
"type": "error",
"message": "beautifulsoup4 não está instalado"
}
if not HTML2TEXT_AVAILABLE:
return {
"type": "error",
"message": "html2text não está instalado"
}
try:
logging.info(f"Iniciando requisição para: {url}")
# Criar headers com curl_cffi (impersonate navegador)
headers = {
"User-Agent": get_random_user_agent(),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate",
"DNT": "1",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
# Executar requisição com timeout
try:
response = requests.get(
url,
headers=headers,
timeout=15,
impersonate="chrome120",
allow_redirects=True,
follow_redirects=True
)
response.raise_for_status()
except Exception as e:
logging.error(f"Erro na requisição HTTP: {e}")
return {
"type": "error",
"message": f"Failed to fetch URL: {str(e)}"
}
# Verificar se recebemos conteúdo HTML válido
if len(response.content) < 100:
return {
"type": "error",
"message": "Resposta muito curta (< 100 bytes)"
}
logging.info(f"Resposta recebida: {len(response.content)} bytes")
# Parse com BeautifulSoup
try:
soup = BeautifulSoup(response.content, 'html.parser')
except Exception as e:
logging.error(f"Erro ao fazer parse HTML: {e}")
return {
"type": "error",
"message": f"Failed to parse HTML: {str(e)}"
}
# Extrair título
title = "Sem título"
title_tag = soup.find('meta', property='og:title') or soup.find('meta', {'name': 'title'}) or soup.find('title')
if title_tag:
title = title_tag.get('content') or title_tag.string or title
# Remover scripts e styles
for script in soup(["script", "style", "meta", "link", "noscript"]):
script.decompose()
# Remover elementos desnecessários
for elem in soup.find_all(["nav", "header", "footer", "aside", "ads"]):
elem.decompose()
# Extrair conteúdo principal
main_content = None
for selector in ['article', 'main', '[role="main"]', '.content', '.post', '.entry']:
if selector.startswith('['):
main_content = soup.select_one(selector)
else:
main_content = soup.find(selector)
if main_content and len(main_content.get_text(strip=True)) > 200:
break
# Fallback: usar body
if not main_content or len(main_content.get_text(strip=True)) < 200:
main_content = soup.find('body') or soup
# Obter HTML do conteúdo principal
html_content = str(main_content) if main_content else str(soup)
# Converter para Markdown usando html2text
try:
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = False
h.body_width = 0
markdown_content = h.handle(html_content)
except Exception as e:
logging.warning(f"Erro ao converter para markdown, usando texto plano: {e}")
markdown_content = main_content.get_text(separator="\n") if main_content else response.text
# Limpeza final
lines = [line.strip() for line in markdown_content.split('\n') if line.strip()]
final_content = '\n'.join(lines)
# Validação de conteúdo
if len(final_content) < 100:
return {
"type": "error",
"message": f"Conteúdo insuficiente ({len(final_content)} chars)"
}
logging.info(f"Sucesso! {len(final_content)} chars extraídos")
return {
"type": "success",
"url": clean_url(url),
"title": title[:500],
"content": final_content[:30000],
"markdown": final_content[:30000]
}
except Exception as e:
logging.error(f"Erro no scraping: {traceback.format_exc()}")
return {
"type": "error",
"message": str(e)
}
def search_duckduckgo(query: str, limit: int = 5) -> List[str]:
"""Realiza busca no DuckDuckGo e retorna URLs"""
try:
if not CURL_CFFI_AVAILABLE or not BS4_AVAILABLE:
logging.error("Dependências não disponíveis")
return []
logging.info(f"Buscando no DuckDuckGo: {query}")
headers = {
"User-Agent": get_random_user_agent(),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
}
search_url = f"https://duckduckgo.com/html/?q={quote(query)}"
response = requests.get(
search_url,
headers=headers,
timeout=15,
impersonate="chrome120",
allow_redirects=True
)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
urls = []
# DuckDuckGo HTML retorna resultados em links diretos
for result in soup.find_all('a', {'class': 'result__a'}):
href = result.get('href')
if href and not is_ad_or_tracker_url(href):
urls.append(href)
if len(urls) >= limit:
break
logging.info(f"Encontradas {len(urls)} URLs")
return urls
except Exception as e:
logging.error(f"Erro na busca DuckDuckGo: {e}")
return []
def smart_search_impl(query: str, config: Dict) -> List[str]:
"""Implementa busca inteligente com categorias e filtros"""
try:
logging.info(f"Smart search iniciado: {query}")
total_limit = config.get('total_sources_limit', 15)
categories = config.get('categories', [])
user_custom_sites = config.get('user_custom_sites', [])
excluded_domains = config.get('excluded_domains', [])
all_urls = []
# 1. Busca geral no DuckDuckGo
general_urls = search_duckduckgo(query, total_limit // 2)
all_urls.extend(general_urls)
# 2. Busca por categorias habilitadas
for category in categories:
if not category.get('enabled', False):
continue
base_sites = category.get('base_sites', [])
for site in base_sites[:2]: # Limitar a 2 sites por categoria
site_query = f"site:{site} {query}"
site_urls = search_duckduckgo(site_query, 3)
all_urls.extend(site_urls)
# 3. Busca em sites personalizados
for custom_site in user_custom_sites[:3]:
custom_query = f"site:{custom_site} {query}"
custom_urls = search_duckduckgo(custom_query, 2)
all_urls.extend(custom_urls)
# 4. Filtrar domínios excluídos e remover duplicatas
filtered_urls = []
seen = set()
for url in all_urls:
if url not in seen and not is_domain_blocked(url, excluded_domains):
filtered_urls.append(url)
seen.add(url)
if len(filtered_urls) >= total_limit:
break
logging.info(f"Smart search concluído: {len(filtered_urls)} URLs")
return filtered_urls
except Exception as e:
logging.error(f"Erro no smart search: {e}")
return []
def scrape_urls_bulk_impl(urls: List[str]) -> List[Dict]:
"""Scraping em paralelo de múltiplas URLs"""
try:
logging.info(f"Scraping bulk iniciado: {len(urls)} URLs")
results = []
def scrape_with_logging(url):
try:
result = scrape_url(url, visible=False)
if result.get('type') == 'success':
logging.info(f"✓ {url}")
else:
logging.warning(f"✗ {url}: {result.get('message', 'unknown error')}")
return result
except Exception as e:
logging.error(f"✗ {url}: {e}")
return {"type": "error", "message": str(e), "url": url}
# Scraping paralelo com até 5 workers
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_url = {executor.submit(scrape_with_logging, url): url for url in urls}
for future in as_completed(future_to_url):
try:
result = future.result()
if result.get('type') == 'success':
results.append(result)
except Exception as e:
logging.error(f"Erro no future: {e}")
logging.info(f"Scraping bulk concluído: {len(results)} sucessos")
return results
except Exception as e:
logging.error(f"Erro no scraping bulk: {e}")
return []
def main(): def main():
"""Entry point do script - lê comandos JSON do stdin""" logging.info("Scraper/Search Service v4.0 (DuckDuckGo Edition) Initialized")
try: while True:
# Ler JSON do stdin
input_data = sys.stdin.read().strip()
if not input_data:
print_json({"type": "error", "message": "No input provided"})
return
# Parse JSON
try: try:
command_data = json.loads(input_data) line = sys.stdin.readline()
except json.JSONDecodeError as e: if not line: break
logging.error(f"Erro ao fazer parse do JSON: {e}") if not line.strip(): continue
print_json({"type": "error", "message": f"Invalid JSON: {str(e)}"}) try:
return request = json.loads(line)
except json.JSONDecodeError:
command = command_data.get("command") write_json_response({"response_type": "error", "message": "Invalid JSON"})
logging.info(f"Comando recebido: {command}") continue
# Dispatch de comandos
if command == "scrape":
url = command_data.get("url")
visible = command_data.get("visible", False)
if not url: command = request.get("command")
print_json({"type": "error", "message": "URL missing"})
return
result = scrape_url(url, visible) if command == "scrape":
print_json(result) url = request.get("url")
if url:
elif command == "search_duckduckgo": result = asyncio.run(scrape_url_smart(url))
query = command_data.get("query") write_json_response(result)
limit = command_data.get("limit", 5) else:
write_json_response({"response_type": "error", "message": "No URL provided"})
if not query: elif command == "search":
print_json({"type": "error", "message": "Query missing"}) # Generic search command, implemented via DuckDuckGo
return keywords = request.get("keywords") or request.get("query")
region = request.get("region", "wt-wt")
urls = search_duckduckgo(query, limit) safesearch = request.get("safesearch", "moderate")
print_json({"type": "success", "urls": urls}) max_results = request.get("max_results", 10)
elif command == "smart_search": if keywords:
query = command_data.get("query") result = asyncio.run(search_duckduckgo(keywords, region, safesearch, max_results))
config = command_data.get("config", {}) write_json_response(result)
else:
if not query: write_json_response({"response_type": "error", "message": "Missing keywords"})
print_json({"type": "error", "message": "Query missing"})
return elif command == "ping":
write_json_response({"response_type": "pong"})
urls = smart_search_impl(query, config)
print_json({"type": "success", "urls": urls})
elif command == "search_and_scrape":
query = command_data.get("query")
config = command_data.get("config", {})
if not query:
print_json({"type": "error", "message": "Query missing"})
return
# Primeiro faz smart_search para obter URLs
urls = smart_search_impl(query, config)
if not urls:
print_json({"type": "success", "results": []})
return
# Depois faz scraping das URLs
results = scrape_urls_bulk_impl(urls)
print_json({"type": "success", "results": results})
elif command == "scrape_urls_bulk":
urls = command_data.get("urls", [])
if not urls:
print_json({"type": "error", "message": "URLs missing"})
return
results = scrape_urls_bulk_impl(urls)
print_json({"type": "success", "results": results})
elif command == "check":
# Verificar dependências
missing = []
if not CURL_CFFI_AVAILABLE:
missing.append("curl_cffi")
if not BS4_AVAILABLE:
missing.append("beautifulsoup4")
if not HTML2TEXT_AVAILABLE:
missing.append("html2text")
if missing:
print_json({"type": "error", "message": f"Missing: {', '.join(missing)}"})
else: else:
print_json({"type": "success", "message": "All dependencies available"}) write_json_response({"response_type": "error", "message": "Unknown command"})
except KeyboardInterrupt:
else: break
print_json({"type": "error", "message": f"Unknown command: {command}"}) except Exception as e:
logging.critical(f"Fatal error in main loop: {e}")
except Exception as e: write_json_response({"response_type": "error", "message": str(e)})
logging.error(f"Erro no main: {traceback.format_exc()}")
print_json({"type": "error", "message": f"Critical error: {str(e)}"})
if __name__ == "__main__": if __name__ == "__main__":
try: main()
main()
except KeyboardInterrupt:
print_json({"type": "error", "message": "Script interrupted"})
sys.exit(1)
except Exception as e:
try:
logging.error(f"Unhandled exception: {traceback.format_exc()}")
print_json({"type": "error", "message": f"Unhandled exception: {str(e)}"})
except:
try:
print(json.dumps({"type": "error", "message": "Critical failure"}))
except:
pass
sys.exit(1)
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
Micro-serviço Python para scraping com Nodriver (anti-detecção)
Pipeline:
1. Scraping: Nodriver (sem CDP, indetectável)
2. Fallback: curl_cffi se Nodriver falhar
"""
import sys
import json
import logging
import traceback
import random
import time
import os
import asyncio
from typing import Dict, Optional, Union, List
from urllib.parse import urljoin
# Bibliotecas de Scraping e Parsing
from bs4 import BeautifulSoup
import html2text
# Nodriver (anti-detecção)
try:
import nodriver as uc
HAS_NODRIVER = True
except ImportError:
HAS_NODRIVER = False
logging.warning("nodriver not installed, falling back to curl_cffi")
# curl_cffi como fallback
try:
from curl_cffi import requests
HAS_CURL_CFFI = True
except ImportError:
HAS_CURL_CFFI = False
# Fallback libs
try:
import requests as pyrequests
HAS_PYREQUESTS = True
except ImportError:
HAS_PYREQUESTS = False
# --- CONFIGURAÇÃO DO AMBIENTE ---
try:
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except (AttributeError, Exception):
pass
logging.basicConfig(
level=logging.INFO,
format='[PYTHON SCRAPER NODRIVER] %(levelname)s: %(message)s',
stream=sys.stderr
)
# --- CONSTANTES E POOLS ---
UA_POOL_FALLBACK = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
]
REQUEST_TIMEOUT = 12 # Reduzido de 20 para 12s para maior velocidade
MAX_RETRIES = 2
NODRIVER_TIMEOUT = 10 # Timeout específico para nodriver (10s)
# --- FUNÇÕES AUXILIARES ---
def get_random_ua() -> str:
return random.choice(UA_POOL_FALLBACK)
def random_jitter():
"""Adiciona jitter aleatório para evitar padrões"""
time.sleep(random.uniform(0.1, 0.5))
def clean_html(soup: BeautifulSoup) -> BeautifulSoup:
"""Remove scripts, styles e outros elementos desnecessários"""
for script in soup(["script", "style", "nav", "header", "footer", "aside", "noscript"]):
script.decompose()
return soup
def extract_main_content(soup: BeautifulSoup) -> BeautifulSoup:
"""Extrai conteúdo principal usando heurísticas"""
# Tentar encontrar main, article, ou div com classe comum
selectors = [
'main',
'article',
'[role="main"]',
'.content',
'.main-content',
'#content',
'#main',
]
for selector in selectors:
main = soup.select_one(selector)
if main:
return main
# Fallback: retornar body
return soup.find('body') or soup
def html_to_markdown(html: str) -> str:
"""Converte HTML para Markdown"""
h = html2text.HTML2Text()
h.ignore_links = False
h.ignore_images = False
h.body_width = 0
return h.handle(html)
# --- SCRAPING COM NODRIVER ---
async def scrape_with_nodriver(url: str) -> Dict:
"""Scraping usando Nodriver (anti-detecção) - OTIMIZADO"""
if not HAS_NODRIVER:
return {"error": "nodriver not installed"}
try:
logging.info(f"[NODRIVER] Starting scrape for {url}")
start_time = time.time()
# Iniciar browser com Nodriver (sem CDP, indetectável)
browser = await uc.start(headless=True)
try:
# Navegar para URL com timeout
page = await asyncio.wait_for(
browser.get(url),
timeout=NODRIVER_TIMEOUT
)
# Aguardar conteúdo carregar com wait condicional otimizado
# Tentar múltiplas estratégias de wait para reduzir tempo total
try:
# Estratégia 1: Aguardar DOMContentLoaded (mais rápido)
await asyncio.wait_for(
page.wait_for_load_state('domcontentloaded'),
timeout=2.0
)
except asyncio.TimeoutError:
# Se DOMContentLoaded demorar, usar sleep reduzido
await asyncio.sleep(0.5)
# Aguardar um pouco mais para JS executar (reduzido de 2s para 0.3s)
await asyncio.sleep(0.3)
# Extrair HTML
html = await page.get_content()
title = await page.evaluate('document.title')
duration = time.time() - start_time
logging.info(f"[NODRIVER] Successfully scraped {url} ({len(html)} chars, {duration:.2f}s)")
return {
"content": html,
"title": title or "",
"method": "nodriver"
}
finally:
await browser.stop()
except asyncio.TimeoutError:
logging.warning(f"[NODRIVER] Timeout after {NODRIVER_TIMEOUT}s for {url}")
return {"error": f"Timeout after {NODRIVER_TIMEOUT}s"}
except Exception as e:
logging.error(f"[NODRIVER] Error scraping {url}: {e}")
return {"error": str(e)}
# --- SCRAPING COM CURL_CFFI (FALLBACK) ---
def scrape_with_curl_cffi(url: str) -> Dict:
"""Scraping usando curl_cffi como fallback - OTIMIZADO"""
if not HAS_CURL_CFFI:
return {"error": "curl_cffi not installed"}
ua = get_random_ua()
headers = {
'User-Agent': ua,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}
try:
# Timeout reduzido para fallback rápido
response = requests.get(
url,
headers=headers,
timeout=8, # Reduzido de 15 para 8s
impersonate="chrome120",
allow_redirects=True
)
response.raise_for_status()
if response.status_code in [403, 429, 503]:
raise requests.RequestsError(f"Blocked with status {response.status_code}")
return {
"content": response.text,
"title": "",
"method": "curl_cffi",
"headers": dict(response.headers)
}
except Exception as e:
return {"error": str(e)}
# --- SCRAPING INTELIGENTE ---
async def scrape_url_smart(url: str) -> Dict:
"""Scraping inteligente: Nodriver (PRIMÁRIO) → curl_cffi (fallback) - OTIMIZADO"""
# Jitter reduzido para maior velocidade
await asyncio.sleep(random.uniform(0.05, 0.15))
# Tentativa 1: Nodriver (PRIMÁRIO, anti-detecção)
if HAS_NODRIVER:
logging.info(f"[PRIMARY] Nodriver -> {url}")
result = await scrape_with_nodriver(url)
if "error" not in result:
html_content = result["content"]
# Verificar se não é página de bloqueio (mais permissivo para velocidade)
if len(html_content) > 1000: # Reduzido de 5000 para 1000 para aceitar mais resultados
if "challenge-platform" not in html_content.lower() and "cloudflare" not in html_content.lower():
return result
else:
logging.warning("[NODRIVER] Detectado desafio Cloudflare/WAF. Tentando fallback.")
elif len(html_content) > 200: # Aceitar conteúdo mínimo
logging.info(f"[NODRIVER] Conteúdo mínimo mas aceitável ({len(html_content)} chars)")
return result
# Tentativa 2: curl_cffi (fallback rápido)
if HAS_CURL_CFFI:
logging.info(f"[FALLBACK] curl_cffi -> {url}")
result = scrape_with_curl_cffi(url)
if "error" not in result:
html_content = result["content"]
if len(html_content) > 1000:
if "challenge-platform" not in html_content.lower() and "cloudflare" not in html_content.lower():
return result
# Se ambos falharam, retornar erro
return {"error": "All scraping methods failed"}
# --- PROCESSAMENTO HTML ---
async def process_scraped_content(url: str, result: Dict) -> Dict:
"""Processa conteúdo scraped e converte para markdown"""
if "error" in result:
return {"response_type": "error", "message": result["error"]}
html_content = result.get("content", "")
title = result.get("title", "")
if not html_content:
return {"response_type": "error", "message": "No content extracted"}
try:
soup = BeautifulSoup(html_content, 'html.parser')
if not title:
title = soup.title.string.strip() if soup.title else url
soup = clean_html(soup)
main_content = extract_main_content(soup)
cleaned_html = str(main_content)
markdown_text = html_to_markdown(cleaned_html)
final_markdown = '\n'.join([line.strip() for line in markdown_text.split('\n') if line.strip()])
if len(final_markdown) < 50:
return {"response_type": "error", "message": "Conteúdo insuficiente após processamento."}
return {
"response_type": "success",
"url": url,
"title": title,
"markdown": final_markdown,
"content": final_markdown
}
except Exception as e:
logging.error(f"Erro no processamento HTML: {traceback.format_exc()}")
return {"response_type": "error", "message": f"Processing error: {str(e)}"}
# --- MAIN LOOP ---
def write_json_response(data: Dict):
try:
json_line = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
print(json_line)
sys.stdout.flush()
except Exception as e:
logging.error(f"Erro enviando resposta JSON: {e}")
async def main():
logging.info("Scraper/Search Service v5.0 (Nodriver Edition) Initialized")
while True:
try:
line = sys.stdin.readline()
if not line:
break
if not line.strip():
continue
try:
request = json.loads(line)
except json.JSONDecodeError:
write_json_response({"response_type": "error", "message": "Invalid JSON"})
continue
command = request.get("command")
if command == "scrape":
url = request.get("url")
if url:
result = await scrape_url_smart(url)
processed = await process_scraped_content(url, result)
write_json_response(processed)
else:
write_json_response({"response_type": "error", "message": "No URL provided"})
else:
write_json_response({"response_type": "error", "message": f"Unknown command: {command}"})
except Exception as e:
logging.error(f"Error in main loop: {traceback.format_exc()}")
write_json_response({"response_type": "error", "message": str(e)})
if __name__ == "__main__":
asyncio.run(main())
+110
View File
@@ -0,0 +1,110 @@
use anyhow::Result;
use reqwest::Client;
use std::time::Duration;
use crate::web_scraper::SearchResultMetadata;
use crate::event_emitter::{SearchQueryEvent, SearchQueryCompletedEvent, emit_search_event, emit_search_completed_event};
use tauri::AppHandle;
use std::time::{SystemTime, UNIX_EPOCH};
/// Busca via Brave Search API
pub async fn brave_search(
query: &str,
limit: usize,
app_handle: Option<&AppHandle>,
) -> Result<Vec<SearchResultMetadata>> {
// Emitir evento de query iniciada
if let Some(handle) = app_handle {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)
.unwrap_or_default().as_secs() * 1000;
emit_search_event(handle, &SearchQueryEvent {
query: query.to_string(),
source: "brave".to_string(),
round: None,
timestamp,
});
}
let api_key = std::env::var("BRAVE_API_KEY")
.map_err(|_| anyhow::anyhow!("BRAVE_API_KEY não configurada"))?;
let client = Client::builder()
.timeout(Duration::from_secs(3)) // Reduzido de 5s para 3s para maior velocidade
.build()?;
let response = client
.get("https://api.search.brave.com/res/v1/web/search")
.header("X-Subscription-Token", &api_key)
.query(&[
("q", query),
("count", &limit.to_string()),
("safesearch", "moderate"),
])
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
// Emitir evento de query completada com falha
if let Some(handle) = app_handle {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)
.unwrap_or_default().as_secs() * 1000;
emit_search_completed_event(handle, &SearchQueryCompletedEvent {
query: query.to_string(),
source: "brave".to_string(),
results_count: 0,
success: false,
timestamp,
});
}
return Err(anyhow::anyhow!("Brave API returned status {}: {}", status, error_text));
}
#[derive(serde::Deserialize)]
struct BraveResponse {
web: BraveWebResults,
}
#[derive(serde::Deserialize)]
struct BraveWebResults {
results: Vec<BraveResult>,
}
#[derive(serde::Deserialize)]
struct BraveResult {
title: String,
url: String,
description: String,
}
let data: BraveResponse = response.json().await?;
let metadata: Vec<SearchResultMetadata> = data
.web
.results
.into_iter()
.take(limit)
.map(|r| SearchResultMetadata {
title: r.title,
url: r.url,
snippet: r.description,
})
.collect();
// Emitir evento de query completada com sucesso
if let Some(handle) = app_handle {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)
.unwrap_or_default().as_secs() * 1000;
emit_search_completed_event(handle, &SearchQueryCompletedEvent {
query: query.to_string(),
source: "brave".to_string(),
results_count: metadata.len(),
success: true,
timestamp,
});
}
Ok(metadata)
}
+222
View File
@@ -0,0 +1,222 @@
use std::path::PathBuf;
use serde::{Serialize, Deserialize};
use sha2::{Sha256, Digest};
use tokio::fs;
use std::io;
use chrono::{DateTime, Utc};
use url::Url;
/// Estrutura do conteúdo salvo em disco (JSON)
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CachedContent {
pub url: String,
pub domain: String,
pub access_timestamp: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>, // TTL
pub http_status: u16,
pub title: String,
pub content_markdown: String,
pub metadata: serde_json::Value,
pub raw_html_path: Option<String>,
}
pub struct ContentStore;
impl ContentStore {
/// Retorna o diretório base do cache: app_data/scraping_cache
pub fn get_cache_dir() -> PathBuf {
// Caminho relativo ao diretório de execução ou base do projeto
// Em produção, idealmente usaria dirs::data_local_dir()
PathBuf::from("app_data").join("scraping_cache")
}
/// Gera o hash SHA256 da URL para usar como nome de arquivo
fn get_url_hash(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Extrai o domínio da URL para particionamento de pastas
fn get_domain(url: &str) -> String {
Url::parse(url)
.map(|u| u.domain().unwrap_or("unknown").to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
/// Recupera conteúdo do cache se existir e não expirado
pub async fn get_cached_content(url: &str) -> Option<CachedContent> {
let domain = Self::get_domain(url);
let hash = Self::get_url_hash(url);
let path = Self::get_cache_dir().join(domain).join(format!("{}.json", hash));
if path.exists() {
// Tenta ler e deserializar
let path_clone = path.clone();
if let Ok(content) = fs::read_to_string(&path).await {
if let Ok(data) = serde_json::from_str::<CachedContent>(&content) {
// Verificar se expirou
if let Some(expires_at) = &data.expires_at {
if *expires_at < Utc::now() {
// Cache expirado, deletar arquivo
let _ = fs::remove_file(&path_clone).await;
return None;
}
}
return Some(data);
}
}
}
None
}
/// Salva conteúdo no cache com TTL
pub async fn save_content(url: &str, title: &str, content_markdown: &str, meta: Option<serde_json::Value>) -> io::Result<()> {
Self::save_content_with_ttl(url, title, content_markdown, meta, Some(168)).await // Default: 7 dias
}
/// Salva conteúdo no cache com TTL customizado (em horas)
pub async fn save_content_with_ttl(
url: &str,
title: &str,
content_markdown: &str,
meta: Option<serde_json::Value>,
ttl_hours: Option<u64>,
) -> io::Result<()> {
let domain = Self::get_domain(url);
let hash = Self::get_url_hash(url);
let dir = Self::get_cache_dir().join(&domain);
// Garante que o diretório do domínio existe
if !dir.exists() {
fs::create_dir_all(&dir).await?;
}
let path = dir.join(format!("{}.json", hash));
let now = Utc::now();
let expires_at = ttl_hours.map(|hours| now + chrono::Duration::hours(hours as i64));
let data = CachedContent {
url: url.to_string(),
domain: domain,
access_timestamp: now,
expires_at,
http_status: 200,
title: title.to_string(),
content_markdown: content_markdown.to_string(),
metadata: meta.unwrap_or(serde_json::json!({})),
raw_html_path: None,
};
let json = serde_json::to_string_pretty(&data)?;
fs::write(path, json).await?;
// Limpeza periódica: a cada 100 inserções, limpar expirados e aplicar LRU
static INSERT_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let count = INSERT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if count % 100 == 0 {
let _ = Self::cleanup_expired().await;
let _ = Self::apply_lru_limit(10000).await; // Manter apenas 10k entradas mais recentes
}
Ok(())
}
/// Limpa entradas expiradas do cache
pub async fn cleanup_expired() -> io::Result<usize> {
let cache_dir = Self::get_cache_dir();
if !cache_dir.exists() {
return Ok(0);
}
let mut cleaned = 0;
let now = Utc::now();
// Iterar sobre todos os arquivos JSON no cache
let mut entries = fs::read_dir(&cache_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
let domain_dir = entry.path();
let mut domain_files = fs::read_dir(&domain_dir).await?;
while let Some(file_entry) = domain_files.next_entry().await? {
if file_entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(file_entry.path()).await {
if let Ok(data) = serde_json::from_str::<CachedContent>(&content) {
if let Some(expires_at) = &data.expires_at {
if *expires_at < now {
let _ = fs::remove_file(file_entry.path()).await;
cleaned += 1;
}
}
}
}
}
}
}
}
Ok(cleaned)
}
/// Aplica limite LRU: mantém apenas N entradas mais recentes
pub async fn apply_lru_limit(max_entries: usize) -> io::Result<usize> {
let cache_dir = Self::get_cache_dir();
if !cache_dir.exists() {
return Ok(0);
}
// Coletar todas as entradas com timestamp
let mut all_entries: Vec<(std::path::PathBuf, DateTime<Utc>)> = Vec::new();
let mut entries = fs::read_dir(&cache_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
let domain_dir = entry.path();
let mut domain_files = fs::read_dir(&domain_dir).await?;
while let Some(file_entry) = domain_files.next_entry().await? {
if file_entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(file_entry.path()).await {
if let Ok(data) = serde_json::from_str::<CachedContent>(&content) {
all_entries.push((file_entry.path(), data.access_timestamp));
}
}
}
}
}
}
// Se temos mais entradas que o limite, remover as mais antigas
if all_entries.len() > max_entries {
// Ordenar por timestamp (mais antigas primeiro)
all_entries.sort_by_key(|(_, ts)| *ts);
// Remover as mais antigas
let to_remove = all_entries.len() - max_entries;
for (path, _) in all_entries.into_iter().take(to_remove) {
let _ = fs::remove_file(&path).await;
}
Ok(to_remove)
} else {
Ok(0)
}
}
/// Registra operação no log de auditoria
pub async fn log_operation(url: &str, status: &str, latency_ms: u64, bytes: usize) {
let log_entry = format!("[{}] [INFO] [{}] [{}] [{}ms] [{}bytes]\n",
Utc::now().to_rfc3339(), url, status, latency_ms, bytes);
let path = Self::get_cache_dir().join("scraping_ops.log");
// Append assíncrono
use tokio::io::AsyncWriteExt;
if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path).await {
let _ = file.write_all(log_entry.as_bytes()).await;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Nonce, Key
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use sha2::{Sha256, Digest};
use std::path::Path;
use std::fs;
// Key size for AES-256 is 32 bytes
const KEY_SIZE: usize = 32;
pub fn get_or_create_key<P: AsRef<Path>>(path: P) -> Result<Key<Aes256Gcm>, String> {
if path.as_ref().exists() {
let bytes = fs::read(&path).map_err(|e| e.to_string())?;
if bytes.len() != KEY_SIZE {
return Err("Invalid key size".to_string());
}
Ok(*Key::<Aes256Gcm>::from_slice(&bytes))
} else {
let key = Aes256Gcm::generate_key(OsRng);
fs::write(&path, key.as_slice()).map_err(|e| e.to_string())?;
Ok(key)
}
}
pub fn encrypt(data: &str, key: &Key<Aes256Gcm>) -> Result<String, String> {
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng); // 96-bits; unique per message
let ciphertext = cipher.encrypt(&nonce, data.as_bytes())
.map_err(|e| e.to_string())?;
// Return nonce + ciphertext encoded in base64
let mut combined = nonce.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(BASE64.encode(combined))
}
pub fn decrypt(data: &str, key: &Key<Aes256Gcm>) -> Result<String, String> {
let combined = BASE64.decode(data).map_err(|e| e.to_string())?;
if combined.len() < 12 {
return Err("Invalid data length".to_string());
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher = Aes256Gcm::new(key);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|e| e.to_string())?;
String::from_utf8(plaintext).map_err(|e| e.to_string())
}
pub fn hash_identifier(id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(id.as_bytes());
format!("{:x}", hasher.finalize())
}
+253 -64
View File
@@ -22,6 +22,25 @@ pub struct ChatMessage {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
<<<<<<< HEAD
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SearchLog {
pub id: String,
pub chat_id: Option<String>,
pub user_id: Option<String>,
pub timestamp: DateTime<Utc>,
pub searxng_instance: Option<String>,
pub query: String,
pub engines: Option<Vec<String>>,
pub filters: Option<String>, // JSON string
pub response_time_ms: Option<i64>,
pub total_results: Option<i64>,
pub results_per_engine: Option<String>, // JSON string
pub error: Option<String>,
}
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
/// Resultado de busca de sessões com contagem de matches /// Resultado de busca de sessões com contagem de matches
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SearchSessionResult { pub struct SearchSessionResult {
@@ -116,6 +135,45 @@ impl Database {
)", )",
[], [],
)?; )?;
// Tabela de logs de busca (SearXNG)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS search_logs (
id TEXT PRIMARY KEY,
chat_id TEXT,
user_id TEXT,
timestamp TEXT NOT NULL,
searxng_instance TEXT,
query TEXT NOT NULL,
engines TEXT,
filters TEXT,
response_time_ms INTEGER,
total_results INTEGER,
results_per_engine TEXT,
error TEXT
)",
[],
)?;
// Tabela de instâncias SearXNG
self.conn.execute(
"CREATE TABLE IF NOT EXISTS searxng_instances (
url TEXT PRIMARY KEY,
network TEXT,
country_code TEXT,
response_time REAL,
status TEXT,
version TEXT,
engines TEXT,
formats TEXT,
validated BOOLEAN DEFAULT 0,
last_validated INTEGER,
failure_count INTEGER DEFAULT 0,
created_at INTEGER,
updated_at INTEGER
)",
[],
)?;
// Índices para performance // Índices para performance
self.conn.execute( self.conn.execute(
@@ -127,6 +185,27 @@ impl Database {
"CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)", "CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)",
[], [],
)?; )?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_search_logs_chat_id ON search_logs(chat_id)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_search_logs_timestamp ON search_logs(timestamp DESC)",
[],
)?;
// Índices para instâncias SearXNG
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_status_validated ON searxng_instances(status, validated)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_response_time ON searxng_instances(response_time)",
[],
)?;
// Índice para ordenação por updated_at // Índice para ordenação por updated_at
self.conn.execute( self.conn.execute(
@@ -208,15 +287,6 @@ impl Database {
[], [],
)?; )?;
// Trigger para atualizar messages_fts quando mensagem é atualizada
self.conn.execute(
"CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content);
INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content);
END",
[],
)?;
// Trigger para deletar de messages_fts quando mensagem é deletada // Trigger para deletar de messages_fts quando mensagem é deletada
self.conn.execute( self.conn.execute(
"CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN "CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
@@ -228,52 +298,39 @@ impl Database {
Ok(()) Ok(())
} }
/// Popula tabelas FTS com dados existentes /// Popula tabelas FTS se estiverem vazias e existirem dados nas tabelas principais
fn populate_fts_tables(&self) -> SqliteResult<()> { fn populate_fts_tables(&self) -> SqliteResult<()> {
// Verificar se sessions_fts já tem dados // Verificar se sessions_fts está vazia
let count: i64 = self.conn.query_row( let fts_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
"SELECT COUNT(*) FROM sessions_fts", if fts_count == 0 {
[], let main_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
|row| row.get(0), if main_count > 0 {
).unwrap_or(0); self.conn.execute(
"INSERT INTO sessions_fts(rowid, id, title) SELECT rowid, id, title FROM sessions",
// Se vazio, popular com dados existentes [],
if count == 0 { )?;
self.conn.execute( }
"INSERT INTO sessions_fts(rowid, id, title)
SELECT rowid, id, title FROM sessions",
[],
)?;
} }
// Verificar se messages_fts já tem dados // Verificar se messages_fts está vazia
let count: i64 = self.conn.query_row( let fts_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
"SELECT COUNT(*) FROM messages_fts", if fts_msg_count == 0 {
[], let main_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
|row| row.get(0), if main_msg_count > 0 {
).unwrap_or(0); self.conn.execute(
"INSERT INTO messages_fts(rowid, session_id, content) SELECT rowid, session_id, content FROM messages",
// Se vazio, popular com dados existentes [],
if count == 0 { )?;
self.conn.execute( }
"INSERT INTO messages_fts(rowid, session_id, content)
SELECT rowid, session_id, content FROM messages",
[],
)?;
} }
Ok(()) Ok(())
} }
/// Cria uma nova sessão de chat /// Cria uma nova sessão de chat
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> { pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
self.conn.execute( self.conn.execute(
"INSERT INTO sessions (id, title, emoji, created_at, updated_at) "INSERT INTO sessions (id, title, emoji, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
title = ?2,
emoji = ?3,
updated_at = ?5",
params![ params![
session.id, session.id,
session.title, session.title,
@@ -326,12 +383,7 @@ impl Database {
} }
} }
/// Salva uma sessão (create ou update) /// Lista todas as sessões ordenadas por atualização
pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> {
self.create_session(session)
}
/// Lista todas as sessões ordenadas por updated_at DESC
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> { pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC" "SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
@@ -358,14 +410,14 @@ impl Database {
Ok(sessions) Ok(sessions)
} }
/// Deleta uma sessão e todas as suas mensagens /// Deleta uma sessão e suas mensagens (cascade)
pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> { pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> {
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
Ok(()) Ok(())
} }
/// Adiciona uma mensagem a uma sessão /// Salva uma mensagem
pub fn add_message(&self, message: &ChatMessage) -> SqliteResult<i64> { pub fn save_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
self.conn.execute( self.conn.execute(
"INSERT INTO messages (session_id, role, content, metadata, created_at) "INSERT INTO messages (session_id, role, content, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)", VALUES (?1, ?2, ?3, ?4, ?5)",
@@ -378,15 +430,6 @@ impl Database {
], ],
)?; )?;
// Atualizar updated_at da sessão
self.conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![
message.created_at.to_rfc3339(),
message.session_id
],
)?;
Ok(self.conn.last_insert_rowid()) Ok(self.conn.last_insert_rowid())
} }
@@ -467,6 +510,11 @@ impl Database {
Ok(messages) Ok(messages)
} }
<<<<<<< HEAD
/// Conta o total de mensagens de uma sessão
pub fn count_messages(&self, session_id: &str) -> SqliteResult<usize> {
let count: usize = self.conn.query_row(
=======
/// Busca mensagens de uma sessão com paginação (lazy loading) /// Busca mensagens de uma sessão com paginação (lazy loading)
/// ///
/// Retorna as últimas `limit` mensagens a partir do `offset`. /// Retorna as últimas `limit` mensagens a partir do `offset`.
@@ -486,21 +534,44 @@ impl Database {
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> { ) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
// Primeiro, obter o total de mensagens // Primeiro, obter o total de mensagens
let total_count: usize = self.conn.query_row( let total_count: usize = self.conn.query_row(
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
"SELECT COUNT(*) FROM messages WHERE session_id = ?1", "SELECT COUNT(*) FROM messages WHERE session_id = ?1",
params![session_id], params![session_id],
|row| row.get(0), |row| row.get(0),
)?; )?;
<<<<<<< HEAD
Ok(count)
}
/// Busca mensagens paginadas (do mais recente para o mais antigo, mas retornado em ordem cronológica)
///
/// limit: número máximo de mensagens a retornar
/// offset: quantas mensagens pular (a partir do final/mais recente)
///
/// Exemplo: Total 100 mensagens. limit=20, offset=0 -> retorna msgs 81-100
/// Exemplo: Total 100 mensagens. limit=20, offset=20 -> retorna msgs 61-80
pub fn get_messages_paginated(&self, session_id: &str, limit: usize, offset: usize) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
// Primeiro obter contagem total
let total_count = self.count_messages(session_id)?;
// Se não houver mensagens, retornar vazio
=======
// Se não há mensagens, retornar vazio // Se não há mensagens, retornar vazio
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
if total_count == 0 { if total_count == 0 {
return Ok((Vec::new(), 0, false)); return Ok((Vec::new(), 0, false));
} }
<<<<<<< HEAD
// Calcular o limite real baseado no que está disponível
=======
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC, // Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
// depois ordenar ASC para manter ordem cronológica // depois ordenar ASC para manter ordem cronológica
// //
// Se offset=0 e limit=30: queremos as últimas 30 mensagens // Se offset=0 e limit=30: queremos as últimas 30 mensagens
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30 // Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset)); let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
if real_limit == 0 { if real_limit == 0 {
@@ -712,7 +783,12 @@ impl Database {
)?; )?;
let like_query = format!("%{}%", query); let like_query = format!("%{}%", query);
<<<<<<< HEAD
let rows = stmt.query_map(params![&like_query, limit], |row| {
=======
let rows = stmt.query_map(params![like_query, limit], |row| { let rows = stmt.query_map(params![like_query, limit], |row| {
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
Ok(SearchSessionResult { Ok(SearchSessionResult {
session: ChatSession { session: ChatSession {
id: row.get(0)?, id: row.get(0)?,
@@ -736,5 +812,118 @@ impl Database {
Ok(sessions) Ok(sessions)
} }
}
/// Log a search request
pub fn log_search(&self, log: &SearchLog) -> SqliteResult<()> {
self.conn.execute(
"INSERT INTO search_logs (
id, chat_id, user_id, timestamp, searxng_instance,
query, engines, filters, response_time_ms,
total_results, results_per_engine, error
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
log.id,
log.chat_id,
log.user_id,
log.timestamp.to_rfc3339(),
log.searxng_instance,
log.query,
log.engines.as_ref().map(|e| serde_json::to_string(e).unwrap_or_default()),
log.filters,
log.response_time_ms,
log.total_results,
log.results_per_engine,
log.error
],
)?;
Ok(())
}
pub fn cleanup_search_logs(&self, days: i64) -> SqliteResult<usize> {
let cutoff = Utc::now() - chrono::Duration::days(days);
self.conn.execute(
"DELETE FROM search_logs WHERE timestamp < ?1",
params![cutoff.to_rfc3339()],
)
}
/// Get search logs with filters
pub fn get_search_logs(
&self,
chat_id: Option<&str>,
limit: usize,
offset: usize
) -> SqliteResult<Vec<SearchLog>> {
let mut query = String::from("SELECT id, chat_id, user_id, timestamp, searxng_instance, query, engines, filters, response_time_ms, total_results, results_per_engine, error FROM search_logs");
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(cid) = chat_id {
query.push_str(" WHERE chat_id = ?1");
params_vec.push(Box::new(cid.to_string()));
}
query.push_str(" ORDER BY timestamp DESC LIMIT ?");
if chat_id.is_some() {
query.push_str(&format!("{}", params_vec.len() + 1));
} else {
query.push_str("1"); // ?1
}
params_vec.push(Box::new(limit));
query.push_str(" OFFSET ?");
if chat_id.is_some() {
query.push_str(&format!("{}", params_vec.len() + 1));
} else {
query.push_str("2"); // ?2
}
params_vec.push(Box::new(offset));
let mut stmt = self.conn.prepare(&query)?;
// Usar uma função auxiliar para evitar problema de tipos de closures incompatíveis
// Criar uma referência ao self para usar na closure
let logs = if let Some(cid) = chat_id {
let rows = stmt.query_map(params![cid, limit, offset], |row| {
self.map_search_log_row(row)
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
result
} else {
let rows = stmt.query_map(params![limit, offset], |row| {
self.map_search_log_row(row)
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
result
};
Ok(logs)
}
fn map_search_log_row(&self, row: &rusqlite::Row) -> SqliteResult<SearchLog> {
Ok(SearchLog {
id: row.get(0)?,
chat_id: row.get(1)?,
user_id: row.get(2)?,
timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
searxng_instance: row.get(4)?,
query: row.get(5)?,
engines: row.get::<_, Option<String>>(6)?
.map(|s| serde_json::from_str(&s).unwrap_or_default()),
filters: row.get(7)?,
response_time_ms: row.get(8)?,
total_results: row.get(9)?,
results_per_engine: row.get(10)?,
error: row.get(11)?,
})
}
}
+285
View File
@@ -0,0 +1,285 @@
use anyhow::Result;
use std::process::Command;
use tauri::{AppHandle, Manager, Emitter};
use serde::Serialize;
#[derive(Serialize, Clone)]
pub struct FixProgressEvent {
pub event_type: FixEventType,
pub message: String,
pub progress: Option<u8>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum FixEventType {
Start,
Progress,
Complete,
Error,
}
/// Corrige problemas encontrados nos requisitos do sistema
pub async fn fix_system_requirements(
app_handle: AppHandle,
os: &str,
issues: &[String],
) -> Result<()> {
emit_fix_event(&app_handle, FixEventType::Start, "Iniciando correção de problemas...", None);
match os {
"windows" => fix_windows_requirements(&app_handle, issues).await,
"linux" => fix_linux_requirements(&app_handle, issues).await,
"mac" => fix_macos_requirements(&app_handle, issues).await,
_ => Err(anyhow::anyhow!("Sistema operacional não suportado")),
}
}
async fn fix_windows_requirements(
app_handle: &AppHandle,
issues: &[String],
) -> Result<()> {
let mut fixed = 0;
let total = issues.len();
for issue in issues {
if issue.contains("VirtualMachinePlatform") {
emit_fix_event(
app_handle,
FixEventType::Progress,
"Habilitando VirtualMachinePlatform...",
Some((fixed * 100 / total) as u8),
);
let output = Command::new("powershell")
.args(&[
"-Command",
"Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -NoRestart -All"
])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
"VirtualMachinePlatform habilitado",
Some((fixed * 100 / total) as u8),
);
}
}
}
if issue.contains("WSL") || issue.contains("Microsoft-Windows-Subsystem-Linux") {
emit_fix_event(
app_handle,
FixEventType::Progress,
"Habilitando WSL...",
Some((fixed * 100 / total) as u8),
);
let output = Command::new("powershell")
.args(&[
"-Command",
"Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -NoRestart -All"
])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
"WSL habilitado",
Some((fixed * 100 / total) as u8),
);
}
}
}
if issue.contains("WSL 2") {
emit_fix_event(
app_handle,
FixEventType::Progress,
"Configurando WSL 2...",
Some((fixed * 100 / total) as u8),
);
let output = Command::new("wsl")
.args(&["--set-default-version", "2"])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
"WSL 2 configurado",
Some((fixed * 100 / total) as u8),
);
}
}
}
}
emit_fix_event(
app_handle,
FixEventType::Complete,
&format!("Correção concluída: {} de {} problemas corrigidos", fixed, total),
Some(100),
);
Ok(())
}
async fn fix_linux_requirements(
app_handle: &AppHandle,
issues: &[String],
) -> Result<()> {
let mut fixed = 0;
let total = issues.len();
for issue in issues {
if issue.contains("Pacote") || issue.contains("não instalado") {
// Extrair nome do pacote
if let Some(pkg) = issue.split("Pacote").nth(1)
.and_then(|s| s.split("não").next())
.map(|s| s.trim())
{
emit_fix_event(
app_handle,
FixEventType::Progress,
&format!("Instalando {}...", pkg),
Some((fixed * 100 / total) as u8),
);
let output = Command::new("sudo")
.args(&["apt", "install", "-y", pkg])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
&format!("{} instalado", pkg),
Some((fixed * 100 / total) as u8),
);
}
}
}
}
if issue.contains("Privilégios sudo") {
emit_fix_event(
app_handle,
FixEventType::Progress,
"Verificando privilégios sudo...",
Some((fixed * 100 / total) as u8),
);
// Verificar se sudo está disponível
let output = Command::new("sudo")
.args(&["-n", "true"])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
"Privilégios sudo disponíveis",
Some((fixed * 100 / total) as u8),
);
} else {
emit_fix_event(
app_handle,
FixEventType::Error,
"Privilégios sudo necessários. Execute com sudo ou configure sudoers.",
None,
);
}
}
}
if issue.contains("grupo docker") || issue.contains("Permissões Docker") {
emit_fix_event(
app_handle,
FixEventType::Progress,
"Adicionando usuário ao grupo docker...",
Some((fixed * 100 / total) as u8),
);
// Obter usuário atual
if let Ok(user) = std::env::var("USER") {
let output = Command::new("sudo")
.args(&["usermod", "-aG", "docker", &user])
.output();
if let Ok(o) = output {
if o.status.success() {
fixed += 1;
emit_fix_event(
app_handle,
FixEventType::Progress,
"Usuário adicionado ao grupo docker. Faça logout e login novamente.",
Some((fixed * 100 / total) as u8),
);
}
}
}
}
}
emit_fix_event(
app_handle,
FixEventType::Complete,
&format!("Correção concluída: {} de {} problemas corrigidos", fixed, total),
Some(100),
);
Ok(())
}
async fn fix_macos_requirements(
app_handle: &AppHandle,
_issues: &[String],
) -> Result<()> {
emit_fix_event(
app_handle,
FixEventType::Progress,
"No macOS, a maioria das correções requer instalação manual via Homebrew",
None,
);
emit_fix_event(
app_handle,
FixEventType::Complete,
"Verifique os avisos e instale manualmente se necessário",
Some(100),
);
Ok(())
}
fn emit_fix_event(
app_handle: &AppHandle,
event_type: FixEventType,
message: &str,
progress: Option<u8>,
) {
let event = FixProgressEvent {
event_type,
message: message.to_string(),
progress,
};
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("docker-fix-progress", event);
}
}
+600
View File
@@ -0,0 +1,600 @@
use serde::Serialize;
use tauri::{AppHandle, Manager, Emitter};
use std::process::Command;
use std::process::Stdio as StdProcessStdio;
use tokio::process::Command as TokioCommand;
use tokio::io::{AsyncBufReadExt, BufReader};
use anyhow::Result;
use std::time::Duration;
use tokio::time::sleep;
#[derive(Serialize, Clone, Debug)]
pub struct InstallStep {
pub id: u8,
pub name: String,
pub weight: u8,
pub command: InstallCommand,
}
#[derive(Serialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InstallCommand {
Shell { cmd: String, args: Vec<String> },
PowerShell { script: String },
WaitDockerReady,
EnableWindowsFeature { feature: String },
}
#[derive(Serialize, Clone)]
pub struct InstallProgressEvent {
pub event_type: ProgressEventType,
pub step: Option<u8>,
pub step_name: Option<String>,
pub total_steps: Option<u8>,
pub progress: u8,
pub message: Option<String>,
pub log_line: Option<String>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum ProgressEventType {
StepStart,
Log,
StepComplete,
Error,
Complete,
}
/// Instala Docker no sistema com monitoramento de progresso
pub async fn install_docker(app_handle: AppHandle, os: &str) -> Result<()> {
let steps = get_install_steps(os);
let total_weight: u16 = steps.iter().map(|s| s.weight as u16).sum();
let mut completed_weight: u16 = 0;
let total_steps = steps.len() as u8;
emit_progress_event(
&app_handle,
ProgressEventType::StepStart,
None,
None,
Some(total_steps),
0,
Some("Iniciando instalação do Docker...".to_string()),
None,
);
for (idx, step) in steps.iter().enumerate() {
let step_num = (idx + 1) as u8;
// Emitir início da etapa
emit_progress_event(
&app_handle,
ProgressEventType::StepStart,
Some(step.id),
Some(step.name.clone()),
Some(total_steps),
((completed_weight * 100) / total_weight) as u8,
Some(format!("Executando: {}", step.name)),
None,
);
// Executar comando
match execute_step(&app_handle, step, step_num).await {
Ok(()) => {
completed_weight += step.weight as u16;
let progress = ((completed_weight * 100) / total_weight) as u8;
emit_progress_event(
&app_handle,
ProgressEventType::StepComplete,
Some(step.id),
Some(step.name.clone()),
Some(total_steps),
progress,
Some(format!("✓ {} concluído", step.name)),
None,
);
}
Err(e) => {
emit_progress_event(
&app_handle,
ProgressEventType::Error,
Some(step.id),
Some(step.name.clone()),
Some(total_steps),
((completed_weight * 100) / total_weight) as u8,
Some(format!("Erro em {}: {}", step.name, e)),
None,
);
return Err(e);
}
}
// Pequeno delay entre etapas
sleep(Duration::from_millis(500)).await;
}
emit_progress_event(
&app_handle,
ProgressEventType::Complete,
None,
None,
Some(total_steps),
100,
Some("Docker instalado com sucesso!".to_string()),
None,
);
Ok(())
}
/// Executa uma etapa de instalação
async fn execute_step(app_handle: &AppHandle, step: &InstallStep, step_num: u8) -> Result<()> {
match &step.command {
InstallCommand::Shell { cmd, args } => {
execute_shell_command(app_handle, cmd, args, step_num).await
}
InstallCommand::PowerShell { script } => {
execute_powershell_script(app_handle, script, step_num).await
}
InstallCommand::WaitDockerReady => {
wait_for_docker_ready(app_handle).await
}
InstallCommand::EnableWindowsFeature { feature } => {
enable_windows_feature(app_handle, feature, step_num).await
}
}
}
/// Executa comando shell e captura saída em tempo real
async fn execute_shell_command(
app_handle: &AppHandle,
cmd: &str,
args: &[String],
step_num: u8,
) -> Result<()> {
let mut command = TokioCommand::new(cmd);
command.args(args);
command.stdout(StdProcessStdio::piped());
command.stderr(StdProcessStdio::piped());
let mut child = command.spawn()?;
// Capturar stdout
if let Some(stdout) = child.stdout.take() {
let app_handle_clone = app_handle.clone();
let step_num_clone = step_num;
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_progress_event(
&app_handle_clone,
ProgressEventType::Log,
Some(step_num_clone),
None,
None,
0,
None,
Some(line),
);
}
});
}
// Capturar stderr
if let Some(stderr) = child.stderr.take() {
let app_handle_clone = app_handle.clone();
let step_num_clone = step_num;
tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_progress_event(
&app_handle_clone,
ProgressEventType::Log,
Some(step_num_clone),
None,
None,
0,
None,
Some(format!("[stderr] {}", line)),
);
}
});
}
let status = child.wait().await?;
if !status.success() {
return Err(anyhow::anyhow!("Comando falhou com código: {:?}", status.code()));
}
Ok(())
}
/// Executa script PowerShell
async fn execute_powershell_script(
app_handle: &AppHandle,
script: &str,
step_num: u8,
) -> Result<()> {
#[cfg(target_os = "windows")]
{
let mut command = TokioCommand::new("powershell");
command.args(&["-Command", script]);
command.stdout(StdProcessStdio::piped());
command.stderr(StdProcessStdio::piped());
let mut child = command.spawn()?;
// Capturar saída
if let Some(stdout) = child.stdout.take() {
let app_handle_clone = app_handle.clone();
let step_num_clone = step_num;
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_progress_event(
&app_handle_clone,
ProgressEventType::Log,
Some(step_num_clone),
None,
None,
0,
None,
Some(line),
);
}
});
}
let status = child.wait().await?;
if !status.success() {
return Err(anyhow::anyhow!("PowerShell script falhou"));
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
{
Err(anyhow::anyhow!("PowerShell não disponível neste sistema"))
}
}
/// Habilita feature do Windows
async fn enable_windows_feature(
app_handle: &AppHandle,
feature: &str,
step_num: u8,
) -> Result<()> {
#[cfg(target_os = "windows")]
{
let script = format!(
"Enable-WindowsOptionalFeature -Online -FeatureName {} -NoRestart -All",
feature
);
execute_powershell_script(app_handle, &script, step_num).await
}
#[cfg(not(target_os = "windows"))]
{
Err(anyhow::anyhow!("Habilitar features do Windows não disponível neste sistema"))
}
}
/// Aguarda Docker ficar pronto
async fn wait_for_docker_ready(app_handle: &AppHandle) -> Result<()> {
emit_progress_event(
app_handle,
ProgressEventType::Log,
None,
None,
None,
0,
Some("Aguardando Docker inicializar...".to_string()),
None,
);
for i in 0..30 {
// Tentar docker info
let output = Command::new("docker")
.arg("info")
.output();
if let Ok(o) = output {
if o.status.success() {
emit_progress_event(
app_handle,
ProgressEventType::Log,
None,
None,
None,
0,
Some("Docker está pronto!".to_string()),
None,
);
return Ok(());
}
}
sleep(Duration::from_secs(1)).await;
if i % 5 == 0 {
emit_progress_event(
app_handle,
ProgressEventType::Log,
None,
None,
None,
0,
Some(format!("Aguardando... ({}s)", i)),
None,
);
}
}
Err(anyhow::anyhow!("Timeout aguardando Docker inicializar"))
}
/// Obtém etapas de instalação por plataforma
fn get_install_steps(os: &str) -> Vec<InstallStep> {
match os {
"linux" => get_linux_steps(),
"windows" => get_windows_steps(),
"mac" => get_macos_steps(),
_ => vec![],
}
}
fn get_linux_steps() -> Vec<InstallStep> {
vec![
InstallStep {
id: 1,
name: "Atualizar repositórios".to_string(),
weight: 10,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec!["apt".to_string(), "update".to_string()],
},
},
InstallStep {
id: 2,
name: "Instalar dependências".to_string(),
weight: 15,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec![
"apt".to_string(),
"install".to_string(),
"-y".to_string(),
"ca-certificates".to_string(),
"curl".to_string(),
"gnupg".to_string(),
],
},
},
InstallStep {
id: 3,
name: "Adicionar chave GPG Docker".to_string(),
weight: 5,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec![
"install".to_string(),
"-m".to_string(),
"0755".to_string(),
"-d".to_string(),
"/etc/apt/keyrings".to_string(),
],
},
},
InstallStep {
id: 4,
name: "Configurar repositório Docker".to_string(),
weight: 10,
command: InstallCommand::Shell {
cmd: "bash".to_string(),
args: vec![
"-c".to_string(),
r#"curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null"#.to_string(),
],
},
},
InstallStep {
id: 5,
name: "Atualizar índice de pacotes".to_string(),
weight: 10,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec!["apt".to_string(), "update".to_string()],
},
},
InstallStep {
id: 6,
name: "Instalar Docker Engine".to_string(),
weight: 40,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec![
"apt".to_string(),
"install".to_string(),
"-y".to_string(),
"docker-ce".to_string(),
"docker-ce-cli".to_string(),
"containerd.io".to_string(),
"docker-buildx-plugin".to_string(),
"docker-compose-plugin".to_string(),
],
},
},
InstallStep {
id: 7,
name: "Verificar instalação".to_string(),
weight: 5,
command: InstallCommand::Shell {
cmd: "docker".to_string(),
args: vec!["--version".to_string()],
},
},
InstallStep {
id: 8,
name: "Testar container".to_string(),
weight: 5,
command: InstallCommand::Shell {
cmd: "sudo".to_string(),
args: vec!["docker".to_string(), "run".to_string(), "hello-world".to_string()],
},
},
]
}
fn get_windows_steps() -> Vec<InstallStep> {
vec![
InstallStep {
id: 1,
name: "Habilitar VirtualMachinePlatform".to_string(),
weight: 20,
command: InstallCommand::EnableWindowsFeature {
feature: "VirtualMachinePlatform".to_string(),
},
},
InstallStep {
id: 2,
name: "Habilitar WSL".to_string(),
weight: 20,
command: InstallCommand::EnableWindowsFeature {
feature: "Microsoft-Windows-Subsystem-Linux".to_string(),
},
},
InstallStep {
id: 3,
name: "Verificar reinício necessário".to_string(),
weight: 5,
command: InstallCommand::PowerShell {
script: r#"$restart = (Get-ComputerRestartRequired); if ($restart) { Write-Host "Reinício necessário. Por favor, reinicie o sistema e execute novamente." } else { Write-Host "Reinício não necessário." }"#.to_string(),
},
},
InstallStep {
id: 4,
name: "Configurar WSL 2".to_string(),
weight: 10,
command: InstallCommand::PowerShell {
script: "wsl --set-default-version 2".to_string(),
},
},
InstallStep {
id: 5,
name: "Instalar distribuição Ubuntu".to_string(),
weight: 15,
command: InstallCommand::PowerShell {
script: "wsl --install -d Ubuntu".to_string(),
},
},
InstallStep {
id: 6,
name: "Instalar Docker no WSL".to_string(),
weight: 25,
command: InstallCommand::PowerShell {
script: r#"wsl -d Ubuntu -e bash -c "curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh""#.to_string(),
},
},
InstallStep {
id: 7,
name: "Verificar instalação".to_string(),
weight: 5,
command: InstallCommand::PowerShell {
script: r#"wsl -d Ubuntu -e docker --version"#.to_string(),
},
},
]
}
fn get_macos_steps() -> Vec<InstallStep> {
vec![
InstallStep {
id: 1,
name: "Verificar Homebrew".to_string(),
weight: 5,
command: InstallCommand::Shell {
cmd: "brew".to_string(),
args: vec!["--version".to_string()],
},
},
InstallStep {
id: 2,
name: "Atualizar Homebrew".to_string(),
weight: 10,
command: InstallCommand::Shell {
cmd: "brew".to_string(),
args: vec!["update".to_string()],
},
},
InstallStep {
id: 3,
name: "Instalar Docker Desktop".to_string(),
weight: 60,
command: InstallCommand::Shell {
cmd: "brew".to_string(),
args: vec!["install".to_string(), "--cask".to_string(), "docker".to_string()],
},
},
InstallStep {
id: 4,
name: "Iniciar Docker Desktop".to_string(),
weight: 15,
command: InstallCommand::Shell {
cmd: "open".to_string(),
args: vec!["-a".to_string(), "Docker".to_string()],
},
},
InstallStep {
id: 5,
name: "Aguardar inicialização".to_string(),
weight: 5,
command: InstallCommand::WaitDockerReady,
},
InstallStep {
id: 6,
name: "Verificar instalação".to_string(),
weight: 5,
command: InstallCommand::Shell {
cmd: "docker".to_string(),
args: vec!["--version".to_string()],
},
},
]
}
/// Emite evento de progresso via Tauri
fn emit_progress_event(
app_handle: &AppHandle,
event_type: ProgressEventType,
step: Option<u8>,
step_name: Option<String>,
total_steps: Option<u8>,
progress: u8,
message: Option<String>,
log_line: Option<String>,
) {
let event = InstallProgressEvent {
event_type,
step,
step_name,
total_steps,
progress,
message,
log_line,
};
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("docker-install-progress", event);
}
}
+538
View File
@@ -0,0 +1,538 @@
use serde::{Serialize, Deserialize};
use sysinfo::System;
use std::process::Command;
use anyhow::Result;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SystemRequirements {
pub status: RequirementStatus,
pub os: String,
pub arch: String,
pub ram_mb: u64,
pub disk_gb: u64,
pub cpu_cores: usize,
pub kernel_version: Option<String>,
pub issues: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum RequirementStatus {
Pass,
Fail,
Warn,
}
/// Verifica requisitos do sistema para instalação do Docker
pub fn check_system_requirements() -> Result<SystemRequirements> {
let os = get_operating_system();
match os.as_str() {
"linux" => check_linux_requirements(),
"windows" => check_windows_requirements(),
"mac" => check_macos_requirements(),
_ => {
Ok(SystemRequirements {
status: RequirementStatus::Fail,
os,
arch: "unknown".to_string(),
ram_mb: 0,
disk_gb: 0,
cpu_cores: 0,
kernel_version: None,
issues: vec!["Sistema operacional não suportado".to_string()],
warnings: vec![],
})
}
}
}
/// Verifica requisitos no Linux
fn check_linux_requirements() -> Result<SystemRequirements> {
let mut sys = System::new_all();
sys.refresh_all();
let mut status = RequirementStatus::Pass;
let mut issues = Vec::new();
let mut warnings = Vec::new();
// Verificar versão do kernel
let kernel_version = get_kernel_version();
if let Some(kernel) = &kernel_version {
if !is_kernel_version_sufficient(kernel) {
issues.push(format!("Kernel {} menor que 3.10 requerido", kernel));
status = RequirementStatus::Fail;
}
} else {
warnings.push("Não foi possível verificar versão do kernel".to_string());
status = match status {
RequirementStatus::Fail => RequirementStatus::Fail,
_ => RequirementStatus::Warn,
};
}
// Verificar RAM (mínimo 2GB)
let ram_mb = sys.total_memory() / (1024 * 1024);
if ram_mb < 2048 {
issues.push(format!("RAM insuficiente: {}MB (mínimo 2GB)", ram_mb));
status = RequirementStatus::Fail;
}
// Verificar espaço em disco (mínimo 10GB)
let disk_gb = get_disk_space_gb();
if disk_gb < 10 {
issues.push(format!("Espaço em disco insuficiente: {}GB (mínimo 10GB)", disk_gb));
status = RequirementStatus::Fail;
}
// Verificar CPU cores (mínimo 2, mas apenas warning)
let cpu_cores = sys.cpus().len();
if cpu_cores < 2 {
warnings.push(format!("CPU cores insuficientes: {} (recomendado 2+)", cpu_cores));
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
// Verificar privilégios sudo
if !check_sudo_available() {
issues.push("Privilégios sudo necessários para instalação".to_string());
status = RequirementStatus::Fail;
}
// Verificar dependências básicas
let missing_deps = check_linux_dependencies();
for dep in missing_deps {
issues.push(format!("Pacote {} não instalado", dep));
status = RequirementStatus::Fail;
}
Ok(SystemRequirements {
status,
os: "linux".to_string(),
arch: get_arch(),
ram_mb,
disk_gb,
cpu_cores,
kernel_version,
issues,
warnings,
})
}
/// Verifica requisitos no Windows
fn check_windows_requirements() -> Result<SystemRequirements> {
let mut sys = System::new_all();
sys.refresh_all();
let mut status = RequirementStatus::Pass;
let mut issues = Vec::new();
let mut warnings = Vec::new();
// Verificar versão do Windows (Windows 10 20H1+ ou Windows 11)
let windows_version = get_windows_version();
if let Some(version) = windows_version {
if version < 19041 {
issues.push(format!("Windows build {} menor que 19041 (20H1) requerido", version));
status = RequirementStatus::Fail;
}
} else {
warnings.push("Não foi possível verificar versão do Windows".to_string());
status = match status {
RequirementStatus::Fail => RequirementStatus::Fail,
_ => RequirementStatus::Warn,
};
}
// Verificar RAM (mínimo 4GB para Windows)
let ram_mb = sys.total_memory() / (1024 * 1024);
if ram_mb < 4096 {
issues.push(format!("RAM insuficiente: {}MB (mínimo 4GB)", ram_mb));
status = RequirementStatus::Fail;
}
// Verificar espaço em disco (mínimo 10GB)
let disk_gb = get_disk_space_gb();
if disk_gb < 10 {
issues.push(format!("Espaço em disco insuficiente: {}GB (mínimo 10GB)", disk_gb));
status = RequirementStatus::Fail;
}
// Verificar CPU cores
let cpu_cores = sys.cpus().len();
if cpu_cores < 2 {
warnings.push(format!("CPU cores insuficientes: {} (recomendado 2+)", cpu_cores));
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
// Verificar VirtualMachinePlatform
if !check_windows_feature("VirtualMachinePlatform") {
issues.push("VirtualMachinePlatform não habilitado".to_string());
status = RequirementStatus::Fail;
}
// Verificar WSL
if !check_windows_feature("Microsoft-Windows-Subsystem-Linux") {
issues.push("WSL não habilitado".to_string());
status = RequirementStatus::Fail;
}
// Verificar versão WSL
if !check_wsl_version() {
issues.push("WSL 2 não configurado ou não disponível".to_string());
status = RequirementStatus::Fail;
}
Ok(SystemRequirements {
status,
os: "windows".to_string(),
arch: get_arch(),
ram_mb,
disk_gb,
cpu_cores,
kernel_version: None,
issues,
warnings,
})
}
/// Verifica requisitos no macOS
fn check_macos_requirements() -> Result<SystemRequirements> {
let mut sys = System::new_all();
sys.refresh_all();
let mut status = RequirementStatus::Pass;
let mut issues = Vec::new();
let mut warnings = Vec::new();
// Verificar versão do macOS (mínimo 11)
let macos_version = get_macos_version();
if let Some(version) = &macos_version {
let major: u32 = version.split('.').next()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if major < 11 {
warnings.push(format!("macOS {} pode não ser suportado (recomendado 11+)", version));
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
} else {
warnings.push("Não foi possível verificar versão do macOS".to_string());
status = match status {
RequirementStatus::Fail => RequirementStatus::Fail,
_ => RequirementStatus::Warn,
};
}
// Verificar RAM (mínimo 4GB para macOS)
let ram_mb = sys.total_memory() / (1024 * 1024);
if ram_mb < 4096 {
issues.push(format!("RAM insuficiente: {}MB (mínimo 4GB)", ram_mb));
status = RequirementStatus::Fail;
}
// Verificar espaço em disco (mínimo 4GB para macOS)
let disk_gb = get_disk_space_gb();
if disk_gb < 4 {
issues.push(format!("Espaço em disco insuficiente: {}GB (mínimo 4GB)", disk_gb));
status = RequirementStatus::Fail;
}
// Verificar CPU cores
let cpu_cores = sys.cpus().len();
if cpu_cores < 2 {
warnings.push(format!("CPU cores insuficientes: {} (recomendado 2+)", cpu_cores));
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
// Verificar Homebrew (opcional, mas recomendado)
if !check_homebrew_available() {
warnings.push("Homebrew não instalado (recomendado para instalação)".to_string());
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
// Verificar Rosetta 2 para Apple Silicon
#[cfg(target_arch = "aarch64")]
{
if !check_rosetta_installed() {
warnings.push("Rosetta 2 não instalado (necessário para imagens x86)".to_string());
if matches!(status, RequirementStatus::Pass) {
status = RequirementStatus::Warn;
}
}
}
Ok(SystemRequirements {
status,
os: "mac".to_string(),
arch: get_arch(),
ram_mb,
disk_gb,
cpu_cores,
kernel_version: macos_version,
issues,
warnings,
})
}
// Funções auxiliares
fn get_operating_system() -> String {
#[cfg(target_os = "windows")]
return "windows".to_string();
#[cfg(target_os = "macos")]
return "mac".to_string();
#[cfg(target_os = "linux")]
return "linux".to_string();
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
return "unknown".to_string();
}
fn get_arch() -> String {
std::env::consts::ARCH.to_string()
}
fn get_kernel_version() -> Option<String> {
#[cfg(target_os = "linux")]
{
Command::new("uname")
.arg("-r")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|v| v.trim().to_string())
}
#[cfg(not(target_os = "linux"))]
None
}
fn is_kernel_version_sufficient(kernel: &str) -> bool {
let parts: Vec<&str> = kernel.split('.').collect();
if parts.len() < 2 {
return false;
}
let major: u32 = parts[0].parse().unwrap_or(0);
let minor: u32 = parts[1].parse().unwrap_or(0);
major > 3 || (major == 3 && minor >= 10)
}
fn get_disk_space_gb() -> u64 {
#[cfg(target_os = "windows")]
{
// Windows: usar wmic
if let Ok(output) = Command::new("wmic")
.args(&["logicaldisk", "get", "size,freespace", "/format:list"])
.output()
{
if let Ok(text) = String::from_utf8(output.stdout) {
// Parse output para obter espaço livre
for line in text.lines() {
if line.starts_with("FreeSpace=") {
if let Some(bytes_str) = line.split('=').nth(1) {
if let Ok(bytes) = bytes_str.trim().parse::<u64>() {
return bytes / (1024 * 1024 * 1024);
}
}
}
}
}
}
}
#[cfg(target_os = "linux")]
{
// Linux: usar df
if let Ok(output) = Command::new("df")
.arg("-BG")
.arg("/")
.output()
{
if let Ok(text) = String::from_utf8(output.stdout) {
if let Some(line) = text.lines().nth(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
if let Ok(gb) = parts[3].trim_end_matches('G').parse::<u64>() {
return gb;
}
}
}
}
}
}
#[cfg(target_os = "macos")]
{
// macOS: usar df
if let Ok(output) = Command::new("df")
.arg("-g")
.arg("/")
.output()
{
if let Ok(text) = String::from_utf8(output.stdout) {
if let Some(line) = text.lines().nth(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
if let Ok(gb) = parts[3].parse::<u64>() {
return gb;
}
}
}
}
}
}
// Fallback: retornar valor alto para não bloquear
100
}
fn check_sudo_available() -> bool {
#[cfg(target_os = "linux")]
{
Command::new("sudo")
.arg("-n")
.arg("true")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
true
}
fn check_linux_dependencies() -> Vec<String> {
let mut missing = Vec::new();
let deps = vec!["curl", "ca-certificates", "gnupg"];
for dep in deps {
let output = Command::new("dpkg")
.args(&["-l", dep])
.output();
if let Ok(o) = output {
let text = String::from_utf8_lossy(&o.stdout);
if !text.contains(&format!("ii {}", dep)) {
missing.push(dep.to_string());
}
} else {
missing.push(dep.to_string());
}
}
missing
}
fn get_windows_version() -> Option<u32> {
#[cfg(target_os = "windows")]
{
if let Ok(output) = Command::new("powershell")
.args(&["-Command", "[System.Environment]::OSVersion.Version.Build"])
.output()
{
if let Ok(text) = String::from_utf8(output.stdout) {
if let Ok(build) = text.trim().parse::<u32>() {
return Some(build);
}
}
}
}
None
}
fn check_windows_feature(feature: &str) -> bool {
#[cfg(target_os = "windows")]
{
if let Ok(output) = Command::new("powershell")
.args(&[
"-Command",
&format!("(Get-WindowsOptionalFeature -Online -FeatureName {}).State", feature)
])
.output()
{
if let Ok(text) = String::from_utf8(output.stdout) {
return text.contains("Enabled");
}
}
}
false
}
fn check_wsl_version() -> bool {
#[cfg(target_os = "windows")]
{
// Verificar se WSL está disponível
if let Ok(output) = Command::new("wsl")
.arg("--version")
.output()
{
if output.status.success() {
// Verificar versão padrão
if let Ok(output) = Command::new("wsl")
.args(&["--status"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
return text.contains("2") || text.contains("WSL 2");
}
}
}
}
false
}
fn get_macos_version() -> Option<String> {
#[cfg(target_os = "macos")]
{
Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|v| v.trim().to_string())
}
#[cfg(not(target_os = "macos"))]
None
}
fn check_homebrew_available() -> bool {
#[cfg(target_os = "macos")]
{
Command::new("brew")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(target_os = "macos"))]
false
}
fn check_rosetta_installed() -> bool {
#[cfg(target_os = "macos")]
{
Command::new("/usr/bin/pgrep")
.arg("oahd")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(target_os = "macos"))]
false
}
+251
View File
@@ -0,0 +1,251 @@
use serde::{Serialize, Deserialize};
use std::process::Command;
use anyhow::Result;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ValidationResult {
pub status: ValidationStatus,
pub tests: Vec<ValidationTest>,
pub docker_version: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ValidationTest {
pub name: String,
pub status: TestStatus,
pub message: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum ValidationStatus {
Pass,
Fail,
Partial,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum TestStatus {
Pass,
Fail,
Warn,
}
/// Valida instalação do Docker executando testes
pub fn validate_docker_installation() -> Result<ValidationResult> {
let mut tests = Vec::new();
let mut docker_version: Option<String> = None;
// Teste 1: Verificar versão
match test_docker_version() {
Ok(Some(version)) => {
docker_version = Some(version.clone());
tests.push(ValidationTest {
name: "Versão do Docker".to_string(),
status: TestStatus::Pass,
message: Some(format!("Docker {} instalado", version)),
});
}
Ok(None) => {
tests.push(ValidationTest {
name: "Versão do Docker".to_string(),
status: TestStatus::Fail,
message: Some("Docker não encontrado no PATH".to_string()),
});
}
Err(e) => {
tests.push(ValidationTest {
name: "Versão do Docker".to_string(),
status: TestStatus::Fail,
message: Some(format!("Erro ao verificar versão: {}", e)),
});
}
}
// Teste 2: Verificar serviço
match test_docker_service() {
Ok(true) => {
tests.push(ValidationTest {
name: "Serviço Docker".to_string(),
status: TestStatus::Pass,
message: Some("Serviço Docker está rodando".to_string()),
});
}
Ok(false) => {
tests.push(ValidationTest {
name: "Serviço Docker".to_string(),
status: TestStatus::Fail,
message: Some("Serviço Docker não está rodando".to_string()),
});
}
Err(e) => {
tests.push(ValidationTest {
name: "Serviço Docker".to_string(),
status: TestStatus::Fail,
message: Some(format!("Erro ao verificar serviço: {}", e)),
});
}
}
// Teste 3: Executar container de teste
match test_docker_container() {
Ok(true) => {
tests.push(ValidationTest {
name: "Container de Teste".to_string(),
status: TestStatus::Pass,
message: Some("Container hello-world executado com sucesso".to_string()),
});
}
Ok(false) => {
tests.push(ValidationTest {
name: "Container de Teste".to_string(),
status: TestStatus::Fail,
message: Some("Falha ao executar container de teste".to_string()),
});
}
Err(e) => {
tests.push(ValidationTest {
name: "Container de Teste".to_string(),
status: TestStatus::Fail,
message: Some(format!("Erro ao testar container: {}", e)),
});
}
}
// Teste 4: Verificar permissões
match test_docker_permissions() {
Ok(true) => {
tests.push(ValidationTest {
name: "Permissões Docker".to_string(),
status: TestStatus::Pass,
message: Some("Usuário pode executar comandos Docker".to_string()),
});
}
Ok(false) => {
tests.push(ValidationTest {
name: "Permissões Docker".to_string(),
status: TestStatus::Warn,
message: Some("Pode ser necessário adicionar usuário ao grupo docker ou usar sudo".to_string()),
});
}
Err(e) => {
tests.push(ValidationTest {
name: "Permissões Docker".to_string(),
status: TestStatus::Warn,
message: Some(format!("Erro ao verificar permissões: {}", e)),
});
}
}
// Determinar status geral
let pass_count = tests.iter().filter(|t| matches!(t.status, TestStatus::Pass)).count();
let fail_count = tests.iter().filter(|t| matches!(t.status, TestStatus::Fail)).count();
let status = if fail_count == 0 {
ValidationStatus::Pass
} else if pass_count > 0 {
ValidationStatus::Partial
} else {
ValidationStatus::Fail
};
Ok(ValidationResult {
status,
tests,
docker_version,
})
}
/// Testa se Docker está instalado e retorna versão
fn test_docker_version() -> Result<Option<String>> {
let output = Command::new("docker")
.arg("--version")
.output();
match output {
Ok(o) if o.status.success() => {
let version = String::from_utf8_lossy(&o.stdout);
let version = version.trim();
// Extrair apenas número da versão (ex: "Docker version 24.0.5" -> "24.0.5")
let version = version
.split_whitespace()
.nth(2)
.map(|s| s.trim_end_matches(',').to_string())
.unwrap_or_else(|| version.to_string());
Ok(Some(version))
}
Ok(_) => Ok(None),
Err(_) => Ok(None),
}
}
/// Testa se serviço Docker está rodando
fn test_docker_service() -> Result<bool> {
// Tentar docker info (mais portável que systemctl)
let output = Command::new("docker")
.arg("info")
.output();
match output {
Ok(o) => Ok(o.status.success()),
Err(_) => Ok(false),
}
}
/// Testa executando container hello-world
fn test_docker_container() -> Result<bool> {
// Primeiro, remover container antigo se existir
let _ = Command::new("docker")
.args(&["rm", "-f", "docker-test-hello-world"])
.output();
// Executar container de teste
let output = Command::new("docker")
.args(&["run", "--rm", "--name", "docker-test-hello-world", "hello-world"])
.output();
match output {
Ok(o) => {
if o.status.success() {
Ok(true)
} else {
// Verificar se erro é por falta de imagem (normal na primeira vez)
let stderr = String::from_utf8_lossy(&o.stderr);
if stderr.contains("Unable to find image") {
// Tentar pull e executar novamente
let pull_output = Command::new("docker")
.args(&["pull", "hello-world"])
.output();
if let Ok(pull_o) = pull_output {
if pull_o.status.success() {
// Tentar executar novamente
let run_output = Command::new("docker")
.args(&["run", "--rm", "--name", "docker-test-hello-world-2", "hello-world"])
.output();
if let Ok(run_o) = run_output {
return Ok(run_o.status.success());
}
}
}
}
Ok(false)
}
}
Err(_) => Ok(false),
}
}
/// Testa se usuário tem permissões para executar docker ps
fn test_docker_permissions() -> Result<bool> {
let output = Command::new("docker")
.arg("ps")
.output();
match output {
Ok(o) => Ok(o.status.success()),
Err(_) => Ok(false),
}
}
+108
View File
@@ -0,0 +1,108 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use url::Url;
/// Estratégia de circuit breaker por domínio
pub struct DomainStrategy {
// Armazena taxa de sucesso por domínio (últimas 10 tentativas)
success_rates: Arc<RwLock<HashMap<String, Vec<bool>>>>,
}
impl DomainStrategy {
pub fn new() -> Self {
Self {
success_rates: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Extrai o domínio de uma URL
fn extract_domain(url: &str) -> Option<String> {
if let Ok(parsed) = Url::parse(url) {
if let Some(host) = parsed.host_str() {
return Some(host.to_string());
}
}
None
}
/// Verifica se deve fazer scraping de um domínio
pub async fn should_scrape(&self, url: &str) -> bool {
let domain = match Self::extract_domain(url) {
Some(d) => d,
None => return true, // Se não conseguir extrair domínio, permite scraping
};
let rates = self.success_rates.read().await;
if let Some(history) = rates.get(&domain) {
if history.is_empty() {
return true;
}
let success_count = history.iter().filter(|&&s| s).count();
let success_rate = success_count as f32 / history.len() as f32;
// Se taxa de sucesso < 30%, pula scraping e usa apenas snippet
if success_rate < 0.3 {
log::warn!(
"Domain {} has low success rate ({:.0}%), skipping scrape",
domain,
success_rate * 100.0
);
return false;
}
}
true
}
/// Registra resultado de scraping
pub async fn record_result(&self, url: &str, success: bool) {
let domain = match Self::extract_domain(url) {
Some(d) => d,
None => return,
};
let mut rates = self.success_rates.write().await;
let history = rates.entry(domain).or_insert_with(Vec::new);
history.push(success);
// Mantém apenas últimas 10 tentativas
if history.len() > 10 {
history.remove(0);
}
}
/// Limpa histórico de um domínio (útil para resetar após mudanças)
pub async fn clear_domain(&self, url: &str) {
let domain = match Self::extract_domain(url) {
Some(d) => d,
None => return,
};
let mut rates = self.success_rates.write().await;
rates.remove(&domain);
}
/// Limpa todo o histórico
pub async fn clear_all(&self) {
let mut rates = self.success_rates.write().await;
rates.clear();
}
}
impl Clone for DomainStrategy {
fn clone(&self) -> Self {
Self {
success_rates: Arc::clone(&self.success_rates),
}
}
}
impl Default for DomainStrategy {
fn default() -> Self {
Self::new()
}
}
+7
View File
@@ -7,7 +7,10 @@ use anyhow::{Result, anyhow};
use ndarray::Array2; use ndarray::Array2;
use ort::session::{Session, builder::GraphOptimizationLevel}; use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::value::Value; use ort::value::Value;
<<<<<<< HEAD
=======
use std::io::Read; use std::io::Read;
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use tokenizers::Tokenizer; use tokenizers::Tokenizer;
@@ -448,7 +451,11 @@ pub fn prune_context(
let mut result = Vec::new(); let mut result = Vec::new();
let mut total_tokens = 0; let mut total_tokens = 0;
<<<<<<< HEAD
for (_score, paragraph, original_idx) in scored_paragraphs {
=======
for (score, paragraph, original_idx) in scored_paragraphs { for (score, paragraph, original_idx) in scored_paragraphs {
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
let paragraph_tokens = paragraph.split_whitespace().count(); let paragraph_tokens = paragraph.split_whitespace().count();
if total_tokens + paragraph_tokens > max_tokens { if total_tokens + paragraph_tokens > max_tokens {
+73
View File
@@ -0,0 +1,73 @@
use tauri::{AppHandle, Manager, Emitter};
use serde::Serialize;
/// Evento de query de busca iniciada
#[derive(Serialize, Clone)]
pub struct SearchQueryEvent {
pub query: String,
pub source: String, // "brave", "searxng", "duckduckgo"
pub round: Option<u32>,
pub timestamp: u64,
}
/// Evento de query de busca completada
#[derive(Serialize, Clone)]
pub struct SearchQueryCompletedEvent {
pub query: String,
pub source: String,
pub results_count: usize,
pub success: bool,
pub timestamp: u64,
}
/// Evento de mudança de fonte de busca
#[derive(Serialize, Clone)]
pub struct SearchSourceSwitchEvent {
pub from: String,
pub to: String,
pub reason: String, // "timeout", "error", "quota_exceeded", "empty_results"
pub timestamp: u64,
}
/// Evento de URL sendo scraped
#[derive(Serialize, Clone)]
pub struct ScrapingUrlEvent {
pub url: String,
pub title: Option<String>,
pub status: String, // "started", "completed", "failed", "cached"
pub duration_ms: Option<u64>,
pub source: Option<String>, // "static", "nodriver", "playwright"
pub timestamp: u64,
}
/// Helper para emitir eventos de busca
pub fn emit_search_event(app_handle: &AppHandle, event: &SearchQueryEvent) {
// Emitir para todas as janelas
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("search-query-started", event.clone());
}
}
/// Helper para emitir eventos de busca completada
pub fn emit_search_completed_event(app_handle: &AppHandle, event: &SearchQueryCompletedEvent) {
// Emitir para todas as janelas
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("search-query-completed", event.clone());
}
}
/// Helper para emitir eventos de mudança de fonte
pub fn emit_source_switch_event(app_handle: &AppHandle, event: &SearchSourceSwitchEvent) {
// Emitir para todas as janelas
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("search-source-switched", event.clone());
}
}
/// Helper para emitir eventos de scraping
pub fn emit_scraping_event(app_handle: &AppHandle, event: &ScrapingUrlEvent) {
// Emitir para todas as janelas
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("scraping-url-event", event.clone());
}
}
+448
View File
@@ -0,0 +1,448 @@
use std::path::{Path, PathBuf};
use std::fs;
use std::io::Write;
use serde::{Deserialize, Serialize};
use reqwest::Client;
use sha2::{Sha256, Digest};
use regex::Regex;
use tauri::{AppHandle, WebviewWindow, Emitter};
use futures_util::StreamExt;
use crate::models_dir;
/// Modelo GGUF disponível no Hugging Face
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HuggingFaceModel {
pub id: String,
pub filename: String,
pub size: u64,
pub quantization: String,
pub sha256: Option<String>,
pub repo_owner: String,
pub repo_name: String,
}
/// Cache de metadados do Hugging Face
#[derive(Debug, Serialize, Deserialize)]
struct HuggingFaceCache {
models: Vec<HuggingFaceModel>,
last_updated: String, // ISO8601
}
const MAX_RETRIES: u32 = 3;
const RATE_LIMIT_BACKOFF: u64 = 60; // segundos
const CACHE_TTL_HOURS: i64 = 1;
/// Extrai quantização do filename usando regex corrigido
fn extract_quantization(filename: &str) -> Option<String> {
let re = Regex::new(r"[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]").ok()?;
re.captures(filename)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
}
/// Faz requisição HTTP com retry e rate limiting
async fn fetch_with_retry(url: &str) -> Result<reqwest::Response, String> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
for attempt in 0..MAX_RETRIES {
match client.get(url).send().await {
Ok(resp) if resp.status() == 429 => {
// Rate limited - usar Retry-After header
let retry_after = resp
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(RATE_LIMIT_BACKOFF);
log::warn!("Rate limited, retrying after {} seconds", retry_after);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
continue;
}
Ok(resp) => return Ok(resp),
Err(e) if attempt < MAX_RETRIES - 1 => {
let backoff = 2u64.pow(attempt);
log::warn!("Request failed (attempt {}), retrying in {}s: {}", attempt + 1, backoff, e);
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
}
Err(e) => return Err(format!("Request failed after {} attempts: {}", MAX_RETRIES, e)),
}
}
Err("Max retries exceeded".to_string())
}
/// Valida SHA256 de um arquivo
fn validate_sha256(path: &Path, expected_sha256: &str) -> Result<(), String> {
use std::io::Read;
let mut file = fs::File::open(path)
.map_err(|e| format!("Failed to open file for validation: {}", e))?;
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)
.map_err(|e| format!("Failed to read file: {}", e))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = format!("{:x}", hasher.finalize());
if hash.to_lowercase() != expected_sha256.to_lowercase() {
return Err(format!(
"SHA256 mismatch: expected {}, got {}",
expected_sha256, hash
));
}
Ok(())
}
/// Busca SHA256 de um arquivo no Hugging Face
async fn fetch_sha256_from_hf(model_id: &str, filename: &str) -> Result<Option<String>, String> {
// API do Hugging Face para obter SHA256
// Nota: Esta API pode não estar disponível para todos os arquivos
let url = format!("https://huggingface.co/api/models/{}/tree/main", model_id);
let response = fetch_with_retry(&url).await?;
if !response.status().is_success() {
return Ok(None); // SHA256 não disponível
}
let tree: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
// Procurar pelo arquivo no tree
if let Some(files) = tree.get("siblings").and_then(|s| s.as_array()) {
for file in files {
if let Some(path) = file.get("rfilename").and_then(|p| p.as_str()) {
if path == filename {
// Tentar obter SHA256 (pode não estar disponível)
if let Some(sha256) = file.get("sha256").and_then(|s| s.as_str()) {
return Ok(Some(sha256.to_string()));
}
}
}
}
}
Ok(None)
}
/// Baixa um modelo GGUF do Hugging Face com streaming de progresso
pub async fn download_gguf_model(
app_handle: AppHandle,
model_id: String,
filename: String,
window: Option<WebviewWindow>,
) -> Result<PathBuf, String> {
// Extrair repo_owner e repo_name do model_id
let parts: Vec<&str> = model_id.split('/').collect();
if parts.len() != 2 {
return Err("Invalid model_id format. Expected 'owner/repo'".to_string());
}
let repo_owner = parts[0];
let repo_name = parts[1];
// Extrair quantização do filename
let quantization = extract_quantization(&filename)
.unwrap_or_else(|| "unknown".to_string());
// Obter diretório de destino
let model_dir = models_dir::get_model_dir(&app_handle, repo_owner, repo_name, &quantization)?;
let dest_path = model_dir.join(&filename);
// Verificar se arquivo já existe
if dest_path.exists() {
log::info!("Model already exists at {}", dest_path.display());
return Ok(dest_path);
}
// URL de download do Hugging Face
let download_url = format!("https://huggingface.co/{}/resolve/main/{}", model_id, filename);
// Buscar SHA256 se disponível
let sha256 = fetch_sha256_from_hf(&model_id, &filename).await?;
// Criar arquivo temporário para download
let temp_path = dest_path.with_extension("tmp");
// Verificar se há download parcial para resumir
let mut start_pos = 0u64;
if temp_path.exists() {
start_pos = fs::metadata(&temp_path)
.map_err(|e| format!("Failed to get temp file size: {}", e))?
.len();
log::info!("Resuming download from byte {}", start_pos);
}
// Fazer download com streaming
let response = fetch_with_retry(&download_url).await?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&temp_path)
.map_err(|e| format!("Failed to open temp file: {}", e))?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = start_pos;
let mut last_update = std::time::Instant::now();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded += chunk.len() as u64;
// Emitir progresso a cada segundo
if last_update.elapsed().as_secs() >= 1 {
let percent = if total_size > 0 {
Some(((downloaded as f64 / total_size as f64) * 100.0) as u8)
} else {
None
};
// Criar evento de progresso compatível com o formato existente
let progress_json = serde_json::json!({
"status": "downloading",
"percent": percent,
"downloaded": format_bytes(downloaded),
"total": if total_size > 0 { format_bytes(total_size) } else { "".to_string() },
"speed": null,
"raw": format!("downloaded: {} / {}", downloaded, total_size)
});
if let Some(w) = &window {
let _ = w.emit("download-progress", progress_json.to_string());
}
last_update = std::time::Instant::now();
}
}
// Mover arquivo temporário para destino final
fs::rename(&temp_path, &dest_path)
.map_err(|e| format!("Failed to move temp file: {}", e))?;
// Validar SHA256 se disponível
if let Some(expected_sha256) = &sha256 {
log::info!("Validating SHA256 for {}", filename);
validate_sha256(&dest_path, expected_sha256)?;
log::info!("SHA256 validation passed");
} else {
log::warn!("SHA256 not available for {}, skipping validation", filename);
}
// Emitir sucesso
let success_progress = serde_json::json!({
"status": "success",
"percent": 100,
"downloaded": format_bytes(downloaded),
"total": if total_size > 0 { format_bytes(total_size) } else { "".to_string() },
"speed": null,
"raw": "success"
});
if let Some(w) = &window {
let _ = w.emit("download-progress", success_progress.to_string());
}
Ok(dest_path)
}
/// Lista modelos GGUF disponíveis no Hugging Face
pub async fn list_huggingface_gguf_models(
app_handle: &AppHandle,
query: Option<String>,
quantization: Option<String>,
) -> Result<Vec<HuggingFaceModel>, String> {
// Verificar cache primeiro
let cache_path = models_dir::get_cache_dir(app_handle)?
.join("huggingface_models.json");
// Tentar carregar cache
if let Ok(cache) = load_cache(&cache_path) {
let cache_age = chrono::DateTime::parse_from_rfc3339(&cache.last_updated)
.ok()
.and_then(|dt| {
let now = chrono::Utc::now();
let cache_time = dt.with_timezone(&chrono::Utc);
Some((now - cache_time).num_hours())
});
if let Some(age) = cache_age {
if age < CACHE_TTL_HOURS {
log::info!("Using cached models (age: {} hours)", age);
return Ok(filter_models(cache.models, query, quantization));
}
}
}
// Buscar modelos do Hugging Face
let search_url = if let Some(q) = &query {
format!("https://huggingface.co/api/models?search={}&filter=gguf", q)
} else {
"https://huggingface.co/api/models?filter=gguf".to_string()
};
let response = fetch_with_retry(&search_url).await?;
if !response.status().is_success() {
return Err(format!("API request failed with status: {}", response.status()));
}
let models_json: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let mut models = Vec::new();
// Processar resultados
if let Some(items) = models_json.as_array() {
for item in items {
if let Some(model_id) = item.get("id").and_then(|i| i.as_str()) {
// Buscar arquivos GGUF no repositório
if let Ok(repo_models) = fetch_repo_gguf_files(model_id).await {
models.extend(repo_models);
}
}
}
}
// Salvar cache
let cache = HuggingFaceCache {
models: models.clone(),
last_updated: chrono::Utc::now().to_rfc3339(),
};
if let Err(e) = save_cache(&cache_path, &cache) {
log::warn!("Failed to save cache: {}", e);
}
Ok(filter_models(models, query, quantization))
}
/// Busca arquivos GGUF em um repositório específico
async fn fetch_repo_gguf_files(model_id: &str) -> Result<Vec<HuggingFaceModel>, String> {
let url = format!("https://huggingface.co/api/models/{}/tree/main", model_id);
let response = fetch_with_retry(&url).await?;
if !response.status().is_success() {
return Ok(Vec::new()); // Repositório não encontrado ou sem acesso
}
let tree: serde_json::Value = response.json().await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let parts: Vec<&str> = model_id.split('/').collect();
let repo_owner = parts.get(0).unwrap_or(&"unknown").to_string();
let repo_name = parts.get(1).unwrap_or(&"unknown").to_string();
let mut models = Vec::new();
if let Some(files) = tree.get("siblings").and_then(|s| s.as_array()) {
for file in files {
if let Some(path) = file.get("rfilename").and_then(|p| p.as_str()) {
if path.ends_with(".gguf") {
let size = file.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
let quantization = extract_quantization(path)
.unwrap_or_else(|| "unknown".to_string());
let sha256 = file.get("sha256").and_then(|s| s.as_str()).map(|s| s.to_string());
models.push(HuggingFaceModel {
id: model_id.to_string(),
filename: path.to_string(),
size,
quantization,
sha256,
repo_owner: repo_owner.clone(),
repo_name: repo_name.clone(),
});
}
}
}
}
Ok(models)
}
/// Filtra modelos por query e quantização
fn filter_models(
models: Vec<HuggingFaceModel>,
query: Option<String>,
quantization: Option<String>,
) -> Vec<HuggingFaceModel> {
models
.into_iter()
.filter(|m| {
if let Some(q) = &query {
m.id.to_lowercase().contains(&q.to_lowercase()) ||
m.filename.to_lowercase().contains(&q.to_lowercase())
} else {
true
}
})
.filter(|m| {
if let Some(quant) = &quantization {
m.quantization.to_lowercase() == quant.to_lowercase()
} else {
true
}
})
.collect()
}
/// Carrega cache de metadados
fn load_cache(path: &Path) -> Result<HuggingFaceCache, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read cache: {}", e))?;
let cache: HuggingFaceCache = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse cache: {}", e))?;
Ok(cache)
}
/// Salva cache de metadados
fn save_cache(path: &Path, cache: &HuggingFaceCache) -> Result<(), String> {
let content = serde_json::to_string_pretty(cache)
.map_err(|e| format!("Failed to serialize cache: {}", e))?;
fs::write(path, content)
.map_err(|e| format!("Failed to write cache: {}", e))?;
Ok(())
}
/// Formata bytes em formato legível
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, UNITS[unit_index])
}
+1814 -278
View File
File diff suppressed because it is too large. Load diff
+108
View File
@@ -0,0 +1,108 @@
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use lru::LruCache;
/// Parâmetros de configuração para llama.cpp
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlamaParams {
/// Número de camadas para carregar na GPU (0 = CPU apenas)
pub n_gpu_layers: i32,
/// Tamanho do contexto
pub n_ctx: u32,
/// Número de threads (0 = auto)
pub threads: u32,
/// Temperatura para sampling
pub temperature: f32,
/// Top-p para sampling
pub top_p: f32,
/// Top-k para sampling
pub top_k: i32,
}
impl Default for LlamaParams {
fn default() -> Self {
Self {
n_gpu_layers: 0, // CPU por padrão
n_ctx: 4096,
threads: 0, // Auto
temperature: 0.7,
top_p: 0.9,
top_k: 40,
}
}
}
/// Metadados de um modelo GGUF
#[derive(Debug, Clone)]
pub struct ModelMetadata {
pub path: PathBuf,
pub architecture: Option<String>,
pub quantization: Option<String>,
pub parameter_count: Option<u64>,
pub size: u64,
}
/// Estado de um modelo carregado
#[derive(Debug, Clone)]
pub struct LlamaModel {
pub path: PathBuf,
#[cfg(feature = "llama-cpp")]
pub context: Option<()>, // Placeholder - será LlamaContext quando feature estiver ativa
pub metadata: ModelMetadata,
pub last_used: std::time::Instant,
}
/// Estado global do llama.cpp com LRU cache
pub struct LlamaCppState {
/// Cache LRU de modelos carregados (limite de 3 para prevenir memory leaks)
pub models: Arc<Mutex<LruCache<String, LlamaModel>>>,
pub default_params: LlamaParams,
}
impl LlamaCppState {
pub fn new() -> Self {
Self {
models: Arc::new(Mutex::new(LruCache::new(
std::num::NonZeroUsize::new(3).unwrap()
))),
default_params: LlamaParams::default(),
}
}
/// Obtém um modelo do cache ou retorna None
pub fn get_model(&self, model_id: &str) -> Option<LlamaModel> {
let mut cache = self.models.lock().unwrap();
cache.get(model_id).cloned()
}
/// Adiciona um modelo ao cache (LRU remove automaticamente o mais antigo se necessário)
pub fn add_model(&self, model_id: String, model: LlamaModel) {
let mut cache = self.models.lock().unwrap();
cache.put(model_id, model);
}
/// Remove um modelo do cache
pub fn remove_model(&self, model_id: &str) -> Option<LlamaModel> {
let mut cache = self.models.lock().unwrap();
cache.pop(model_id)
}
/// Limpa todos os modelos do cache
pub fn clear(&self) {
let mut cache = self.models.lock().unwrap();
cache.clear();
}
/// Lista todos os modelos no cache
pub fn list_models(&self) -> Vec<String> {
let cache = self.models.lock().unwrap();
cache.iter().map(|(k, _)| k.clone()).collect()
}
}
impl Default for LlamaCppState {
fn default() -> Self {
Self::new()
}
}
+174
View File
@@ -0,0 +1,174 @@
use std::path::Path;
use std::fs;
use walkdir::WalkDir;
use serde::{Deserialize, Serialize};
use regex::Regex;
use tauri::AppHandle;
use crate::models_dir;
/// Modelo GGUF local encontrado no sistema de arquivos
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalGgufModel {
pub path: String,
pub name: String,
pub size: u64,
pub quantization: Option<String>,
pub architecture: Option<String>,
pub parameter_count: Option<u64>,
}
/// Extrai quantização do filename usando regex corrigido
fn extract_quantization(filename: &str) -> Option<String> {
let re = Regex::new(r"[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]").ok()?;
re.captures(filename)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
}
/// Extrai metadados de um arquivo GGUF usando a crate gguf
/// Retorna None se o parsing falhar (não é crítico, temos fallback do filename)
fn extract_gguf_metadata(path: &Path) -> Option<GgufMetadata> {
use gguf::GGUFFile;
use std::io::Read;
// Ler apenas os primeiros bytes para parsing (mais eficiente)
let mut file = std::fs::File::open(path).ok()?;
let mut buffer = vec![0u8; 1024 * 1024]; // Ler 1MB (suficiente para header)
let bytes_read = file.read(&mut buffer).ok()?;
buffer.truncate(bytes_read);
// Tentar parsear
let _gguf_file = match GGUFFile::read(&buffer) {
Ok(Some(file)) => file,
Ok(None) | Err(_) => return None, // Dados incompletos ou erro
};
// A API do gguf 0.1 pode ser diferente, vamos tentar acessar metadados
// Se a estrutura for diferente, retornamos None e usamos fallback
// Por enquanto, retornamos None para evitar erros de compilação
// TODO: Implementar acesso correto aos metadados quando a estrutura for conhecida
None
// Placeholder para quando soubermos a estrutura exata:
// Some(GgufMetadata {
// architecture: None,
// quantization: None,
// parameter_count: None,
// })
}
#[derive(Debug)]
struct GgufMetadata {
architecture: Option<String>,
quantization: Option<String>,
parameter_count: Option<u64>,
}
/// Lista todos os modelos GGUF locais
pub fn list_local_gguf_models(app_handle: &AppHandle) -> Result<Vec<LocalGgufModel>, String> {
let base_dir = models_dir::get_models_base_dir(app_handle)?;
let mut models = Vec::new();
// Scan recursivo de arquivos .gguf
for entry in WalkDir::new(&base_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
// Verificar se é arquivo GGUF
if let Some(ext) = path.extension() {
if ext.to_string_lossy().to_lowercase() != "gguf" {
continue;
}
} else {
// Arquivo sem extensão - verificar pelo tamanho (modelos GGUF são grandes)
let metadata = fs::metadata(path)
.map_err(|e| format!("Failed to read metadata: {}", e))?;
if metadata.len() < 50 * 1024 * 1024 {
// Menos de 50MB, provavelmente não é um modelo GGUF
continue;
}
}
// Obter tamanho do arquivo
let size = fs::metadata(path)
.map_err(|e| format!("Failed to read file size: {}", e))?
.len();
// Extrair nome do arquivo
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// Tentar extrair quantização do filename
let quantization_from_filename = extract_quantization(&filename);
// Tentar extrair metadados do arquivo GGUF (opcional, não crítico)
let (architecture, quantization_from_file, parameter_count) =
match extract_gguf_metadata(path) {
Some(meta) => (
meta.architecture,
meta.quantization,
meta.parameter_count,
),
None => {
// Parsing falhou ou não disponível - usar apenas filename
(None, None, None)
}
};
// Preferir quantização do arquivo, fallback para filename
let quantization = quantization_from_file.or(quantization_from_filename);
// Construir nome amigável (sem extensão)
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&filename)
.to_string();
models.push(LocalGgufModel {
path: path.to_string_lossy().to_string(),
name,
size,
quantization,
architecture,
parameter_count,
});
}
// Ordenar por nome
models.sort_by(|a, b| a.name.cmp(&b.name));
Ok(models)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_quantization() {
assert_eq!(
extract_quantization("mistral-7b-instruct-v0.2.Q4_K_M.gguf"),
Some("Q4_K_M".to_string())
);
assert_eq!(
extract_quantization("model.Q5_0.gguf"),
Some("Q5_0".to_string())
);
assert_eq!(
extract_quantization("model-F16.gguf"),
Some("F16".to_string())
);
assert_eq!(
extract_quantization("model_FP32.gguf"),
Some("FP32".to_string())
);
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// Obtém o diretório base para modelos GGUF
/// Estrutura: {app_data_dir}/models/gguf/
pub fn get_models_base_dir(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let models_dir = app_data_dir.join("models").join("gguf");
// Criar diretório se não existir
std::fs::create_dir_all(&models_dir)
.map_err(|e| format!("Failed to create models dir: {}", e))?;
Ok(models_dir)
}
/// Obtém o diretório para um modelo específico
/// Estrutura hierárquica: {base_dir}/{repo_owner}/{repo_name}/{quantization}/
pub fn get_model_dir(
app_handle: &AppHandle,
repo_owner: &str,
repo_name: &str,
quantization: &str,
) -> Result<PathBuf, String> {
let base_dir = get_models_base_dir(app_handle)?;
let model_dir = base_dir
.join(sanitize_path_component(repo_owner))
.join(sanitize_path_component(repo_name))
.join(sanitize_path_component(quantization));
// Criar diretório se não existir
std::fs::create_dir_all(&model_dir)
.map_err(|e| format!("Failed to create model dir: {}", e))?;
Ok(model_dir)
}
/// Obtém o diretório de cache
pub fn get_cache_dir(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let cache_dir = app_data_dir.join("cache");
// Criar diretório se não existir
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache dir: {}", e))?;
Ok(cache_dir)
}
/// Sanitiza um componente de path para evitar caracteres inválidos
fn sanitize_path_component(component: &str) -> String {
component
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_path_component() {
assert_eq!(sanitize_path_component("test/model"), "test_model");
assert_eq!(sanitize_path_component("TheBloke/Mistral-7B"), "TheBloke_Mistral-7B");
assert_eq!(sanitize_path_component("Q4_K_M"), "Q4_K_M");
}
}
+245 -5
View File
@@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Mensagem para o Ollama API /// Mensagem para o Ollama API
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, Deserialize, Clone)]
struct OllamaMessage { pub struct OllamaMessage {
role: String, pub role: String,
content: String, pub content: String,
} }
/// Request para chat do Ollama /// Request para chat do Ollama
@@ -70,6 +70,9 @@ impl OllamaClient {
system_prompt: Option<&str>, system_prompt: Option<&str>,
user_prompt: &str, user_prompt: &str,
) -> Result<String, String> { ) -> Result<String, String> {
log::info!("[LLM] Headless query model={} system_prompt_present={} user_prompt_len={}", model, system_prompt.is_some(), user_prompt.len());
if let Some(sys) = system_prompt { log::info!("[LLM] System Prompt:\n{}", sys); }
log::info!("[LLM] User Prompt:\n{}", user_prompt);
// Verificar conexão primeiro // Verificar conexão primeiro
self.check_connection().await?; self.check_connection().await?;
@@ -96,12 +99,15 @@ impl OllamaClient {
}; };
let url = format!("{}/api/chat", self.base_url); let url = format!("{}/api/chat", self.base_url);
log::info!("[LLM_DEBUG] Iniciando request HTTP para o modelo (chat stream)...");
let response = self.client let response = self.client
.post(&url) .post(&url)
.json(&request) .json(&request)
.timeout(std::time::Duration::from_secs(30))
.send() .send()
.await .await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?; .map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
log::info!("[LLM] HTTP response received (chat stream). Status: {}", response.status());
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status())); return Err(format!("Ollama returned status: {}", response.status()));
@@ -113,9 +119,14 @@ impl OllamaClient {
use futures_util::StreamExt; use futures_util::StreamExt;
let mut logged_first_chunk = false;
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?; let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
let text = String::from_utf8_lossy(&chunk); let text = String::from_utf8_lossy(&chunk);
if !logged_first_chunk {
log::debug!("[LLM] First chunk received ({} bytes)", text.len());
logged_first_chunk = true;
}
// Processar cada linha (Ollama envia JSON por linha) // Processar cada linha (Ollama envia JSON por linha)
for line in text.lines() { for line in text.lines() {
@@ -141,7 +152,7 @@ impl OllamaClient {
} }
} }
} }
log::info!("[LLM] Headless Raw Output ({} chars):\n{}", full_response.len(), full_response.trim());
if full_response.is_empty() { if full_response.is_empty() {
return Err("Empty response from Ollama".to_string()); return Err("Empty response from Ollama".to_string());
} }
@@ -252,5 +263,234 @@ impl OllamaClient {
"💬".to_string() // Default "💬".to_string() // Default
} }
} }
/// Gera completion usando /api/generate (não streaming)
pub async fn generate_completion(
&self,
model: &str,
prompt: &str,
options: Option<GenerationOptions>,
) -> Result<String, String> {
log::info!("[LLM] generate_completion model={} prompt_len={}", model, prompt.len());
log::info!("[LLM] Prompt:\n{}", prompt);
self.check_connection().await?;
let mut request_body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
});
// Adicionar options se fornecidas
if let Some(opts) = options.as_ref() {
let mut opts_json = serde_json::Map::new();
if let Some(temp) = opts.temperature {
// serde_json::Number não implementa From<f64>, usar json! macro ou Number::from_f64
if let Some(num) = serde_json::Number::from_f64(temp) {
opts_json.insert("temperature".to_string(), serde_json::Value::Number(num));
} else {
// Fallback: usar como string se não conseguir converter
opts_json.insert("temperature".to_string(), serde_json::Value::String(temp.to_string()));
}
}
if let Some(num_predict) = opts.num_predict {
opts_json.insert("num_predict".to_string(), serde_json::Value::Number(num_predict.into()));
}
if let Some(format) = opts.format.clone() {
opts_json.insert("format".to_string(), serde_json::Value::String(format));
}
if !opts_json.is_empty() {
request_body["options"] = serde_json::Value::Object(opts_json);
}
log::info!("[LLM] Options: temperature={:?} num_predict={:?} format={:?}", opts.temperature, opts.num_predict, opts.format.as_deref());
}
let url = format!("{}/api/generate", self.base_url);
log::info!("[LLM_DEBUG] Iniciando request HTTP para o modelo (generate)...");
let response = self.client
.post(&url)
.json(&request_body)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
log::info!("[LLM] HTTP response received (generate). Status: {}", response.status());
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
#[derive(serde::Deserialize)]
struct GenerateResponse {
response: String,
}
let body_text = response.text()
.await
.map_err(|e| format!("Failed to read response text: {}", e))?;
log::info!("[LLM] Raw HTTP Body ({} chars)", body_text.len());
let result: GenerateResponse = match serde_json::from_str::<GenerateResponse>(&body_text) {
Ok(val) => val,
Err(e) => {
log::error!("[LLM] Failed to parse JSON body: {}", e);
log::error!("[LLM] Body content: {}", body_text);
return Err(format!("Failed to parse response: {}", e));
}
};
let mut raw = result.response.trim().to_string();
if let Ok(re) = regex::Regex::new(r"(?s)<think>.*?</think>") { raw = re.replace_all(&raw, "").to_string(); }
if let Ok(re) = regex::Regex::new(r"(?i)^\s*(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*") { raw = re.replace(&raw, "").to_string(); }
if let Ok(re) = regex::Regex::new(r"^\s*```[a-zA-Z]*\s*") { raw = re.replace(&raw, "").to_string(); }
if let Ok(re) = regex::Regex::new(r"\s*```\s*$") { raw = re.replace(&raw, "").to_string(); }
raw = raw.trim().to_string();
log::info!("[LLM] Raw Output ({} chars):\n{}", raw.len(), raw);
if let Some(fmt) = options.as_ref().and_then(|o| o.format.as_ref()) {
if fmt.to_lowercase() == "json" {
match serde_json::from_str::<serde_json::Value>(&raw) {
Ok(val) => {
let kind = if val.is_array() { "array" } else if val.is_object() { "object" } else { "other" };
log::info!("[LLM] JSON parse ok ({})", kind);
}
Err(e) => {
log::warn!("[LLM] JSON parse failed: {}", e);
}
}
}
}
Ok(raw)
}
/// Gera chat completion usando /api/chat (não streaming)
pub async fn generate_chat_completion(
&self,
model: &str,
messages: Vec<OllamaMessage>,
options: Option<GenerationOptions>,
) -> Result<String, String> {
let msgs_json = serde_json::to_string(&messages).unwrap_or_else(|_| "[]".to_string());
log::info!("[LLM] generate_chat_completion model={} messages_len={} messages_json={} options_temp={:?} options_num_predict={:?}", model, messages.len(), msgs_json, options.as_ref().and_then(|o| o.temperature), options.as_ref().and_then(|o| o.num_predict));
self.check_connection().await?;
let mut request_body = serde_json::json!({
"model": model,
"messages": messages,
"stream": false,
});
// Adicionar options se fornecidas
if let Some(opts) = options {
let mut opts_json = serde_json::Map::new();
if let Some(temp) = opts.temperature {
// serde_json::Number não implementa From<f64>, usar Number::from_f64
if let Some(num) = serde_json::Number::from_f64(temp) {
opts_json.insert("temperature".to_string(), serde_json::Value::Number(num));
} else {
// Fallback: usar como string se não conseguir converter
opts_json.insert("temperature".to_string(), serde_json::Value::String(temp.to_string()));
}
}
if let Some(num_predict) = opts.num_predict {
opts_json.insert("num_predict".to_string(), serde_json::Value::Number(num_predict.into()));
}
if !opts_json.is_empty() {
request_body["options"] = serde_json::Value::Object(opts_json);
}
}
let url = format!("{}/api/chat", self.base_url);
let response = self.client
.post(&url)
.json(&request_body)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
#[derive(serde::Deserialize)]
struct ChatResponse {
message: OllamaMessageResponse,
}
let result: ChatResponse = response.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let raw = result.message.content.trim().to_string();
log::info!("[LLM] Chat Raw Output ({} chars):\n{}", raw.len(), raw);
Ok(raw)
}
/// Obtém informações de um modelo usando /api/show
pub async fn get_model_info(&self, model_name: &str) -> Result<serde_json::Value, String> {
self.check_connection().await?;
let url = format!("{}/api/show", self.base_url);
let response = self.client
.post(&url)
.json(&serde_json::json!({ "name": model_name }))
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
let result: serde_json::Value = response.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result)
}
/// Lista modelos disponíveis usando /api/tags
pub async fn get_tags(&self) -> Result<Vec<OllamaModel>, String> {
self.check_connection().await?;
let url = format!("{}/api/tags", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
#[derive(serde::Deserialize)]
struct TagsResponse {
models: Vec<OllamaModel>,
}
let result: TagsResponse = response.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(result.models)
}
} }
/// Opções para geração de texto
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GenerationOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_predict: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
}
/// Modelo Ollama retornado por /api/tags
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct OllamaModel {
pub name: String,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub modified_at: Option<String>,
#[serde(default)]
pub digest: Option<String>,
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more