chore: expurgo de blobs excedentes e realinhamento de estado
release.yml (push) Invalid workflow file
release.yml (push) Invalid workflow file
This commit is contained in:
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)
|
||||
@@ -96,6 +96,62 @@ jobs:
|
||||
fi
|
||||
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
|
||||
run: |
|
||||
cd src-tauri
|
||||
|
||||
+11
@@ -40,3 +40,14 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
~/.tauri/ollahub.key
|
||||
|
||||
# tauri build artifacts
|
||||
src-tauri/target/
|
||||
src-tauri/bundle/
|
||||
*.exe
|
||||
*.msi
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.dmg
|
||||
OllamaSetup.exe
|
||||
src-tauri/bundle/ollama/
|
||||
@@ -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:
|
||||
|
||||
Website: https://evandro.dev.br
|
||||
Website: https://evandro.dev.br
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+312
-124
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ModelSelector } from "@/components/chat/model-selector";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { ReasoningChain } from "@/components/chat/reasoning-chain";
|
||||
@@ -13,6 +14,7 @@ import { TitleBar } from "@/components/titlebar";
|
||||
import { SidebarList } from "@/components/chat/sidebar-list";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
import { useLocalModels } from "@/hooks/use-local-models";
|
||||
import { useModelSelection } from "@/hooks/use-model-selection";
|
||||
import { useChatStorage } from "@/hooks/use-chat-storage";
|
||||
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
|
||||
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 { useSettingsStore } from "@/store/settings-store";
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
||||
import { chatLog } from "@/lib/terminal-logger";
|
||||
@@ -43,6 +46,7 @@ export default function ChatPage() {
|
||||
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
||||
const { models, refresh } = useLocalModels();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { isSetupCompleted } = useSettingsStore();
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -112,9 +116,68 @@ export default function ChatPage() {
|
||||
}));
|
||||
}, [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 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
|
||||
const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
||||
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
||||
@@ -137,25 +200,10 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [searchQuery, currentSessionId]);
|
||||
|
||||
// Auto-select first model
|
||||
useEffect(() => {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
|
||||
@@ -425,7 +473,10 @@ export default function ChatPage() {
|
||||
|
||||
// Executar busca progressiva para cada query do plano
|
||||
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
|
||||
updateThinkingMessage('web-research', {
|
||||
@@ -438,6 +489,22 @@ export default function ChatPage() {
|
||||
const context = deepResearch.state.context;
|
||||
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
|
||||
const fallbackResult = await executeProgressiveSearch(
|
||||
query,
|
||||
@@ -461,22 +528,47 @@ export default function ChatPage() {
|
||||
}
|
||||
);
|
||||
|
||||
// Log do resultado
|
||||
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}`);
|
||||
const queryDuration = Date.now() - queryStartTime;
|
||||
|
||||
// Log detalhado de cada resultado
|
||||
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
||||
chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`);
|
||||
chatLog.info(` URL: ${result.url}`);
|
||||
const contentLength = result.markdown?.length || 0;
|
||||
chatLog.info(` Content length: ${contentLength} chars`);
|
||||
// Log detalhado do resultado
|
||||
chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1} RESULTS ==========`);
|
||||
chatLog.info(`[WebResearch] Duration: ${queryDuration}ms`);
|
||||
chatLog.info(`[WebResearch] Success: ${fallbackResult.success}`);
|
||||
chatLog.info(`[WebResearch] Total results: ${fallbackResult.scrapedSources.length}`);
|
||||
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
|
||||
if (fallbackResult.scrapedSources.length > 0) {
|
||||
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}`);
|
||||
|
||||
// Coletar todas as fontes para exibição
|
||||
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
||||
url: entry.sourceUrl,
|
||||
title: entry.title,
|
||||
content: entry.content,
|
||||
markdown: entry.content,
|
||||
snippet: entry.content.substring(0, 200)
|
||||
}));
|
||||
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
||||
url: entry.sourceUrl,
|
||||
title: entry.title,
|
||||
content: entry.content,
|
||||
markdown: entry.content,
|
||||
snippet: entry.content.substring(0, 200),
|
||||
cached: false
|
||||
}));
|
||||
|
||||
// Adicionar mensagem: Fontes encontradas
|
||||
if (scrapedSources.length > 0) {
|
||||
@@ -535,56 +628,84 @@ export default function ChatPage() {
|
||||
|
||||
if (deepResearch.state.knowledgeBase.length > 0) {
|
||||
// Passo 3: Validação e Contexto
|
||||
chatLog.info('\nStep 3: Validation');
|
||||
chatLog.info('\n[DeepResearch] ========== STEP 3: VALIDATION ==========');
|
||||
deepResearch.setStep('aggregating');
|
||||
|
||||
// Adicionar mensagem: Processamento
|
||||
addThinkingMessage('processing', 'Processando contexto...', 'running');
|
||||
const validationStart = Date.now();
|
||||
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)
|
||||
chatLog.info('\nStep 4: Getting Curated Context (Optimized)');
|
||||
chatLog.info('\n[DeepResearch] ========== STEP 4: CONTEXT CONDENSATION ==========');
|
||||
|
||||
// Calcular tokens disponíveis dinamicamente
|
||||
const { getModelContextInfo } = await import('@/lib/model-context');
|
||||
const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages);
|
||||
const availableTokens = contextInfo.recommendedContextWindow;
|
||||
|
||||
chatLog.info(`Model Context Window: ${contextInfo.maxContextWindow} tokens`);
|
||||
chatLog.info(`Available Tokens for KB: ${availableTokens} tokens`);
|
||||
chatLog.info(`[DeepResearch] Model: ${selectedModel}`);
|
||||
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 condensationDuration = Date.now() - condensationStart;
|
||||
knowledgeBaseContext = optimizedResult.context;
|
||||
|
||||
chatLog.info(`Knowledge Base Context Length: ${knowledgeBaseContext.length} chars`);
|
||||
chatLog.info(`Condensation Method: ${optimizedResult.result.method}`);
|
||||
chatLog.info(`Chunks Used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
|
||||
chatLog.info(`Compression Ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
|
||||
chatLog.info(`\n[DeepResearch] ========== CONDENSATION SUMMARY ==========`);
|
||||
chatLog.info(`[DeepResearch] Duration: ${condensationDuration}ms`);
|
||||
chatLog.info(`[DeepResearch] Final context length: ${knowledgeBaseContext.length} chars`);
|
||||
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
|
||||
if (knowledgeBaseContext.length > 0) {
|
||||
chatLog.info(`\n[DeepResearch] Context preview:`);
|
||||
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 {
|
||||
chatLog.info(`Full Context:\n${knowledgeBaseContext}`);
|
||||
chatLog.info(knowledgeBaseContext);
|
||||
}
|
||||
|
||||
// Validar contexto
|
||||
const { validateCondensedContext } = await import('@/lib/knowledge-base-processor');
|
||||
const validation = validateCondensedContext(optimizedResult.result, content);
|
||||
|
||||
chatLog.info(`\n[DeepResearch] ========== CONTEXT VALIDATION ==========`);
|
||||
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) {
|
||||
validation.warnings.forEach(warning => {
|
||||
chatLog.warn(`⚠️ ${warning}`);
|
||||
chatLog.warn(`[DeepResearch] ⚠️ ${warning}`);
|
||||
});
|
||||
} else {
|
||||
chatLog.info('✓ Context validation passed');
|
||||
chatLog.info(`[DeepResearch] ✓ Context validation passed`);
|
||||
}
|
||||
chatLog.info(`[DeepResearch] ============================================\n`);
|
||||
} 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) {
|
||||
// Tratar erro graciosamente sem quebrar o fluxo
|
||||
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)
|
||||
updateThinkingMessage('web-research', {
|
||||
status: 'completed', // Marcar como completed mesmo com erro parcial
|
||||
details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`,
|
||||
status: 'completed', // Marcar como completed mesmo com erro
|
||||
details: `Erro na pesquisa web (${errorMsg}). Usando modelo local como fallback.`,
|
||||
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
|
||||
// O sistema continuará com conhecimento interno se necessário
|
||||
// O sistema continuará com conhecimento interno (fallback automático)
|
||||
}
|
||||
} else {
|
||||
if (!webSearch.isEnabled) {
|
||||
@@ -694,8 +819,9 @@ export default function ChatPage() {
|
||||
let enhancedSystemPrompt = systemPrompt;
|
||||
|
||||
// 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 hasWebSearchError = webSearch.status === 'error' || webSearch.error !== null;
|
||||
|
||||
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
||||
const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION
|
||||
@@ -704,25 +830,36 @@ export default function ChatPage() {
|
||||
.replace('{{userQuery}}', content);
|
||||
|
||||
enhancedSystemPrompt = strictPrompt;
|
||||
} else if (usedFallback) {
|
||||
} else if (usedFallback || hasWebSearchError) {
|
||||
// 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}
|
||||
|
||||
## ⚠️ 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:**
|
||||
- Responda usando APENAS seu conhecimento interno (treinamento)
|
||||
- Seja honesto e diga: "Não encontrei essa informação nas fontes consultadas."
|
||||
- Se tiver conhecimento sobre o tópico, compartilhe, mas deixe claro que não há fontes externas verificadas
|
||||
- Não invente informações ou cite fontes que não foram consultadas
|
||||
**INSTRUÇÕES CRÍTICAS:**
|
||||
- Você DEVE responder usando APENAS seu conhecimento interno (treinamento do modelo)
|
||||
- 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-o de forma útil e detalhada
|
||||
- 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/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
|
||||
else if (knowledgeBaseContext) {
|
||||
@@ -792,6 +929,9 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
let finalUserContent = content;
|
||||
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}`;
|
||||
} 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`);
|
||||
@@ -822,6 +962,75 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
chatLog.info('✅ Response generation complete');
|
||||
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
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
@@ -834,6 +1043,16 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
latency: Date.now() - responseStart,
|
||||
systemPrompt: enhancedSystemPrompt,
|
||||
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'),
|
||||
webResearch: {
|
||||
queries: deepResearch.state.plan || [],
|
||||
@@ -841,15 +1060,39 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
sources: scrapedSources,
|
||||
logs: deepResearch.state.logs,
|
||||
plan: deepResearch.state.plan,
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
|
||||
sourceUrl: entry.sourceUrl,
|
||||
title: entry.title,
|
||||
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,
|
||||
finalResponse: lastMsg.content,
|
||||
rawResponse: lastMsg.content,
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
};
|
||||
|
||||
// Armazenar debug data usando o índice da mensagem
|
||||
@@ -1030,7 +1273,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
<TitleBar />
|
||||
|
||||
{/* 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">
|
||||
|
||||
<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 min-w-0">
|
||||
<TooltipProvider>
|
||||
<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>
|
||||
<ModelSelector />
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
@@ -1463,4 +1652,3 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,6 +376,16 @@
|
||||
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 */
|
||||
@keyframes reasoning-fade-in {
|
||||
from {
|
||||
|
||||
@@ -4,6 +4,13 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AppUpdaterProvider } from "@/components/app-updater-provider";
|
||||
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({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -40,9 +47,21 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<<<<<<< HEAD
|
||||
<OllamaHealthProvider>
|
||||
<DownloadProvider>
|
||||
<KeyboardShortcuts />
|
||||
{children}
|
||||
<AppUpdaterProvider />
|
||||
<GlobalDownloadStatusBar />
|
||||
<ServiceInterruptionOverlay />
|
||||
</DownloadProvider>
|
||||
</OllamaHealthProvider>
|
||||
=======
|
||||
<KeyboardShortcuts />
|
||||
{children}
|
||||
<AppUpdaterProvider />
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+130
@@ -1,5 +1,15 @@
|
||||
'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 { Hero } from "@/components/landing/hero";
|
||||
import { InstallModal } from "@/components/modals/install-modal";
|
||||
@@ -11,8 +21,111 @@ import { useEffect, startTransition } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
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
|
||||
useEffect(() => {
|
||||
if (status === 'ready') {
|
||||
@@ -54,13 +167,29 @@ export default function Home() {
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
|
||||
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Após verificação, mostrar loading de redirecionamento (não deve aparecer por muito tempo)
|
||||
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">
|
||||
{/* Notificação de boas-vindas */}
|
||||
<WelcomeNotification />
|
||||
@@ -108,5 +237,6 @@ export default function Home() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
);
|
||||
}
|
||||
+1550
-1049
File diff suppressed because it is too large.
Load diff
+11
-25
@@ -1,5 +1,11 @@
|
||||
'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 { HardwareScan } from "@/components/setup/hardware-scan";
|
||||
import { GpuSelector } from "@/components/setup/gpu-selector";
|
||||
@@ -24,32 +30,12 @@ export default function SetupPage() {
|
||||
});
|
||||
};
|
||||
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
|
||||
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
|
||||
<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>
|
||||
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||
<TitleBar />
|
||||
<SetupWizard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export default function TasksPage() {
|
||||
return (
|
||||
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||
<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="mb-4 sm:mb-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
<<<<<<< HEAD
|
||||
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
=======
|
||||
import { Message } from '@/hooks/use-chat';
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
import type { DeepResearchState } from '@/hooks/use-deep-research';
|
||||
import type { ScrapedContent } from '@/services/webSearch';
|
||||
|
||||
@@ -39,6 +43,18 @@ export interface DebugData {
|
||||
contextual: string[];
|
||||
};
|
||||
sources: ScrapedContent[];
|
||||
<<<<<<< HEAD
|
||||
sitesResearched?: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
status: string;
|
||||
source: string;
|
||||
duration?: number;
|
||||
contentLength: number;
|
||||
scrapedAt: number;
|
||||
}>;
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
logs: Array<{
|
||||
stage: string;
|
||||
timestamp: number;
|
||||
@@ -53,6 +69,35 @@ export interface DebugData {
|
||||
title: 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;
|
||||
finalResponse: string;
|
||||
@@ -60,6 +105,7 @@ export interface DebugData {
|
||||
tokenUsage?: {
|
||||
input: number;
|
||||
output: number;
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
import { SearchQueriesMonitor } from './search-queries-monitor';
|
||||
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
|
||||
|
||||
interface MessageStepThinkingProps {
|
||||
metadata: ThinkingMessageMetadata;
|
||||
@@ -105,13 +107,31 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
|
||||
{/* Conteúdo Expandido */}
|
||||
{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 && (
|
||||
<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 && (
|
||||
<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) => (
|
||||
<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" />
|
||||
@@ -121,6 +141,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -87,10 +87,7 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
await downloadState.handleInstall();
|
||||
toast({
|
||||
title: 'Instalador iniciado',
|
||||
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
||||
});
|
||||
// Toast será mostrado automaticamente via eventos de progresso
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Erro ao executar instalador',
|
||||
@@ -144,8 +141,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
const isActive = activeTab === os;
|
||||
const isDownloading = downloadState.isDownloading && 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;
|
||||
}
|
||||
|
||||
@@ -162,7 +161,18 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<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">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||
<div className="text-sm flex-1">
|
||||
@@ -170,7 +180,20 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
Download concluído
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,7 +206,9 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
||||
<DialogTitle data-tauri-drag-region className="cursor-move select-none">
|
||||
Ollama não encontrado
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||
Baixe e instale o Ollama usando os passos abaixo.
|
||||
|
||||
@@ -67,3 +67,11 @@ export function WelcomeNotification() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 “{searchQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Sim" ou "Allow" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,12 @@ export function TitleBar() {
|
||||
|
||||
return (
|
||||
<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
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
WebkitAppRegion: 'drag'
|
||||
} as React.CSSProperties & { WebkitAppRegion?: string }}
|
||||
>
|
||||
{/* Área de arrasto com logo/título */}
|
||||
<div
|
||||
|
||||
@@ -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 }
|
||||
@@ -38,9 +38,14 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
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
|
||||
)}
|
||||
style={{
|
||||
// Garantir que o overlay não bloqueie eventos na área do TitleBar (topo 32px)
|
||||
clipPath: 'inset(0 0 0 0)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -60,9 +65,10 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,6 +49,14 @@ export function useAppUpdater(): AppUpdaterState {
|
||||
|
||||
// Verificar atualizações
|
||||
const checkUpdate = useCallback(async () => {
|
||||
<<<<<<< HEAD
|
||||
// Ignorar verificação de updates em ambiente de desenvolvimento
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
@@ -104,6 +112,14 @@ export function useAppUpdater(): AppUpdaterState {
|
||||
|
||||
// Verificação automática periódica (a cada 6 horas)
|
||||
useEffect(() => {
|
||||
<<<<<<< HEAD
|
||||
// Ignorar verificação automática em desenvolvimento
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
if (!autoCheckEnabled) return;
|
||||
|
||||
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
|
||||
|
||||
@@ -15,7 +15,8 @@ export function useAutoLabelingModel() {
|
||||
|
||||
const checkAndDownload = async () => {
|
||||
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) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
@@ -49,6 +50,25 @@ export function useAutoLabelingModel() {
|
||||
setIsDownloading(false);
|
||||
setIsReady(true);
|
||||
} 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);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
|
||||
@@ -37,24 +37,17 @@ export function useBatchVerify() {
|
||||
|
||||
const callLLM = async (model: string, prompt: string, json = false) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const response = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 1024,
|
||||
format: json ? 'json' : undefined,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 1024,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to call LLM');
|
||||
const data = await response.json();
|
||||
return data.response;
|
||||
return response;
|
||||
} catch (e) {
|
||||
console.error('LLM Call Error:', e);
|
||||
throw e;
|
||||
|
||||
+16
-43
@@ -254,29 +254,15 @@ export function useChatStorage() {
|
||||
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
||||
// Generate title immediately from user's first message
|
||||
try {
|
||||
const response = await fetch('http://localhost:11434/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ 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 }
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
let title = await invoke<string>('generate_chat_completion', {
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ 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,
|
||||
});
|
||||
|
||||
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 (!title || title.length === 0) {
|
||||
@@ -320,30 +306,17 @@ export function useChatStorage() {
|
||||
content: removeMetadataNoise(m.content),
|
||||
}));
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rawTitle = await invoke<string>('generate_chat_completion', {
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
||||
...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(data.message?.content?.trim() || "");
|
||||
let title = removeMetadataNoise(rawTitle?.trim() || "");
|
||||
|
||||
// If empty from start, throw to trigger fallback
|
||||
if (!title || title.length === 0) {
|
||||
|
||||
+16
-3
@@ -26,6 +26,8 @@ export interface ThinkingMessageMetadata {
|
||||
error?: string;
|
||||
timestamp: 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 {
|
||||
@@ -528,10 +530,21 @@ export function useChat() {
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
const stop = async () => {
|
||||
try {
|
||||
// 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);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+95
-70
@@ -23,7 +23,7 @@ export interface DeepResearchLog {
|
||||
timestamp: number;
|
||||
input: string;
|
||||
rawOutput?: string;
|
||||
parsedOutput?: any;
|
||||
parsedOutput?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function logStep(message: string) {
|
||||
log.info(message);
|
||||
}
|
||||
|
||||
function logData(label: string, data: any) {
|
||||
function logData(label: string, data: unknown) {
|
||||
if (typeof data === 'string' && data.length > 500) {
|
||||
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
||||
} 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}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -75,17 +75,17 @@ function extractQueriesFromResponse(raw: string): string[] {
|
||||
logData('Raw response', raw);
|
||||
|
||||
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
||||
let cleaned = raw
|
||||
const cleaned = raw
|
||||
.replace(/```json\s*/gi, '')
|
||||
.replace(/```\s*/g, '')
|
||||
.trim();
|
||||
|
||||
// 2. Try to parse as JSON
|
||||
let parsed: any;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
logStep('Direct JSON parse successful');
|
||||
} catch (e) {
|
||||
} catch {
|
||||
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
||||
// If JSON parse fails, try to find JSON-like content in the string
|
||||
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
||||
@@ -93,7 +93,7 @@ function extractQueriesFromResponse(raw: string): string[] {
|
||||
try {
|
||||
parsed = JSON.parse(jsonMatch[0]);
|
||||
logStep('Extracted JSON from string successfully');
|
||||
} catch (e2) {
|
||||
} catch {
|
||||
logError('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
|
||||
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`);
|
||||
return result;
|
||||
}
|
||||
@@ -113,17 +113,19 @@ function extractQueriesFromResponse(raw: string): string[] {
|
||||
// 4. Check common object keys used by different models
|
||||
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
||||
for (const key of commonKeys) {
|
||||
if (parsed[key] && Array.isArray(parsed[key])) {
|
||||
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
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`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback: find first array value in the object
|
||||
for (const val of Object.values(parsed)) {
|
||||
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
|
||||
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||
for (const val of Object.values(parsed as Record<string, unknown>)) {
|
||||
if (Array.isArray(val) && val.length > 0 && typeof (val as unknown[])[0] === 'string') {
|
||||
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`);
|
||||
return result;
|
||||
}
|
||||
@@ -151,29 +153,19 @@ export function useDeepResearch() {
|
||||
logData('Prompt length', `${prompt.length} chars`);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const response = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
options: {
|
||||
temperature: 0.2,
|
||||
num_predict: 2048,
|
||||
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 (${data.response?.length || 0} chars)`);
|
||||
return data.response;
|
||||
logSuccess(`LLM response received (${response?.length || 0} chars)`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
logError('LLM Call Error', errorMsg);
|
||||
@@ -255,40 +247,41 @@ export function useDeepResearch() {
|
||||
plan = [query];
|
||||
}
|
||||
|
||||
// Safety Check
|
||||
logStep('--- Safety Check ---');
|
||||
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||
logData('Keywords from query', keywords);
|
||||
|
||||
const isRelevant = plan.some(q =>
|
||||
keywords.some(k => q.toLowerCase().includes(k))
|
||||
);
|
||||
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];
|
||||
const isSmallModel = /(?:270m|0\.5b|1b|1\.7b|2b|mini|small|abliterated|uncensored|qwen3|llama2)/i.test(model);
|
||||
if (isSmallModel) {
|
||||
logStep('Safety Check bypassed for small/experimental model');
|
||||
} else {
|
||||
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
||||
addLog({
|
||||
stage: 'decomposition',
|
||||
timestamp: Date.now(),
|
||||
input: prompt,
|
||||
rawOutput: response,
|
||||
parsedOutput: plan,
|
||||
error: parseError
|
||||
});
|
||||
const isRelevant = plan.some(q =>
|
||||
keywords.some(k => q.toLowerCase().includes(k))
|
||||
);
|
||||
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];
|
||||
}
|
||||
}
|
||||
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 }));
|
||||
return plan;
|
||||
@@ -303,31 +296,63 @@ export function useDeepResearch() {
|
||||
|
||||
// --- ADD TO KNOWLEDGE BASE ---
|
||||
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
||||
logStep(`--- Adding to Knowledge Base ---`);
|
||||
logSection('ADDING TO KNOWLEDGE BASE');
|
||||
logData('Query', query);
|
||||
logData('Sources count', sources.length);
|
||||
logData('Timestamp', new Date().toISOString());
|
||||
|
||||
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
||||
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
||||
query,
|
||||
sourceUrl: src.url,
|
||||
title: src.title,
|
||||
content: src.markdown,
|
||||
content: src.markdown || src.content || '',
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
// Log detalhado de cada fonte
|
||||
sources.forEach((src, idx) => {
|
||||
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
|
||||
const contentLength = src.markdown?.length || 0;
|
||||
logData(` Content length`, `${contentLength} chars`);
|
||||
logStep(`\n--- Source ${idx + 1}/${sources.length} ---`);
|
||||
logData('Title', src.title);
|
||||
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) {
|
||||
const preview = src.markdown.substring(0, 300);
|
||||
logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
|
||||
// Log preview mais detalhado
|
||||
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 {
|
||||
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 => ({
|
||||
...s,
|
||||
|
||||
@@ -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
@@ -1,50 +1,224 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
|
||||
|
||||
export function useOllamaCheck() {
|
||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
||||
interface UseOllamaCheckReturn {
|
||||
status: OllamaStatus;
|
||||
isChecking: boolean;
|
||||
check: () => Promise<void>;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
reset: () => void;
|
||||
elapsedTime: number;
|
||||
}
|
||||
|
||||
const check = async () => {
|
||||
setStatus('checking');
|
||||
const INITIAL_INTERVAL = 2000; // 2 segundos
|
||||
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 {
|
||||
// Usar verificação completa que checa instalação E execução
|
||||
const result = await invoke<{
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
status: string;
|
||||
}>('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) {
|
||||
console.error('Failed to check ollama:', error);
|
||||
// Se falhar, tentar verificação individual como fallback
|
||||
// Se falhar, tentar verificação robusta como fallback
|
||||
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');
|
||||
if (!installed) {
|
||||
setStatus('not_installed');
|
||||
return;
|
||||
return false; // Continua polling (não instalado ainda)
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
setStatus('installed_stopped');
|
||||
return;
|
||||
return false; // Continua polling (instalado mas não rodando)
|
||||
}
|
||||
|
||||
setStatus('running');
|
||||
return true; // Sucesso, parar polling
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback check also failed:', fallbackError);
|
||||
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
@@ -12,6 +12,13 @@ interface DownloadState {
|
||||
isInstalled: boolean;
|
||||
isChecking: boolean;
|
||||
checkStatus: string;
|
||||
installStatus: string | null;
|
||||
isInstalling: boolean;
|
||||
speed: string;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
eta: number;
|
||||
status: 'downloading' | 'verifying' | 'completed';
|
||||
}
|
||||
|
||||
export function useOllamaDownload() {
|
||||
@@ -24,6 +31,13 @@ export function useOllamaDownload() {
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
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);
|
||||
@@ -31,17 +45,71 @@ export function useOllamaDownload() {
|
||||
|
||||
// Escutar eventos de progresso do download
|
||||
useEffect(() => {
|
||||
const unlistenPromise = listen('installer-download-progress', (event: any) => {
|
||||
const unlistenDownloadPromise = listen('installer-download-progress', (event: any) => {
|
||||
const data = event.payload;
|
||||
const statusText = data.status || '';
|
||||
const isVerifying = statusText.includes('Verificando');
|
||||
const isCompleted = data.progress === 100 && !isVerifying;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
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 () => {
|
||||
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,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
<<<<<<< HEAD
|
||||
installStatus: null,
|
||||
isInstalling: false,
|
||||
speed: '0 MB/s',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
eta: 0,
|
||||
status: 'downloading',
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -70,6 +148,16 @@ export function useOllamaDownload() {
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
<<<<<<< HEAD
|
||||
installStatus: null,
|
||||
isInstalling: false,
|
||||
speed: '0 MB/s',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
eta: 0,
|
||||
status: 'completed',
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -85,6 +173,16 @@ export function useOllamaDownload() {
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
<<<<<<< HEAD
|
||||
installStatus: null,
|
||||
isInstalling: false,
|
||||
speed: '0 MB/s',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
eta: 0,
|
||||
status: 'completed',
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
@@ -96,6 +194,16 @@ export function useOllamaDownload() {
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
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;
|
||||
}
|
||||
|
||||
<<<<<<< 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
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
@@ -120,27 +246,40 @@ export function useOllamaDownload() {
|
||||
|
||||
// Chamar Tauri em background (não bloquear)
|
||||
runInstaller(state.filePath)
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
.catch((error) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
||||
<<<<<<< HEAD
|
||||
isInstalling: false,
|
||||
installStatus: null,
|
||||
=======
|
||||
isInstalled: false, // Reverter se falhar
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const checkOllama = async (): Promise<boolean> => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
return running;
|
||||
// Usar verificação robusta multi-camada (PATH, caminhos absolutos, HTTP)
|
||||
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
||||
return isInstalledAndRunning;
|
||||
} catch (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,
|
||||
downloadProgress: 100,
|
||||
downloadStatus: 'Download já concluído',
|
||||
status: 'completed',
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -252,6 +392,13 @@ export function useOllamaDownload() {
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
installStatus: null,
|
||||
isInstalling: false,
|
||||
speed: '0 MB/s',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
eta: 0,
|
||||
status: 'downloading',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -56,3 +56,11 @@ export function useOperatingSystem() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
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> => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const response = await fetch('http://localhost:11434/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
||||
{ role: 'user', content: userGoal }
|
||||
],
|
||||
stream: false, // We want the full prompt at once for this utility
|
||||
}),
|
||||
const response = await invoke<string>('generate_chat_completion', {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
||||
{ role: 'user', content: userGoal }
|
||||
],
|
||||
options: undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to generate prompt');
|
||||
|
||||
const data = await response.json();
|
||||
return data.message?.content || "";
|
||||
return response || "";
|
||||
} catch (error) {
|
||||
console.error("Prompt generation failed:", error);
|
||||
throw error;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
/**
|
||||
* 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):`;
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
||||
num_predict: 50, // Limitar tokens para resposta rápida
|
||||
},
|
||||
}),
|
||||
const generatedText = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
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)
|
||||
let query = generatedText
|
||||
.replace(/^["']|["']$/g, '') // Remover aspas
|
||||
|
||||
@@ -43,12 +43,31 @@ export function useSetupCheck() {
|
||||
// Aguardar um pouco para o servidor iniciar
|
||||
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
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
// Ainda não está rodando, precisa de intervenção do usuário
|
||||
return 'needs_setup';
|
||||
}
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
// Agora está rodando, verificar modelos
|
||||
const hasAnyModels = await checkModels();
|
||||
return hasAnyModels ? 'ready' : 'needs_setup';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function useSystemHealth() {
|
||||
title: 'Modo de Economia Ativado',
|
||||
description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.',
|
||||
variant: 'destructive',
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
duration: 5000,
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
});
|
||||
setHasShownCriticalToast(true);
|
||||
} else if (newHealth.status !== 'critical') {
|
||||
|
||||
@@ -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
@@ -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 { 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 {
|
||||
isEnabled: boolean;
|
||||
@@ -10,6 +12,8 @@ export interface WebSearchState {
|
||||
currentQuery: string;
|
||||
scrapedSources: ScrapedContent[];
|
||||
error: string | null;
|
||||
activeQueries: ActiveQuery[];
|
||||
activeUrls: ActiveUrl[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: WebSearchState = {
|
||||
@@ -18,12 +22,16 @@ const DEFAULT_STATE: WebSearchState = {
|
||||
currentQuery: '',
|
||||
scrapedSources: [],
|
||||
error: null,
|
||||
activeQueries: [],
|
||||
activeUrls: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook para gerenciar estado do Web Search
|
||||
*/
|
||||
export function useWebSearch() {
|
||||
const events = useWebSearchEvents();
|
||||
|
||||
const [state, setState] = useState<WebSearchState>(() => {
|
||||
// Carregar preferência do localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -36,6 +44,15 @@ export function useWebSearch() {
|
||||
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
|
||||
*/
|
||||
@@ -175,7 +192,7 @@ export function useWebSearch() {
|
||||
} catch (error) {
|
||||
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
||||
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 => ({
|
||||
...prev,
|
||||
@@ -185,6 +202,7 @@ export function useWebSearch() {
|
||||
}));
|
||||
|
||||
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||
// O sistema continuará usando o modelo local como fallback
|
||||
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
|
||||
*/
|
||||
@@ -235,14 +269,19 @@ export function useWebSearch() {
|
||||
webSearchService.clearCache();
|
||||
}, []);
|
||||
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeQueries: events.activeQueries,
|
||||
activeUrls: events.activeUrls,
|
||||
setEnabled,
|
||||
search,
|
||||
smartSearchRag,
|
||||
extractUrl,
|
||||
cancel,
|
||||
reset,
|
||||
clearCache,
|
||||
setState, // Expor setState para permitir atualização externa em caso de erro
|
||||
};
|
||||
}
|
||||
|
||||
+8
-19
@@ -108,27 +108,16 @@ export async function extractKeyFacts(
|
||||
.replace('{{url}}', entry.sourceUrl)
|
||||
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 600,
|
||||
},
|
||||
}),
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rawResponse = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
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
|
||||
let jsonText = rawResponse
|
||||
.replace(/^```json\s*/i, '')
|
||||
|
||||
+52
-20
@@ -154,27 +154,17 @@ export async function analyzeQueryContext(
|
||||
try {
|
||||
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3, // Baixa temperatura para análise mais determinística
|
||||
num_predict: 800, // Espaço suficiente para JSON completo
|
||||
},
|
||||
}),
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rawResponse = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
options: {
|
||||
temperature: 0.3,
|
||||
num_predict: 800,
|
||||
format: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
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)
|
||||
let jsonText = rawResponse
|
||||
.replace(/^```json\s*/i, '')
|
||||
@@ -188,7 +178,7 @@ export async function analyzeQueryContext(
|
||||
jsonText = jsonMatch[0];
|
||||
}
|
||||
|
||||
const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
|
||||
const analysis = tryParseJsonWithRepair(jsonText) as Partial<QueryContext>;
|
||||
|
||||
// Validar e preencher campos obrigatórios
|
||||
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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -53,14 +53,15 @@ export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
|
||||
* Faz download do instalador (oficial ou fallback)
|
||||
* 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 filename = LOCAL_INSTALLER_FILES[os];
|
||||
|
||||
try {
|
||||
const filePath = await invoke<string>('download_installer', {
|
||||
url,
|
||||
filename
|
||||
filename,
|
||||
expected_sha256: expectedSha256 || null
|
||||
});
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
@@ -87,9 +88,12 @@ export async function getDownloadedInstallerPath(os: OS): Promise<string | null>
|
||||
/**
|
||||
* Executa o instalador baixado
|
||||
*/
|
||||
export async function runInstaller(filePath: string): Promise<void> {
|
||||
export async function runInstaller(filePath: string, expectedSha256?: string): Promise<void> {
|
||||
try {
|
||||
await invoke('run_installer', { filePath });
|
||||
await invoke('run_installer', {
|
||||
file_path: filePath,
|
||||
expected_sha256: expectedSha256 || null
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Falha ao executar instalador: ${error}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { QueryContext } from './contextual-analyzer';
|
||||
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||
import { summarizeMarkdown } from './content-condenser';
|
||||
import { chatLog } from './terminal-logger';
|
||||
|
||||
export interface CondensationResult {
|
||||
context: string;
|
||||
@@ -79,9 +80,21 @@ export function condenseKnowledgeBase(
|
||||
query: string,
|
||||
options: CondensationOptions = {}
|
||||
): 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 };
|
||||
|
||||
if (entries.length === 0) {
|
||||
chatLog.warn(`[KnowledgeBase] ⚠️ No entries to condense`);
|
||||
return {
|
||||
context: '',
|
||||
totalTokens: 0,
|
||||
@@ -97,6 +110,9 @@ export function condenseKnowledgeBase(
|
||||
// Calcular tokens originais
|
||||
const originalContent = entries.map(e => e.content).join('\n\n');
|
||||
const originalTokens = estimateTokens(originalContent);
|
||||
|
||||
chatLog.info(`[KnowledgeBase] Original content: ${originalContent.length} chars`);
|
||||
chatLog.info(`[KnowledgeBase] Original tokens (estimated): ${originalTokens}`);
|
||||
|
||||
// Processar cada entrada
|
||||
const allChunks: Chunk[] = [];
|
||||
@@ -106,7 +122,15 @@ export function condenseKnowledgeBase(
|
||||
chunks: Chunk[];
|
||||
}> = [];
|
||||
|
||||
chatLog.info(`[KnowledgeBase] Processing ${entries.length} entries into chunks...`);
|
||||
|
||||
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, {
|
||||
summarizeThreshold: opts.summarizeThreshold,
|
||||
});
|
||||
@@ -119,6 +143,19 @@ export function condenseKnowledgeBase(
|
||||
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({
|
||||
entry,
|
||||
processed,
|
||||
@@ -127,11 +164,18 @@ export function condenseKnowledgeBase(
|
||||
|
||||
allChunks.push(...chunksWithSource);
|
||||
});
|
||||
|
||||
chatLog.info(`[KnowledgeBase] Total chunks generated: ${allChunks.length}`);
|
||||
|
||||
// Calcular tokens disponíveis (reservar 20% para margem)
|
||||
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)
|
||||
const selectionStart = Date.now();
|
||||
chatLog.info(`[KnowledgeBase] Selecting most relevant chunks...`);
|
||||
|
||||
let selectedChunks = scoreAndSelectChunks(
|
||||
allChunks,
|
||||
query,
|
||||
@@ -139,6 +183,19 @@ export function condenseKnowledgeBase(
|
||||
opts.minRelevanceScore,
|
||||
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
|
||||
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
|
||||
@@ -196,6 +253,26 @@ export function condenseKnowledgeBase(
|
||||
|
||||
const totalTokens = estimateTokens(context);
|
||||
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 {
|
||||
context: context.trim(),
|
||||
|
||||
@@ -29,16 +29,12 @@ export async function detectModelContextWindow(modelName: string): Promise<numbe
|
||||
|
||||
try {
|
||||
// Tentar obter informações do modelo via Ollama API
|
||||
const response = await fetch('http://localhost:11434/api/show', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: modelName,
|
||||
}),
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const data = await invoke<any>('get_ollama_model_info', {
|
||||
modelName,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data) {
|
||||
|
||||
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
||||
// Mapear tamanhos conhecidos de modelos
|
||||
|
||||
@@ -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
@@ -118,27 +118,16 @@ export async function enrichQueries(
|
||||
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
|
||||
.replace('{{domain}}', context.domain || 'geral');
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.5, // Temperatura média para criatividade controlada
|
||||
num_predict: 1000, // Espaço suficiente para múltiplas queries
|
||||
},
|
||||
}),
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const rawResponse = await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt,
|
||||
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)
|
||||
let jsonText = rawResponse
|
||||
.replace(/^```json\s*/i, '')
|
||||
|
||||
@@ -211,3 +211,11 @@ export function formatSearchLog(log: SearchLog): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,3 +38,11 @@ export const deepResearchLog = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,34 @@ export interface SourcesConfig {
|
||||
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
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: FallbackConfig = {
|
||||
maxRounds: 4,
|
||||
maxResultsPerRound: 10,
|
||||
maxTotalResults: 40,
|
||||
minRelevanceScore: 0.3,
|
||||
minRelevanceScore: 0.1,
|
||||
enableQueryExpansion: true,
|
||||
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
||||
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
||||
@@ -100,37 +100,33 @@ Instruções:
|
||||
|
||||
Query expandida:`;
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt: expansionPrompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.5,
|
||||
num_predict: 100,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const expanded = (await invoke<string>('generate_completion', {
|
||||
model,
|
||||
prompt: expansionPrompt,
|
||||
options: {
|
||||
temperature: 0.5,
|
||||
num_predict: 100,
|
||||
},
|
||||
}))
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.trim();
|
||||
|
||||
if (!response.ok) {
|
||||
chatLog.warn('Failed to expand query, using original');
|
||||
const sanitized = expanded
|
||||
.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;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const expanded = (data.response || '').trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/, '')
|
||||
.trim();
|
||||
|
||||
if (expanded.length < 3) {
|
||||
return originalQuery;
|
||||
}
|
||||
|
||||
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
|
||||
return expanded;
|
||||
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
|
||||
return sanitized;
|
||||
} catch (error) {
|
||||
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return originalQuery;
|
||||
@@ -155,7 +151,7 @@ export function calculateRelevanceScoreForFallback(
|
||||
|
||||
// Contar matches de palavras-chave
|
||||
let matches = 0;
|
||||
let totalWords = queryWords.length;
|
||||
const totalWords = queryWords.length;
|
||||
|
||||
for (const word of queryWords) {
|
||||
if (combinedText.includes(word)) {
|
||||
@@ -283,7 +279,7 @@ export async function executeProgressiveSearch(
|
||||
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let totalResultsAnalyzed = 0;
|
||||
let allResults: ScrapedContent[] = [];
|
||||
const allResults: ScrapedContent[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
const queryStartTime = Date.now();
|
||||
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)`);
|
||||
|
||||
const roundStartTime = Date.now();
|
||||
let roundResults: ScrapedContent[] = [];
|
||||
let roundQueries: string[] = [];
|
||||
const roundResults: ScrapedContent[] = [];
|
||||
const roundQueries: string[] = [];
|
||||
|
||||
// Executar todas as queries do round em paralelo com timeout por round
|
||||
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||
@@ -351,8 +347,13 @@ export async function executeProgressiveSearch(
|
||||
// Passar round para searchFn para timeout adaptativo
|
||||
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
||||
return { query: finalQuery, results };
|
||||
<<<<<<< HEAD
|
||||
} catch (err) {
|
||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
=======
|
||||
} catch (error) {
|
||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
return { query: finalQuery, results: [] };
|
||||
}
|
||||
});
|
||||
@@ -367,7 +368,7 @@ export async function executeProgressiveSearch(
|
||||
let queryResults: QueryResult[];
|
||||
try {
|
||||
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
} catch (_err) {
|
||||
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
||||
// Tentar obter resultados parciais das promises que já completaram
|
||||
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
||||
@@ -406,13 +407,15 @@ export async function executeProgressiveSearch(
|
||||
}
|
||||
|
||||
// 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
|
||||
? roundResults.reduce((sum, r) => {
|
||||
// Usar contexto para cálculo de relevância se disponível
|
||||
if (finalConfig.context) {
|
||||
// Importar função dinamicamente para evitar circular dependency
|
||||
const { calculateSemanticSimilarity } = require('./relevance-scorer');
|
||||
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
|
||||
if (semanticSimilarity && finalConfig.context) {
|
||||
return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title);
|
||||
}
|
||||
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
||||
}, 0) / roundResults.length
|
||||
@@ -479,4 +482,3 @@ export async function executeProgressiveSearch(
|
||||
usedFallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,19 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
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) => {
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
// Adiciona suporte para raw-loader para arquivos .md
|
||||
config.module.rules.push({
|
||||
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;
|
||||
},
|
||||
// Configuração Turbopack: objeto vazio silencia o erro
|
||||
|
||||
+7
-2
@@ -32,7 +32,7 @@
|
||||
"emoji-regex": "^10.6.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "16.0.3",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
@@ -51,10 +51,15 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"mdast-util-to-hast": ">=13.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+59
-55
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
mdast-util-to-hast: '>=13.2.1'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -72,8 +75,8 @@ importers:
|
||||
specifier: ^0.554.0
|
||||
version: 0.554.0([email protected])
|
||||
next:
|
||||
specifier: 16.0.3
|
||||
version: 16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||
@@ -124,8 +127,8 @@ importers:
|
||||
specifier: ^9
|
||||
version: 9.39.1([email protected])
|
||||
eslint-config-next:
|
||||
specifier: 16.0.3
|
||||
version: 16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
||||
raw-loader:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2([email protected])
|
||||
@@ -453,56 +456,56 @@ packages:
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/env@16.0.3':
|
||||
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
|
||||
'@next/env@16.1.1':
|
||||
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
|
||||
|
||||
'@next/eslint-plugin-next@16.0.3':
|
||||
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
|
||||
'@next/eslint-plugin-next@16.1.1':
|
||||
resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.0.3':
|
||||
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
|
||||
'@next/swc-darwin-arm64@16.1.1':
|
||||
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.0.3':
|
||||
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
|
||||
'@next/swc-darwin-x64@16.1.1':
|
||||
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
||||
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
|
||||
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.0.3':
|
||||
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
|
||||
'@next/swc-linux-arm64-musl@16.1.1':
|
||||
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.0.3':
|
||||
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
|
||||
'@next/swc-linux-x64-gnu@16.1.1':
|
||||
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.0.3':
|
||||
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
|
||||
'@next/swc-linux-x64-musl@16.1.1':
|
||||
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
||||
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
|
||||
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.0.3':
|
||||
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
|
||||
'@next/swc-win32-x64-msvc@16.1.1':
|
||||
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1770,8 +1773,8 @@ packages:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
eslint-config-next@16.0.3:
|
||||
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
|
||||
eslint-config-next@16.1.1:
|
||||
resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -2467,8 +2470,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
||||
@@ -2620,8 +2623,8 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.0.3:
|
||||
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
|
||||
next@16.1.1:
|
||||
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3599,34 +3602,34 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
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:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@16.0.3':
|
||||
'@next/swc-darwin-arm64@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.0.3':
|
||||
'@next/swc-darwin-x64@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
||||
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.0.3':
|
||||
'@next/swc-linux-arm64-musl@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.0.3':
|
||||
'@next/swc-linux-x64-gnu@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.0.3':
|
||||
'@next/swc-linux-x64-musl@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
||||
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.0.3':
|
||||
'@next/swc-win32-x64-msvc@16.1.1':
|
||||
optional: true
|
||||
|
||||
'@nodelib/[email protected]':
|
||||
@@ -4948,9 +4951,9 @@ snapshots:
|
||||
|
||||
[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:
|
||||
'@next/eslint-plugin-next': 16.0.3
|
||||
'@next/eslint-plugin-next': 16.1.1
|
||||
eslint: 9.39.1([email protected])
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
|
||||
@@ -5803,7 +5806,7 @@ snapshots:
|
||||
'@types/mdast': 4.0.4
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
[email protected].0:
|
||||
[email protected].1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -6068,24 +6071,25 @@ snapshots:
|
||||
react: 19.2.0
|
||||
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:
|
||||
'@next/env': 16.0.3
|
||||
'@next/env': 16.1.1
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.8.29
|
||||
caniuse-lite: 1.0.30001755
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0([email protected])
|
||||
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.0.3
|
||||
'@next/swc-darwin-x64': 16.0.3
|
||||
'@next/swc-linux-arm64-gnu': 16.0.3
|
||||
'@next/swc-linux-arm64-musl': 16.0.3
|
||||
'@next/swc-linux-x64-gnu': 16.0.3
|
||||
'@next/swc-linux-x64-musl': 16.0.3
|
||||
'@next/swc-win32-arm64-msvc': 16.0.3
|
||||
'@next/swc-win32-x64-msvc': 16.0.3
|
||||
'@next/swc-darwin-arm64': 16.1.1
|
||||
'@next/swc-darwin-x64': 16.1.1
|
||||
'@next/swc-linux-arm64-gnu': 16.1.1
|
||||
'@next/swc-linux-arm64-musl': 16.1.1
|
||||
'@next/swc-linux-x64-gnu': 16.1.1
|
||||
'@next/swc-linux-x64-musl': 16.1.1
|
||||
'@next/swc-win32-arm64-msvc': 16.1.1
|
||||
'@next/swc-win32-x64-msvc': 16.1.1
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -6239,7 +6243,7 @@ snapshots:
|
||||
devlop: 1.1.0
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
html-url-attributes: 3.0.1
|
||||
mdast-util-to-hast: 13.2.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
react: 19.2.0
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
@@ -6344,7 +6348,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/hast': 3.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
|
||||
vfile: 6.0.3
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -4,11 +4,9 @@ import {
|
||||
retryWithBackoff,
|
||||
withTimeout,
|
||||
isRetryableError,
|
||||
type RetryConfig,
|
||||
calculateEngineTimeout,
|
||||
TIMEOUT_CONFIG
|
||||
} from '@/lib/retry-utils';
|
||||
import { EngineCircuitBreaker } from './engine-circuit-breaker';
|
||||
import { FailureCache } from './failure-cache';
|
||||
|
||||
export interface ScrapedContent {
|
||||
@@ -16,7 +14,7 @@ export interface ScrapedContent {
|
||||
url: string;
|
||||
content: string;
|
||||
markdown: string;
|
||||
cached?: boolean;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export interface SearchConfig {
|
||||
@@ -58,7 +56,6 @@ class WebSearchService {
|
||||
private lastSearchTime = 0;
|
||||
private pendingRequest: PendingRequest | null = null;
|
||||
private rateLimitQueue: PendingRequest[] = [];
|
||||
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
|
||||
private failureCache: FailureCache = new FailureCache();
|
||||
|
||||
constructor() {
|
||||
@@ -200,8 +197,9 @@ class WebSearchService {
|
||||
): Promise<ScrapedContent[]> {
|
||||
try {
|
||||
// Se SearchConfig foi fornecido, converter para formato Rust
|
||||
let rustConfig: any = undefined;
|
||||
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||
if (searchConfig) {
|
||||
const settings = useSettingsStore.getState();
|
||||
rustConfig = {
|
||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||
@@ -213,6 +211,10 @@ class WebSearchService {
|
||||
})),
|
||||
user_custom_sites: searchConfig.userCustomSites,
|
||||
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),
|
||||
search_config: rustConfig,
|
||||
});
|
||||
|
||||
console.log('Raw scraped results from backend:', results?.length ?? 0);
|
||||
return results || [];
|
||||
} catch (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)
|
||||
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
|
||||
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
|
||||
|
||||
// Filtrar motores não disponíveis (circuit breaker aberto)
|
||||
const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder);
|
||||
|
||||
if (availableEngines.length === 0) {
|
||||
console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`);
|
||||
// Se todos estão abertos, tentar mesmo assim (pode ser temporário)
|
||||
engineOrder = engineOrder;
|
||||
} else {
|
||||
engineOrder = availableEngines;
|
||||
if (availableEngines.length < engineOrder.length) {
|
||||
console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`);
|
||||
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||
if (searchConfig) {
|
||||
const settings = useSettingsStore.getState();
|
||||
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,
|
||||
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
|
||||
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
|
||||
|
||||
// Log da tentativa
|
||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
||||
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`);
|
||||
// Log detalhado da tentativa
|
||||
const searchStartTime = Date.now();
|
||||
console.log(`\n[WebSearch] ========== METADATA SEARCH START ==========`);
|
||||
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
|
||||
let retryAttempt = 0;
|
||||
@@ -315,7 +310,7 @@ class WebSearchService {
|
||||
query: query.trim(),
|
||||
limit,
|
||||
search_config: rustConfig,
|
||||
engineOrder: engineOrder, // Passar ordem de motores
|
||||
engine_order: undefined, // Não usado mais, mantido para compatibilidade
|
||||
});
|
||||
|
||||
const result = await withTimeout(
|
||||
@@ -323,16 +318,25 @@ class WebSearchService {
|
||||
retryTimeout,
|
||||
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
||||
);
|
||||
|
||||
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
|
||||
// (assumindo que o primeiro motor disponível foi usado)
|
||||
if (engineOrder.length > 0) {
|
||||
const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem
|
||||
this.circuitBreaker.recordAttempt(usedEngine, true, duration);
|
||||
// Log detalhado dos resultados
|
||||
console.log(`\n[WebSearch] ========== METADATA SEARCH RESULTS ==========`);
|
||||
console.log(`[WebSearch] Duration: ${duration}ms`);
|
||||
console.log(`[WebSearch] Results count: ${result?.length ?? 0}`);
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
@@ -340,12 +344,6 @@ class WebSearchService {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
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;
|
||||
}
|
||||
},
|
||||
@@ -360,7 +358,7 @@ class WebSearchService {
|
||||
const results = retryResult.result || [];
|
||||
if (results.length === 0) {
|
||||
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;
|
||||
}
|
||||
@@ -384,9 +382,8 @@ class WebSearchService {
|
||||
errorMsg
|
||||
);
|
||||
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`);
|
||||
// Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores
|
||||
// Retornar vazio mas não quebrar o fluxo
|
||||
return [];
|
||||
} else {
|
||||
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
||||
@@ -395,7 +392,6 @@ class WebSearchService {
|
||||
errorMsg
|
||||
);
|
||||
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -501,6 +497,13 @@ class WebSearchService {
|
||||
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
||||
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
|
||||
const metas = await this.executeMetadataSearch(
|
||||
query,
|
||||
@@ -513,7 +516,14 @@ class WebSearchService {
|
||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||
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) {
|
||||
console.warn(`[WebSearch] ⚠️ No URLs to scrape, returning metadata only`);
|
||||
console.log(`[WebSearch] ============================================\n`);
|
||||
// Retornar resultados parciais (metadados vazios mas sem erro)
|
||||
return { metadata: metas, contents: [] };
|
||||
}
|
||||
@@ -521,6 +531,8 @@ class WebSearchService {
|
||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
||||
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
||||
console.log(`[WebSearch] Starting scraping with timeout: ${scrapingTimeout}ms`);
|
||||
const scrapingStart = Date.now();
|
||||
|
||||
try {
|
||||
const contents = await withTimeout(
|
||||
@@ -528,6 +540,34 @@ class WebSearchService {
|
||||
scrapingTimeout,
|
||||
`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 || [] };
|
||||
} catch (error) {
|
||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
||||
|
||||
Generated
+490
-317
File diff suppressed because it is too large.
Load diff
+35
-3
@@ -16,6 +16,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
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]
|
||||
serde_json = "1.0"
|
||||
@@ -30,10 +40,7 @@ sysinfo = "0.36.1"
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
headless_chrome = "1.0"
|
||||
scraper = "0.18"
|
||||
readability = "0.3"
|
||||
html2text = "0.5"
|
||||
anyhow = "1.0"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
@@ -48,6 +55,11 @@ tauri-plugin-updater = "2"
|
||||
dirs = "5.0"
|
||||
rfd = "0.14"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
<<<<<<< HEAD
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.21"
|
||||
=======
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
|
||||
# ONNX Runtime para embeddings locais (ranking de relevância)
|
||||
# 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"] }
|
||||
ndarray = "0.16"
|
||||
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
@@ -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() {
|
||||
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())
|
||||
}
|
||||
Binary file not shown.
@@ -2,3 +2,7 @@ curl_cffi>=0.5.10
|
||||
beautifulsoup4>=4.12.0
|
||||
lxml>=4.9.0
|
||||
html2text>=2024.1.0
|
||||
playwright>=1.40.0
|
||||
fake-useragent>=1.4.0
|
||||
duckduckgo-search>=4.0.0
|
||||
nodriver>=0.33
|
||||
+334
-466
@@ -1,499 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script Python para scraping web com curl_cffi + BeautifulSoup
|
||||
Pipeline: curl_cffi (Request HTTP) -> BeautifulSoup (Parsing) -> html2text (Formatação)
|
||||
Comunica via JSON stdin/stdout com o backend Rust
|
||||
Micro-serviço Python para scraping e busca
|
||||
Pipeline:
|
||||
1. Scraping: curl_cffi / Playwright Stealth
|
||||
2. Busca: duckduckgo-search
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
from typing import List, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse, quote
|
||||
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:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
except (AttributeError, Exception):
|
||||
pass
|
||||
|
||||
# Configuração de Logging para STDERR (Não polui o STDOUT)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[PYTHON LOG] %(message)s',
|
||||
format='[PYTHON SCRAPER] %(levelname)s: %(message)s',
|
||||
stream=sys.stderr
|
||||
)
|
||||
|
||||
def print_json(data):
|
||||
"""Função auxiliar para garantir output limpo em JSON"""
|
||||
# --- 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'
|
||||
]
|
||||
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:
|
||||
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()
|
||||
except Exception as e:
|
||||
try:
|
||||
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 []
|
||||
logging.error(f"Erro enviando resposta JSON: {e}")
|
||||
|
||||
def main():
|
||||
"""Entry point do script - lê comandos JSON do stdin"""
|
||||
try:
|
||||
# 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
|
||||
logging.info("Scraper/Search Service v4.0 (DuckDuckGo Edition) Initialized")
|
||||
while True:
|
||||
try:
|
||||
command_data = json.loads(input_data)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.error(f"Erro ao fazer parse do JSON: {e}")
|
||||
print_json({"type": "error", "message": f"Invalid JSON: {str(e)}"})
|
||||
return
|
||||
|
||||
command = command_data.get("command")
|
||||
logging.info(f"Comando recebido: {command}")
|
||||
|
||||
# Dispatch de comandos
|
||||
if command == "scrape":
|
||||
url = command_data.get("url")
|
||||
visible = command_data.get("visible", False)
|
||||
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
|
||||
|
||||
if not url:
|
||||
print_json({"type": "error", "message": "URL missing"})
|
||||
return
|
||||
command = request.get("command")
|
||||
|
||||
result = scrape_url(url, visible)
|
||||
print_json(result)
|
||||
|
||||
elif command == "search_duckduckgo":
|
||||
query = command_data.get("query")
|
||||
limit = command_data.get("limit", 5)
|
||||
if command == "scrape":
|
||||
url = request.get("url")
|
||||
if url:
|
||||
result = asyncio.run(scrape_url_smart(url))
|
||||
write_json_response(result)
|
||||
else:
|
||||
write_json_response({"response_type": "error", "message": "No URL provided"})
|
||||
|
||||
if not query:
|
||||
print_json({"type": "error", "message": "Query missing"})
|
||||
return
|
||||
|
||||
urls = search_duckduckgo(query, limit)
|
||||
print_json({"type": "success", "urls": urls})
|
||||
|
||||
elif command == "smart_search":
|
||||
query = command_data.get("query")
|
||||
config = command_data.get("config", {})
|
||||
|
||||
if not query:
|
||||
print_json({"type": "error", "message": "Query missing"})
|
||||
return
|
||||
|
||||
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)}"})
|
||||
elif command == "search":
|
||||
# Generic search command, implemented via DuckDuckGo
|
||||
keywords = request.get("keywords") or request.get("query")
|
||||
region = request.get("region", "wt-wt")
|
||||
safesearch = request.get("safesearch", "moderate")
|
||||
max_results = request.get("max_results", 10)
|
||||
|
||||
if keywords:
|
||||
result = asyncio.run(search_duckduckgo(keywords, region, safesearch, max_results))
|
||||
write_json_response(result)
|
||||
else:
|
||||
write_json_response({"response_type": "error", "message": "Missing keywords"})
|
||||
|
||||
elif command == "ping":
|
||||
write_json_response({"response_type": "pong"})
|
||||
else:
|
||||
print_json({"type": "success", "message": "All dependencies available"})
|
||||
|
||||
else:
|
||||
print_json({"type": "error", "message": f"Unknown command: {command}"})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Erro no main: {traceback.format_exc()}")
|
||||
print_json({"type": "error", "message": f"Critical error: {str(e)}"})
|
||||
write_json_response({"response_type": "error", "message": "Unknown command"})
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logging.critical(f"Fatal error in main loop: {e}")
|
||||
write_json_response({"response_type": "error", "message": str(e)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
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)
|
||||
main()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -22,6 +22,25 @@ pub struct ChatMessage {
|
||||
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
|
||||
#[derive(Debug, Clone)]
|
||||
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
|
||||
self.conn.execute(
|
||||
@@ -127,6 +185,27 @@ impl Database {
|
||||
"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
|
||||
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
|
||||
self.conn.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||
@@ -228,52 +298,39 @@ impl Database {
|
||||
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<()> {
|
||||
// Verificar se sessions_fts já tem dados
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions_fts",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
// 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 sessions_fts está vazia
|
||||
let fts_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
|
||||
if fts_count == 0 {
|
||||
let main_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
|
||||
if main_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
|
||||
let count: i64 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages_fts",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
// 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",
|
||||
[],
|
||||
)?;
|
||||
// Verificar se messages_fts está vazia
|
||||
let fts_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
|
||||
if fts_msg_count == 0 {
|
||||
let main_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
|
||||
if main_msg_count > 0 {
|
||||
self.conn.execute(
|
||||
"INSERT INTO messages_fts(rowid, session_id, content) SELECT rowid, session_id, content FROM messages",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Cria uma nova sessão de chat
|
||||
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO sessions (id, title, emoji, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = ?2,
|
||||
emoji = ?3,
|
||||
updated_at = ?5",
|
||||
"INSERT INTO sessions (id, title, emoji, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
session.id,
|
||||
session.title,
|
||||
@@ -326,12 +383,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Salva uma sessão (create ou update)
|
||||
pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||
self.create_session(session)
|
||||
}
|
||||
|
||||
/// Lista todas as sessões ordenadas por updated_at DESC
|
||||
/// Lista todas as sessões ordenadas por atualização
|
||||
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
|
||||
@@ -358,14 +410,14 @@ impl Database {
|
||||
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<()> {
|
||||
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adiciona uma mensagem a uma sessão
|
||||
pub fn add_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
|
||||
/// Salva uma mensagem
|
||||
pub fn save_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, metadata, created_at)
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -467,6 +510,11 @@ impl Database {
|
||||
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)
|
||||
///
|
||||
/// Retorna as últimas `limit` mensagens a partir do `offset`.
|
||||
@@ -486,21 +534,44 @@ impl Database {
|
||||
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
|
||||
// Primeiro, obter o total de mensagens
|
||||
let total_count: usize = self.conn.query_row(
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
||||
params![session_id],
|
||||
|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
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
if total_count == 0 {
|
||||
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,
|
||||
// depois ordenar ASC para manter ordem cronológica
|
||||
//
|
||||
// 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
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
|
||||
|
||||
if real_limit == 0 {
|
||||
@@ -712,7 +783,12 @@ impl Database {
|
||||
)?;
|
||||
|
||||
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| {
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
Ok(SearchSessionResult {
|
||||
session: ChatSession {
|
||||
id: row.get(0)?,
|
||||
@@ -736,5 +812,118 @@ impl Database {
|
||||
|
||||
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)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,7 +7,10 @@ use anyhow::{Result, anyhow};
|
||||
use ndarray::Array2;
|
||||
use ort::session::{Session, builder::GraphOptimizationLevel};
|
||||
use ort::value::Value;
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
use std::io::Read;
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokenizers::Tokenizer;
|
||||
@@ -448,7 +451,11 @@ pub fn prune_context(
|
||||
let mut result = Vec::new();
|
||||
let mut total_tokens = 0;
|
||||
|
||||
<<<<<<< HEAD
|
||||
for (_score, paragraph, original_idx) in scored_paragraphs {
|
||||
=======
|
||||
for (score, paragraph, original_idx) in scored_paragraphs {
|
||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||
let paragraph_tokens = paragraph.split_whitespace().count();
|
||||
|
||||
if total_tokens + paragraph_tokens > max_tokens {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Mensagem para o Ollama API
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OllamaMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Request para chat do Ollama
|
||||
@@ -70,6 +70,9 @@ impl OllamaClient {
|
||||
system_prompt: Option<&str>,
|
||||
user_prompt: &str,
|
||||
) -> 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
|
||||
self.check_connection().await?;
|
||||
|
||||
@@ -96,12 +99,15 @@ impl OllamaClient {
|
||||
};
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
log::info!("[LLM_DEBUG] Iniciando request HTTP para o modelo (chat stream)...");
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.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() {
|
||||
return Err(format!("Ollama returned status: {}", response.status()));
|
||||
@@ -113,9 +119,14 @@ impl OllamaClient {
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut logged_first_chunk = false;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
|
||||
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)
|
||||
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() {
|
||||
return Err("Empty response from Ollama".to_string());
|
||||
}
|
||||
@@ -252,5 +263,234 @@ impl OllamaClient {
|
||||
"💬".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
Reference in new issue
Block a user