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
|
fi
|
||||||
echo "Frontend build successful - 'out' directory exists"
|
echo "Frontend build successful - 'out' directory exists"
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
- name: Prepare bundle resources (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
# Criar diretórios
|
||||||
|
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/ollama" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/models/embeddings" | Out-Null
|
||||||
|
|
||||||
|
# Baixar instalador do Ollama (se não existir)
|
||||||
|
$ollamaPath = "src-tauri/bundle/ollama/OllamaSetup.exe"
|
||||||
|
if (!(Test-Path $ollamaPath)) {
|
||||||
|
Write-Host "Downloading Ollama installer..."
|
||||||
|
Invoke-WebRequest -Uri "https://ollama.com/download/OllamaSetup.exe" -OutFile $ollamaPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baixar modelos de embeddings (se não existirem)
|
||||||
|
$modelPath = "src-tauri/bundle/models/embeddings/all-MiniLM-L6-v2.onnx"
|
||||||
|
if (!(Test-Path $modelPath)) {
|
||||||
|
Write-Host "Downloading ONNX embedding model (~90MB, this may take a while)..."
|
||||||
|
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx" -OutFile $modelPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenizerPath = "src-tauri/bundle/models/embeddings/tokenizer.json"
|
||||||
|
if (!(Test-Path $tokenizerPath)) {
|
||||||
|
Write-Host "Downloading tokenizer..."
|
||||||
|
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json" -OutFile $tokenizerPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
# Baixar e extrair ONNX Runtime DLL
|
||||||
|
$ortDllPath = "src-tauri/bundle/models/embeddings/onnxruntime.dll"
|
||||||
|
if (!(Test-Path $ortDllPath)) {
|
||||||
|
$ortZipPath = "src-tauri/bundle/models/embeddings/onnxruntime.zip"
|
||||||
|
if (!(Test-Path $ortZipPath)) {
|
||||||
|
Write-Host "Downloading ONNX Runtime library (~50MB)..."
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip" -OutFile $ortZipPath -UseBasicParsing
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Extracting ONNX Runtime DLL..."
|
||||||
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||||
|
$zip = [System.IO.Compression.ZipFile]::OpenRead($ortZipPath)
|
||||||
|
foreach ($entry in $zip.Entries) {
|
||||||
|
if ($entry.Name -eq "onnxruntime.dll") {
|
||||||
|
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $ortDllPath, $true)
|
||||||
|
Write-Host "Extracted onnxruntime.dll"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$zip.Dispose()
|
||||||
|
Remove-Item $ortZipPath -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Bundle resources prepared successfully"
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
run: |
|
run: |
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
|
|||||||
+11
@@ -40,3 +40,14 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
~/.tauri/ollahub.key
|
~/.tauri/ollahub.key
|
||||||
|
|
||||||
|
# tauri build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/bundle/
|
||||||
|
*.exe
|
||||||
|
*.msi
|
||||||
|
*.AppImage
|
||||||
|
*.deb
|
||||||
|
*.dmg
|
||||||
|
OllamaSetup.exe
|
||||||
|
src-tauri/bundle/ollama/
|
||||||
@@ -17,4 +17,11 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|||||||
|
|
||||||
7. COMMERCIAL LICENSING For usage scenarios that fall outside these terms (e.g., white-labeling, redistribution in paid suites, or custom enterprise agreements), please contact the author for a commercial license:
|
7. COMMERCIAL LICENSING For usage scenarios that fall outside these terms (e.g., white-labeling, redistribution in paid suites, or custom enterprise agreements), please contact the author for a commercial license:
|
||||||
|
|
||||||
Website: https://evandro.dev.br
|
Website: https://evandro.dev.br
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+312
-124
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, RefreshCw, Cpu } from "lucide-react";
|
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, RefreshCw, Cpu } from "lucide-react";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { ModelSelector } from "@/components/chat/model-selector";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { ChatMessage } from "@/components/chat/chat-message";
|
import { ChatMessage } from "@/components/chat/chat-message";
|
||||||
import { ReasoningChain } from "@/components/chat/reasoning-chain";
|
import { ReasoningChain } from "@/components/chat/reasoning-chain";
|
||||||
@@ -13,6 +14,7 @@ import { TitleBar } from "@/components/titlebar";
|
|||||||
import { SidebarList } from "@/components/chat/sidebar-list";
|
import { SidebarList } from "@/components/chat/sidebar-list";
|
||||||
import { useChat } from "@/hooks/use-chat";
|
import { useChat } from "@/hooks/use-chat";
|
||||||
import { useLocalModels } from "@/hooks/use-local-models";
|
import { useLocalModels } from "@/hooks/use-local-models";
|
||||||
|
import { useModelSelection } from "@/hooks/use-model-selection";
|
||||||
import { useChatStorage } from "@/hooks/use-chat-storage";
|
import { useChatStorage } from "@/hooks/use-chat-storage";
|
||||||
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
|
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
|
||||||
import { useWebSearch } from "@/hooks/use-web-search";
|
import { useWebSearch } from "@/hooks/use-web-search";
|
||||||
@@ -24,9 +26,10 @@ import { useDeepResearch } from "@/hooks/use-deep-research";
|
|||||||
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
|
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
|
||||||
import { useSettingsStore } from "@/store/settings-store";
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
import type { ScrapedContent } from "@/services/webSearch";
|
import type { ScrapedContent } from "@/services/webSearch";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, startTransition } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
||||||
import { chatLog } from "@/lib/terminal-logger";
|
import { chatLog } from "@/lib/terminal-logger";
|
||||||
@@ -43,6 +46,7 @@ export default function ChatPage() {
|
|||||||
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
||||||
const { models, refresh } = useLocalModels();
|
const { models, refresh } = useLocalModels();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { isSetupCompleted } = useSettingsStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
@@ -112,9 +116,68 @@ export default function ChatPage() {
|
|||||||
}));
|
}));
|
||||||
}, [setMessages]);
|
}, [setMessages]);
|
||||||
|
|
||||||
const [selectedModel, setSelectedModel] = useState("");
|
const { selectedModel, setSelectedModel } = useModelSelection();
|
||||||
|
|
||||||
|
// Sincronizar eventos de busca com thinking messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (webSearch.activeQueries.length > 0 || webSearch.activeUrls.length > 0) {
|
||||||
|
updateThinkingMessage('web-research', {
|
||||||
|
activeQueries: webSearch.activeQueries,
|
||||||
|
activeUrls: webSearch.activeUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [webSearch.activeQueries, webSearch.activeUrls, updateThinkingMessage]);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Refs para evitar loops e execuções múltiplas
|
||||||
|
const hasCheckedIntegrityRef = useRef(false);
|
||||||
|
const isRedirectingRef = useRef(false);
|
||||||
|
|
||||||
|
// Guarda de roteamento: verificar se setup foi completado
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSetupCompleted && !isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace("/setup"); // Mudar de push para replace para consistência
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isSetupCompleted, router]);
|
||||||
|
|
||||||
|
// Guarda de integridade: verificar se Ollama está funcional
|
||||||
|
// Executa SEMPRE que o componente montar, independente do status do setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasCheckedIntegrityRef.current) return; // Proteção contra múltiplas execuções
|
||||||
|
if (isRedirectingRef.current) return; // Evitar redirecionamentos múltiplos
|
||||||
|
|
||||||
|
const checkIntegrity = async () => {
|
||||||
|
try {
|
||||||
|
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
|
||||||
|
if (!isOllamaIntact && !isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
// Ollama não está funcional - redirecionar para setup
|
||||||
|
console.warn("Integridade do Ollama falhou no chat. Redirecionando para Setup.");
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Em caso de erro na verificação, redirecionar para setup (fallback seguro)
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
console.error("Falha ao verificar integridade do Ollama no chat:", e);
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
hasCheckedIntegrityRef.current = true; // Marcar como executado
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIntegrity();
|
||||||
|
}, [router]); // Remover isSetupCompleted das dependências - executar sempre
|
||||||
// Initialize with default format prompt
|
// Initialize with default format prompt
|
||||||
const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
||||||
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
||||||
@@ -137,25 +200,10 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, currentSessionId]);
|
}, [searchQuery, currentSessionId]);
|
||||||
|
|
||||||
// Auto-select first model
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initializedRef.current) return;
|
|
||||||
const stored = settings.selectedModel;
|
|
||||||
const first = models.length > 0 ? models[0].name : "";
|
|
||||||
const next = stored || first || "";
|
|
||||||
if (next) {
|
|
||||||
setSelectedModel(next);
|
|
||||||
if (!stored) {
|
|
||||||
settings.setSelectedModel(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
initializedRef.current = true;
|
|
||||||
}, [settings, models]);
|
|
||||||
|
|
||||||
// Auto-scroll para o final durante streaming
|
// Auto-scroll para o final durante streaming
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
|
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
|
||||||
@@ -425,7 +473,10 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
// Executar busca progressiva para cada query do plano
|
// Executar busca progressiva para cada query do plano
|
||||||
const searchPromises = searchPlan.map(async (query, idx) => {
|
const searchPromises = searchPlan.map(async (query, idx) => {
|
||||||
chatLog.info(` → Starting progressive search ${idx + 1}/${searchPlan.length}: "${query}"`);
|
const queryStartTime = Date.now();
|
||||||
|
chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1}/${searchPlan.length} ==========`);
|
||||||
|
chatLog.info(`[WebResearch] Query: "${query}"`);
|
||||||
|
chatLog.info(`[WebResearch] Timestamp: ${new Date().toISOString()}`);
|
||||||
|
|
||||||
// Atualizar progresso
|
// Atualizar progresso
|
||||||
updateThinkingMessage('web-research', {
|
updateThinkingMessage('web-research', {
|
||||||
@@ -438,6 +489,22 @@ export default function ChatPage() {
|
|||||||
const context = deepResearch.state.context;
|
const context = deepResearch.state.context;
|
||||||
const enrichedQueries = deepResearch.state.enrichedQueries;
|
const enrichedQueries = deepResearch.state.enrichedQueries;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
chatLog.info(`[WebResearch] Using contextual analysis:`, {
|
||||||
|
intent: context.intent,
|
||||||
|
entities: context.entities.length,
|
||||||
|
topics: context.topics.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichedQueries) {
|
||||||
|
chatLog.info(`[WebResearch] Using enriched queries:`, {
|
||||||
|
literal: enrichedQueries.literal.length,
|
||||||
|
semantic: enrichedQueries.semantic.length,
|
||||||
|
related: enrichedQueries.related.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Executar busca progressiva com semantic search se disponível
|
// Executar busca progressiva com semantic search se disponível
|
||||||
const fallbackResult = await executeProgressiveSearch(
|
const fallbackResult = await executeProgressiveSearch(
|
||||||
query,
|
query,
|
||||||
@@ -461,22 +528,47 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log do resultado
|
const queryDuration = Date.now() - queryStartTime;
|
||||||
chatLog.info(` ✓ Progressive search ${idx + 1} complete:`);
|
|
||||||
chatLog.info(` - Success: ${fallbackResult.success}`);
|
|
||||||
chatLog.info(` - Total results: ${fallbackResult.scrapedSources.length}`);
|
|
||||||
chatLog.info(` - Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
|
|
||||||
chatLog.info(` - Attempts: ${fallbackResult.attempts.length}`);
|
|
||||||
chatLog.info(` - Used fallback: ${fallbackResult.usedFallback}`);
|
|
||||||
|
|
||||||
// Log detalhado de cada resultado
|
// Log detalhado do resultado
|
||||||
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1} RESULTS ==========`);
|
||||||
chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`);
|
chatLog.info(`[WebResearch] Duration: ${queryDuration}ms`);
|
||||||
chatLog.info(` URL: ${result.url}`);
|
chatLog.info(`[WebResearch] Success: ${fallbackResult.success}`);
|
||||||
const contentLength = result.markdown?.length || 0;
|
chatLog.info(`[WebResearch] Total results: ${fallbackResult.scrapedSources.length}`);
|
||||||
chatLog.info(` Content length: ${contentLength} chars`);
|
chatLog.info(`[WebResearch] Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
|
||||||
|
chatLog.info(`[WebResearch] Attempts: ${fallbackResult.attempts.length}`);
|
||||||
|
chatLog.info(`[WebResearch] Used fallback: ${fallbackResult.usedFallback}`);
|
||||||
|
|
||||||
|
// Log detalhado de cada tentativa
|
||||||
|
fallbackResult.attempts.forEach((attempt, attemptIdx) => {
|
||||||
|
chatLog.info(`\n Attempt ${attemptIdx + 1}:`);
|
||||||
|
chatLog.info(` Round: ${attempt.round}`);
|
||||||
|
chatLog.info(` Strategy: ${attempt.strategy || 'N/A'}`);
|
||||||
|
chatLog.info(` Query: "${attempt.query}"`);
|
||||||
|
chatLog.info(` Results: ${attempt.results.length}`);
|
||||||
|
chatLog.info(` Relevance score: ${attempt.relevanceScore.toFixed(3)}`);
|
||||||
|
chatLog.info(` Duration: ${attempt.duration}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log detalhado de cada resultado extraído
|
||||||
|
if (fallbackResult.scrapedSources.length > 0) {
|
||||||
|
chatLog.info(`\n[WebResearch] Extracted Sources:`);
|
||||||
|
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
||||||
|
chatLog.info(`\n [${resultIdx + 1}] ${result.title}`);
|
||||||
|
chatLog.info(` URL: ${result.url}`);
|
||||||
|
const contentLength = result.markdown?.length || result.content?.length || 0;
|
||||||
|
const snippetLength = result.snippet?.length || 0;
|
||||||
|
chatLog.info(` Markdown length: ${contentLength} chars`);
|
||||||
|
chatLog.info(` Snippet length: ${snippetLength} chars`);
|
||||||
|
chatLog.info(` Cached: ${result.cached ? 'Yes' : 'No'}`);
|
||||||
|
if (result.markdown) {
|
||||||
|
const preview = result.markdown.substring(0, 200);
|
||||||
|
chatLog.info(` Preview: ${preview}${contentLength > 200 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chatLog.info(`[WebResearch] ============================================\n`);
|
||||||
|
|
||||||
// Adicionar resultados à Knowledge Base
|
// Adicionar resultados à Knowledge Base
|
||||||
if (fallbackResult.scrapedSources.length > 0) {
|
if (fallbackResult.scrapedSources.length > 0) {
|
||||||
deepResearch.addToKnowledgeBase(query, fallbackResult.scrapedSources);
|
deepResearch.addToKnowledgeBase(query, fallbackResult.scrapedSources);
|
||||||
@@ -516,13 +608,14 @@ export default function ChatPage() {
|
|||||||
chatLog.info(`All searches complete. Knowledge Base size: ${deepResearch.state.knowledgeBase.length}`);
|
chatLog.info(`All searches complete. Knowledge Base size: ${deepResearch.state.knowledgeBase.length}`);
|
||||||
|
|
||||||
// Coletar todas as fontes para exibição
|
// Coletar todas as fontes para exibição
|
||||||
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
||||||
url: entry.sourceUrl,
|
url: entry.sourceUrl,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
content: entry.content,
|
content: entry.content,
|
||||||
markdown: entry.content,
|
markdown: entry.content,
|
||||||
snippet: entry.content.substring(0, 200)
|
snippet: entry.content.substring(0, 200),
|
||||||
}));
|
cached: false
|
||||||
|
}));
|
||||||
|
|
||||||
// Adicionar mensagem: Fontes encontradas
|
// Adicionar mensagem: Fontes encontradas
|
||||||
if (scrapedSources.length > 0) {
|
if (scrapedSources.length > 0) {
|
||||||
@@ -535,56 +628,84 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
if (deepResearch.state.knowledgeBase.length > 0) {
|
if (deepResearch.state.knowledgeBase.length > 0) {
|
||||||
// Passo 3: Validação e Contexto
|
// Passo 3: Validação e Contexto
|
||||||
chatLog.info('\nStep 3: Validation');
|
chatLog.info('\n[DeepResearch] ========== STEP 3: VALIDATION ==========');
|
||||||
deepResearch.setStep('aggregating');
|
deepResearch.setStep('aggregating');
|
||||||
|
|
||||||
// Adicionar mensagem: Processamento
|
// Adicionar mensagem: Processamento
|
||||||
addThinkingMessage('processing', 'Processando contexto...', 'running');
|
addThinkingMessage('processing', 'Processando contexto...', 'running');
|
||||||
|
const validationStart = Date.now();
|
||||||
validationReport = await deepResearch.validate(selectedModel, content);
|
validationReport = await deepResearch.validate(selectedModel, content);
|
||||||
chatLog.info(`Validation Report Length: ${validationReport.length} chars`);
|
const validationDuration = Date.now() - validationStart;
|
||||||
|
|
||||||
|
chatLog.info(`[DeepResearch] Validation duration: ${validationDuration}ms`);
|
||||||
|
chatLog.info(`[DeepResearch] Validation report length: ${validationReport.length} chars`);
|
||||||
|
if (validationReport) {
|
||||||
|
const preview = validationReport.substring(0, 500);
|
||||||
|
chatLog.info(`[DeepResearch] Validation preview: ${preview}${validationReport.length > 500 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Passo 4: Obter contexto curado (usando versão otimizada)
|
// Passo 4: Obter contexto curado (usando versão otimizada)
|
||||||
chatLog.info('\nStep 4: Getting Curated Context (Optimized)');
|
chatLog.info('\n[DeepResearch] ========== STEP 4: CONTEXT CONDENSATION ==========');
|
||||||
|
|
||||||
// Calcular tokens disponíveis dinamicamente
|
// Calcular tokens disponíveis dinamicamente
|
||||||
const { getModelContextInfo } = await import('@/lib/model-context');
|
const { getModelContextInfo } = await import('@/lib/model-context');
|
||||||
const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages);
|
const contextInfo = await getModelContextInfo(selectedModel, systemPrompt, messages);
|
||||||
const availableTokens = contextInfo.recommendedContextWindow;
|
const availableTokens = contextInfo.recommendedContextWindow;
|
||||||
|
|
||||||
chatLog.info(`Model Context Window: ${contextInfo.maxContextWindow} tokens`);
|
chatLog.info(`[DeepResearch] Model: ${selectedModel}`);
|
||||||
chatLog.info(`Available Tokens for KB: ${availableTokens} tokens`);
|
chatLog.info(`[DeepResearch] Max context window: ${contextInfo.maxContextWindow} tokens`);
|
||||||
|
chatLog.info(`[DeepResearch] Available tokens for KB: ${availableTokens} tokens`);
|
||||||
|
|
||||||
|
const condensationStart = Date.now();
|
||||||
const optimizedResult = deepResearch.getCuratedContextOptimized(content, availableTokens);
|
const optimizedResult = deepResearch.getCuratedContextOptimized(content, availableTokens);
|
||||||
|
const condensationDuration = Date.now() - condensationStart;
|
||||||
knowledgeBaseContext = optimizedResult.context;
|
knowledgeBaseContext = optimizedResult.context;
|
||||||
|
|
||||||
chatLog.info(`Knowledge Base Context Length: ${knowledgeBaseContext.length} chars`);
|
chatLog.info(`\n[DeepResearch] ========== CONDENSATION SUMMARY ==========`);
|
||||||
chatLog.info(`Condensation Method: ${optimizedResult.result.method}`);
|
chatLog.info(`[DeepResearch] Duration: ${condensationDuration}ms`);
|
||||||
chatLog.info(`Chunks Used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
|
chatLog.info(`[DeepResearch] Final context length: ${knowledgeBaseContext.length} chars`);
|
||||||
chatLog.info(`Compression Ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
|
chatLog.info(`[DeepResearch] Method: ${optimizedResult.result.method}`);
|
||||||
|
chatLog.info(`[DeepResearch] Chunks used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
|
||||||
|
chatLog.info(`[DeepResearch] Compression ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
|
||||||
|
chatLog.info(`[DeepResearch] Original tokens: ${optimizedResult.result.originalTokens}`);
|
||||||
|
chatLog.info(`[DeepResearch] Final tokens: ${optimizedResult.result.totalTokens}`);
|
||||||
|
|
||||||
|
// Log detalhado por fonte
|
||||||
|
if (optimizedResult.result.sources.length > 0) {
|
||||||
|
chatLog.info(`[DeepResearch] Chunks per source:`);
|
||||||
|
optimizedResult.result.sources.forEach((source, idx) => {
|
||||||
|
chatLog.info(` [${idx + 1}] ${source.title}: ${source.chunksUsed} chunks`);
|
||||||
|
chatLog.info(` URL: ${source.url}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log preview do contexto se não for muito longo
|
// Log preview do contexto se não for muito longo
|
||||||
if (knowledgeBaseContext.length > 0) {
|
if (knowledgeBaseContext.length > 0) {
|
||||||
|
chatLog.info(`\n[DeepResearch] Context preview:`);
|
||||||
if (knowledgeBaseContext.length > 2000) {
|
if (knowledgeBaseContext.length > 2000) {
|
||||||
chatLog.info(`Context Preview (first 2000 chars):\n${knowledgeBaseContext.substring(0, 2000)}...`);
|
chatLog.info(`${knowledgeBaseContext.substring(0, 2000)}...`);
|
||||||
|
chatLog.info(`[DeepResearch] (truncated, ${knowledgeBaseContext.length} chars total)`);
|
||||||
} else {
|
} else {
|
||||||
chatLog.info(`Full Context:\n${knowledgeBaseContext}`);
|
chatLog.info(knowledgeBaseContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar contexto
|
// Validar contexto
|
||||||
const { validateCondensedContext } = await import('@/lib/knowledge-base-processor');
|
const { validateCondensedContext } = await import('@/lib/knowledge-base-processor');
|
||||||
const validation = validateCondensedContext(optimizedResult.result, content);
|
const validation = validateCondensedContext(optimizedResult.result, content);
|
||||||
|
|
||||||
|
chatLog.info(`\n[DeepResearch] ========== CONTEXT VALIDATION ==========`);
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
chatLog.error(`Context validation failed: ${validation.warnings.join(', ')}`);
|
chatLog.error(`[DeepResearch] Validation failed: ${validation.warnings.join(', ')}`);
|
||||||
} else if (validation.warnings.length > 0) {
|
} else if (validation.warnings.length > 0) {
|
||||||
validation.warnings.forEach(warning => {
|
validation.warnings.forEach(warning => {
|
||||||
chatLog.warn(`⚠️ ${warning}`);
|
chatLog.warn(`[DeepResearch] ⚠️ ${warning}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
chatLog.info('✓ Context validation passed');
|
chatLog.info(`[DeepResearch] ✓ Context validation passed`);
|
||||||
}
|
}
|
||||||
|
chatLog.info(`[DeepResearch] ============================================\n`);
|
||||||
} else {
|
} else {
|
||||||
chatLog.warn('⚠️ Knowledge Base Context is empty!');
|
chatLog.warn('[DeepResearch] ⚠️ Knowledge Base Context is empty!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -623,17 +744,21 @@ export default function ChatPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Tratar erro graciosamente sem quebrar o fluxo
|
// Tratar erro graciosamente sem quebrar o fluxo
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${errorMsg}`);
|
chatLog.warn(`⚠️ Error in Deep Research pipeline (using local model fallback): ${errorMsg}`);
|
||||||
|
|
||||||
// Atualizar mensagem de processo com aviso (não erro fatal)
|
// Atualizar mensagem de processo com aviso (não erro fatal)
|
||||||
updateThinkingMessage('web-research', {
|
updateThinkingMessage('web-research', {
|
||||||
status: 'completed', // Marcar como completed mesmo com erro parcial
|
status: 'completed', // Marcar como completed mesmo com erro
|
||||||
details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`,
|
details: `Erro na pesquisa web (${errorMsg}). Usando modelo local como fallback.`,
|
||||||
duration: Date.now() - webResearchStart,
|
duration: Date.now() - webResearchStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Garantir que o estado de erro seja registrado para ativar fallback
|
||||||
|
// O erro já será detectado pelo hasWebSearchError através do estado do hook
|
||||||
|
chatLog.info('Web search error registered - will use local model fallback');
|
||||||
|
|
||||||
// Não definir erro global - permitir que o fluxo continue
|
// Não definir erro global - permitir que o fluxo continue
|
||||||
// O sistema continuará com conhecimento interno se necessário
|
// O sistema continuará com conhecimento interno (fallback automático)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!webSearch.isEnabled) {
|
if (!webSearch.isEnabled) {
|
||||||
@@ -694,8 +819,9 @@ export default function ChatPage() {
|
|||||||
let enhancedSystemPrompt = systemPrompt;
|
let enhancedSystemPrompt = systemPrompt;
|
||||||
|
|
||||||
// Se tivermos uma Knowledge Base, usar o prompt STRICT_GENERATION
|
// Se tivermos uma Knowledge Base, usar o prompt STRICT_GENERATION
|
||||||
// Verificar se houve fallback (nenhum resultado relevante encontrado)
|
// Verificar se houve fallback (nenhum resultado relevante encontrado ou erro na pesquisa)
|
||||||
const usedFallback = knowledgeBaseContext.length === 0 || knowledgeBaseContext.length < 100;
|
const usedFallback = knowledgeBaseContext.length === 0 || knowledgeBaseContext.length < 100;
|
||||||
|
const hasWebSearchError = webSearch.status === 'error' || webSearch.error !== null;
|
||||||
|
|
||||||
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
||||||
const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION
|
const strictPrompt = DEEP_RESEARCH_PROMPTS.STRICT_GENERATION
|
||||||
@@ -704,25 +830,36 @@ export default function ChatPage() {
|
|||||||
.replace('{{userQuery}}', content);
|
.replace('{{userQuery}}', content);
|
||||||
|
|
||||||
enhancedSystemPrompt = strictPrompt;
|
enhancedSystemPrompt = strictPrompt;
|
||||||
} else if (usedFallback) {
|
} else if (usedFallback || hasWebSearchError) {
|
||||||
// Fallback: usar prompt sem contexto web, mas informando que não encontrou fontes
|
// Fallback: usar prompt sem contexto web, mas informando que não encontrou fontes
|
||||||
|
// Isso garante que o modelo local sempre gere uma resposta mesmo sem dados web
|
||||||
|
const errorContext = hasWebSearchError && webSearch.error
|
||||||
|
? `\n**Motivo:** ${webSearch.error}`
|
||||||
|
: '';
|
||||||
|
|
||||||
enhancedSystemPrompt = `${systemPrompt}
|
enhancedSystemPrompt = `${systemPrompt}
|
||||||
|
|
||||||
## ⚠️ AVISO IMPORTANTE
|
## ⚠️ FALLBACK PARA CONHECIMENTO INTERNO
|
||||||
|
|
||||||
Não foi possível encontrar informações relevantes nas fontes web consultadas após múltiplas tentativas de busca.
|
Não foi possível acessar fontes web para esta consulta após múltiplas tentativas de busca.${errorContext}
|
||||||
|
|
||||||
**INSTRUÇÕES:**
|
**INSTRUÇÕES CRÍTICAS:**
|
||||||
- Responda usando APENAS seu conhecimento interno (treinamento)
|
- Você DEVE responder usando APENAS seu conhecimento interno (treinamento do modelo)
|
||||||
- Seja honesto e diga: "Não encontrei essa informação nas fontes consultadas."
|
- Seja transparente: informe que não foi possível acessar fontes web, mas que você responderá com base no seu conhecimento
|
||||||
- Se tiver conhecimento sobre o tópico, compartilhe, mas deixe claro que não há fontes externas verificadas
|
- Se tiver conhecimento sobre o tópico, compartilhe-o de forma útil e detalhada
|
||||||
- Não invente informações ou cite fontes que não foram consultadas
|
- Seja honesto sobre limitações: se não souber algo, diga claramente
|
||||||
|
- NÃO invente informações ou cite fontes que não foram consultadas
|
||||||
|
- NÃO mencione "fontes consultadas" ou "pesquisa realizada" - apenas responda com seu conhecimento
|
||||||
|
|
||||||
|
**IMPORTANTE:** Este é um fallback legítimo. O usuário espera uma resposta útil mesmo sem dados web. Use seu conhecimento para ajudar da melhor forma possível.
|
||||||
|
|
||||||
## DATA E HORA ATUAL DO SISTEMA
|
## DATA E HORA ATUAL DO SISTEMA
|
||||||
|
|
||||||
**DATA/HORA FORMATADA:** ${currentDateTime}
|
**DATA/HORA FORMATADA:** ${currentDateTime}
|
||||||
|
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
|
||||||
|
**ISO 8601:** ${currentDateISO}
|
||||||
|
|
||||||
Use esta data exata para referências temporais.`;
|
Use esta data exata para referências temporais quando relevante.`;
|
||||||
}
|
}
|
||||||
// Fallback para lógica antiga se não tiver Knowledge Base
|
// Fallback para lógica antiga se não tiver Knowledge Base
|
||||||
else if (knowledgeBaseContext) {
|
else if (knowledgeBaseContext) {
|
||||||
@@ -792,6 +929,9 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
let finalUserContent = content;
|
let finalUserContent = content;
|
||||||
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
if (knowledgeBaseContext && knowledgeBaseContext.length > 100) {
|
||||||
finalUserContent = `[KNOWLEDGE BASE - ÚNICA FONTE DE VERDADE]\n${knowledgeBaseContext}\n[/KNOWLEDGE BASE]\n\nResponda a pergunta usando APENAS os dados acima. Se a informação não estiver na Knowledge Base, diga "Não encontrei essa informação nas fontes consultadas."\n\nPergunta: ${content}`;
|
finalUserContent = `[KNOWLEDGE BASE - ÚNICA FONTE DE VERDADE]\n${knowledgeBaseContext}\n[/KNOWLEDGE BASE]\n\nResponda a pergunta usando APENAS os dados acima. Se a informação não estiver na Knowledge Base, diga "Não encontrei essa informação nas fontes consultadas."\n\nPergunta: ${content}`;
|
||||||
|
} else if (usedFallback || hasWebSearchError) {
|
||||||
|
// Quando usar fallback, garantir que o modelo saiba que deve usar conhecimento interno
|
||||||
|
finalUserContent = `Pergunta do usuário: ${content}\n\nNota: Não foi possível acessar fontes web para esta consulta. Responda usando seu conhecimento interno de forma útil e detalhada.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
chatLog.info(`Final User Content Length: ${finalUserContent.length} chars`);
|
chatLog.info(`Final User Content Length: ${finalUserContent.length} chars`);
|
||||||
@@ -822,6 +962,75 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
chatLog.info('✅ Response generation complete');
|
chatLog.info('✅ Response generation complete');
|
||||||
chatLog.info('========== QUERY COMPLETE ==========\n');
|
chatLog.info('========== QUERY COMPLETE ==========\n');
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Capturar dados antes do setMessages para garantir que estão no escopo correto
|
||||||
|
const currentScrapedSources = scrapedSources.length > 0
|
||||||
|
? scrapedSources
|
||||||
|
: deepResearch.state.knowledgeBase.map(entry => ({
|
||||||
|
url: entry.sourceUrl,
|
||||||
|
title: entry.title,
|
||||||
|
content: entry.content,
|
||||||
|
markdown: entry.content,
|
||||||
|
cached: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Coletar thinking steps das mensagens (apenas os relacionados a esta query)
|
||||||
|
const allThinkingSteps: ThinkingMessageMetadata[] = [];
|
||||||
|
const currentMessages = messages; // Capturar referência atual
|
||||||
|
currentMessages.forEach((msg, idx) => {
|
||||||
|
// Coletar thinking steps das últimas mensagens (relacionadas a esta query)
|
||||||
|
if (idx >= currentMessages.length - 10) { // Últimas 10 mensagens
|
||||||
|
if (msg.metadata && typeof msg.metadata === 'object' && 'type' in msg.metadata && msg.metadata.type === 'thinking') {
|
||||||
|
allThinkingSteps.push(msg.metadata as ThinkingMessageMetadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coletar sites pesquisados com detalhes completos
|
||||||
|
const sitesResearched = currentScrapedSources.map((source, idx) => {
|
||||||
|
// Tentar encontrar informações de duração e status dos logs
|
||||||
|
const sourceLog = deepResearch.state.logs.find(log => {
|
||||||
|
if (!log.parsedOutput || typeof log.parsedOutput !== 'object') return false;
|
||||||
|
const parsed = log.parsedOutput as Record<string, unknown>;
|
||||||
|
return 'url' in parsed && parsed.url === source.url;
|
||||||
|
});
|
||||||
|
|
||||||
|
let duration: number | undefined = undefined;
|
||||||
|
if (sourceLog?.parsedOutput && typeof sourceLog.parsedOutput === 'object') {
|
||||||
|
const parsed = sourceLog.parsedOutput as Record<string, unknown>;
|
||||||
|
if ('duration' in parsed && typeof parsed.duration === 'number') {
|
||||||
|
duration = parsed.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: source.url,
|
||||||
|
title: source.title || 'Sem título',
|
||||||
|
status: source.cached ? 'cached' : 'scraped',
|
||||||
|
source: source.cached ? 'cache' : 'nodriver',
|
||||||
|
contentLength: source.content?.length || source.markdown?.length || 0,
|
||||||
|
scrapedAt: sourceLog?.timestamp || Date.now(),
|
||||||
|
duration: duration,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coletar dados de raciocínio dos logs do deep research
|
||||||
|
const reasoningSteps = deepResearch.state.logs
|
||||||
|
.filter(log => log.stage === 'generation' || log.stage === 'validation')
|
||||||
|
.map(log => ({
|
||||||
|
stage: log.stage,
|
||||||
|
input: log.input,
|
||||||
|
output: log.rawOutput || JSON.stringify(log.parsedOutput),
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Estimar token usage (aproximado)
|
||||||
|
const estimatedInputTokens = Math.ceil((enhancedSystemPrompt.length + finalUserContent.length) / 4);
|
||||||
|
const lastAssistantMsg = currentMessages.find(m => m.role === 'assistant');
|
||||||
|
const estimatedOutputTokens = Math.ceil((lastAssistantMsg?.content?.length || 0) / 4);
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
// Construir dados de debug para a última mensagem do assistente
|
// Construir dados de debug para a última mensagem do assistente
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const newMessages = [...prev];
|
const newMessages = [...prev];
|
||||||
@@ -834,6 +1043,16 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
latency: Date.now() - responseStart,
|
latency: Date.now() - responseStart,
|
||||||
systemPrompt: enhancedSystemPrompt,
|
systemPrompt: enhancedSystemPrompt,
|
||||||
userQuery: content,
|
userQuery: content,
|
||||||
|
<<<<<<< HEAD
|
||||||
|
contextUsed: prev.filter(m => m.role !== 'system' && m.role !== 'assistant'),
|
||||||
|
webResearch: {
|
||||||
|
queries: deepResearch.state.plan || [],
|
||||||
|
enrichedQueries: deepResearch.state.enrichedQueries || undefined,
|
||||||
|
sources: currentScrapedSources,
|
||||||
|
sitesResearched: sitesResearched,
|
||||||
|
logs: deepResearch.state.logs || [],
|
||||||
|
plan: deepResearch.state.plan || [],
|
||||||
|
=======
|
||||||
contextUsed: messages.filter(m => m.role !== 'system'),
|
contextUsed: messages.filter(m => m.role !== 'system'),
|
||||||
webResearch: {
|
webResearch: {
|
||||||
queries: deepResearch.state.plan || [],
|
queries: deepResearch.state.plan || [],
|
||||||
@@ -841,15 +1060,39 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
sources: scrapedSources,
|
sources: scrapedSources,
|
||||||
logs: deepResearch.state.logs,
|
logs: deepResearch.state.logs,
|
||||||
plan: deepResearch.state.plan,
|
plan: deepResearch.state.plan,
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
|
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
|
||||||
sourceUrl: entry.sourceUrl,
|
sourceUrl: entry.sourceUrl,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
content: entry.content,
|
content: entry.content,
|
||||||
})),
|
})),
|
||||||
|
<<<<<<< HEAD
|
||||||
|
activeQueries: webSearch.activeQueries.length > 0 ? webSearch.activeQueries : undefined,
|
||||||
|
activeUrls: webSearch.activeUrls.length > 0 ? webSearch.activeUrls : undefined,
|
||||||
|
},
|
||||||
|
deepResearchState: deepResearch.state,
|
||||||
|
thinkingSteps: allThinkingSteps.length > 0 ? allThinkingSteps : undefined,
|
||||||
|
reasoning: reasoningSteps.length > 0 ? {
|
||||||
|
steps: reasoningSteps.map(r => `${r.stage}: ${r.input.substring(0, 100)}...`),
|
||||||
|
intermediateResults: reasoningSteps.map(r => ({
|
||||||
|
stage: r.stage,
|
||||||
|
output: r.output,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
finalResponse: lastMsg.content || '',
|
||||||
|
rawResponse: lastMsg.content || '',
|
||||||
|
tokenUsage: {
|
||||||
|
input: estimatedInputTokens,
|
||||||
|
output: estimatedOutputTokens,
|
||||||
|
total: estimatedInputTokens + estimatedOutputTokens,
|
||||||
|
},
|
||||||
|
=======
|
||||||
},
|
},
|
||||||
deepResearchState: deepResearch.state,
|
deepResearchState: deepResearch.state,
|
||||||
finalResponse: lastMsg.content,
|
finalResponse: lastMsg.content,
|
||||||
rawResponse: lastMsg.content,
|
rawResponse: lastMsg.content,
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
};
|
};
|
||||||
|
|
||||||
// Armazenar debug data usando o índice da mensagem
|
// Armazenar debug data usando o índice da mensagem
|
||||||
@@ -1030,7 +1273,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden pt-8">
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
|
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@@ -1137,61 +1380,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
|
|
||||||
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
|
<div className="flex-1 max-w-full sm:max-w-[420px] flex items-center gap-2 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<TooltipProvider>
|
<ModelSelector />
|
||||||
<Select
|
|
||||||
value={selectedModel || undefined}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v === "__add_model__") {
|
|
||||||
setShowDownloadDialog(true);
|
|
||||||
// Não alterar selectedModel quando abrir dialog
|
|
||||||
} else {
|
|
||||||
setSelectedModel(v);
|
|
||||||
settings.setSelectedModel(v);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SelectTrigger className="h-9 min-w-0 max-w-full">
|
|
||||||
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{selectedModel && (
|
|
||||||
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
|
||||||
<p className="text-sm">{selectedModel}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
<SelectContent className="max-w-[90vw] sm:max-w-[420px]">
|
|
||||||
{models.map(m => (
|
|
||||||
<Tooltip key={m.name}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SelectItem value={m.name} className="min-w-0">
|
|
||||||
<span className="flex items-center justify-between w-full min-w-0 gap-2">
|
|
||||||
<span className="flex items-center gap-2 truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
|
|
||||||
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
||||||
<span className="truncate">{m.name}</span>
|
|
||||||
</span>
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{m.name.length > 40 && (
|
|
||||||
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
|
||||||
<p className="text-sm">{m.name}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
<SelectItem value="__add_model__" className="text-primary font-medium">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Adicionar modelo...
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
@@ -1463,4 +1652,3 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +376,16 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility class para ocultar scrollbar */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
|
||||||
/* Reasoning Chain animations */
|
/* Reasoning Chain animations */
|
||||||
@keyframes reasoning-fade-in {
|
@keyframes reasoning-fade-in {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import "./globals.css";
|
|||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { AppUpdaterProvider } from "@/components/app-updater-provider";
|
import { AppUpdaterProvider } from "@/components/app-updater-provider";
|
||||||
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import { DownloadProvider } from "@/contexts/download-context";
|
||||||
|
import { GlobalDownloadStatusBar } from "@/components/global-download-status-bar";
|
||||||
|
import { OllamaHealthProvider } from "@/contexts/ollama-health-context";
|
||||||
|
import { ServiceInterruptionOverlay } from "@/components/ui/service-interruption-overlay";
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -40,9 +47,21 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<OllamaHealthProvider>
|
||||||
|
<DownloadProvider>
|
||||||
|
<KeyboardShortcuts />
|
||||||
|
{children}
|
||||||
|
<AppUpdaterProvider />
|
||||||
|
<GlobalDownloadStatusBar />
|
||||||
|
<ServiceInterruptionOverlay />
|
||||||
|
</DownloadProvider>
|
||||||
|
</OllamaHealthProvider>
|
||||||
|
=======
|
||||||
<KeyboardShortcuts />
|
<KeyboardShortcuts />
|
||||||
{children}
|
{children}
|
||||||
<AppUpdaterProvider />
|
<AppUpdaterProvider />
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+130
@@ -1,5 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import { TitleBar } from "@/components/titlebar";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, startTransition, useRef } from "react";
|
||||||
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
=======
|
||||||
import { useSetupCheck } from "@/hooks/use-setup-check";
|
import { useSetupCheck } from "@/hooks/use-setup-check";
|
||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { InstallModal } from "@/components/modals/install-modal";
|
import { InstallModal } from "@/components/modals/install-modal";
|
||||||
@@ -11,8 +21,111 @@ import { useEffect, startTransition } from "react";
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
|
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { setSetupCompleted } = useSettingsStore();
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
|
// Refs para evitar loops e execuções múltiplas
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const isRedirectingRef = useRef(false);
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Hydration check: aguardar Zustand carregar do localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
setIsHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Guarda de roteamento: verificação de integridade do Ollama + flag persistente
|
||||||
|
// Prioridade: Integridade do Sistema > Flag de Setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHydrated) return;
|
||||||
|
if (hasCheckedRef.current) return; // Proteção contra múltiplas execuções
|
||||||
|
|
||||||
|
const performCheck = async () => {
|
||||||
|
if (isRedirectingRef.current) return; // Evitar múltiplos redirecionamentos
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Verificação Real do Sistema (Fonte da Verdade)
|
||||||
|
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
|
||||||
|
// Ler isSetupCompleted diretamente do store (não usar como dependência)
|
||||||
|
const currentSetupStatus = useSettingsStore.getState().isSetupCompleted;
|
||||||
|
|
||||||
|
if (!isOllamaIntact) {
|
||||||
|
// Se o motor não existe, força o setup, não importa o que o storage diga
|
||||||
|
console.warn("Integridade do Ollama falhou. Redirecionando para Setup.");
|
||||||
|
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
// Resetar flag de setup se Ollama não está íntegro (ANTES de redirecionar)
|
||||||
|
setSetupCompleted(false);
|
||||||
|
|
||||||
|
// Pequeno delay para garantir que o estado seja persistido no localStorage
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificação de Fluxo de UI (apenas se Ollama está OK)
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
|
||||||
|
if (currentSetupStatus) {
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/chat');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Falha crítica no check de inicialização", e);
|
||||||
|
// Fallback seguro: sempre ir para setup em caso de erro
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
setSetupCompleted(false);
|
||||||
|
|
||||||
|
// Pequeno delay para garantir que o estado seja persistido no localStorage
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
hasCheckedRef.current = true; // Marcar como executado
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
performCheck();
|
||||||
|
}, [isHydrated, router, setSetupCompleted]);
|
||||||
|
|
||||||
|
// Enquanto não hidratou ou está verificando, retornar skeleton simples
|
||||||
|
if (!isHydrated || isChecking) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex-1 flex items-center justify-center pt-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{!isHydrated ? 'Carregando...' : 'Verificando sistema...'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||||
|
</div>
|
||||||
|
=======
|
||||||
// Redirecionar automaticamente se tudo estiver pronto
|
// Redirecionar automaticamente se tudo estiver pronto
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'ready') {
|
if (status === 'ready') {
|
||||||
@@ -54,13 +167,29 @@ export default function Home() {
|
|||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
|
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
|
||||||
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Após verificação, mostrar loading de redirecionamento (não deve aparecer por muito tempo)
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex-1 flex items-center justify-center pt-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">Redirecionando...</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
=======
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
||||||
{/* Notificação de boas-vindas */}
|
{/* Notificação de boas-vindas */}
|
||||||
<WelcomeNotification />
|
<WelcomeNotification />
|
||||||
@@ -108,5 +237,6 @@ export default function Home() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+1550
-1049
File diff suppressed because it is too large.
Load diff
+11
-25
@@ -1,5 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import { SetupWizard } from '@/components/setup/setup-wizard';
|
||||||
|
import { TitleBar } from '@/components/titlebar';
|
||||||
|
|
||||||
|
export default function SetupPage() {
|
||||||
|
=======
|
||||||
import { useHardware } from "@/hooks/use-hardware";
|
import { useHardware } from "@/hooks/use-hardware";
|
||||||
import { HardwareScan } from "@/components/setup/hardware-scan";
|
import { HardwareScan } from "@/components/setup/hardware-scan";
|
||||||
import { GpuSelector } from "@/components/setup/gpu-selector";
|
import { GpuSelector } from "@/components/setup/gpu-selector";
|
||||||
@@ -24,32 +30,12 @@ export default function SetupPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
|
<TitleBar />
|
||||||
<div className="text-center space-y-2">
|
<SetupWizard />
|
||||||
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
|
</div>
|
||||||
<p className="text-muted-foreground">Vamos preparar o ambiente ideal para você.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HardwareScan specs={specs} loading={loading} />
|
|
||||||
|
|
||||||
{!loading && specs && gpus.length > 0 && (
|
|
||||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
|
|
||||||
<GpuSelector gpus={gpus} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && specs && (
|
|
||||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
|
|
||||||
<ModelSelector
|
|
||||||
recommendation={getRecommendation(specs)}
|
|
||||||
onComplete={handleComplete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -105,7 +105,7 @@ export default function TasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto pt-8">
|
||||||
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
||||||
<div className="mb-4 sm:mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
<<<<<<< HEAD
|
||||||
|
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||||
|
=======
|
||||||
import { Message } from '@/hooks/use-chat';
|
import { Message } from '@/hooks/use-chat';
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
import type { DeepResearchState } from '@/hooks/use-deep-research';
|
import type { DeepResearchState } from '@/hooks/use-deep-research';
|
||||||
import type { ScrapedContent } from '@/services/webSearch';
|
import type { ScrapedContent } from '@/services/webSearch';
|
||||||
|
|
||||||
@@ -39,6 +43,18 @@ export interface DebugData {
|
|||||||
contextual: string[];
|
contextual: string[];
|
||||||
};
|
};
|
||||||
sources: ScrapedContent[];
|
sources: ScrapedContent[];
|
||||||
|
<<<<<<< HEAD
|
||||||
|
sitesResearched?: Array<{
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
duration?: number;
|
||||||
|
contentLength: number;
|
||||||
|
scrapedAt: number;
|
||||||
|
}>;
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
logs: Array<{
|
logs: Array<{
|
||||||
stage: string;
|
stage: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -53,6 +69,35 @@ export interface DebugData {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
}>;
|
}>;
|
||||||
|
<<<<<<< HEAD
|
||||||
|
activeQueries?: Array<{
|
||||||
|
query: string;
|
||||||
|
source: string;
|
||||||
|
round?: number;
|
||||||
|
startedAt: number;
|
||||||
|
}>;
|
||||||
|
activeUrls?: Array<{
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
startedAt: number;
|
||||||
|
duration?: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
deepResearchState?: DeepResearchState;
|
||||||
|
thinkingSteps?: ThinkingMessageMetadata[];
|
||||||
|
reasoning?: {
|
||||||
|
steps?: string[];
|
||||||
|
intermediateResults?: any[];
|
||||||
|
};
|
||||||
|
finalResponse: string;
|
||||||
|
rawResponse: string;
|
||||||
|
tokenUsage?: {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
total: number;
|
||||||
|
=======
|
||||||
};
|
};
|
||||||
deepResearchState?: DeepResearchState;
|
deepResearchState?: DeepResearchState;
|
||||||
finalResponse: string;
|
finalResponse: string;
|
||||||
@@ -60,6 +105,7 @@ export interface DebugData {
|
|||||||
tokenUsage?: {
|
tokenUsage?: {
|
||||||
input: number;
|
input: number;
|
||||||
output: number;
|
output: number;
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||||
|
import { SearchQueriesMonitor } from './search-queries-monitor';
|
||||||
|
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
|
||||||
|
|
||||||
interface MessageStepThinkingProps {
|
interface MessageStepThinkingProps {
|
||||||
metadata: ThinkingMessageMetadata;
|
metadata: ThinkingMessageMetadata;
|
||||||
@@ -105,13 +107,31 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
|||||||
|
|
||||||
{/* Conteúdo Expandido */}
|
{/* Conteúdo Expandido */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-4 text-sm text-muted-foreground animate-in fade-in">
|
||||||
{metadata.details && (
|
{metadata.details && (
|
||||||
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Monitoramento em Tempo Real - Queries */}
|
||||||
|
{metadata.activeQueries && metadata.activeQueries.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<SearchQueriesMonitor queries={metadata.activeQueries} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monitoramento em Tempo Real - URLs */}
|
||||||
|
{metadata.activeUrls && metadata.activeUrls.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<ScrapingUrlsMonitor urls={metadata.activeUrls} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{metadata.sources && metadata.sources.length > 0 && (
|
{metadata.sources && metadata.sources.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 pt-1">
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Fontes Encontradas ({metadata.sources.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{metadata.sources.map((source, idx) => (
|
{metadata.sources.map((source, idx) => (
|
||||||
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||||
@@ -121,6 +141,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
const handleInstall = async () => {
|
||||||
try {
|
try {
|
||||||
await downloadState.handleInstall();
|
await downloadState.handleInstall();
|
||||||
toast({
|
// Toast será mostrado automaticamente via eventos de progresso
|
||||||
title: 'Instalador iniciado',
|
|
||||||
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Erro ao executar instalador',
|
title: 'Erro ao executar instalador',
|
||||||
@@ -144,8 +141,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
const isActive = activeTab === os;
|
const isActive = activeTab === os;
|
||||||
const isDownloading = downloadState.isDownloading && isActive;
|
const isDownloading = downloadState.isDownloading && isActive;
|
||||||
const hasDownloaded = downloadState.filePath !== null && isActive;
|
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||||
|
const isInstalling = downloadState.isInstalling && isActive;
|
||||||
|
const installStatus = downloadState.installStatus;
|
||||||
|
|
||||||
if (!isDownloading && !hasDownloaded) {
|
if (!isDownloading && !hasDownloaded && !isInstalling) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +161,18 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<Progress value={downloadState.downloadProgress} className="h-2" />
|
<Progress value={downloadState.downloadProgress} className="h-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasDownloaded && !isDownloading && (
|
{isInstalling && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{installStatus || 'Instalando...'}
|
||||||
|
</span>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<Progress value={undefined} className="h-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasDownloaded && !isDownloading && !isInstalling && (
|
||||||
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
<div className="text-sm flex-1">
|
<div className="text-sm flex-1">
|
||||||
@@ -170,7 +180,20 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
Download concluído
|
Download concluído
|
||||||
</p>
|
</p>
|
||||||
<p className="text-green-700 dark:text-green-300">
|
<p className="text-green-700 dark:text-green-300">
|
||||||
Clique em "Instalar Ollama" para iniciar a instalação.
|
Clique em "Instalar Ollama" para iniciar a instalação automática.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{downloadState.isInstalled && !isInstalling && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
|
<div className="text-sm flex-1">
|
||||||
|
<p className="font-medium text-green-900 dark:text-green-100">
|
||||||
|
Instalação concluída!
|
||||||
|
</p>
|
||||||
|
<p className="text-green-700 dark:text-green-300">
|
||||||
|
{installStatus || 'Ollama foi instalado com sucesso.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +206,9 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<Dialog open={open}>
|
<Dialog open={open}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
<DialogTitle data-tauri-drag-region className="cursor-move select-none">
|
||||||
|
Ollama não encontrado
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||||
Baixe e instale o Ollama usando os passos abaixo.
|
Baixe e instale o Ollama usando os passos abaixo.
|
||||||
|
|||||||
@@ -67,3 +67,11 @@ export function WelcomeNotification() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-[9999] shrink-0 fixed top-0 left-0 right-0"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
WebkitAppRegion: 'drag'
|
||||||
|
} as React.CSSProperties & { WebkitAppRegion?: string }}
|
||||||
>
|
>
|
||||||
{/* Área de arrasto com logo/título */}
|
{/* Área de arrasto com logo/título */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-8 left-0 right-0 bottom-0 z-50 bg-black/50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
// Garantir que o overlay não bloqueie eventos na área do TitleBar (topo 32px)
|
||||||
|
clipPath: 'inset(0 0 0 0)',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -60,9 +65,10 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[calc(50%+16px)] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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
|
// Verificar atualizações
|
||||||
const checkUpdate = useCallback(async () => {
|
const checkUpdate = useCallback(async () => {
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Ignorar verificação de updates em ambiente de desenvolvimento
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
setIsChecking(true);
|
setIsChecking(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -104,6 +112,14 @@ export function useAppUpdater(): AppUpdaterState {
|
|||||||
|
|
||||||
// Verificação automática periódica (a cada 6 horas)
|
// Verificação automática periódica (a cada 6 horas)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Ignorar verificação automática em desenvolvimento
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
if (!autoCheckEnabled) return;
|
if (!autoCheckEnabled) return;
|
||||||
|
|
||||||
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
|
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export function useAutoLabelingModel() {
|
|||||||
|
|
||||||
const checkAndDownload = async () => {
|
const checkAndDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
|
// O comando Tauri é check_if_model_installed_command, mas o Tauri remove o sufixo _command
|
||||||
|
const installed = await invoke<boolean>('check_if_model_installed_command', { name: TARGET_MODEL });
|
||||||
if (installed) {
|
if (installed) {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
return;
|
return;
|
||||||
@@ -49,6 +50,25 @@ export function useAutoLabelingModel() {
|
|||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Detectar erros de conexão/rede e tratá-los silenciosamente
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const isConnectionError =
|
||||||
|
errorMessage.toLowerCase().includes('connection') ||
|
||||||
|
errorMessage.toLowerCase().includes('network') ||
|
||||||
|
errorMessage.toLowerCase().includes('econnrefused') ||
|
||||||
|
errorMessage.toLowerCase().includes('timeout') ||
|
||||||
|
errorMessage.toLowerCase().includes('failed to connect') ||
|
||||||
|
errorMessage.toLowerCase().includes('connection refused');
|
||||||
|
|
||||||
|
if (isConnectionError) {
|
||||||
|
// Erro de conexão: Ollama está offline - tratar silenciosamente
|
||||||
|
// Não logar console.error para não poluir o console
|
||||||
|
setIsDownloading(false);
|
||||||
|
setIsReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outros erros: manter log atual para debug
|
||||||
console.error("Failed to setup auto-labeling model:", error);
|
console.error("Failed to setup auto-labeling model:", error);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,24 +37,17 @@ export function useBatchVerify() {
|
|||||||
|
|
||||||
const callLLM = async (model: string, prompt: string, json = false) => {
|
const callLLM = async (model: string, prompt: string, json = false) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const response = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt,
|
||||||
model,
|
options: {
|
||||||
prompt,
|
temperature: 0.3,
|
||||||
stream: false,
|
num_predict: 1024,
|
||||||
format: json ? 'json' : undefined,
|
format: json ? 'json' : undefined,
|
||||||
options: {
|
},
|
||||||
temperature: 0.3,
|
|
||||||
num_predict: 1024,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
if (!response.ok) throw new Error('Failed to call LLM');
|
|
||||||
const data = await response.json();
|
|
||||||
return data.response;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('LLM Call Error:', e);
|
console.error('LLM Call Error:', e);
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
+16
-43
@@ -254,29 +254,15 @@ export function useChatStorage() {
|
|||||||
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
||||||
// Generate title immediately from user's first message
|
// Generate title immediately from user's first message
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
let title = await invoke<string>('generate_chat_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model: TITLE_MODEL,
|
||||||
body: JSON.stringify({
|
messages: [
|
||||||
model: TITLE_MODEL,
|
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
||||||
messages: [
|
{ role: 'user', content: userMessage }
|
||||||
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
],
|
||||||
{ role: 'user', content: userMessage }
|
options: undefined,
|
||||||
],
|
|
||||||
stream: false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// If model not available, use fallback
|
|
||||||
if (response.status === 404 || response.status >= 500) {
|
|
||||||
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
|
||||||
}
|
|
||||||
throw new Error(`Title generation failed with status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
let title = data.message?.content?.trim() || "";
|
|
||||||
|
|
||||||
// If empty from start, use fallback
|
// If empty from start, use fallback
|
||||||
if (!title || title.length === 0) {
|
if (!title || title.length === 0) {
|
||||||
@@ -320,30 +306,17 @@ export function useChatStorage() {
|
|||||||
content: removeMetadataNoise(m.content),
|
content: removeMetadataNoise(m.content),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawTitle = await invoke<string>('generate_chat_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model: TITLE_MODEL,
|
||||||
body: JSON.stringify({
|
messages: [
|
||||||
model: TITLE_MODEL,
|
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
|
||||||
...sanitizedMessages
|
...sanitizedMessages
|
||||||
],
|
],
|
||||||
stream: false,
|
options: undefined,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// If model not found or unavailable, throw to trigger fallback
|
|
||||||
if (response.status === 404 || response.status >= 500) {
|
|
||||||
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
|
||||||
}
|
|
||||||
// For other errors, use fallback
|
|
||||||
throw new Error(`Title generation failed with status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
let title = removeMetadataNoise(rawTitle?.trim() || "");
|
||||||
let title = removeMetadataNoise(data.message?.content?.trim() || "");
|
|
||||||
|
|
||||||
// If empty from start, throw to trigger fallback
|
// If empty from start, throw to trigger fallback
|
||||||
if (!title || title.length === 0) {
|
if (!title || title.length === 0) {
|
||||||
|
|||||||
+16
-3
@@ -26,6 +26,8 @@ export interface ThinkingMessageMetadata {
|
|||||||
error?: string;
|
error?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
activeQueries?: Array<{ query: string; source: string; round?: number; startedAt: number }>;
|
||||||
|
activeUrls?: Array<{ url: string; title?: string; status: string; source?: string; startedAt: number; duration?: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -528,10 +530,21 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
const stop = async () => {
|
||||||
if (abortControllerRef.current) {
|
try {
|
||||||
abortControllerRef.current.abort();
|
// Cancelar processamento de scraping (prioridade alta)
|
||||||
|
try {
|
||||||
|
await invoke('cancel_scraping');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao cancelar scraping:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSessionIdRef.current) {
|
||||||
|
await invoke('stop_processing', { sessionId: currentSessionIdRef.current });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+95
-70
@@ -23,7 +23,7 @@ export interface DeepResearchLog {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
input: string;
|
input: string;
|
||||||
rawOutput?: string;
|
rawOutput?: string;
|
||||||
parsedOutput?: any;
|
parsedOutput?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ function logStep(message: string) {
|
|||||||
log.info(message);
|
log.info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logData(label: string, data: any) {
|
function logData(label: string, data: unknown) {
|
||||||
if (typeof data === 'string' && data.length > 500) {
|
if (typeof data === 'string' && data.length > 500) {
|
||||||
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
||||||
} else if (typeof data === 'object') {
|
} else if (typeof data === 'object') {
|
||||||
@@ -57,7 +57,7 @@ function logData(label: string, data: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logError(message: string, error?: any) {
|
function logError(message: string, error?: unknown) {
|
||||||
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
|
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +75,17 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
logData('Raw response', raw);
|
logData('Raw response', raw);
|
||||||
|
|
||||||
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
||||||
let cleaned = raw
|
const cleaned = raw
|
||||||
.replace(/```json\s*/gi, '')
|
.replace(/```json\s*/gi, '')
|
||||||
.replace(/```\s*/g, '')
|
.replace(/```\s*/g, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// 2. Try to parse as JSON
|
// 2. Try to parse as JSON
|
||||||
let parsed: any;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(cleaned);
|
parsed = JSON.parse(cleaned);
|
||||||
logStep('Direct JSON parse successful');
|
logStep('Direct JSON parse successful');
|
||||||
} catch (e) {
|
} catch {
|
||||||
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
||||||
// If JSON parse fails, try to find JSON-like content in the string
|
// If JSON parse fails, try to find JSON-like content in the string
|
||||||
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
||||||
@@ -93,7 +93,7 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(jsonMatch[0]);
|
parsed = JSON.parse(jsonMatch[0]);
|
||||||
logStep('Extracted JSON from string successfully');
|
logStep('Extracted JSON from string successfully');
|
||||||
} catch (e2) {
|
} catch {
|
||||||
logError('Could not extract JSON from response');
|
logError('Could not extract JSON from response');
|
||||||
throw new Error('Could not extract JSON from response');
|
throw new Error('Could not extract JSON from response');
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
|
|
||||||
// 3. Handle different formats
|
// 3. Handle different formats
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0);
|
const result = (parsed as unknown[]).filter(item => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Parsed as plain array: ${result.length} queries`);
|
logSuccess(`Parsed as plain array: ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -113,17 +113,19 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
// 4. Check common object keys used by different models
|
// 4. Check common object keys used by different models
|
||||||
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
||||||
for (const key of commonKeys) {
|
for (const key of commonKeys) {
|
||||||
if (parsed[key] && Array.isArray(parsed[key])) {
|
const obj = parsed as Record<string, unknown>;
|
||||||
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
const val = obj[key];
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
|
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Fallback: find first array value in the object
|
// 5. Fallback: find first array value in the object
|
||||||
for (const val of Object.values(parsed)) {
|
for (const val of Object.values(parsed as Record<string, unknown>)) {
|
||||||
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
|
if (Array.isArray(val) && val.length > 0 && typeof (val as unknown[])[0] === 'string') {
|
||||||
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Found queries in object value: ${result.length} queries`);
|
logSuccess(`Found queries in object value: ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -151,29 +153,19 @@ export function useDeepResearch() {
|
|||||||
logData('Prompt length', `${prompt.length} chars`);
|
logData('Prompt length', `${prompt.length} chars`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const response = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt,
|
||||||
model,
|
options: {
|
||||||
prompt,
|
temperature: 0.2,
|
||||||
stream: false,
|
num_predict: 2048,
|
||||||
format: json ? 'json' : undefined,
|
format: json ? 'json' : undefined,
|
||||||
options: {
|
},
|
||||||
temperature: 0.2,
|
|
||||||
num_predict: 2048,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logError(`LLM API returned status ${response.status}`);
|
|
||||||
throw new Error('Failed to call LLM');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
logSuccess(`LLM response received (${response?.length || 0} chars)`);
|
||||||
logSuccess(`LLM response received (${data.response?.length || 0} chars)`);
|
return response;
|
||||||
return data.response;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
logError('LLM Call Error', errorMsg);
|
logError('LLM Call Error', errorMsg);
|
||||||
@@ -255,40 +247,41 @@ export function useDeepResearch() {
|
|||||||
plan = [query];
|
plan = [query];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety Check
|
|
||||||
logStep('--- Safety Check ---');
|
logStep('--- Safety Check ---');
|
||||||
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||||
logData('Keywords from query', keywords);
|
logData('Keywords from query', keywords);
|
||||||
|
const isSmallModel = /(?:270m|0\.5b|1b|1\.7b|2b|mini|small|abliterated|uncensored|qwen3|llama2)/i.test(model);
|
||||||
const isRelevant = plan.some(q =>
|
if (isSmallModel) {
|
||||||
keywords.some(k => q.toLowerCase().includes(k))
|
logStep('Safety Check bypassed for small/experimental model');
|
||||||
);
|
|
||||||
logData('Plan is relevant', isRelevant);
|
|
||||||
|
|
||||||
if (!isRelevant && keywords.length > 0) {
|
|
||||||
logError('Safety Check Failed: Plan is irrelevant to query');
|
|
||||||
logData('Generated plan', plan);
|
|
||||||
logStep('Falling back to original query');
|
|
||||||
addLog({
|
|
||||||
stage: 'decomposition',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
input: prompt,
|
|
||||||
rawOutput: response,
|
|
||||||
parsedOutput: plan,
|
|
||||||
error: 'Safety Check Failed: Plan irrelevant to query'
|
|
||||||
});
|
|
||||||
plan = [query];
|
|
||||||
} else {
|
} else {
|
||||||
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
const isRelevant = plan.some(q =>
|
||||||
addLog({
|
keywords.some(k => q.toLowerCase().includes(k))
|
||||||
stage: 'decomposition',
|
);
|
||||||
timestamp: Date.now(),
|
logData('Plan is relevant', isRelevant);
|
||||||
input: prompt,
|
if (!isRelevant && keywords.length > 0) {
|
||||||
rawOutput: response,
|
logError('Safety Check Failed: Plan is irrelevant to query');
|
||||||
parsedOutput: plan,
|
logData('Generated plan', plan);
|
||||||
error: parseError
|
logStep('Falling back to original query');
|
||||||
});
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: 'Safety Check Failed: Plan irrelevant to query'
|
||||||
|
});
|
||||||
|
plan = [query];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: parseError
|
||||||
|
});
|
||||||
|
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
||||||
|
|
||||||
setState(s => ({ ...s, plan }));
|
setState(s => ({ ...s, plan }));
|
||||||
return plan;
|
return plan;
|
||||||
@@ -303,31 +296,63 @@ export function useDeepResearch() {
|
|||||||
|
|
||||||
// --- ADD TO KNOWLEDGE BASE ---
|
// --- ADD TO KNOWLEDGE BASE ---
|
||||||
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
||||||
logStep(`--- Adding to Knowledge Base ---`);
|
logSection('ADDING TO KNOWLEDGE BASE');
|
||||||
logData('Query', query);
|
logData('Query', query);
|
||||||
logData('Sources count', sources.length);
|
logData('Sources count', sources.length);
|
||||||
|
logData('Timestamp', new Date().toISOString());
|
||||||
|
|
||||||
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
||||||
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
||||||
query,
|
query,
|
||||||
sourceUrl: src.url,
|
sourceUrl: src.url,
|
||||||
title: src.title,
|
title: src.title,
|
||||||
content: src.markdown,
|
content: src.markdown || src.content || '',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Log detalhado de cada fonte
|
||||||
sources.forEach((src, idx) => {
|
sources.forEach((src, idx) => {
|
||||||
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
|
logStep(`\n--- Source ${idx + 1}/${sources.length} ---`);
|
||||||
const contentLength = src.markdown?.length || 0;
|
logData('Title', src.title);
|
||||||
logData(` Content length`, `${contentLength} chars`);
|
logData('URL', src.url);
|
||||||
|
|
||||||
|
const contentLength = src.markdown?.length || src.content?.length || 0;
|
||||||
|
const snippetLength = src.snippet?.length || 0;
|
||||||
|
|
||||||
|
logData('Content length (markdown)', `${contentLength} chars`);
|
||||||
|
logData('Snippet length', `${snippetLength} chars`);
|
||||||
|
logData('Cached', src.cached ? 'Yes' : 'No');
|
||||||
|
|
||||||
if (src.markdown && contentLength > 0) {
|
if (src.markdown && contentLength > 0) {
|
||||||
const preview = src.markdown.substring(0, 300);
|
// Log preview mais detalhado
|
||||||
logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
|
const preview = src.markdown.substring(0, 500);
|
||||||
|
logData('Content preview', preview + (contentLength > 500 ? '...' : ''));
|
||||||
|
|
||||||
|
// Estatísticas do conteúdo
|
||||||
|
const wordCount = src.markdown.split(/\s+/).length;
|
||||||
|
const paragraphCount = src.markdown.split(/\n\n/).length;
|
||||||
|
logData('Word count', wordCount);
|
||||||
|
logData('Paragraph count', paragraphCount);
|
||||||
|
} else if (src.content && src.content.length > 0) {
|
||||||
|
const preview = src.content.substring(0, 500);
|
||||||
|
logData('Content preview (raw)', preview + (src.content.length > 500 ? '...' : ''));
|
||||||
} else {
|
} else {
|
||||||
logStep(` ⚠️ No content extracted`);
|
logError('No content extracted from this source');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log snippet se disponível
|
||||||
|
if (src.snippet) {
|
||||||
|
logData('Snippet', src.snippet.substring(0, 200) + (snippetLength > 200 ? '...' : ''));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Estatísticas agregadas
|
||||||
|
const totalContentLength = entries.reduce((sum, e) => sum + e.content.length, 0);
|
||||||
|
const avgContentLength = entries.length > 0 ? totalContentLength / entries.length : 0;
|
||||||
|
logStep('\n--- Aggregated Statistics ---');
|
||||||
|
logData('Total entries', entries.length);
|
||||||
|
logData('Total content length', `${totalContentLength} chars`);
|
||||||
|
logData('Average content length', `${Math.round(avgContentLength)} chars`);
|
||||||
|
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
|
|||||||
@@ -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';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
|
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
|
||||||
|
|
||||||
export function useOllamaCheck() {
|
interface UseOllamaCheckReturn {
|
||||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
status: OllamaStatus;
|
||||||
|
isChecking: boolean;
|
||||||
|
check: () => Promise<void>;
|
||||||
|
startPolling: () => void;
|
||||||
|
stopPolling: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
elapsedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
const check = async () => {
|
const INITIAL_INTERVAL = 2000; // 2 segundos
|
||||||
setStatus('checking');
|
const MAX_INTERVAL = 30000; // 30 segundos
|
||||||
|
const BACKOFF_MULTIPLIER = 1.5;
|
||||||
|
const TOTAL_TIMEOUT = 300000; // 5 minutos
|
||||||
|
|
||||||
|
export function useOllamaCheck(): UseOllamaCheckReturn {
|
||||||
|
const [status, setStatus] = useState<OllamaStatus>('not_installed');
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const currentIntervalRef = useRef<number>(INITIAL_INTERVAL);
|
||||||
|
const attemptCountRef = useRef<number>(0);
|
||||||
|
const isPollingActiveRef = useRef<boolean>(false);
|
||||||
|
const elapsedTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const performCheck = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
// Usar verificação completa que checa instalação E execução
|
|
||||||
const result = await invoke<{
|
const result = await invoke<{
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}>('check_ollama_full');
|
}>('check_ollama_full');
|
||||||
|
|
||||||
setStatus(result.status as OllamaStatus);
|
const newStatus = result.status as OllamaStatus;
|
||||||
|
setStatus(newStatus);
|
||||||
|
|
||||||
|
// Se Ollama está rodando, polling deve parar
|
||||||
|
if (newStatus === 'running' && result.installed && result.running) {
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Continua polling
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check ollama:', error);
|
console.error('Failed to check ollama:', error);
|
||||||
// Se falhar, tentar verificação individual como fallback
|
// Se falhar, tentar verificação robusta como fallback
|
||||||
try {
|
try {
|
||||||
|
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
if (isInstalledAndRunning) {
|
||||||
|
setStatus('running');
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se verify_ollama_integrity retornou false, pode ser instalado mas não rodando
|
||||||
|
// ou não instalado. Verificar separadamente para determinar status exato
|
||||||
const installed = await invoke<boolean>('check_ollama_installed');
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
setStatus('not_installed');
|
setStatus('not_installed');
|
||||||
return;
|
return false; // Continua polling (não instalado ainda)
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = await invoke<boolean>('check_ollama_running');
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
if (!running) {
|
if (!running) {
|
||||||
setStatus('installed_stopped');
|
setStatus('installed_stopped');
|
||||||
return;
|
return false; // Continua polling (instalado mas não rodando)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('running');
|
setStatus('running');
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('Fallback check also failed:', fallbackError);
|
console.error('Fallback check also failed:', fallbackError);
|
||||||
setStatus('not_installed');
|
setStatus('not_installed');
|
||||||
|
return false; // Continua polling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
check();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { status, check };
|
const startPolling = useCallback(() => {
|
||||||
}
|
if (isPollingActiveRef.current) {
|
||||||
|
return; // Já está rodando
|
||||||
|
}
|
||||||
|
|
||||||
|
isPollingActiveRef.current = true;
|
||||||
|
setIsChecking(true);
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
attemptCountRef.current = 0;
|
||||||
|
currentIntervalRef.current = INITIAL_INTERVAL;
|
||||||
|
|
||||||
|
// Atualizar tempo decorrido a cada segundo
|
||||||
|
elapsedTimerRef.current = setInterval(() => {
|
||||||
|
if (startTimeRef.current) {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||||
|
setElapsedTime(elapsed);
|
||||||
|
|
||||||
|
// Verificar timeout total
|
||||||
|
if (elapsed * 1000 >= TOTAL_TIMEOUT) {
|
||||||
|
setIsChecking(false);
|
||||||
|
isPollingActiveRef.current = false;
|
||||||
|
if (elapsedTimerRef.current) {
|
||||||
|
clearInterval(elapsedTimerRef.current);
|
||||||
|
elapsedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Primeira verificação imediata
|
||||||
|
performCheck().then((shouldStop) => {
|
||||||
|
if (shouldStop) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar polling com exponential backoff
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (!isPollingActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptCountRef.current += 1;
|
||||||
|
|
||||||
|
// Calcular próximo intervalo com exponential backoff
|
||||||
|
const nextInterval = Math.min(
|
||||||
|
INITIAL_INTERVAL * Math.pow(BACKOFF_MULTIPLIER, attemptCountRef.current - 1),
|
||||||
|
MAX_INTERVAL
|
||||||
|
);
|
||||||
|
currentIntervalRef.current = nextInterval;
|
||||||
|
|
||||||
|
intervalRef.current = setTimeout(async () => {
|
||||||
|
if (!isPollingActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldStop = await performCheck();
|
||||||
|
if (shouldStop) {
|
||||||
|
stopPolling();
|
||||||
|
} else {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}, nextInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout total
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (isPollingActiveRef.current) {
|
||||||
|
console.warn('Ollama check polling timeout após 5 minutos');
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, TOTAL_TIMEOUT);
|
||||||
|
}, [performCheck]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
isPollingActiveRef.current = false;
|
||||||
|
setIsChecking(false);
|
||||||
|
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearTimeout(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsedTimerRef.current) {
|
||||||
|
clearInterval(elapsedTimerRef.current);
|
||||||
|
elapsedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
// Forçar verificação manual
|
||||||
|
await performCheck();
|
||||||
|
}, [performCheck]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
stopPolling();
|
||||||
|
currentIntervalRef.current = INITIAL_INTERVAL;
|
||||||
|
attemptCountRef.current = 0;
|
||||||
|
setElapsedTime(0);
|
||||||
|
startTimeRef.current = null;
|
||||||
|
startPolling();
|
||||||
|
}, [stopPolling, startPolling]);
|
||||||
|
|
||||||
|
// Limpar polling ao desmontar
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [stopPolling]);
|
||||||
|
|
||||||
|
// Parar polling automaticamente quando status for 'running'
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'running' && isPollingActiveRef.current) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, [status, stopPolling]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
isChecking,
|
||||||
|
check,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
reset,
|
||||||
|
elapsedTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
+158
-11
@@ -12,6 +12,13 @@ interface DownloadState {
|
|||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
checkStatus: string;
|
checkStatus: string;
|
||||||
|
installStatus: string | null;
|
||||||
|
isInstalling: boolean;
|
||||||
|
speed: string;
|
||||||
|
downloadedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
eta: number;
|
||||||
|
status: 'downloading' | 'verifying' | 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOllamaDownload() {
|
export function useOllamaDownload() {
|
||||||
@@ -24,6 +31,13 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -31,17 +45,71 @@ export function useOllamaDownload() {
|
|||||||
|
|
||||||
// Escutar eventos de progresso do download
|
// Escutar eventos de progresso do download
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = listen('installer-download-progress', (event: any) => {
|
const unlistenDownloadPromise = listen('installer-download-progress', (event: any) => {
|
||||||
const data = event.payload;
|
const data = event.payload;
|
||||||
|
const statusText = data.status || '';
|
||||||
|
const isVerifying = statusText.includes('Verificando');
|
||||||
|
const isCompleted = data.progress === 100 && !isVerifying;
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloadProgress: data.progress || 0,
|
downloadProgress: data.progress || 0,
|
||||||
downloadStatus: data.status || '',
|
downloadStatus: statusText,
|
||||||
|
speed: data.speed_formatted || '0 MB/s',
|
||||||
|
downloadedBytes: data.downloaded || 0,
|
||||||
|
totalBytes: data.total || 0,
|
||||||
|
eta: data.eta_seconds || 0,
|
||||||
|
status: isVerifying ? 'verifying' : isCompleted ? 'completed' : 'downloading',
|
||||||
|
isDownloading: !isCompleted,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escutar eventos de progresso da instalação
|
||||||
|
const unlistenInstallPromise = listen('installer-progress', (event: any) => {
|
||||||
|
const data = event.payload;
|
||||||
|
const status = data.status || 'unknown';
|
||||||
|
const message = data.message || 'Processando...';
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalling: status !== 'success' && status !== 'error' && status !== 'partial',
|
||||||
|
installStatus: message,
|
||||||
|
isInstalled: status === 'success',
|
||||||
|
downloadError: status === 'error' ? message : prev.downloadError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Se a instalação foi bem-sucedida, iniciar verificação automática
|
||||||
|
if (status === 'success') {
|
||||||
|
// Usar setTimeout para evitar dependência circular
|
||||||
|
setTimeout(() => {
|
||||||
|
// startAutoCheck será chamado via closure do componente
|
||||||
|
// Por enquanto, apenas atualizar o estado
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: true,
|
||||||
|
checkStatus: 'Verificando instalação...',
|
||||||
|
}));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escutar evento de Ollama pronto
|
||||||
|
const unlistenReadyPromise = listen('ollama-ready', () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalling: false,
|
||||||
|
installStatus: 'Ollama instalado e rodando!',
|
||||||
|
isInstalled: true,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: 'Ollama encontrado e rodando!',
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('ollama-installed'));
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then(unlisten => unlisten());
|
unlistenDownloadPromise.then(unlisten => unlisten());
|
||||||
|
unlistenInstallPromise.then(unlisten => unlisten());
|
||||||
|
unlistenReadyPromise.then(unlisten => unlisten());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -55,6 +123,16 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -70,6 +148,16 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'completed',
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,6 +173,16 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'completed',
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setState({
|
setState({
|
||||||
@@ -96,6 +194,16 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -109,6 +217,24 @@ export function useOllamaDownload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Atualizar estado para mostrar que a instalação está começando
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalling: true,
|
||||||
|
installStatus: 'Iniciando instalação silenciosa...',
|
||||||
|
downloadError: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Chamar Tauri em background (não bloquear)
|
||||||
|
runInstaller(state.filePath)
|
||||||
|
.then(() => {
|
||||||
|
// Após iniciar a instalação, começar a verificação automática após um delay
|
||||||
|
setTimeout(() => {
|
||||||
|
startAutoCheck();
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
=======
|
||||||
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
|
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -120,27 +246,40 @@ export function useOllamaDownload() {
|
|||||||
|
|
||||||
// Chamar Tauri em background (não bloquear)
|
// Chamar Tauri em background (não bloquear)
|
||||||
runInstaller(state.filePath)
|
runInstaller(state.filePath)
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
isInstalling: false,
|
||||||
|
installStatus: null,
|
||||||
|
=======
|
||||||
isInstalled: false, // Reverter se falhar
|
isInstalled: false, // Reverter se falhar
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkOllama = async (): Promise<boolean> => {
|
const checkOllama = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const installed = await invoke<boolean>('check_ollama_installed');
|
// Usar verificação robusta multi-camada (PATH, caminhos absolutos, HTTP)
|
||||||
if (!installed) {
|
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
return false;
|
return isInstalledAndRunning;
|
||||||
}
|
|
||||||
|
|
||||||
const running = await invoke<boolean>('check_ollama_running');
|
|
||||||
return running;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao verificar Ollama:', error);
|
console.error('Erro ao verificar Ollama:', error);
|
||||||
return false;
|
// Fallback para verificação simples se o comando robusto falhar
|
||||||
|
try {
|
||||||
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
|
if (!installed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
|
return running;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback check also failed:', fallbackError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -237,6 +376,7 @@ export function useOllamaDownload() {
|
|||||||
filePath: existingPath,
|
filePath: existingPath,
|
||||||
downloadProgress: 100,
|
downloadProgress: 100,
|
||||||
downloadStatus: 'Download já concluído',
|
downloadStatus: 'Download já concluído',
|
||||||
|
status: 'completed',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -252,6 +392,13 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ export function useOperatingSystem() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// Hardcoded for now to avoid FS complexity in v1, but ideally read from file
|
// Hardcoded for now to avoid FS complexity in v1, but ideally read from file
|
||||||
const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade.
|
const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade.
|
||||||
@@ -40,23 +41,16 @@ export function usePromptGenerator() {
|
|||||||
const generatePrompt = async (userGoal: string, model: string): Promise<string> => {
|
const generatePrompt = async (userGoal: string, model: string): Promise<string> => {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const response = await invoke<string>('generate_chat_completion', {
|
||||||
method: 'POST',
|
model,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
messages: [
|
||||||
body: JSON.stringify({
|
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
||||||
model,
|
{ role: 'user', content: userGoal }
|
||||||
messages: [
|
],
|
||||||
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
options: undefined,
|
||||||
{ role: 'user', content: userGoal }
|
|
||||||
],
|
|
||||||
stream: false, // We want the full prompt at once for this utility
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to generate prompt');
|
|
||||||
|
|
||||||
const data = await response.json();
|
return response || "";
|
||||||
return data.message?.content || "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Prompt generation failed:", error);
|
console.error("Prompt generation failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook para gerar query de busca otimizada usando a LLM
|
* Hook para gerar query de busca otimizada usando a LLM
|
||||||
@@ -33,27 +34,15 @@ Pergunta do usuário: "${userInput}"
|
|||||||
|
|
||||||
Query de busca (ou NO_SEARCH):`;
|
Query de busca (ou NO_SEARCH):`;
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const generatedText = await invoke<string>('generate_completion', {
|
||||||
method: 'POST',
|
model,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
prompt,
|
||||||
body: JSON.stringify({
|
options: {
|
||||||
model,
|
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
||||||
prompt,
|
num_predict: 50, // Limitar tokens para resposta rápida
|
||||||
stream: false,
|
},
|
||||||
options: {
|
|
||||||
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
|
||||||
num_predict: 50, // Limitar tokens para resposta rápida
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Falha ao gerar query');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const generatedText = (data.response || '').trim();
|
|
||||||
|
|
||||||
// Limpar resposta (remover aspas, pontos finais, etc)
|
// Limpar resposta (remover aspas, pontos finais, etc)
|
||||||
let query = generatedText
|
let query = generatedText
|
||||||
.replace(/^["']|["']$/g, '') // Remover aspas
|
.replace(/^["']|["']$/g, '') // Remover aspas
|
||||||
|
|||||||
@@ -43,12 +43,31 @@ export function useSetupCheck() {
|
|||||||
// Aguardar um pouco para o servidor iniciar
|
// Aguardar um pouco para o servidor iniciar
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Verificar integridade completa (instalação + serviço rodando)
|
||||||
|
try {
|
||||||
|
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
if (!isInstalledAndRunning) {
|
||||||
|
// Ainda não está rodando ou instalação está corrompida
|
||||||
|
return 'needs_setup';
|
||||||
|
}
|
||||||
|
} catch (verifyError) {
|
||||||
|
// Fallback para verificação simples se o comando robusto falhar
|
||||||
|
console.warn('verify_ollama_integrity failed, using fallback:', verifyError);
|
||||||
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
|
if (!running) {
|
||||||
|
return 'needs_setup';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
// Verificar novamente se está rodando
|
// Verificar novamente se está rodando
|
||||||
const running = await invoke<boolean>('check_ollama_running');
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
if (!running) {
|
if (!running) {
|
||||||
// Ainda não está rodando, precisa de intervenção do usuário
|
// Ainda não está rodando, precisa de intervenção do usuário
|
||||||
return 'needs_setup';
|
return 'needs_setup';
|
||||||
}
|
}
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
// Agora está rodando, verificar modelos
|
// Agora está rodando, verificar modelos
|
||||||
const hasAnyModels = await checkModels();
|
const hasAnyModels = await checkModels();
|
||||||
return hasAnyModels ? 'ready' : 'needs_setup';
|
return hasAnyModels ? 'ready' : 'needs_setup';
|
||||||
|
|||||||
@@ -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',
|
title: 'Modo de Economia Ativado',
|
||||||
description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.',
|
description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
});
|
});
|
||||||
setHasShownCriticalToast(true);
|
setHasShownCriticalToast(true);
|
||||||
} else if (newHealth.status !== 'critical') {
|
} else if (newHealth.status !== 'critical') {
|
||||||
|
|||||||
@@ -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 { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
|
||||||
import { useSettingsStore } from '@/store/settings-store';
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { useWebSearchEvents, type ActiveQuery, type ActiveUrl } from './use-web-search-events';
|
||||||
|
|
||||||
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
|
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
export interface WebSearchState {
|
export interface WebSearchState {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -10,6 +12,8 @@ export interface WebSearchState {
|
|||||||
currentQuery: string;
|
currentQuery: string;
|
||||||
scrapedSources: ScrapedContent[];
|
scrapedSources: ScrapedContent[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
activeQueries: ActiveQuery[];
|
||||||
|
activeUrls: ActiveUrl[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATE: WebSearchState = {
|
const DEFAULT_STATE: WebSearchState = {
|
||||||
@@ -18,12 +22,16 @@ const DEFAULT_STATE: WebSearchState = {
|
|||||||
currentQuery: '',
|
currentQuery: '',
|
||||||
scrapedSources: [],
|
scrapedSources: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
activeQueries: [],
|
||||||
|
activeUrls: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook para gerenciar estado do Web Search
|
* Hook para gerenciar estado do Web Search
|
||||||
*/
|
*/
|
||||||
export function useWebSearch() {
|
export function useWebSearch() {
|
||||||
|
const events = useWebSearchEvents();
|
||||||
|
|
||||||
const [state, setState] = useState<WebSearchState>(() => {
|
const [state, setState] = useState<WebSearchState>(() => {
|
||||||
// Carregar preferência do localStorage
|
// Carregar preferência do localStorage
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -36,6 +44,15 @@ export function useWebSearch() {
|
|||||||
return DEFAULT_STATE;
|
return DEFAULT_STATE;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sincronizar eventos com estado usando useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
activeQueries: events.activeQueries,
|
||||||
|
activeUrls: events.activeUrls,
|
||||||
|
}));
|
||||||
|
}, [events.activeQueries, events.activeUrls]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ativa/desativa Web Search
|
* Ativa/desativa Web Search
|
||||||
*/
|
*/
|
||||||
@@ -175,7 +192,7 @@ export function useWebSearch() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
||||||
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
|
console.warn('Erro em smartSearchRag (retornando vazio para usar fallback de modelo local):', errorMessage);
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -185,6 +202,7 @@ export function useWebSearch() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Retornar vazio em vez de throw para não quebrar o fluxo
|
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||||
|
// O sistema continuará usando o modelo local como fallback
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -218,6 +236,22 @@ export function useWebSearch() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancela a busca atual
|
||||||
|
*/
|
||||||
|
const cancel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await invoke('cancel_scraping');
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'cancelled',
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao cancelar busca:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reseta o estado
|
* Reseta o estado
|
||||||
*/
|
*/
|
||||||
@@ -235,14 +269,19 @@ export function useWebSearch() {
|
|||||||
webSearchService.clearCache();
|
webSearchService.clearCache();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
activeQueries: events.activeQueries,
|
||||||
|
activeUrls: events.activeUrls,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
search,
|
search,
|
||||||
smartSearchRag,
|
smartSearchRag,
|
||||||
extractUrl,
|
extractUrl,
|
||||||
|
cancel,
|
||||||
reset,
|
reset,
|
||||||
clearCache,
|
clearCache,
|
||||||
|
setState, // Expor setState para permitir atualização externa em caso de erro
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+8
-19
@@ -108,27 +108,16 @@ export async function extractKeyFacts(
|
|||||||
.replace('{{url}}', entry.sourceUrl)
|
.replace('{{url}}', entry.sourceUrl)
|
||||||
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt,
|
||||||
model,
|
options: {
|
||||||
prompt,
|
temperature: 0.3,
|
||||||
stream: false,
|
num_predict: 600,
|
||||||
options: {
|
},
|
||||||
temperature: 0.3,
|
|
||||||
num_predict: 600,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const rawResponse = (data.response || '').trim();
|
|
||||||
|
|
||||||
// Limpar e extrair JSON
|
// Limpar e extrair JSON
|
||||||
let jsonText = rawResponse
|
let jsonText = rawResponse
|
||||||
.replace(/^```json\s*/i, '')
|
.replace(/^```json\s*/i, '')
|
||||||
|
|||||||
+52
-20
@@ -154,27 +154,17 @@ export async function analyzeQueryContext(
|
|||||||
try {
|
try {
|
||||||
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt,
|
||||||
model,
|
options: {
|
||||||
prompt,
|
temperature: 0.3,
|
||||||
stream: false,
|
num_predict: 800,
|
||||||
options: {
|
format: 'json',
|
||||||
temperature: 0.3, // Baixa temperatura para análise mais determinística
|
},
|
||||||
num_predict: 800, // Espaço suficiente para JSON completo
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const rawResponse = (data.response || '').trim();
|
|
||||||
|
|
||||||
// Limpar resposta (remover markdown code blocks se houver)
|
// Limpar resposta (remover markdown code blocks se houver)
|
||||||
let jsonText = rawResponse
|
let jsonText = rawResponse
|
||||||
.replace(/^```json\s*/i, '')
|
.replace(/^```json\s*/i, '')
|
||||||
@@ -188,7 +178,7 @@ export async function analyzeQueryContext(
|
|||||||
jsonText = jsonMatch[0];
|
jsonText = jsonMatch[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
|
const analysis = tryParseJsonWithRepair(jsonText) as Partial<QueryContext>;
|
||||||
|
|
||||||
// Validar e preencher campos obrigatórios
|
// Validar e preencher campos obrigatórios
|
||||||
const context: QueryContext = {
|
const context: QueryContext = {
|
||||||
@@ -274,3 +264,45 @@ function createFallbackContext(query: string): QueryContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repairJson(text: string): string {
|
||||||
|
let s = text.trim();
|
||||||
|
s = s.replace(/,\s*([}\]])/g, '$1');
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
let braceCount = 0;
|
||||||
|
let bracketCount = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const c = s[i];
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (c === '\\') { escape = true; continue; }
|
||||||
|
if (c === '"') { inString = !inString; continue; }
|
||||||
|
if (!inString) {
|
||||||
|
if (c === '{') braceCount++;
|
||||||
|
else if (c === '}') braceCount--;
|
||||||
|
else if (c === '[') bracketCount++;
|
||||||
|
else if (c === ']') bracketCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (braceCount > 0) s += '}'.repeat(braceCount);
|
||||||
|
if (bracketCount > 0) s += ']'.repeat(bracketCount);
|
||||||
|
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
|
||||||
|
if (lastClose > 0) {
|
||||||
|
const after = s.slice(lastClose + 1);
|
||||||
|
if (/\S/.test(after)) {
|
||||||
|
s = s.slice(0, lastClose + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJsonWithRepair(text: string): unknown {
|
||||||
|
try { return JSON.parse(text); } catch {}
|
||||||
|
const repaired = repairJson(text);
|
||||||
|
try { return JSON.parse(repaired); } catch {}
|
||||||
|
const lastClose = Math.max(repaired.lastIndexOf('}'), repaired.lastIndexOf(']'));
|
||||||
|
if (lastClose > 0) {
|
||||||
|
const truncated = repaired.slice(0, lastClose + 1);
|
||||||
|
return JSON.parse(truncated);
|
||||||
|
}
|
||||||
|
throw new Error('Invalid JSON');
|
||||||
|
}
|
||||||
@@ -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)
|
* Faz download do instalador (oficial ou fallback)
|
||||||
* Retorna o caminho do arquivo baixado
|
* Retorna o caminho do arquivo baixado
|
||||||
*/
|
*/
|
||||||
export async function downloadInstaller(os: OS): Promise<string> {
|
export async function downloadInstaller(os: OS, expectedSha256?: string): Promise<string> {
|
||||||
const url = OLLAMA_DOWNLOAD_URLS[os];
|
const url = OLLAMA_DOWNLOAD_URLS[os];
|
||||||
const filename = LOCAL_INSTALLER_FILES[os];
|
const filename = LOCAL_INSTALLER_FILES[os];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filePath = await invoke<string>('download_installer', {
|
const filePath = await invoke<string>('download_installer', {
|
||||||
url,
|
url,
|
||||||
filename
|
filename,
|
||||||
|
expected_sha256: expectedSha256 || null
|
||||||
});
|
});
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,9 +88,12 @@ export async function getDownloadedInstallerPath(os: OS): Promise<string | null>
|
|||||||
/**
|
/**
|
||||||
* Executa o instalador baixado
|
* Executa o instalador baixado
|
||||||
*/
|
*/
|
||||||
export async function runInstaller(filePath: string): Promise<void> {
|
export async function runInstaller(filePath: string, expectedSha256?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke('run_installer', { filePath });
|
await invoke('run_installer', {
|
||||||
|
file_path: filePath,
|
||||||
|
expected_sha256: expectedSha256 || null
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Falha ao executar instalador: ${error}`);
|
throw new Error(`Falha ao executar instalador: ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { QueryContext } from './contextual-analyzer';
|
|||||||
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||||
import { scoreAndSelectChunks } from './relevance-scorer';
|
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||||
import { summarizeMarkdown } from './content-condenser';
|
import { summarizeMarkdown } from './content-condenser';
|
||||||
|
import { chatLog } from './terminal-logger';
|
||||||
|
|
||||||
export interface CondensationResult {
|
export interface CondensationResult {
|
||||||
context: string;
|
context: string;
|
||||||
@@ -79,9 +80,21 @@ export function condenseKnowledgeBase(
|
|||||||
query: string,
|
query: string,
|
||||||
options: CondensationOptions = {}
|
options: CondensationOptions = {}
|
||||||
): CondensationResult {
|
): CondensationResult {
|
||||||
|
const condensationStart = Date.now();
|
||||||
|
|
||||||
|
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION START ==========`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Query: "${query}"`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Entries: ${entries.length}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Options:`, {
|
||||||
|
maxTokens: options.maxTokens || DEFAULT_OPTIONS.maxTokens,
|
||||||
|
minRelevanceScore: options.minRelevanceScore || DEFAULT_OPTIONS.minRelevanceScore,
|
||||||
|
autoSummarize: options.autoSummarize ?? DEFAULT_OPTIONS.autoSummarize,
|
||||||
|
});
|
||||||
|
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
chatLog.warn(`[KnowledgeBase] ⚠️ No entries to condense`);
|
||||||
return {
|
return {
|
||||||
context: '',
|
context: '',
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
@@ -97,6 +110,9 @@ export function condenseKnowledgeBase(
|
|||||||
// Calcular tokens originais
|
// Calcular tokens originais
|
||||||
const originalContent = entries.map(e => e.content).join('\n\n');
|
const originalContent = entries.map(e => e.content).join('\n\n');
|
||||||
const originalTokens = estimateTokens(originalContent);
|
const originalTokens = estimateTokens(originalContent);
|
||||||
|
|
||||||
|
chatLog.info(`[KnowledgeBase] Original content: ${originalContent.length} chars`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Original tokens (estimated): ${originalTokens}`);
|
||||||
|
|
||||||
// Processar cada entrada
|
// Processar cada entrada
|
||||||
const allChunks: Chunk[] = [];
|
const allChunks: Chunk[] = [];
|
||||||
@@ -106,7 +122,15 @@ export function condenseKnowledgeBase(
|
|||||||
chunks: Chunk[];
|
chunks: Chunk[];
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
|
chatLog.info(`[KnowledgeBase] Processing ${entries.length} entries into chunks...`);
|
||||||
|
|
||||||
entries.forEach((entry, entryIndex) => {
|
entries.forEach((entry, entryIndex) => {
|
||||||
|
const entryStart = Date.now();
|
||||||
|
chatLog.info(`\n[KnowledgeBase] --- Processing Entry ${entryIndex + 1}/${entries.length} ---`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Title: ${entry.title}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] URL: ${entry.sourceUrl}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Content length: ${entry.content.length} chars`);
|
||||||
|
|
||||||
const processed = processContent(entry.content, query, {
|
const processed = processContent(entry.content, query, {
|
||||||
summarizeThreshold: opts.summarizeThreshold,
|
summarizeThreshold: opts.summarizeThreshold,
|
||||||
});
|
});
|
||||||
@@ -119,6 +143,19 @@ export function condenseKnowledgeBase(
|
|||||||
sourceTitle: entry.title,
|
sourceTitle: entry.title,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const entryDuration = Date.now() - entryStart;
|
||||||
|
chatLog.info(`[KnowledgeBase] Generated ${chunksWithSource.length} chunks in ${entryDuration}ms`);
|
||||||
|
|
||||||
|
// Log detalhes dos chunks
|
||||||
|
if (chunksWithSource.length > 0) {
|
||||||
|
chunksWithSource.slice(0, 3).forEach((chunk, chunkIdx) => {
|
||||||
|
chatLog.info(` Chunk ${chunkIdx + 1}: ${chunk.content.length} chars, relevance: ${chunk.relevanceScore?.toFixed(3) || 'N/A'}`);
|
||||||
|
});
|
||||||
|
if (chunksWithSource.length > 3) {
|
||||||
|
chatLog.info(` ... and ${chunksWithSource.length - 3} more chunks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processedEntries.push({
|
processedEntries.push({
|
||||||
entry,
|
entry,
|
||||||
processed,
|
processed,
|
||||||
@@ -127,11 +164,18 @@ export function condenseKnowledgeBase(
|
|||||||
|
|
||||||
allChunks.push(...chunksWithSource);
|
allChunks.push(...chunksWithSource);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatLog.info(`[KnowledgeBase] Total chunks generated: ${allChunks.length}`);
|
||||||
|
|
||||||
// Calcular tokens disponíveis (reservar 20% para margem)
|
// Calcular tokens disponíveis (reservar 20% para margem)
|
||||||
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
||||||
|
chatLog.info(`[KnowledgeBase] Available tokens: ${availableTokens} (max: ${opts.maxTokens}, reserved 20%)`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Min relevance score: ${opts.minRelevanceScore}`);
|
||||||
|
|
||||||
// Selecionar chunks mais relevantes (usando contexto se disponível)
|
// Selecionar chunks mais relevantes (usando contexto se disponível)
|
||||||
|
const selectionStart = Date.now();
|
||||||
|
chatLog.info(`[KnowledgeBase] Selecting most relevant chunks...`);
|
||||||
|
|
||||||
let selectedChunks = scoreAndSelectChunks(
|
let selectedChunks = scoreAndSelectChunks(
|
||||||
allChunks,
|
allChunks,
|
||||||
query,
|
query,
|
||||||
@@ -139,6 +183,19 @@ export function condenseKnowledgeBase(
|
|||||||
opts.minRelevanceScore,
|
opts.minRelevanceScore,
|
||||||
opts.context
|
opts.context
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const selectionDuration = Date.now() - selectionStart;
|
||||||
|
chatLog.info(`[KnowledgeBase] Selected ${selectedChunks.length} chunks from ${allChunks.length} total in ${selectionDuration}ms`);
|
||||||
|
|
||||||
|
// Log top chunks selecionados
|
||||||
|
if (selectedChunks.length > 0) {
|
||||||
|
chatLog.info(`[KnowledgeBase] Top 5 selected chunks:`);
|
||||||
|
selectedChunks.slice(0, 5).forEach((chunk, idx) => {
|
||||||
|
chatLog.info(` [${idx + 1}] Score: ${chunk.relevanceScore?.toFixed(3) || 'N/A'}, Length: ${chunk.content.length} chars`);
|
||||||
|
chatLog.info(` Source: ${chunk.sourceTitle || 'Unknown'}`);
|
||||||
|
chatLog.info(` Preview: ${chunk.content.substring(0, 100)}...`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
||||||
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
|
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
|
||||||
@@ -196,6 +253,26 @@ export function condenseKnowledgeBase(
|
|||||||
|
|
||||||
const totalTokens = estimateTokens(context);
|
const totalTokens = estimateTokens(context);
|
||||||
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
|
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
|
||||||
|
const condensationDuration = Date.now() - condensationStart;
|
||||||
|
|
||||||
|
// Log resultado final
|
||||||
|
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION RESULTS ==========`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Method: ${method}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Duration: ${condensationDuration}ms`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Original tokens: ${originalTokens}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Final tokens: ${totalTokens}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Compression ratio: ${(compressionRatio * 100).toFixed(1)}%`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Chunks used: ${selectedChunks.length} / ${allChunks.length}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Final context length: ${context.length} chars`);
|
||||||
|
|
||||||
|
// Log estatísticas por fonte
|
||||||
|
chatLog.info(`[KnowledgeBase] Chunks per source:`);
|
||||||
|
entries.forEach((entry, idx) => {
|
||||||
|
const chunksUsed = selectedChunks.filter(c => c.sourceIndex === idx).length;
|
||||||
|
chatLog.info(` ${entry.title}: ${chunksUsed} chunks`);
|
||||||
|
});
|
||||||
|
|
||||||
|
chatLog.info(`[KnowledgeBase] ============================================\n`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: context.trim(),
|
context: context.trim(),
|
||||||
|
|||||||
@@ -29,16 +29,12 @@ export async function detectModelContextWindow(modelName: string): Promise<numbe
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Tentar obter informações do modelo via Ollama API
|
// Tentar obter informações do modelo via Ollama API
|
||||||
const response = await fetch('http://localhost:11434/api/show', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const data = await invoke<any>('get_ollama_model_info', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
modelName,
|
||||||
body: JSON.stringify({
|
|
||||||
name: modelName,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (data) {
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
||||||
// Mapear tamanhos conhecidos de modelos
|
// Mapear tamanhos conhecidos de modelos
|
||||||
|
|||||||
@@ -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('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
|
||||||
.replace('{{domain}}', context.domain || 'geral');
|
.replace('{{domain}}', context.domain || 'geral');
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt,
|
||||||
model,
|
options: {
|
||||||
prompt,
|
temperature: 0.5, // Temperatura média para criatividade controlada
|
||||||
stream: false,
|
num_predict: 1000, // Espaço suficiente para múltiplas queries
|
||||||
options: {
|
},
|
||||||
temperature: 0.5, // Temperatura média para criatividade controlada
|
|
||||||
num_predict: 1000, // Espaço suficiente para múltiplas queries
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const rawResponse = (data.response || '').trim();
|
|
||||||
|
|
||||||
// Limpar resposta (remover markdown code blocks se houver)
|
// Limpar resposta (remover markdown code blocks se houver)
|
||||||
let jsonText = rawResponse
|
let jsonText = rawResponse
|
||||||
.replace(/^```json\s*/i, '')
|
.replace(/^```json\s*/i, '')
|
||||||
|
|||||||
@@ -211,3 +211,11 @@ export function formatSearchLog(log: SearchLog): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -38,3 +38,11 @@ export const deepResearchLog = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +13,34 @@ export interface SourcesConfig {
|
|||||||
last_updated: string;
|
last_updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearxngConfig {
|
||||||
|
searxng_url: string;
|
||||||
|
api_format: string;
|
||||||
|
default_engines: string[];
|
||||||
|
default_category: string;
|
||||||
|
timeout_seconds: number;
|
||||||
|
verify_ssl: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
log_search_content: boolean;
|
||||||
|
anonymize_identifiers: boolean;
|
||||||
|
encrypt_logs: boolean;
|
||||||
|
retention_days: number;
|
||||||
|
use_public_instances: boolean;
|
||||||
|
search_via_scraping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchLog {
|
||||||
|
id: string;
|
||||||
|
chat_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
timestamp: string;
|
||||||
|
searxng_instance?: string;
|
||||||
|
query: string;
|
||||||
|
engines?: string[];
|
||||||
|
filters?: string;
|
||||||
|
response_time_ms?: number;
|
||||||
|
total_results?: number;
|
||||||
|
results_per_engine?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
+41
-39
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: FallbackConfig = {
|
|||||||
maxRounds: 4,
|
maxRounds: 4,
|
||||||
maxResultsPerRound: 10,
|
maxResultsPerRound: 10,
|
||||||
maxTotalResults: 40,
|
maxTotalResults: 40,
|
||||||
minRelevanceScore: 0.3,
|
minRelevanceScore: 0.1,
|
||||||
enableQueryExpansion: true,
|
enableQueryExpansion: true,
|
||||||
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
||||||
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
||||||
@@ -100,37 +100,33 @@ Instruções:
|
|||||||
|
|
||||||
Query expandida:`;
|
Query expandida:`;
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const expanded = (await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
model,
|
||||||
body: JSON.stringify({
|
prompt: expansionPrompt,
|
||||||
model,
|
options: {
|
||||||
prompt: expansionPrompt,
|
temperature: 0.5,
|
||||||
stream: false,
|
num_predict: 100,
|
||||||
options: {
|
},
|
||||||
temperature: 0.5,
|
}))
|
||||||
num_predict: 100,
|
.replace(/^["']|["']$/g, '')
|
||||||
},
|
.replace(/\.$/, '')
|
||||||
}),
|
.trim();
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
const sanitized = expanded
|
||||||
chatLog.warn('Failed to expand query, using original');
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/^\s*```[a-zA-Z]*\s*/g, '')
|
||||||
|
.replace(/\s*```\s*$/g, '')
|
||||||
|
.replace(/^(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*/i, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\r\n]+/g, ' ');
|
||||||
|
|
||||||
|
if (sanitized.length < 3 || sanitized.length > 150 || /[<>]/.test(sanitized)) {
|
||||||
return originalQuery;
|
return originalQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
|
||||||
const expanded = (data.response || '').trim()
|
return sanitized;
|
||||||
.replace(/^["']|["']$/g, '')
|
|
||||||
.replace(/\.$/, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (expanded.length < 3) {
|
|
||||||
return originalQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
|
|
||||||
return expanded;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
|
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return originalQuery;
|
return originalQuery;
|
||||||
@@ -155,7 +151,7 @@ export function calculateRelevanceScoreForFallback(
|
|||||||
|
|
||||||
// Contar matches de palavras-chave
|
// Contar matches de palavras-chave
|
||||||
let matches = 0;
|
let matches = 0;
|
||||||
let totalWords = queryWords.length;
|
const totalWords = queryWords.length;
|
||||||
|
|
||||||
for (const word of queryWords) {
|
for (const word of queryWords) {
|
||||||
if (combinedText.includes(word)) {
|
if (combinedText.includes(word)) {
|
||||||
@@ -283,7 +279,7 @@ export async function executeProgressiveSearch(
|
|||||||
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
||||||
const attempts: FallbackAttempt[] = [];
|
const attempts: FallbackAttempt[] = [];
|
||||||
let totalResultsAnalyzed = 0;
|
let totalResultsAnalyzed = 0;
|
||||||
let allResults: ScrapedContent[] = [];
|
const allResults: ScrapedContent[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
const queryStartTime = Date.now();
|
const queryStartTime = Date.now();
|
||||||
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
||||||
@@ -337,8 +333,8 @@ export async function executeProgressiveSearch(
|
|||||||
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
||||||
|
|
||||||
const roundStartTime = Date.now();
|
const roundStartTime = Date.now();
|
||||||
let roundResults: ScrapedContent[] = [];
|
const roundResults: ScrapedContent[] = [];
|
||||||
let roundQueries: string[] = [];
|
const roundQueries: string[] = [];
|
||||||
|
|
||||||
// Executar todas as queries do round em paralelo com timeout por round
|
// Executar todas as queries do round em paralelo com timeout por round
|
||||||
const queryPromises = roundConfig.queries.map(async (query) => {
|
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||||
@@ -351,8 +347,13 @@ export async function executeProgressiveSearch(
|
|||||||
// Passar round para searchFn para timeout adaptativo
|
// Passar round para searchFn para timeout adaptativo
|
||||||
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
||||||
return { query: finalQuery, results };
|
return { query: finalQuery, results };
|
||||||
|
<<<<<<< HEAD
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
=======
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
return { query: finalQuery, results: [] };
|
return { query: finalQuery, results: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -367,7 +368,7 @@ export async function executeProgressiveSearch(
|
|||||||
let queryResults: QueryResult[];
|
let queryResults: QueryResult[];
|
||||||
try {
|
try {
|
||||||
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
||||||
} catch (error) {
|
} catch (_err) {
|
||||||
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
||||||
// Tentar obter resultados parciais das promises que já completaram
|
// Tentar obter resultados parciais das promises que já completaram
|
||||||
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
||||||
@@ -406,13 +407,15 @@ export async function executeProgressiveSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcular relevância usando contexto se disponível
|
// Calcular relevância usando contexto se disponível
|
||||||
|
let semanticSimilarity: ((ctx: QueryContext, text: string) => number) | null = null;
|
||||||
|
if (finalConfig.context) {
|
||||||
|
const mod = await import('./relevance-scorer');
|
||||||
|
semanticSimilarity = mod.calculateSemanticSimilarity;
|
||||||
|
}
|
||||||
const relevanceScore = roundResults.length > 0
|
const relevanceScore = roundResults.length > 0
|
||||||
? roundResults.reduce((sum, r) => {
|
? roundResults.reduce((sum, r) => {
|
||||||
// Usar contexto para cálculo de relevância se disponível
|
if (semanticSimilarity && finalConfig.context) {
|
||||||
if (finalConfig.context) {
|
return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title);
|
||||||
// Importar função dinamicamente para evitar circular dependency
|
|
||||||
const { calculateSemanticSimilarity } = require('./relevance-scorer');
|
|
||||||
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
|
|
||||||
}
|
}
|
||||||
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
||||||
}, 0) / roundResults.length
|
}, 0) / roundResults.length
|
||||||
@@ -479,4 +482,3 @@ export async function executeProgressiveSearch(
|
|||||||
usedFallback: true,
|
usedFallback: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,19 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
|
webpack: (config, { isServer, webpack }) => {
|
||||||
|
// Garantir que config.module e config.module.rules existam
|
||||||
|
if (!config.module) {
|
||||||
|
config.module = { rules: [] };
|
||||||
|
}
|
||||||
|
if (!config.module.rules) {
|
||||||
|
config.module.rules = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
// Adiciona suporte para raw-loader para arquivos .md
|
// Adiciona suporte para raw-loader para arquivos .md
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.md$/,
|
test: /\.md$/,
|
||||||
@@ -16,6 +28,30 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Desabilitar source maps completamente para evitar problemas com stack traces
|
||||||
|
// O erro "ignore-listed frames" está relacionado ao processamento de source maps
|
||||||
|
config.devtool = false;
|
||||||
|
|
||||||
|
// Configurar ignoreWarnings para evitar erros relacionados a stack traces
|
||||||
|
if (!config.ignoreWarnings) {
|
||||||
|
config.ignoreWarnings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignorar avisos relacionados a módulos não encontrados ou problemas de parsing
|
||||||
|
config.ignoreWarnings.push(
|
||||||
|
/Failed to parse source map/,
|
||||||
|
/Can't resolve/,
|
||||||
|
/ignore-listed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurar stats para reduzir verbosidade e evitar problemas com stack traces
|
||||||
|
if (!config.stats) {
|
||||||
|
config.stats = {};
|
||||||
|
}
|
||||||
|
config.stats.errorDetails = false;
|
||||||
|
config.stats.errorStack = false;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
// Configuração Turbopack: objeto vazio silencia o erro
|
// Configuração Turbopack: objeto vazio silencia o erro
|
||||||
|
|||||||
+7
-2
@@ -32,7 +32,7 @@
|
|||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -51,10 +51,15 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.1.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"mdast-util-to-hast": ">=13.2.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+59
-55
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
mdast-util-to-hast: '>=13.2.1'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -72,8 +75,8 @@ importers:
|
|||||||
specifier: ^0.554.0
|
specifier: ^0.554.0
|
||||||
version: 0.554.0([email protected])
|
version: 0.554.0([email protected])
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.3
|
specifier: 16.1.1
|
||||||
version: 16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected])
|
version: 16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected])
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||||
@@ -124,8 +127,8 @@ importers:
|
|||||||
specifier: ^9
|
specifier: ^9
|
||||||
version: 9.39.1([email protected])
|
version: 9.39.1([email protected])
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.0.3
|
specifier: 16.1.1
|
||||||
version: 16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
version: 16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
||||||
raw-loader:
|
raw-loader:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2([email protected])
|
version: 4.0.2([email protected])
|
||||||
@@ -453,56 +456,56 @@ packages:
|
|||||||
'@napi-rs/[email protected]':
|
'@napi-rs/[email protected]':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@next/env@16.0.3':
|
'@next/env@16.1.1':
|
||||||
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
|
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.3':
|
'@next/eslint-plugin-next@16.1.1':
|
||||||
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
|
resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.3':
|
'@next/swc-darwin-arm64@16.1.1':
|
||||||
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
|
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.3':
|
'@next/swc-darwin-x64@16.1.1':
|
||||||
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
|
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||||
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
|
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.3':
|
'@next/swc-linux-arm64-musl@16.1.1':
|
||||||
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
|
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.3':
|
'@next/swc-linux-x64-gnu@16.1.1':
|
||||||
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
|
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.3':
|
'@next/swc-linux-x64-musl@16.1.1':
|
||||||
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
|
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||||
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
|
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.3':
|
'@next/swc-win32-x64-msvc@16.1.1':
|
||||||
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
|
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1770,8 +1773,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
eslint-config-next@16.0.3:
|
eslint-config-next@16.1.1:
|
||||||
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
|
resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=9.0.0'
|
eslint: '>=9.0.0'
|
||||||
typescript: '>=3.3.1'
|
typescript: '>=3.3.1'
|
||||||
@@ -2467,8 +2470,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||||
|
|
||||||
[email protected].0:
|
[email protected].1:
|
||||||
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
|
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
||||||
@@ -2620,8 +2623,8 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@16.0.3:
|
next@16.1.1:
|
||||||
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
|
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3599,34 +3602,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.0.3': {}
|
'@next/env@16.1.1': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.3':
|
'@next/eslint-plugin-next@16.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.3':
|
'@next/swc-darwin-arm64@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.3':
|
'@next/swc-darwin-x64@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.3':
|
'@next/swc-linux-arm64-musl@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.3':
|
'@next/swc-linux-x64-gnu@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.3':
|
'@next/swc-linux-x64-musl@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.3':
|
'@next/swc-win32-x64-msvc@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/[email protected]':
|
'@nodelib/[email protected]':
|
||||||
@@ -4948,9 +4951,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
eslint-config-next@16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
|
eslint-config-next@16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 16.0.3
|
'@next/eslint-plugin-next': 16.1.1
|
||||||
eslint: 9.39.1([email protected])
|
eslint: 9.39.1([email protected])
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
|
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
|
||||||
@@ -5803,7 +5806,7 @@ snapshots:
|
|||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
unist-util-is: 6.0.1
|
unist-util-is: 6.0.1
|
||||||
|
|
||||||
[email protected].0:
|
[email protected].1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -6068,24 +6071,25 @@ snapshots:
|
|||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0([email protected])
|
react-dom: 19.2.0([email protected])
|
||||||
|
|
||||||
next@16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected]):
|
next@16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.0.3
|
'@next/env': 16.1.1
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
|
baseline-browser-mapping: 2.8.29
|
||||||
caniuse-lite: 1.0.30001755
|
caniuse-lite: 1.0.30001755
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0([email protected])
|
react-dom: 19.2.0([email protected])
|
||||||
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.0.3
|
'@next/swc-darwin-arm64': 16.1.1
|
||||||
'@next/swc-darwin-x64': 16.0.3
|
'@next/swc-darwin-x64': 16.1.1
|
||||||
'@next/swc-linux-arm64-gnu': 16.0.3
|
'@next/swc-linux-arm64-gnu': 16.1.1
|
||||||
'@next/swc-linux-arm64-musl': 16.0.3
|
'@next/swc-linux-arm64-musl': 16.1.1
|
||||||
'@next/swc-linux-x64-gnu': 16.0.3
|
'@next/swc-linux-x64-gnu': 16.1.1
|
||||||
'@next/swc-linux-x64-musl': 16.0.3
|
'@next/swc-linux-x64-musl': 16.1.1
|
||||||
'@next/swc-win32-arm64-msvc': 16.0.3
|
'@next/swc-win32-arm64-msvc': 16.1.1
|
||||||
'@next/swc-win32-x64-msvc': 16.0.3
|
'@next/swc-win32-x64-msvc': 16.1.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -6239,7 +6243,7 @@ snapshots:
|
|||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
hast-util-to-jsx-runtime: 2.3.6
|
hast-util-to-jsx-runtime: 2.3.6
|
||||||
html-url-attributes: 3.0.1
|
html-url-attributes: 3.0.1
|
||||||
mdast-util-to-hast: 13.2.0
|
mdast-util-to-hast: 13.2.1
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
remark-parse: 11.0.0
|
remark-parse: 11.0.0
|
||||||
remark-rehype: 11.1.2
|
remark-rehype: 11.1.2
|
||||||
@@ -6344,7 +6348,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
mdast-util-to-hast: 13.2.0
|
mdast-util-to-hast: 13.2.1
|
||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
retryWithBackoff,
|
||||||
withTimeout,
|
withTimeout,
|
||||||
isRetryableError,
|
isRetryableError,
|
||||||
type RetryConfig,
|
|
||||||
calculateEngineTimeout,
|
calculateEngineTimeout,
|
||||||
TIMEOUT_CONFIG
|
TIMEOUT_CONFIG
|
||||||
} from '@/lib/retry-utils';
|
} from '@/lib/retry-utils';
|
||||||
import { EngineCircuitBreaker } from './engine-circuit-breaker';
|
|
||||||
import { FailureCache } from './failure-cache';
|
import { FailureCache } from './failure-cache';
|
||||||
|
|
||||||
export interface ScrapedContent {
|
export interface ScrapedContent {
|
||||||
@@ -16,7 +14,7 @@ export interface ScrapedContent {
|
|||||||
url: string;
|
url: string;
|
||||||
content: string;
|
content: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
cached?: boolean;
|
cached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchConfig {
|
export interface SearchConfig {
|
||||||
@@ -58,7 +56,6 @@ class WebSearchService {
|
|||||||
private lastSearchTime = 0;
|
private lastSearchTime = 0;
|
||||||
private pendingRequest: PendingRequest | null = null;
|
private pendingRequest: PendingRequest | null = null;
|
||||||
private rateLimitQueue: PendingRequest[] = [];
|
private rateLimitQueue: PendingRequest[] = [];
|
||||||
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
|
|
||||||
private failureCache: FailureCache = new FailureCache();
|
private failureCache: FailureCache = new FailureCache();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -200,8 +197,9 @@ class WebSearchService {
|
|||||||
): Promise<ScrapedContent[]> {
|
): Promise<ScrapedContent[]> {
|
||||||
try {
|
try {
|
||||||
// Se SearchConfig foi fornecido, converter para formato Rust
|
// Se SearchConfig foi fornecido, converter para formato Rust
|
||||||
let rustConfig: any = undefined;
|
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||||
if (searchConfig) {
|
if (searchConfig) {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
rustConfig = {
|
rustConfig = {
|
||||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||||
@@ -213,6 +211,10 @@ class WebSearchService {
|
|||||||
})),
|
})),
|
||||||
user_custom_sites: searchConfig.userCustomSites,
|
user_custom_sites: searchConfig.userCustomSites,
|
||||||
excluded_domains: searchConfig.excludedDomains,
|
excluded_domains: searchConfig.excludedDomains,
|
||||||
|
searxng: {
|
||||||
|
enabled: settings.webSearch.searxng.enabled,
|
||||||
|
url: settings.webSearch.searxng.url,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ class WebSearchService {
|
|||||||
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
|
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
|
||||||
search_config: rustConfig,
|
search_config: rustConfig,
|
||||||
});
|
});
|
||||||
|
console.log('Raw scraped results from backend:', results?.length ?? 0);
|
||||||
return results || [];
|
return results || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao buscar conteúdo:', error);
|
console.error('Erro ao buscar conteúdo:', error);
|
||||||
@@ -255,51 +257,44 @@ class WebSearchService {
|
|||||||
// Se não há resultados parciais, retornar vazio imediatamente (sem retry)
|
// Se não há resultados parciais, retornar vazio imediatamente (sem retry)
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Obter configurações de motores do store
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
|
||||||
let engineOrder = settings.webSearch.engineOrder || defaultEngines;
|
|
||||||
|
|
||||||
// Priorizar motores usando circuit breaker
|
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||||
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
|
if (searchConfig) {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
// Filtrar motores não disponíveis (circuit breaker aberto)
|
rustConfig = {
|
||||||
const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder);
|
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||||
|
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||||
if (availableEngines.length === 0) {
|
categories: searchConfig.categories.map(cat => ({
|
||||||
console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`);
|
id: cat.id,
|
||||||
// Se todos estão abertos, tentar mesmo assim (pode ser temporário)
|
name: cat.name,
|
||||||
engineOrder = engineOrder;
|
base_sites: cat.baseSites,
|
||||||
} else {
|
enabled: cat.enabled,
|
||||||
engineOrder = availableEngines;
|
})),
|
||||||
if (availableEngines.length < engineOrder.length) {
|
user_custom_sites: searchConfig.userCustomSites,
|
||||||
console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`);
|
excluded_domains: searchConfig.excludedDomains,
|
||||||
|
searxng: {
|
||||||
|
enabled: settings.webSearch.searxng.enabled,
|
||||||
|
url: settings.webSearch.searxng.url,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let rustConfig: any = undefined;
|
|
||||||
if (searchConfig) {
|
|
||||||
rustConfig = {
|
|
||||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
|
||||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
|
||||||
categories: searchConfig.categories.map(cat => ({
|
|
||||||
id: cat.id,
|
|
||||||
name: cat.name,
|
|
||||||
base_sites: cat.baseSites,
|
|
||||||
enabled: cat.enabled,
|
|
||||||
})),
|
|
||||||
user_custom_sites: searchConfig.userCustomSites,
|
|
||||||
excluded_domains: searchConfig.excludedDomains,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular timeout adaptativo baseado na tentativa
|
// Calcular timeout adaptativo baseado na tentativa
|
||||||
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
|
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
|
||||||
|
|
||||||
// Log da tentativa
|
// Log detalhado da tentativa
|
||||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
const searchStartTime = Date.now();
|
||||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
console.log(`\n[WebSearch] ========== METADATA SEARCH START ==========`);
|
||||||
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`);
|
console.log(`[WebSearch] Query: "${query}"`);
|
||||||
|
console.log(`[WebSearch] Limit: ${limit}`);
|
||||||
|
console.log(`[WebSearch] Timeout: ${adaptiveTimeout}ms`);
|
||||||
|
console.log(`[WebSearch] Attempt: ${attempt}`);
|
||||||
|
console.log(`[WebSearch] Config:`, searchConfig ? {
|
||||||
|
maxConcurrentTabs: searchConfig.maxConcurrentTabs,
|
||||||
|
totalSourcesLimit: searchConfig.totalSourcesLimit,
|
||||||
|
categories: searchConfig.categories.map(c => c.name),
|
||||||
|
excludedDomains: searchConfig.excludedDomains.length,
|
||||||
|
} : 'default');
|
||||||
|
|
||||||
// Executar com retry e timeout adaptativo
|
// Executar com retry e timeout adaptativo
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
@@ -315,7 +310,7 @@ class WebSearchService {
|
|||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
limit,
|
limit,
|
||||||
search_config: rustConfig,
|
search_config: rustConfig,
|
||||||
engineOrder: engineOrder, // Passar ordem de motores
|
engine_order: undefined, // Não usado mais, mantido para compatibilidade
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
@@ -323,16 +318,25 @@ class WebSearchService {
|
|||||||
retryTimeout,
|
retryTimeout,
|
||||||
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
|
||||||
|
|
||||||
// Registrar sucesso no circuit breaker para cada motor usado
|
// Log detalhado dos resultados
|
||||||
// (assumindo que o primeiro motor disponível foi usado)
|
console.log(`\n[WebSearch] ========== METADATA SEARCH RESULTS ==========`);
|
||||||
if (engineOrder.length > 0) {
|
console.log(`[WebSearch] Duration: ${duration}ms`);
|
||||||
const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem
|
console.log(`[WebSearch] Results count: ${result?.length ?? 0}`);
|
||||||
this.circuitBreaker.recordAttempt(usedEngine, true, duration);
|
|
||||||
|
if (result && result.length > 0) {
|
||||||
|
console.log(`[WebSearch] Top ${Math.min(5, result.length)} results:`);
|
||||||
|
result.slice(0, 5).forEach((meta, idx) => {
|
||||||
|
console.log(` [${idx + 1}] ${meta.title}`);
|
||||||
|
console.log(` URL: ${meta.url}`);
|
||||||
|
console.log(` Snippet: ${meta.snippet?.substring(0, 100) || 'N/A'}...`);
|
||||||
|
console.log(` Engine: ${meta.engine || 'unknown'}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No metadata results returned`);
|
||||||
}
|
}
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -340,12 +344,6 @@ class WebSearchService {
|
|||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
|
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
|
||||||
|
|
||||||
// Registrar falha no circuit breaker para cada motor tentado
|
|
||||||
// (assumindo que tentou todos os motores na ordem)
|
|
||||||
for (const engine of engineOrder) {
|
|
||||||
this.circuitBreaker.recordAttempt(engine, false, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -360,7 +358,7 @@ class WebSearchService {
|
|||||||
const results = retryResult.result || [];
|
const results = retryResult.result || [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
|
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
|
||||||
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`);
|
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}`);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -384,9 +382,8 @@ class WebSearchService {
|
|||||||
errorMsg
|
errorMsg
|
||||||
);
|
);
|
||||||
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
|
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
|
||||||
// Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores
|
// Retornar vazio mas não quebrar o fluxo
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
||||||
@@ -395,7 +392,6 @@ class WebSearchService {
|
|||||||
errorMsg
|
errorMsg
|
||||||
);
|
);
|
||||||
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,6 +497,13 @@ class WebSearchService {
|
|||||||
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
||||||
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
|
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
|
||||||
|
|
||||||
|
const smartSearchStart = Date.now();
|
||||||
|
console.log(`\n[WebSearch] ========== SMART SEARCH RAG START ==========`);
|
||||||
|
console.log(`[WebSearch] Query: "${query}"`);
|
||||||
|
console.log(`[WebSearch] Limit: ${limit}`);
|
||||||
|
console.log(`[WebSearch] Round: ${round}`);
|
||||||
|
console.log(`[WebSearch] Timeout: ${roundTimeout}ms`);
|
||||||
|
|
||||||
// Buscar metadados com retry e timeout adaptativo
|
// Buscar metadados com retry e timeout adaptativo
|
||||||
const metas = await this.executeMetadataSearch(
|
const metas = await this.executeMetadataSearch(
|
||||||
query,
|
query,
|
||||||
@@ -513,7 +516,14 @@ class WebSearchService {
|
|||||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||||
const topUrls = metas.map(m => m.url).slice(0, limit);
|
const topUrls = metas.map(m => m.url).slice(0, limit);
|
||||||
|
|
||||||
|
console.log(`[WebSearch] Selected ${topUrls.length} URLs for scraping:`);
|
||||||
|
topUrls.forEach((url, idx) => {
|
||||||
|
console.log(` [${idx + 1}] ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
if (topUrls.length === 0) {
|
if (topUrls.length === 0) {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No URLs to scrape, returning metadata only`);
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
// Retornar resultados parciais (metadados vazios mas sem erro)
|
// Retornar resultados parciais (metadados vazios mas sem erro)
|
||||||
return { metadata: metas, contents: [] };
|
return { metadata: metas, contents: [] };
|
||||||
}
|
}
|
||||||
@@ -521,6 +531,8 @@ class WebSearchService {
|
|||||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||||
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
||||||
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
||||||
|
console.log(`[WebSearch] Starting scraping with timeout: ${scrapingTimeout}ms`);
|
||||||
|
const scrapingStart = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contents = await withTimeout(
|
const contents = await withTimeout(
|
||||||
@@ -528,6 +540,34 @@ class WebSearchService {
|
|||||||
scrapingTimeout,
|
scrapingTimeout,
|
||||||
`Timeout ao fazer scraping para "${query}"`
|
`Timeout ao fazer scraping para "${query}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrapingDuration = Date.now() - scrapingStart;
|
||||||
|
const totalDuration = Date.now() - smartSearchStart;
|
||||||
|
|
||||||
|
// Log detalhado do scraping
|
||||||
|
console.log(`\n[WebSearch] ========== SCRAPING RESULTS ==========`);
|
||||||
|
console.log(`[WebSearch] Scraping duration: ${scrapingDuration}ms`);
|
||||||
|
console.log(`[WebSearch] Total duration: ${totalDuration}ms`);
|
||||||
|
console.log(`[WebSearch] Contents extracted: ${contents?.length ?? 0}`);
|
||||||
|
|
||||||
|
if (contents && contents.length > 0) {
|
||||||
|
contents.forEach((content, idx) => {
|
||||||
|
console.log(`\n [${idx + 1}] ${content.title}`);
|
||||||
|
console.log(` URL: ${content.url}`);
|
||||||
|
const contentLength = content.markdown?.length || 0;
|
||||||
|
const snippetLength = content.snippet?.length || 0;
|
||||||
|
console.log(` Markdown length: ${contentLength} chars`);
|
||||||
|
console.log(` Snippet length: ${snippetLength} chars`);
|
||||||
|
if (content.markdown) {
|
||||||
|
const preview = content.markdown.substring(0, 200);
|
||||||
|
console.log(` Preview: ${preview}${contentLength > 200 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No content extracted from URLs`);
|
||||||
|
}
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
|
|
||||||
return { metadata: metas, contents: contents || [] };
|
return { metadata: metas, contents: contents || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
||||||
|
|||||||
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]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.1", features = [] }
|
tauri-build = { version = "2.5.1", features = [] }
|
||||||
|
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.21"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = [] # Por padrão, não incluir llama-cpp (requer CUDA/libclang)
|
||||||
|
llama-cpp = ["dep:llama_cpp"]
|
||||||
|
dev-no-llama = [] # Skip llama.cpp em dev builds (builds 30-45 min podem ser lentos)
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -30,10 +40,7 @@ sysinfo = "0.36.1"
|
|||||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
headless_chrome = "1.0"
|
|
||||||
scraper = "0.18"
|
scraper = "0.18"
|
||||||
readability = "0.3"
|
|
||||||
html2text = "0.5"
|
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
@@ -48,6 +55,11 @@ tauri-plugin-updater = "2"
|
|||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
rfd = "0.14"
|
rfd = "0.14"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
<<<<<<< HEAD
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.21"
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
|
||||||
# ONNX Runtime para embeddings locais (ranking de relevância)
|
# ONNX Runtime para embeddings locais (ranking de relevância)
|
||||||
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
|
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
|
||||||
@@ -55,3 +67,23 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
|||||||
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
|
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
|
||||||
ndarray = "0.16"
|
ndarray = "0.16"
|
||||||
tokenizers = "0.21"
|
tokenizers = "0.21"
|
||||||
|
<<<<<<< HEAD
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
# Dependências para llama.cpp e parsing GGUF
|
||||||
|
# Nota: llama_cpp 0.3.x é a versão mais recente disponível
|
||||||
|
llama_cpp = { version = "0.3", optional = true }
|
||||||
|
gguf = "0.1"
|
||||||
|
lru = "0.12"
|
||||||
|
|
||||||
|
# Features condicionais por OS (Metal/CUDA são mutuamente exclusivos)
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
llama_cpp = { version = "0.3", features = ["metal"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||||
|
llama_cpp = { version = "0.3", features = ["cuda"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
llama_cpp = { version = "0.3", features = ["cuda"], optional = true }
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
+444
-1
@@ -1,3 +1,446 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// Constantes para download e validação do OllamaSetup.exe
|
||||||
|
const OLLAMA_SETUP_URL: &str = "https://ollama.com/download/OllamaSetup.exe";
|
||||||
|
const MAX_RETRIES: u32 = 3;
|
||||||
|
const DOWNLOAD_TIMEOUT_SECS: u64 = 300;
|
||||||
|
|
||||||
|
// Hash SHA256 esperado do OllamaSetup.exe
|
||||||
|
// Pode ser sobrescrito via variável de ambiente OLLAMA_SETUP_EXE_SHA256
|
||||||
|
// IMPORTANTE: Atualizar este valor com o hash SHA256 oficial do instalador
|
||||||
|
const DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256: &str = "AC9C74B41A8303E5566C5AD3ED6EF0E0123D58AEBC87D3109973F02A38D3FF61";
|
||||||
|
|
||||||
|
/// Calcula o hash SHA256 de um arquivo
|
||||||
|
/// Usa chunks de 8KB para leitura eficiente (padrão do projeto)
|
||||||
|
fn calculate_sha256(file_path: &Path) -> Result<String, String> {
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
let mut file = File::open(file_path)
|
||||||
|
.map_err(|e| format!("Failed to open file for hash calculation: {}", e))?;
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = vec![0u8; 8192]; // Buffer de 8KB para leitura em chunks
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bytes_read = file.read(&mut buffer)
|
||||||
|
.map_err(|e| format!("Failed to read file for hash: {}", e))?;
|
||||||
|
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.update(&buffer[..bytes_read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hasher.finalize();
|
||||||
|
Ok(format!("{:x}", hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Faz download de um arquivo com retry logic e backoff exponencial
|
||||||
|
fn download_with_retry(url: &str, dest_path: &Path) -> Result<(), String> {
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
|
||||||
|
// Limpar arquivo parcial se existir antes de iniciar
|
||||||
|
if dest_path.exists() {
|
||||||
|
std::fs::remove_file(dest_path)
|
||||||
|
.map_err(|e| format!("Failed to remove existing file before download: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 1..=MAX_RETRIES {
|
||||||
|
println!("cargo:warning=Download attempt {}/{}: {}", attempt, MAX_RETRIES, url);
|
||||||
|
|
||||||
|
// Limpar arquivo parcial antes de cada tentativa (exceto na primeira, já limpo acima)
|
||||||
|
if attempt > 1 && dest_path.exists() {
|
||||||
|
std::fs::remove_file(dest_path)
|
||||||
|
.map_err(|e| format!("Failed to remove partial file before retry: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir que o diretório pai existe
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match client.get(url).send() {
|
||||||
|
Ok(response) => {
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let err_msg = format!("HTTP error: {}", response.status());
|
||||||
|
println!("cargo:warning={}", err_msg);
|
||||||
|
last_error = Some(err_msg);
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES {
|
||||||
|
let backoff_secs = 2_u64.pow(attempt - 1); // 1s, 2s, 4s
|
||||||
|
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
|
||||||
|
std::thread::sleep(Duration::from_secs(backoff_secs));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter bytes do response
|
||||||
|
match response.bytes() {
|
||||||
|
Ok(bytes) => {
|
||||||
|
// Criar arquivo e escrever conteúdo
|
||||||
|
match File::create(dest_path) {
|
||||||
|
Ok(mut dest_file) => {
|
||||||
|
match dest_file.write_all(&bytes) {
|
||||||
|
Ok(_) => {
|
||||||
|
drop(dest_file); // Fechar arquivo antes de validar
|
||||||
|
println!("cargo:warning=Download completed successfully");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Failed to write file: {}", e);
|
||||||
|
println!("cargo:warning={}", err_msg);
|
||||||
|
last_error = Some(err_msg);
|
||||||
|
|
||||||
|
// Limpar arquivo parcial
|
||||||
|
let _ = std::fs::remove_file(dest_path);
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES {
|
||||||
|
let backoff_secs = 2_u64.pow(attempt - 1);
|
||||||
|
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
|
||||||
|
std::thread::sleep(Duration::from_secs(backoff_secs));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Failed to create destination file: {}", e);
|
||||||
|
println!("cargo:warning={}", err_msg);
|
||||||
|
last_error = Some(err_msg);
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES {
|
||||||
|
let backoff_secs = 2_u64.pow(attempt - 1);
|
||||||
|
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
|
||||||
|
std::thread::sleep(Duration::from_secs(backoff_secs));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Failed to read response bytes: {}", e);
|
||||||
|
println!("cargo:warning={}", err_msg);
|
||||||
|
last_error = Some(err_msg);
|
||||||
|
|
||||||
|
// Limpar arquivo parcial
|
||||||
|
let _ = std::fs::remove_file(dest_path);
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES {
|
||||||
|
let backoff_secs = 2_u64.pow(attempt - 1);
|
||||||
|
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
|
||||||
|
std::thread::sleep(Duration::from_secs(backoff_secs));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let err_msg = format!("Download failed: {}", e);
|
||||||
|
println!("cargo:warning={}", err_msg);
|
||||||
|
last_error = Some(err_msg);
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES {
|
||||||
|
let backoff_secs = 2_u64.pow(attempt - 1);
|
||||||
|
println!("cargo:warning=Waiting {} seconds before retry...", backoff_secs);
|
||||||
|
std::thread::sleep(Duration::from_secs(backoff_secs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Failed to download {} after {} attempts. Last error: {}",
|
||||||
|
url,
|
||||||
|
MAX_RETRIES,
|
||||||
|
last_error.unwrap_or_else(|| "Unknown error".to_string())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valida o hash SHA256 de um arquivo contra o hash esperado
|
||||||
|
fn validate_sha256(file_path: &Path, expected_hash: &str) -> Result<(), String> {
|
||||||
|
println!("cargo:warning=Validating SHA256 hash...");
|
||||||
|
|
||||||
|
let calculated_hash = calculate_sha256(file_path)?;
|
||||||
|
let calculated_lower = calculated_hash.to_lowercase();
|
||||||
|
let expected_lower = expected_hash.to_lowercase();
|
||||||
|
|
||||||
|
if calculated_lower != expected_lower {
|
||||||
|
// Remover arquivo corrompido
|
||||||
|
let _ = std::fs::remove_file(file_path);
|
||||||
|
return Err(format!(
|
||||||
|
"SHA256 hash mismatch! Expected: {}, Got: {}. Corrupted file removed.",
|
||||||
|
expected_hash,
|
||||||
|
calculated_hash
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:warning=SHA256 hash validated successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtém o hash SHA256 esperado (variável de ambiente ou constante padrão)
|
||||||
|
fn get_expected_sha256() -> String {
|
||||||
|
std::env::var("OLLAMA_SETUP_EXE_SHA256")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baixa e valida o instalador do Ollama
|
||||||
|
fn download_and_validate_ollama_setup(ollama_path: &Path) -> Result<(), String> {
|
||||||
|
let expected_hash = get_expected_sha256();
|
||||||
|
|
||||||
|
// Se o hash esperado está vazio, falhar imediatamente
|
||||||
|
if expected_hash.is_empty() {
|
||||||
|
panic!(
|
||||||
|
"OLLAMA_SETUP_EXE_SHA256 não configurado! \
|
||||||
|
Configure a variável de ambiente OLLAMA_SETUP_EXE_SHA256 ou \
|
||||||
|
atualize DEFAULT_EXPECTED_OLLAMA_SETUP_SHA256 no build.rs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se arquivo já existe
|
||||||
|
if ollama_path.exists() {
|
||||||
|
println!("cargo:warning=OllamaSetup.exe already exists, validating hash...");
|
||||||
|
|
||||||
|
// Validar hash do arquivo existente
|
||||||
|
match validate_sha256(ollama_path, &expected_hash) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("cargo:warning=Existing OllamaSetup.exe is valid, skipping download");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("cargo:warning=Existing file failed validation: {}", e);
|
||||||
|
println!("cargo:warning=Re-downloading...");
|
||||||
|
// Continuar para baixar novamente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fazer download com retry
|
||||||
|
println!("cargo:warning=Downloading Ollama installer...");
|
||||||
|
download_with_retry(OLLAMA_SETUP_URL, ollama_path)?;
|
||||||
|
|
||||||
|
// Validar hash após download
|
||||||
|
validate_sha256(ollama_path, &expected_hash)?;
|
||||||
|
|
||||||
|
println!("cargo:warning=Ollama installer downloaded and validated successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
// Criar estrutura de diretórios para bundle
|
||||||
|
let bundle_dir = Path::new("bundle");
|
||||||
|
let ollama_dir = bundle_dir.join("ollama");
|
||||||
|
let embeddings_dir = bundle_dir.join("models/embeddings");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&ollama_dir).ok();
|
||||||
|
std::fs::create_dir_all(&embeddings_dir).ok();
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
println!("cargo:warning=Preparing bundle resources for Windows...");
|
||||||
|
|
||||||
|
// 1. Baixar e validar instalador do Ollama com SHA256
|
||||||
|
let ollama_path = ollama_dir.join("OllamaSetup.exe");
|
||||||
|
|
||||||
|
match download_and_validate_ollama_setup(&ollama_path) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
panic!("cargo:error=Failed to download and validate OllamaSetup.exe: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Baixar modelo de embeddings ONNX (apenas se não existir)
|
||||||
|
let model_url = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
|
||||||
|
let model_path = embeddings_dir.join("all-MiniLM-L6-v2.onnx");
|
||||||
|
|
||||||
|
if !model_path.exists() {
|
||||||
|
println!("cargo:warning=Downloading ONNX embedding model (this may take a while, ~90MB)...");
|
||||||
|
let status = Command::new("powershell")
|
||||||
|
.args(&[
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
|
||||||
|
model_path.display(),
|
||||||
|
model_url,
|
||||||
|
model_path.display()
|
||||||
|
)
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
if let Ok(exit_status) = status {
|
||||||
|
if exit_status.success() {
|
||||||
|
println!("cargo:warning=ONNX model downloaded successfully");
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to download ONNX model (non-zero exit code)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to download ONNX model (command failed)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=ONNX model already exists, skipping download");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Baixar tokenizer
|
||||||
|
let tokenizer_url = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
|
||||||
|
let tokenizer_path = embeddings_dir.join("tokenizer.json");
|
||||||
|
|
||||||
|
if !tokenizer_path.exists() {
|
||||||
|
println!("cargo:warning=Downloading tokenizer...");
|
||||||
|
let status = Command::new("powershell")
|
||||||
|
.args(&[
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
|
||||||
|
tokenizer_path.display(),
|
||||||
|
tokenizer_url,
|
||||||
|
tokenizer_path.display()
|
||||||
|
)
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
if let Ok(exit_status) = status {
|
||||||
|
if exit_status.success() {
|
||||||
|
println!("cargo:warning=Tokenizer downloaded successfully");
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to download tokenizer (non-zero exit code)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to download tokenizer (command failed)");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Tokenizer already exists, skipping download");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Baixar e extrair ONNX Runtime DLL
|
||||||
|
let ort_url = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip";
|
||||||
|
let ort_zip_path = embeddings_dir.join("onnxruntime.zip");
|
||||||
|
let ort_dll_path = embeddings_dir.join("onnxruntime.dll");
|
||||||
|
|
||||||
|
if !ort_dll_path.exists() {
|
||||||
|
if !ort_zip_path.exists() {
|
||||||
|
println!("cargo:warning=Downloading ONNX Runtime library (~50MB)...");
|
||||||
|
let status = Command::new("powershell")
|
||||||
|
.args(&[
|
||||||
|
"-Command",
|
||||||
|
&format!(
|
||||||
|
"if (!(Test-Path '{}')) {{ Invoke-WebRequest -Uri '{}' -OutFile '{}' -UseBasicParsing }}",
|
||||||
|
ort_zip_path.display(),
|
||||||
|
ort_url,
|
||||||
|
ort_zip_path.display()
|
||||||
|
)
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
|
||||||
|
if let Ok(exit_status) = status {
|
||||||
|
if !exit_status.success() {
|
||||||
|
println!("cargo:warning=Failed to download ONNX Runtime (non-zero exit code)");
|
||||||
|
} else {
|
||||||
|
// Aguardar um pouco para garantir que o download foi concluído
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to download ONNX Runtime (command failed)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair DLL do zip se o zip existe e está completo
|
||||||
|
if ort_zip_path.exists() && !ort_dll_path.exists() {
|
||||||
|
// Verificar se o arquivo zip não está vazio e tem tamanho mínimo razoável (~40MB)
|
||||||
|
let min_zip_size = 40 * 1024 * 1024; // 40MB
|
||||||
|
let zip_valid = if let Ok(metadata) = std::fs::metadata(&ort_zip_path) {
|
||||||
|
let size = metadata.len();
|
||||||
|
if size < 1000 {
|
||||||
|
println!("cargo:warning=ONNX Runtime zip file appears to be incomplete (too small: {} bytes), will be downloaded at runtime", size);
|
||||||
|
false
|
||||||
|
} else if size < min_zip_size {
|
||||||
|
println!("cargo:warning=ONNX Runtime zip file appears to be incomplete ({} bytes, expected at least {} bytes), will be re-downloaded", size, min_zip_size);
|
||||||
|
// Tentar remover ZIP corrompido para forçar re-download
|
||||||
|
let _ = std::fs::remove_file(&ort_zip_path);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=Failed to get metadata for ONNX Runtime zip file");
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if zip_valid {
|
||||||
|
println!("cargo:warning=Extracting ONNX Runtime DLL from zip...");
|
||||||
|
|
||||||
|
// Usar zip crate para extrair (mais confiável que PowerShell)
|
||||||
|
match extract_onnx_dll_from_zip(&ort_zip_path, &ort_dll_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("cargo:warning=ONNX Runtime DLL extracted successfully");
|
||||||
|
// Remover zip após extração bem-sucedida
|
||||||
|
let _ = std::fs::remove_file(&ort_zip_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("cargo:warning=Failed to extract ONNX Runtime DLL: {}. ZIP may be corrupted, will be downloaded at runtime", e);
|
||||||
|
// Remover ZIP corrompido para forçar re-download em runtime
|
||||||
|
let _ = std::fs::remove_file(&ort_zip_path);
|
||||||
|
// Não falhar o build se a extração falhar - será baixado em runtime se necessário
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=ONNX Runtime DLL already exists, skipping download");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrai onnxruntime.dll do arquivo zip
|
||||||
|
fn extract_onnx_dll_from_zip(zip_path: &Path, dll_path: &Path) -> Result<(), String> {
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
let file = File::open(zip_path)
|
||||||
|
.map_err(|e| format!("Failed to open zip file: {}", e))?;
|
||||||
|
|
||||||
|
let mut archive = ZipArchive::new(file)
|
||||||
|
.map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
||||||
|
|
||||||
|
// Procurar pela DLL no zip
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let mut file = archive.by_index(i)
|
||||||
|
.map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?;
|
||||||
|
|
||||||
|
let name = file.name().to_string();
|
||||||
|
|
||||||
|
// Procurar por onnxruntime.dll (pode estar em subdiretórios)
|
||||||
|
if name.ends_with("onnxruntime.dll") {
|
||||||
|
// Criar diretório de destino se não existir
|
||||||
|
if let Some(parent) = dll_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create destination directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair arquivo
|
||||||
|
let mut outfile = File::create(dll_path)
|
||||||
|
.map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||||
|
|
||||||
|
std::io::copy(&mut file, &mut outfile)
|
||||||
|
.map_err(|e| format!("Failed to copy file: {}", e))?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("onnxruntime.dll not found in zip archive".to_string())
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -2,3 +2,7 @@ curl_cffi>=0.5.10
|
|||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
lxml>=4.9.0
|
lxml>=4.9.0
|
||||||
html2text>=2024.1.0
|
html2text>=2024.1.0
|
||||||
|
playwright>=1.40.0
|
||||||
|
fake-useragent>=1.4.0
|
||||||
|
duckduckgo-search>=4.0.0
|
||||||
|
nodriver>=0.33
|
||||||
+334
-466
@@ -1,499 +1,367 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Script Python para scraping web com curl_cffi + BeautifulSoup
|
Micro-serviço Python para scraping e busca
|
||||||
Pipeline: curl_cffi (Request HTTP) -> BeautifulSoup (Parsing) -> html2text (Formatação)
|
Pipeline:
|
||||||
Comunica via JSON stdin/stdout com o backend Rust
|
1. Scraping: curl_cffi / Playwright Stealth
|
||||||
|
2. Busca: duckduckgo-search
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from urllib.parse import urljoin, urlparse, quote
|
|
||||||
import random
|
import random
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
import time
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
from itertools import cycle
|
||||||
|
from typing import Dict, Optional, Union, List
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
# Bibliotecas de Scraping e Parsing
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from curl_cffi import requests
|
||||||
|
import html2text
|
||||||
|
|
||||||
|
# DuckDuckGo Search
|
||||||
|
try:
|
||||||
|
from duckduckgo_search import DDGS
|
||||||
|
HAS_DDG = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_DDG = False
|
||||||
|
|
||||||
|
# Fallback libs
|
||||||
|
try:
|
||||||
|
import requests as pyrequests
|
||||||
|
HAS_PYREQUESTS = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PYREQUESTS = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
HAS_HTTPX = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_HTTPX = False
|
||||||
|
|
||||||
|
# Playwright & Anti-Detect
|
||||||
|
try:
|
||||||
|
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError
|
||||||
|
HAS_PLAYWRIGHT = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PLAYWRIGHT = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fake_useragent import UserAgent
|
||||||
|
ua_generator = UserAgent()
|
||||||
|
HAS_FAKE_UA = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_FAKE_UA = False
|
||||||
|
ua_generator = None
|
||||||
|
|
||||||
|
# --- CONFIGURAÇÃO DO AMBIENTE ---
|
||||||
|
|
||||||
# Configuração CRÍTICA de Encoding para Windows
|
|
||||||
try:
|
try:
|
||||||
sys.stdout.reconfigure(encoding='utf-8')
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
sys.stderr.reconfigure(encoding='utf-8')
|
sys.stderr.reconfigure(encoding='utf-8')
|
||||||
except (AttributeError, Exception):
|
except (AttributeError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Configuração de Logging para STDERR (Não polui o STDOUT)
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='[PYTHON LOG] %(message)s',
|
format='[PYTHON SCRAPER] %(levelname)s: %(message)s',
|
||||||
stream=sys.stderr
|
stream=sys.stderr
|
||||||
)
|
)
|
||||||
|
|
||||||
def print_json(data):
|
# --- CONSTANTES E POOLS ---
|
||||||
"""Função auxiliar para garantir output limpo em JSON"""
|
|
||||||
|
UA_POOL_FALLBACK = [
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'
|
||||||
|
]
|
||||||
|
ua_cycle = cycle(UA_POOL_FALLBACK)
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = 20
|
||||||
|
MAX_RETRIES = 2
|
||||||
|
|
||||||
|
PROXY_POOL = None
|
||||||
|
env_proxies = os.environ.get('SCRAPER_PROXIES') or os.environ.get('SCRAPER_HTTP_PROXIES')
|
||||||
|
if env_proxies:
|
||||||
|
parts = [p.strip() for p in env_proxies.split(',') if p.strip()]
|
||||||
|
if parts:
|
||||||
|
PROXY_POOL = cycle(parts)
|
||||||
|
|
||||||
|
# --- FUNÇÕES AUXILIARES ---
|
||||||
|
|
||||||
|
def get_random_ua() -> str:
|
||||||
|
if HAS_FAKE_UA:
|
||||||
|
try:
|
||||||
|
return ua_generator.random
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return next(ua_cycle)
|
||||||
|
|
||||||
|
def generate_consistent_headers(user_agent: str) -> Dict[str, str]:
|
||||||
|
base_headers = {
|
||||||
|
'User-Agent': user_agent,
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'DNT': '1',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Upgrade-Insecure-Requests': '1',
|
||||||
|
'Sec-Fetch-Dest': 'document',
|
||||||
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
|
'Sec-Fetch-Site': 'none',
|
||||||
|
'Sec-Fetch-User': '?1',
|
||||||
|
'Cache-Control': 'max-age=0'
|
||||||
|
}
|
||||||
|
if 'Chrome' in user_agent:
|
||||||
|
try:
|
||||||
|
version = user_agent.split('Chrome/')[1].split('.')[0]
|
||||||
|
except IndexError:
|
||||||
|
version = "120"
|
||||||
|
base_headers.update({
|
||||||
|
'sec-ch-ua': f'"Chromium";v="{version}", "Google Chrome";v="{version}", "Not-A.Brand";v="99"',
|
||||||
|
'sec-ch-ua-mobile': '?0',
|
||||||
|
'sec-ch-ua-platform': '"Windows"' if 'Windows' in user_agent else ('"macOS"' if 'Macintosh' in user_agent else '"Linux"')
|
||||||
|
})
|
||||||
|
return base_headers
|
||||||
|
|
||||||
|
def get_proxy_dict() -> Optional[Dict[str, str]]:
|
||||||
|
if PROXY_POOL:
|
||||||
|
proxy = next(PROXY_POOL)
|
||||||
|
return {"http": proxy, "https": proxy}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def random_jitter(min_sec=0.5, max_sec=2.0):
|
||||||
|
time.sleep(random.uniform(min_sec, max_sec))
|
||||||
|
|
||||||
|
# --- LIMPEZA E EXTRAÇÃO ---
|
||||||
|
|
||||||
|
def clean_html(soup: BeautifulSoup) -> BeautifulSoup:
|
||||||
|
for tag in soup(["script", "style", "meta", "link", "noscript", "iframe", "object", "embed", "applet", "canvas", "svg"]):
|
||||||
|
tag.decompose()
|
||||||
|
for tag in soup.find_all(["nav", "header", "footer", "aside", "menu", "dialog"]):
|
||||||
|
tag.decompose()
|
||||||
|
ad_keywords = ["ads", "advertisement", "banner", "ad-", "popup", "modal", "cookie", "subscription", "newsletter", "share", "social", "sidebar", "promo", "sponsor"]
|
||||||
|
selectors_to_remove = ['.ad', '.advertisement', '[class*="ad-"]', '[id*="ad-"]', '[id*="cookie"]', '[class*="cookie"]', '.share-buttons', '.social-media', '.comments-section', '#comments']
|
||||||
|
for selector in selectors_to_remove:
|
||||||
|
for tag in soup.select(selector):
|
||||||
|
tag.decompose()
|
||||||
|
def is_ad(tag):
|
||||||
|
if not tag.name: return False
|
||||||
|
attrs = str(tag.attrs).lower()
|
||||||
|
return any(k in attrs for k in ad_keywords)
|
||||||
|
for tag in soup.find_all(is_ad):
|
||||||
|
tag.decompose()
|
||||||
|
return soup
|
||||||
|
|
||||||
|
def extract_main_content(soup: BeautifulSoup) -> BeautifulSoup:
|
||||||
|
selectors = ['article', 'main', '[role="main"]', '.post-content', '.article-body', '#content', '.content', '.entry-content', '.page-content']
|
||||||
|
for selector in selectors:
|
||||||
|
found = soup.select_one(selector)
|
||||||
|
if found and len(found.get_text(strip=True)) > 200:
|
||||||
|
return found
|
||||||
|
body = soup.find('body')
|
||||||
|
return body if body else soup
|
||||||
|
|
||||||
|
def html_to_markdown(html_content: str) -> str:
|
||||||
try:
|
try:
|
||||||
print(json.dumps(data, ensure_ascii=False))
|
h = html2text.HTML2Text()
|
||||||
|
h.ignore_links = False
|
||||||
|
h.ignore_images = False
|
||||||
|
h.images_to_alt = True
|
||||||
|
h.body_width = 0
|
||||||
|
h.protect_links = True
|
||||||
|
return h.handle(html_content)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Erro na conversão Markdown: {e}")
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
# --- ESTRATÉGIAS DE SCRAPING ---
|
||||||
|
|
||||||
|
async def scrape_with_playwright(url: str) -> Dict:
|
||||||
|
if not HAS_PLAYWRIGHT:
|
||||||
|
return {"error": "Playwright not installed"}
|
||||||
|
logging.info(f"[PLAYWRIGHT] Iniciando scraping stealth para: {url}")
|
||||||
|
async with async_playwright() as p:
|
||||||
|
args = ['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', '--disable-infobars', '--window-size=1920,1080', '--start-maximized']
|
||||||
|
try:
|
||||||
|
browser = await p.chromium.launch(headless=True, args=args)
|
||||||
|
ua = get_random_ua()
|
||||||
|
context = await browser.new_context(user_agent=ua, viewport={'width': 1920, 'height': 1080}, locale='pt-BR', timezone_id='America/Sao_Paulo', java_script_enabled=True, ignore_https_errors=True)
|
||||||
|
await context.add_init_script("""
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||||
|
window.chrome = { runtime: {} };
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
||||||
|
Object.defineProperty(navigator, 'languages', { get: () => ['pt-BR', 'pt', 'en-US', 'en'] });
|
||||||
|
""")
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until='domcontentloaded', timeout=25000)
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
logging.warning(f"[PLAYWRIGHT] Timeout carregando {url}")
|
||||||
|
content = await page.content()
|
||||||
|
title = await page.title()
|
||||||
|
await browser.close()
|
||||||
|
return {"content": content, "title": title, "method": "playwright"}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[PLAYWRIGHT] Erro fatal: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def scrape_with_curl_cffi(url: str) -> Dict:
|
||||||
|
ua = get_random_ua()
|
||||||
|
headers = generate_consistent_headers(ua)
|
||||||
|
proxies = get_proxy_dict()
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, proxies=proxies, timeout=15, impersonate="chrome120", allow_redirects=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
if response.status_code in [403, 429, 503]:
|
||||||
|
raise requests.RequestsError(f"Blocked with status {response.status_code}")
|
||||||
|
return {"content": response.text, "title": "", "method": "curl_cffi", "headers": response.headers}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def scrape_url_smart(url: str) -> Dict:
|
||||||
|
random_jitter()
|
||||||
|
logging.info(f"Tentativa 1: curl_cffi -> {url}")
|
||||||
|
result = scrape_with_curl_cffi(url)
|
||||||
|
html_content = None
|
||||||
|
title = ""
|
||||||
|
if "error" not in result:
|
||||||
|
html_content = result["content"]
|
||||||
|
if "challenge-platform" in html_content or "Cloudflare" in html_content and len(html_content) < 5000:
|
||||||
|
logging.warning("Detectado desafio Cloudflare/WAF no curl_cffi. Acionando fallback.")
|
||||||
|
html_content = None
|
||||||
|
if not html_content:
|
||||||
|
if HAS_PLAYWRIGHT:
|
||||||
|
logging.info(f"Tentativa 2: Playwright Stealth -> {url}")
|
||||||
|
pw_result = await scrape_with_playwright(url)
|
||||||
|
if "error" not in pw_result:
|
||||||
|
html_content = pw_result["content"]
|
||||||
|
title = pw_result.get("title", "")
|
||||||
|
else:
|
||||||
|
logging.error(f"Playwright falhou: {pw_result['error']}")
|
||||||
|
else:
|
||||||
|
logging.warning("Playwright não disponível para fallback.")
|
||||||
|
if html_content:
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(html_content, 'html.parser')
|
||||||
|
if not title:
|
||||||
|
title = soup.title.string.strip() if soup.title else url
|
||||||
|
soup = clean_html(soup)
|
||||||
|
main_content = extract_main_content(soup)
|
||||||
|
cleaned_html = str(main_content)
|
||||||
|
markdown_text = html_to_markdown(cleaned_html)
|
||||||
|
final_markdown = '\n'.join([line.strip() for line in markdown_text.split('\n') if line.strip()])
|
||||||
|
if len(final_markdown) < 50:
|
||||||
|
return {"response_type": "error", "message": "Conteúdo insuficiente após processamento."}
|
||||||
|
return {"response_type": "success", "url": url, "title": title, "markdown": final_markdown, "content": final_markdown}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Erro no processamento HTML: {traceback.format_exc()}")
|
||||||
|
return {"response_type": "error", "message": f"Processing error: {str(e)}"}
|
||||||
|
return {"response_type": "error", "message": f"Falha em todos os métodos de scraping. Erro inicial: {result.get('error', 'Unknown')}"}
|
||||||
|
|
||||||
|
# --- DUCKDUCKGO SEARCH LOGIC ---
|
||||||
|
|
||||||
|
async def search_duckduckgo(keywords: str, region: str = "wt-wt", safesearch: str = "moderate", max_results: int = 10) -> Dict:
|
||||||
|
if not HAS_DDG:
|
||||||
|
return {"response_type": "error", "message": "duckduckgo-search module not installed"}
|
||||||
|
|
||||||
|
logging.info(f"Searching DuckDuckGo: {keywords} (region={region}, safe={safesearch})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
results_list = []
|
||||||
|
# DDGS is synchronous, run in thread executor to avoid blocking async loop if needed
|
||||||
|
# But since this script main loop is simple, direct call is fine, or wrap in to_thread
|
||||||
|
|
||||||
|
with DDGS() as ddgs:
|
||||||
|
# ddgs.text() is a generator
|
||||||
|
ddg_gen = ddgs.text(
|
||||||
|
keywords=keywords,
|
||||||
|
region=region,
|
||||||
|
safesearch=safesearch,
|
||||||
|
max_results=max_results
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in ddg_gen:
|
||||||
|
results_list.append({
|
||||||
|
"title": r.get("title", ""),
|
||||||
|
"url": r.get("href", ""),
|
||||||
|
"content": r.get("body", ""),
|
||||||
|
"engine": "duckduckgo",
|
||||||
|
"score": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response_type": "success",
|
||||||
|
"query": keywords,
|
||||||
|
"results": results_list,
|
||||||
|
"number_of_results": len(results_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"DuckDuckGo Search Error: {e}")
|
||||||
|
return {"response_type": "error", "message": str(e)}
|
||||||
|
|
||||||
|
# --- MAIN ---
|
||||||
|
|
||||||
|
def write_json_response(data: Dict):
|
||||||
|
try:
|
||||||
|
json_line = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
||||||
|
print(json_line)
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
logging.error(f"Erro enviando resposta JSON: {e}")
|
||||||
print(json.dumps({"type": "error", "message": f"Failed to format output: {str(e)}"}))
|
|
||||||
sys.stdout.flush()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Tentar importar dependências
|
|
||||||
CURL_CFFI_AVAILABLE = False
|
|
||||||
BS4_AVAILABLE = False
|
|
||||||
HTML2TEXT_AVAILABLE = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from curl_cffi import requests
|
|
||||||
CURL_CFFI_AVAILABLE = True
|
|
||||||
except ImportError as e:
|
|
||||||
logging.error(f"curl_cffi não disponível: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
BS4_AVAILABLE = True
|
|
||||||
except ImportError as e:
|
|
||||||
logging.error(f"BeautifulSoup4 não disponível: {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import html2text
|
|
||||||
HTML2TEXT_AVAILABLE = True
|
|
||||||
except ImportError as e:
|
|
||||||
logging.error(f"html2text não disponível: {e}")
|
|
||||||
|
|
||||||
# Pool de User Agents para parecer mais humano
|
|
||||||
USER_AGENTS = [
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_random_user_agent():
|
|
||||||
"""Retorna um user agent aleatório"""
|
|
||||||
return random.choice(USER_AGENTS)
|
|
||||||
|
|
||||||
def clean_url(url: str) -> str:
|
|
||||||
"""Remove fragmentos e parâmetros de rastreamento da URL"""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
||||||
except:
|
|
||||||
return url
|
|
||||||
|
|
||||||
def extract_domain(url: str) -> str:
|
|
||||||
"""Extrai o domínio de uma URL"""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
return parsed.netloc
|
|
||||||
except:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def is_ad_or_tracker_url(url: str) -> bool:
|
|
||||||
"""Verifica se uma URL é de anúncio ou rastreador"""
|
|
||||||
trackers = ['analytics', 'google-analytics', 'doubleclick', 'facebook.com/tr', 'ads.', 'banner', 'tracking']
|
|
||||||
return any(tracker in url.lower() for tracker in trackers)
|
|
||||||
|
|
||||||
def is_domain_blocked(url: str, excluded_domains: List[str]) -> bool:
|
|
||||||
"""Verifica se o domínio da URL está na lista de exclusão"""
|
|
||||||
domain = extract_domain(url)
|
|
||||||
return any(excluded in domain for excluded in excluded_domains)
|
|
||||||
|
|
||||||
def scrape_url(url: str, visible: bool = False) -> Dict:
|
|
||||||
"""Função principal de scraping com curl_cffi + BeautifulSoup"""
|
|
||||||
|
|
||||||
# Verificar dependências
|
|
||||||
if not CURL_CFFI_AVAILABLE:
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": "curl_cffi não está instalado"
|
|
||||||
}
|
|
||||||
|
|
||||||
if not BS4_AVAILABLE:
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": "beautifulsoup4 não está instalado"
|
|
||||||
}
|
|
||||||
|
|
||||||
if not HTML2TEXT_AVAILABLE:
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": "html2text não está instalado"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info(f"Iniciando requisição para: {url}")
|
|
||||||
|
|
||||||
# Criar headers com curl_cffi (impersonate navegador)
|
|
||||||
headers = {
|
|
||||||
"User-Agent": get_random_user_agent(),
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
||||||
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
||||||
"Accept-Encoding": "gzip, deflate",
|
|
||||||
"DNT": "1",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Executar requisição com timeout
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
impersonate="chrome120",
|
|
||||||
allow_redirects=True,
|
|
||||||
follow_redirects=True
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro na requisição HTTP: {e}")
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": f"Failed to fetch URL: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Verificar se recebemos conteúdo HTML válido
|
|
||||||
if len(response.content) < 100:
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": "Resposta muito curta (< 100 bytes)"
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"Resposta recebida: {len(response.content)} bytes")
|
|
||||||
|
|
||||||
# Parse com BeautifulSoup
|
|
||||||
try:
|
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro ao fazer parse HTML: {e}")
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": f"Failed to parse HTML: {str(e)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extrair título
|
|
||||||
title = "Sem título"
|
|
||||||
title_tag = soup.find('meta', property='og:title') or soup.find('meta', {'name': 'title'}) or soup.find('title')
|
|
||||||
if title_tag:
|
|
||||||
title = title_tag.get('content') or title_tag.string or title
|
|
||||||
|
|
||||||
# Remover scripts e styles
|
|
||||||
for script in soup(["script", "style", "meta", "link", "noscript"]):
|
|
||||||
script.decompose()
|
|
||||||
|
|
||||||
# Remover elementos desnecessários
|
|
||||||
for elem in soup.find_all(["nav", "header", "footer", "aside", "ads"]):
|
|
||||||
elem.decompose()
|
|
||||||
|
|
||||||
# Extrair conteúdo principal
|
|
||||||
main_content = None
|
|
||||||
for selector in ['article', 'main', '[role="main"]', '.content', '.post', '.entry']:
|
|
||||||
if selector.startswith('['):
|
|
||||||
main_content = soup.select_one(selector)
|
|
||||||
else:
|
|
||||||
main_content = soup.find(selector)
|
|
||||||
if main_content and len(main_content.get_text(strip=True)) > 200:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback: usar body
|
|
||||||
if not main_content or len(main_content.get_text(strip=True)) < 200:
|
|
||||||
main_content = soup.find('body') or soup
|
|
||||||
|
|
||||||
# Obter HTML do conteúdo principal
|
|
||||||
html_content = str(main_content) if main_content else str(soup)
|
|
||||||
|
|
||||||
# Converter para Markdown usando html2text
|
|
||||||
try:
|
|
||||||
h = html2text.HTML2Text()
|
|
||||||
h.ignore_links = False
|
|
||||||
h.ignore_images = False
|
|
||||||
h.body_width = 0
|
|
||||||
markdown_content = h.handle(html_content)
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Erro ao converter para markdown, usando texto plano: {e}")
|
|
||||||
markdown_content = main_content.get_text(separator="\n") if main_content else response.text
|
|
||||||
|
|
||||||
# Limpeza final
|
|
||||||
lines = [line.strip() for line in markdown_content.split('\n') if line.strip()]
|
|
||||||
final_content = '\n'.join(lines)
|
|
||||||
|
|
||||||
# Validação de conteúdo
|
|
||||||
if len(final_content) < 100:
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": f"Conteúdo insuficiente ({len(final_content)} chars)"
|
|
||||||
}
|
|
||||||
|
|
||||||
logging.info(f"Sucesso! {len(final_content)} chars extraídos")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "success",
|
|
||||||
"url": clean_url(url),
|
|
||||||
"title": title[:500],
|
|
||||||
"content": final_content[:30000],
|
|
||||||
"markdown": final_content[:30000]
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro no scraping: {traceback.format_exc()}")
|
|
||||||
return {
|
|
||||||
"type": "error",
|
|
||||||
"message": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def search_duckduckgo(query: str, limit: int = 5) -> List[str]:
|
|
||||||
"""Realiza busca no DuckDuckGo e retorna URLs"""
|
|
||||||
try:
|
|
||||||
if not CURL_CFFI_AVAILABLE or not BS4_AVAILABLE:
|
|
||||||
logging.error("Dependências não disponíveis")
|
|
||||||
return []
|
|
||||||
|
|
||||||
logging.info(f"Buscando no DuckDuckGo: {query}")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"User-Agent": get_random_user_agent(),
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
||||||
}
|
|
||||||
|
|
||||||
search_url = f"https://duckduckgo.com/html/?q={quote(query)}"
|
|
||||||
response = requests.get(
|
|
||||||
search_url,
|
|
||||||
headers=headers,
|
|
||||||
timeout=15,
|
|
||||||
impersonate="chrome120",
|
|
||||||
allow_redirects=True
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
|
||||||
|
|
||||||
urls = []
|
|
||||||
# DuckDuckGo HTML retorna resultados em links diretos
|
|
||||||
for result in soup.find_all('a', {'class': 'result__a'}):
|
|
||||||
href = result.get('href')
|
|
||||||
if href and not is_ad_or_tracker_url(href):
|
|
||||||
urls.append(href)
|
|
||||||
if len(urls) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info(f"Encontradas {len(urls)} URLs")
|
|
||||||
return urls
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro na busca DuckDuckGo: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def smart_search_impl(query: str, config: Dict) -> List[str]:
|
|
||||||
"""Implementa busca inteligente com categorias e filtros"""
|
|
||||||
try:
|
|
||||||
logging.info(f"Smart search iniciado: {query}")
|
|
||||||
|
|
||||||
total_limit = config.get('total_sources_limit', 15)
|
|
||||||
categories = config.get('categories', [])
|
|
||||||
user_custom_sites = config.get('user_custom_sites', [])
|
|
||||||
excluded_domains = config.get('excluded_domains', [])
|
|
||||||
|
|
||||||
all_urls = []
|
|
||||||
|
|
||||||
# 1. Busca geral no DuckDuckGo
|
|
||||||
general_urls = search_duckduckgo(query, total_limit // 2)
|
|
||||||
all_urls.extend(general_urls)
|
|
||||||
|
|
||||||
# 2. Busca por categorias habilitadas
|
|
||||||
for category in categories:
|
|
||||||
if not category.get('enabled', False):
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_sites = category.get('base_sites', [])
|
|
||||||
for site in base_sites[:2]: # Limitar a 2 sites por categoria
|
|
||||||
site_query = f"site:{site} {query}"
|
|
||||||
site_urls = search_duckduckgo(site_query, 3)
|
|
||||||
all_urls.extend(site_urls)
|
|
||||||
|
|
||||||
# 3. Busca em sites personalizados
|
|
||||||
for custom_site in user_custom_sites[:3]:
|
|
||||||
custom_query = f"site:{custom_site} {query}"
|
|
||||||
custom_urls = search_duckduckgo(custom_query, 2)
|
|
||||||
all_urls.extend(custom_urls)
|
|
||||||
|
|
||||||
# 4. Filtrar domínios excluídos e remover duplicatas
|
|
||||||
filtered_urls = []
|
|
||||||
seen = set()
|
|
||||||
for url in all_urls:
|
|
||||||
if url not in seen and not is_domain_blocked(url, excluded_domains):
|
|
||||||
filtered_urls.append(url)
|
|
||||||
seen.add(url)
|
|
||||||
if len(filtered_urls) >= total_limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
logging.info(f"Smart search concluído: {len(filtered_urls)} URLs")
|
|
||||||
return filtered_urls
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro no smart search: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def scrape_urls_bulk_impl(urls: List[str]) -> List[Dict]:
|
|
||||||
"""Scraping em paralelo de múltiplas URLs"""
|
|
||||||
try:
|
|
||||||
logging.info(f"Scraping bulk iniciado: {len(urls)} URLs")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def scrape_with_logging(url):
|
|
||||||
try:
|
|
||||||
result = scrape_url(url, visible=False)
|
|
||||||
if result.get('type') == 'success':
|
|
||||||
logging.info(f"✓ {url}")
|
|
||||||
else:
|
|
||||||
logging.warning(f"✗ {url}: {result.get('message', 'unknown error')}")
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"✗ {url}: {e}")
|
|
||||||
return {"type": "error", "message": str(e), "url": url}
|
|
||||||
|
|
||||||
# Scraping paralelo com até 5 workers
|
|
||||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
||||||
future_to_url = {executor.submit(scrape_with_logging, url): url for url in urls}
|
|
||||||
for future in as_completed(future_to_url):
|
|
||||||
try:
|
|
||||||
result = future.result()
|
|
||||||
if result.get('type') == 'success':
|
|
||||||
results.append(result)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro no future: {e}")
|
|
||||||
|
|
||||||
logging.info(f"Scraping bulk concluído: {len(results)} sucessos")
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro no scraping bulk: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Entry point do script - lê comandos JSON do stdin"""
|
logging.info("Scraper/Search Service v4.0 (DuckDuckGo Edition) Initialized")
|
||||||
try:
|
while True:
|
||||||
# Ler JSON do stdin
|
|
||||||
input_data = sys.stdin.read().strip()
|
|
||||||
|
|
||||||
if not input_data:
|
|
||||||
print_json({"type": "error", "message": "No input provided"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Parse JSON
|
|
||||||
try:
|
try:
|
||||||
command_data = json.loads(input_data)
|
line = sys.stdin.readline()
|
||||||
except json.JSONDecodeError as e:
|
if not line: break
|
||||||
logging.error(f"Erro ao fazer parse do JSON: {e}")
|
if not line.strip(): continue
|
||||||
print_json({"type": "error", "message": f"Invalid JSON: {str(e)}"})
|
try:
|
||||||
return
|
request = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
command = command_data.get("command")
|
write_json_response({"response_type": "error", "message": "Invalid JSON"})
|
||||||
logging.info(f"Comando recebido: {command}")
|
continue
|
||||||
|
|
||||||
# Dispatch de comandos
|
|
||||||
if command == "scrape":
|
|
||||||
url = command_data.get("url")
|
|
||||||
visible = command_data.get("visible", False)
|
|
||||||
|
|
||||||
if not url:
|
command = request.get("command")
|
||||||
print_json({"type": "error", "message": "URL missing"})
|
|
||||||
return
|
|
||||||
|
|
||||||
result = scrape_url(url, visible)
|
if command == "scrape":
|
||||||
print_json(result)
|
url = request.get("url")
|
||||||
|
if url:
|
||||||
elif command == "search_duckduckgo":
|
result = asyncio.run(scrape_url_smart(url))
|
||||||
query = command_data.get("query")
|
write_json_response(result)
|
||||||
limit = command_data.get("limit", 5)
|
else:
|
||||||
|
write_json_response({"response_type": "error", "message": "No URL provided"})
|
||||||
|
|
||||||
if not query:
|
elif command == "search":
|
||||||
print_json({"type": "error", "message": "Query missing"})
|
# Generic search command, implemented via DuckDuckGo
|
||||||
return
|
keywords = request.get("keywords") or request.get("query")
|
||||||
|
region = request.get("region", "wt-wt")
|
||||||
urls = search_duckduckgo(query, limit)
|
safesearch = request.get("safesearch", "moderate")
|
||||||
print_json({"type": "success", "urls": urls})
|
max_results = request.get("max_results", 10)
|
||||||
|
|
||||||
elif command == "smart_search":
|
if keywords:
|
||||||
query = command_data.get("query")
|
result = asyncio.run(search_duckduckgo(keywords, region, safesearch, max_results))
|
||||||
config = command_data.get("config", {})
|
write_json_response(result)
|
||||||
|
else:
|
||||||
if not query:
|
write_json_response({"response_type": "error", "message": "Missing keywords"})
|
||||||
print_json({"type": "error", "message": "Query missing"})
|
|
||||||
return
|
elif command == "ping":
|
||||||
|
write_json_response({"response_type": "pong"})
|
||||||
urls = smart_search_impl(query, config)
|
|
||||||
print_json({"type": "success", "urls": urls})
|
|
||||||
|
|
||||||
elif command == "search_and_scrape":
|
|
||||||
query = command_data.get("query")
|
|
||||||
config = command_data.get("config", {})
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
print_json({"type": "error", "message": "Query missing"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Primeiro faz smart_search para obter URLs
|
|
||||||
urls = smart_search_impl(query, config)
|
|
||||||
|
|
||||||
if not urls:
|
|
||||||
print_json({"type": "success", "results": []})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Depois faz scraping das URLs
|
|
||||||
results = scrape_urls_bulk_impl(urls)
|
|
||||||
print_json({"type": "success", "results": results})
|
|
||||||
|
|
||||||
elif command == "scrape_urls_bulk":
|
|
||||||
urls = command_data.get("urls", [])
|
|
||||||
|
|
||||||
if not urls:
|
|
||||||
print_json({"type": "error", "message": "URLs missing"})
|
|
||||||
return
|
|
||||||
|
|
||||||
results = scrape_urls_bulk_impl(urls)
|
|
||||||
print_json({"type": "success", "results": results})
|
|
||||||
|
|
||||||
elif command == "check":
|
|
||||||
# Verificar dependências
|
|
||||||
missing = []
|
|
||||||
if not CURL_CFFI_AVAILABLE:
|
|
||||||
missing.append("curl_cffi")
|
|
||||||
if not BS4_AVAILABLE:
|
|
||||||
missing.append("beautifulsoup4")
|
|
||||||
if not HTML2TEXT_AVAILABLE:
|
|
||||||
missing.append("html2text")
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
print_json({"type": "error", "message": f"Missing: {', '.join(missing)}"})
|
|
||||||
else:
|
else:
|
||||||
print_json({"type": "success", "message": "All dependencies available"})
|
write_json_response({"response_type": "error", "message": "Unknown command"})
|
||||||
|
except KeyboardInterrupt:
|
||||||
else:
|
break
|
||||||
print_json({"type": "error", "message": f"Unknown command: {command}"})
|
except Exception as e:
|
||||||
|
logging.critical(f"Fatal error in main loop: {e}")
|
||||||
except Exception as e:
|
write_json_response({"response_type": "error", "message": str(e)})
|
||||||
logging.error(f"Erro no main: {traceback.format_exc()}")
|
|
||||||
print_json({"type": "error", "message": f"Critical error: {str(e)}"})
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
main()
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print_json({"type": "error", "message": "Script interrupted"})
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
logging.error(f"Unhandled exception: {traceback.format_exc()}")
|
|
||||||
print_json({"type": "error", "message": f"Unhandled exception: {str(e)}"})
|
|
||||||
except:
|
|
||||||
try:
|
|
||||||
print(json.dumps({"type": "error", "message": "Critical failure"}))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -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>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct SearchLog {
|
||||||
|
pub id: String,
|
||||||
|
pub chat_id: Option<String>,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub searxng_instance: Option<String>,
|
||||||
|
pub query: String,
|
||||||
|
pub engines: Option<Vec<String>>,
|
||||||
|
pub filters: Option<String>, // JSON string
|
||||||
|
pub response_time_ms: Option<i64>,
|
||||||
|
pub total_results: Option<i64>,
|
||||||
|
pub results_per_engine: Option<String>, // JSON string
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
/// Resultado de busca de sessões com contagem de matches
|
/// Resultado de busca de sessões com contagem de matches
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SearchSessionResult {
|
pub struct SearchSessionResult {
|
||||||
@@ -116,6 +135,45 @@ impl Database {
|
|||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Tabela de logs de busca (SearXNG)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS search_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
chat_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
searxng_instance TEXT,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
engines TEXT,
|
||||||
|
filters TEXT,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
total_results INTEGER,
|
||||||
|
results_per_engine TEXT,
|
||||||
|
error TEXT
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Tabela de instâncias SearXNG
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS searxng_instances (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
network TEXT,
|
||||||
|
country_code TEXT,
|
||||||
|
response_time REAL,
|
||||||
|
status TEXT,
|
||||||
|
version TEXT,
|
||||||
|
engines TEXT,
|
||||||
|
formats TEXT,
|
||||||
|
validated BOOLEAN DEFAULT 0,
|
||||||
|
last_validated INTEGER,
|
||||||
|
failure_count INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
// Índices para performance
|
// Índices para performance
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -127,6 +185,27 @@ impl Database {
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)",
|
"CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)",
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_search_logs_chat_id ON search_logs(chat_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_search_logs_timestamp ON search_logs(timestamp DESC)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Índices para instâncias SearXNG
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_status_validated ON searxng_instances(status, validated)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_response_time ON searxng_instances(response_time)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
// Índice para ordenação por updated_at
|
// Índice para ordenação por updated_at
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -208,15 +287,6 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Trigger para atualizar messages_fts quando mensagem é atualizada
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
|
||||||
INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content);
|
|
||||||
INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content);
|
|
||||||
END",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Trigger para deletar de messages_fts quando mensagem é deletada
|
// Trigger para deletar de messages_fts quando mensagem é deletada
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
"CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||||
@@ -228,52 +298,39 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Popula tabelas FTS com dados existentes
|
/// Popula tabelas FTS se estiverem vazias e existirem dados nas tabelas principais
|
||||||
fn populate_fts_tables(&self) -> SqliteResult<()> {
|
fn populate_fts_tables(&self) -> SqliteResult<()> {
|
||||||
// Verificar se sessions_fts já tem dados
|
// Verificar se sessions_fts está vazia
|
||||||
let count: i64 = self.conn.query_row(
|
let fts_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions_fts", [], |row| row.get(0))?;
|
||||||
"SELECT COUNT(*) FROM sessions_fts",
|
if fts_count == 0 {
|
||||||
[],
|
let main_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?;
|
||||||
|row| row.get(0),
|
if main_count > 0 {
|
||||||
).unwrap_or(0);
|
self.conn.execute(
|
||||||
|
"INSERT INTO sessions_fts(rowid, id, title) SELECT rowid, id, title FROM sessions",
|
||||||
// Se vazio, popular com dados existentes
|
[],
|
||||||
if count == 0 {
|
)?;
|
||||||
self.conn.execute(
|
}
|
||||||
"INSERT INTO sessions_fts(rowid, id, title)
|
|
||||||
SELECT rowid, id, title FROM sessions",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se messages_fts já tem dados
|
// Verificar se messages_fts está vazia
|
||||||
let count: i64 = self.conn.query_row(
|
let fts_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages_fts", [], |row| row.get(0))?;
|
||||||
"SELECT COUNT(*) FROM messages_fts",
|
if fts_msg_count == 0 {
|
||||||
[],
|
let main_msg_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
|
||||||
|row| row.get(0),
|
if main_msg_count > 0 {
|
||||||
).unwrap_or(0);
|
self.conn.execute(
|
||||||
|
"INSERT INTO messages_fts(rowid, session_id, content) SELECT rowid, session_id, content FROM messages",
|
||||||
// Se vazio, popular com dados existentes
|
[],
|
||||||
if count == 0 {
|
)?;
|
||||||
self.conn.execute(
|
}
|
||||||
"INSERT INTO messages_fts(rowid, session_id, content)
|
|
||||||
SELECT rowid, session_id, content FROM messages",
|
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cria uma nova sessão de chat
|
/// Cria uma nova sessão de chat
|
||||||
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO sessions (id, title, emoji, created_at, updated_at)
|
"INSERT INTO sessions (id, title, emoji, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
title = ?2,
|
|
||||||
emoji = ?3,
|
|
||||||
updated_at = ?5",
|
|
||||||
params![
|
params![
|
||||||
session.id,
|
session.id,
|
||||||
session.title,
|
session.title,
|
||||||
@@ -326,12 +383,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Salva uma sessão (create ou update)
|
/// Lista todas as sessões ordenadas por atualização
|
||||||
pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
|
||||||
self.create_session(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lista todas as sessões ordenadas por updated_at DESC
|
|
||||||
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
|
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
|
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
|
||||||
@@ -358,14 +410,14 @@ impl Database {
|
|||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deleta uma sessão e todas as suas mensagens
|
/// Deleta uma sessão e suas mensagens (cascade)
|
||||||
pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> {
|
pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> {
|
||||||
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
|
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adiciona uma mensagem a uma sessão
|
/// Salva uma mensagem
|
||||||
pub fn add_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
|
pub fn save_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO messages (session_id, role, content, metadata, created_at)
|
"INSERT INTO messages (session_id, role, content, metadata, created_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
@@ -378,15 +430,6 @@ impl Database {
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Atualizar updated_at da sessão
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
|
|
||||||
params![
|
|
||||||
message.created_at.to_rfc3339(),
|
|
||||||
message.session_id
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(self.conn.last_insert_rowid())
|
Ok(self.conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +510,11 @@ impl Database {
|
|||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
/// Conta o total de mensagens de uma sessão
|
||||||
|
pub fn count_messages(&self, session_id: &str) -> SqliteResult<usize> {
|
||||||
|
let count: usize = self.conn.query_row(
|
||||||
|
=======
|
||||||
/// Busca mensagens de uma sessão com paginação (lazy loading)
|
/// Busca mensagens de uma sessão com paginação (lazy loading)
|
||||||
///
|
///
|
||||||
/// Retorna as últimas `limit` mensagens a partir do `offset`.
|
/// Retorna as últimas `limit` mensagens a partir do `offset`.
|
||||||
@@ -486,21 +534,44 @@ impl Database {
|
|||||||
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
|
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
|
||||||
// Primeiro, obter o total de mensagens
|
// Primeiro, obter o total de mensagens
|
||||||
let total_count: usize = self.conn.query_row(
|
let total_count: usize = self.conn.query_row(
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
||||||
params![session_id],
|
params![session_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
|
<<<<<<< HEAD
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca mensagens paginadas (do mais recente para o mais antigo, mas retornado em ordem cronológica)
|
||||||
|
///
|
||||||
|
/// limit: número máximo de mensagens a retornar
|
||||||
|
/// offset: quantas mensagens pular (a partir do final/mais recente)
|
||||||
|
///
|
||||||
|
/// Exemplo: Total 100 mensagens. limit=20, offset=0 -> retorna msgs 81-100
|
||||||
|
/// Exemplo: Total 100 mensagens. limit=20, offset=20 -> retorna msgs 61-80
|
||||||
|
pub fn get_messages_paginated(&self, session_id: &str, limit: usize, offset: usize) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
|
||||||
|
// Primeiro obter contagem total
|
||||||
|
let total_count = self.count_messages(session_id)?;
|
||||||
|
|
||||||
|
// Se não houver mensagens, retornar vazio
|
||||||
|
=======
|
||||||
|
|
||||||
// Se não há mensagens, retornar vazio
|
// Se não há mensagens, retornar vazio
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
if total_count == 0 {
|
if total_count == 0 {
|
||||||
return Ok((Vec::new(), 0, false));
|
return Ok((Vec::new(), 0, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Calcular o limite real baseado no que está disponível
|
||||||
|
=======
|
||||||
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
|
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
|
||||||
// depois ordenar ASC para manter ordem cronológica
|
// depois ordenar ASC para manter ordem cronológica
|
||||||
//
|
//
|
||||||
// Se offset=0 e limit=30: queremos as últimas 30 mensagens
|
// Se offset=0 e limit=30: queremos as últimas 30 mensagens
|
||||||
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
|
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
|
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
|
||||||
|
|
||||||
if real_limit == 0 {
|
if real_limit == 0 {
|
||||||
@@ -712,7 +783,12 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let like_query = format!("%{}%", query);
|
let like_query = format!("%{}%", query);
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![&like_query, limit], |row| {
|
||||||
|
=======
|
||||||
let rows = stmt.query_map(params![like_query, limit], |row| {
|
let rows = stmt.query_map(params![like_query, limit], |row| {
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
Ok(SearchSessionResult {
|
Ok(SearchSessionResult {
|
||||||
session: ChatSession {
|
session: ChatSession {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -736,5 +812,118 @@ impl Database {
|
|||||||
|
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Log a search request
|
||||||
|
pub fn log_search(&self, log: &SearchLog) -> SqliteResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO search_logs (
|
||||||
|
id, chat_id, user_id, timestamp, searxng_instance,
|
||||||
|
query, engines, filters, response_time_ms,
|
||||||
|
total_results, results_per_engine, error
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
|
params![
|
||||||
|
log.id,
|
||||||
|
log.chat_id,
|
||||||
|
log.user_id,
|
||||||
|
log.timestamp.to_rfc3339(),
|
||||||
|
log.searxng_instance,
|
||||||
|
log.query,
|
||||||
|
log.engines.as_ref().map(|e| serde_json::to_string(e).unwrap_or_default()),
|
||||||
|
log.filters,
|
||||||
|
log.response_time_ms,
|
||||||
|
log.total_results,
|
||||||
|
log.results_per_engine,
|
||||||
|
log.error
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cleanup_search_logs(&self, days: i64) -> SqliteResult<usize> {
|
||||||
|
let cutoff = Utc::now() - chrono::Duration::days(days);
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM search_logs WHERE timestamp < ?1",
|
||||||
|
params![cutoff.to_rfc3339()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get search logs with filters
|
||||||
|
pub fn get_search_logs(
|
||||||
|
&self,
|
||||||
|
chat_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize
|
||||||
|
) -> SqliteResult<Vec<SearchLog>> {
|
||||||
|
let mut query = String::from("SELECT id, chat_id, user_id, timestamp, searxng_instance, query, engines, filters, response_time_ms, total_results, results_per_engine, error FROM search_logs");
|
||||||
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(cid) = chat_id {
|
||||||
|
query.push_str(" WHERE chat_id = ?1");
|
||||||
|
params_vec.push(Box::new(cid.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" ORDER BY timestamp DESC LIMIT ?");
|
||||||
|
if chat_id.is_some() {
|
||||||
|
query.push_str(&format!("{}", params_vec.len() + 1));
|
||||||
|
} else {
|
||||||
|
query.push_str("1"); // ?1
|
||||||
|
}
|
||||||
|
params_vec.push(Box::new(limit));
|
||||||
|
|
||||||
|
query.push_str(" OFFSET ?");
|
||||||
|
if chat_id.is_some() {
|
||||||
|
query.push_str(&format!("{}", params_vec.len() + 1));
|
||||||
|
} else {
|
||||||
|
query.push_str("2"); // ?2
|
||||||
|
}
|
||||||
|
params_vec.push(Box::new(offset));
|
||||||
|
|
||||||
|
let mut stmt = self.conn.prepare(&query)?;
|
||||||
|
|
||||||
|
// Usar uma função auxiliar para evitar problema de tipos de closures incompatíveis
|
||||||
|
// Criar uma referência ao self para usar na closure
|
||||||
|
let logs = if let Some(cid) = chat_id {
|
||||||
|
let rows = stmt.query_map(params![cid, limit, offset], |row| {
|
||||||
|
self.map_search_log_row(row)
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
result.push(row?);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||||
|
self.map_search_log_row(row)
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
result.push(row?);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_search_log_row(&self, row: &rusqlite::Row) -> SqliteResult<SearchLog> {
|
||||||
|
Ok(SearchLog {
|
||||||
|
id: row.get(0)?,
|
||||||
|
chat_id: row.get(1)?,
|
||||||
|
user_id: row.get(2)?,
|
||||||
|
timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
searxng_instance: row.get(4)?,
|
||||||
|
query: row.get(5)?,
|
||||||
|
engines: row.get::<_, Option<String>>(6)?
|
||||||
|
.map(|s| serde_json::from_str(&s).unwrap_or_default()),
|
||||||
|
filters: row.get(7)?,
|
||||||
|
response_time_ms: row.get(8)?,
|
||||||
|
total_results: row.get(9)?,
|
||||||
|
results_per_engine: row.get(10)?,
|
||||||
|
error: row.get(11)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 ndarray::Array2;
|
||||||
use ort::session::{Session, builder::GraphOptimizationLevel};
|
use ort::session::{Session, builder::GraphOptimizationLevel};
|
||||||
use ort::value::Value;
|
use ort::value::Value;
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use tokenizers::Tokenizer;
|
use tokenizers::Tokenizer;
|
||||||
@@ -448,7 +451,11 @@ pub fn prune_context(
|
|||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut total_tokens = 0;
|
let mut total_tokens = 0;
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
for (_score, paragraph, original_idx) in scored_paragraphs {
|
||||||
|
=======
|
||||||
for (score, paragraph, original_idx) in scored_paragraphs {
|
for (score, paragraph, original_idx) in scored_paragraphs {
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
let paragraph_tokens = paragraph.split_whitespace().count();
|
let paragraph_tokens = paragraph.split_whitespace().count();
|
||||||
|
|
||||||
if total_tokens + paragraph_tokens > max_tokens {
|
if total_tokens + paragraph_tokens > max_tokens {
|
||||||
|
|||||||
@@ -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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Mensagem para o Ollama API
|
/// Mensagem para o Ollama API
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
struct OllamaMessage {
|
pub struct OllamaMessage {
|
||||||
role: String,
|
pub role: String,
|
||||||
content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request para chat do Ollama
|
/// Request para chat do Ollama
|
||||||
@@ -70,6 +70,9 @@ impl OllamaClient {
|
|||||||
system_prompt: Option<&str>,
|
system_prompt: Option<&str>,
|
||||||
user_prompt: &str,
|
user_prompt: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
log::info!("[LLM] Headless query model={} system_prompt_present={} user_prompt_len={}", model, system_prompt.is_some(), user_prompt.len());
|
||||||
|
if let Some(sys) = system_prompt { log::info!("[LLM] System Prompt:\n{}", sys); }
|
||||||
|
log::info!("[LLM] User Prompt:\n{}", user_prompt);
|
||||||
// Verificar conexão primeiro
|
// Verificar conexão primeiro
|
||||||
self.check_connection().await?;
|
self.check_connection().await?;
|
||||||
|
|
||||||
@@ -96,12 +99,15 @@ impl OllamaClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
let url = format!("{}/api/chat", self.base_url);
|
||||||
|
log::info!("[LLM_DEBUG] Iniciando request HTTP para o modelo (chat stream)...");
|
||||||
let response = self.client
|
let response = self.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&request)
|
.json(&request)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
||||||
|
log::info!("[LLM] HTTP response received (chat stream). Status: {}", response.status());
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("Ollama returned status: {}", response.status()));
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
@@ -113,9 +119,14 @@ impl OllamaClient {
|
|||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
let mut logged_first_chunk = false;
|
||||||
while let Some(chunk) = stream.next().await {
|
while let Some(chunk) = stream.next().await {
|
||||||
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
|
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
|
||||||
let text = String::from_utf8_lossy(&chunk);
|
let text = String::from_utf8_lossy(&chunk);
|
||||||
|
if !logged_first_chunk {
|
||||||
|
log::debug!("[LLM] First chunk received ({} bytes)", text.len());
|
||||||
|
logged_first_chunk = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Processar cada linha (Ollama envia JSON por linha)
|
// Processar cada linha (Ollama envia JSON por linha)
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
@@ -141,7 +152,7 @@ impl OllamaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log::info!("[LLM] Headless Raw Output ({} chars):\n{}", full_response.len(), full_response.trim());
|
||||||
if full_response.is_empty() {
|
if full_response.is_empty() {
|
||||||
return Err("Empty response from Ollama".to_string());
|
return Err("Empty response from Ollama".to_string());
|
||||||
}
|
}
|
||||||
@@ -252,5 +263,234 @@ impl OllamaClient {
|
|||||||
"💬".to_string() // Default
|
"💬".to_string() // Default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gera completion usando /api/generate (não streaming)
|
||||||
|
pub async fn generate_completion(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
prompt: &str,
|
||||||
|
options: Option<GenerationOptions>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
log::info!("[LLM] generate_completion model={} prompt_len={}", model, prompt.len());
|
||||||
|
log::info!("[LLM] Prompt:\n{}", prompt);
|
||||||
|
self.check_connection().await?;
|
||||||
|
|
||||||
|
let mut request_body = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar options se fornecidas
|
||||||
|
if let Some(opts) = options.as_ref() {
|
||||||
|
let mut opts_json = serde_json::Map::new();
|
||||||
|
if let Some(temp) = opts.temperature {
|
||||||
|
// serde_json::Number não implementa From<f64>, usar json! macro ou Number::from_f64
|
||||||
|
if let Some(num) = serde_json::Number::from_f64(temp) {
|
||||||
|
opts_json.insert("temperature".to_string(), serde_json::Value::Number(num));
|
||||||
|
} else {
|
||||||
|
// Fallback: usar como string se não conseguir converter
|
||||||
|
opts_json.insert("temperature".to_string(), serde_json::Value::String(temp.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(num_predict) = opts.num_predict {
|
||||||
|
opts_json.insert("num_predict".to_string(), serde_json::Value::Number(num_predict.into()));
|
||||||
|
}
|
||||||
|
if let Some(format) = opts.format.clone() {
|
||||||
|
opts_json.insert("format".to_string(), serde_json::Value::String(format));
|
||||||
|
}
|
||||||
|
if !opts_json.is_empty() {
|
||||||
|
request_body["options"] = serde_json::Value::Object(opts_json);
|
||||||
|
}
|
||||||
|
log::info!("[LLM] Options: temperature={:?} num_predict={:?} format={:?}", opts.temperature, opts.num_predict, opts.format.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/api/generate", self.base_url);
|
||||||
|
log::info!("[LLM_DEBUG] Iniciando request HTTP para o modelo (generate)...");
|
||||||
|
let response = self.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
||||||
|
log::info!("[LLM] HTTP response received (generate). Status: {}", response.status());
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GenerateResponse {
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_text = response.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response text: {}", e))?;
|
||||||
|
log::info!("[LLM] Raw HTTP Body ({} chars)", body_text.len());
|
||||||
|
let result: GenerateResponse = match serde_json::from_str::<GenerateResponse>(&body_text) {
|
||||||
|
Ok(val) => val,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[LLM] Failed to parse JSON body: {}", e);
|
||||||
|
log::error!("[LLM] Body content: {}", body_text);
|
||||||
|
return Err(format!("Failed to parse response: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut raw = result.response.trim().to_string();
|
||||||
|
if let Ok(re) = regex::Regex::new(r"(?s)<think>.*?</think>") { raw = re.replace_all(&raw, "").to_string(); }
|
||||||
|
if let Ok(re) = regex::Regex::new(r"(?i)^\s*(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*") { raw = re.replace(&raw, "").to_string(); }
|
||||||
|
if let Ok(re) = regex::Regex::new(r"^\s*```[a-zA-Z]*\s*") { raw = re.replace(&raw, "").to_string(); }
|
||||||
|
if let Ok(re) = regex::Regex::new(r"\s*```\s*$") { raw = re.replace(&raw, "").to_string(); }
|
||||||
|
raw = raw.trim().to_string();
|
||||||
|
log::info!("[LLM] Raw Output ({} chars):\n{}", raw.len(), raw);
|
||||||
|
if let Some(fmt) = options.as_ref().and_then(|o| o.format.as_ref()) {
|
||||||
|
if fmt.to_lowercase() == "json" {
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&raw) {
|
||||||
|
Ok(val) => {
|
||||||
|
let kind = if val.is_array() { "array" } else if val.is_object() { "object" } else { "other" };
|
||||||
|
log::info!("[LLM] JSON parse ok ({})", kind);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[LLM] JSON parse failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera chat completion usando /api/chat (não streaming)
|
||||||
|
pub async fn generate_chat_completion(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
messages: Vec<OllamaMessage>,
|
||||||
|
options: Option<GenerationOptions>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let msgs_json = serde_json::to_string(&messages).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
log::info!("[LLM] generate_chat_completion model={} messages_len={} messages_json={} options_temp={:?} options_num_predict={:?}", model, messages.len(), msgs_json, options.as_ref().and_then(|o| o.temperature), options.as_ref().and_then(|o| o.num_predict));
|
||||||
|
self.check_connection().await?;
|
||||||
|
|
||||||
|
let mut request_body = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar options se fornecidas
|
||||||
|
if let Some(opts) = options {
|
||||||
|
let mut opts_json = serde_json::Map::new();
|
||||||
|
if let Some(temp) = opts.temperature {
|
||||||
|
// serde_json::Number não implementa From<f64>, usar Number::from_f64
|
||||||
|
if let Some(num) = serde_json::Number::from_f64(temp) {
|
||||||
|
opts_json.insert("temperature".to_string(), serde_json::Value::Number(num));
|
||||||
|
} else {
|
||||||
|
// Fallback: usar como string se não conseguir converter
|
||||||
|
opts_json.insert("temperature".to_string(), serde_json::Value::String(temp.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(num_predict) = opts.num_predict {
|
||||||
|
opts_json.insert("num_predict".to_string(), serde_json::Value::Number(num_predict.into()));
|
||||||
|
}
|
||||||
|
if !opts_json.is_empty() {
|
||||||
|
request_body["options"] = serde_json::Value::Object(opts_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/api/chat", self.base_url);
|
||||||
|
let response = self.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
message: OllamaMessageResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: ChatResponse = response.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
let raw = result.message.content.trim().to_string();
|
||||||
|
log::info!("[LLM] Chat Raw Output ({} chars):\n{}", raw.len(), raw);
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtém informações de um modelo usando /api/show
|
||||||
|
pub async fn get_model_info(&self, model_name: &str) -> Result<serde_json::Value, String> {
|
||||||
|
self.check_connection().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/show", self.base_url);
|
||||||
|
let response = self.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&serde_json::json!({ "name": model_name }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista modelos disponíveis usando /api/tags
|
||||||
|
pub async fn get_tags(&self) -> Result<Vec<OllamaModel>, String> {
|
||||||
|
self.check_connection().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/tags", self.base_url);
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TagsResponse {
|
||||||
|
models: Vec<OllamaModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: TagsResponse = response.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result.models)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opções para geração de texto
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct GenerationOptions {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub num_predict: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modelo Ollama retornado por /api/tags
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct OllamaModel {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub modified_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub digest: Option<String>,
|
||||||
|
}
|
||||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user