18 Commits
Author SHA1 Message Date
evandro f0b7db8c12 docs(readme): redesenha o README com hero em SVG [skip ci] 2026-10-24 04:57:52 -03:00
evandro 2835957e40 docs: redesign do README (padrão TwitterBR-MOD) — dados atualizados
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m25s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
2026-10-13 21:50:58 -03:00
Evandro Fonseca Junior 3e57b78aa7 Merge pull request #7 from evandrodevbr:develop
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m18s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
chore: add 'develop' branch to release workflow triggers
2025-11-27 23:04:26 -03:00
Evandro Fonseca Junior cde8e5ac00 chore: add 'develop' branch to release workflow triggers
- Included 'develop' branch in the GitHub Actions release workflow to enable automated releases from the development branch, enhancing CI/CD processes.
2025-11-27 23:03:58 -03:00
Evandro Fonseca Junior 6fdbd4bfe6 chore: update pnpm workspace configuration
- Added root package to the pnpm workspace configuration to streamline dependency management across the project.
2025-11-27 23:02:59 -03:00
Evandro Fonseca Junior d01d26e74f Merge pull request #6 from evandrodevbr:develop
chore: streamline pnpm installation in release workflow
2025-11-27 22:59:06 -03:00
Evandro Fonseca Junior e51a54c568 Merge remote-tracking branch 'origin/master' into develop 2025-11-27 22:58:59 -03:00
Evandro Fonseca Junior a319a9cf8d chore: streamline pnpm installation in release workflow
- Removed duplicate pnpm installation steps in the GitHub Actions release workflow.
- Ensured consistent setup of pnpm across jobs to optimize workflow efficiency.
2025-11-27 22:58:29 -03:00
Evandro Fonseca Junior 1daa6c77ee Merge pull request #5 from evandrodevbr:develop
remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality.
2025-11-27 22:54:28 -03:00
Evandro Fonseca Junior 3e5727ffc7 remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality. 2025-11-27 22:53:55 -03:00
Evandro Fonseca Junior 162a6f37f7 Merge pull request #4 from evandrodevbr:app-automatic-att
feat: integrate automatic update checks and enhance settings UI
2025-11-27 22:48:06 -03:00
Evandro Fonseca Junior 6c366eecb0 feat: integrate automatic update checks and enhance settings UI
- Added functionality for automatic update checks in the settings page, allowing users to enable or disable this feature.
- Integrated app version display and update availability notifications.
- Updated settings store to manage auto-check updates state and handle version migration.
- Enhanced layout with a new card for update settings, improving user experience and accessibility.
2025-11-27 22:47:48 -03:00
Evandro Fonseca Junior 6cdc8d4294 Merge pull request #3 from evandrodevbr:optimizations
Optimizations
2025-11-27 21:53:56 -03:00
Evandro Fonseca Junior 0d19f496bb feat(chat): implement infinite scroll for message loading and optimize chat message rendering
- Added infinite scroll functionality to load older messages when the user scrolls near the top of the chat.
- Implemented pagination state management to track loading status and available messages.
- Enhanced ChatMessage component with React.memo for performance optimization during streaming.
- Updated database interactions to support paginated message retrieval, improving efficiency in loading chat history.
2025-11-27 21:53:27 -03:00
Evandro Fonseca Junior e53e49c8f8 feat(chat): implement speculative web search and embeddings commands
- Added speculative search functionality to enhance query processing by detecting when a web search may be needed based on user input patterns.
- Integrated commands for managing embeddings, including downloading models, checking availability, calculating relevance scores, generating embeddings, and pruning context.
- Optimized chat streaming by buffering tokens to reduce overhead and improve responsiveness.
- Implemented lazy initialization for the headless browser to conserve memory until needed.
- Enhanced web scraping capabilities with a static scraping method for faster content retrieval.
2025-11-27 21:19:16 -03:00
Evandro Fonseca Junior ba03131a78 feat(chat): enhance search functionality and message highlighting
- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
2025-11-27 20:39:39 -03:00
Evandro Fonseca Junior 1a64f5aa18 Merge pull request #2 from evandrodevbr/full-rust-backend
Full-rust-backend
2025-11-27 18:46:03 -03:00
Evandro Fonseca Junior 7d169d8a61 Merge pull request #1 from evandrodevbr/develop
feat: enhance chat functionality with markdown support and default prompts
2025-11-19 00:40:26 -03:00
140 changed files with 4697 additions and 22726 deletions

No files matched your search

@@ -1,569 +0,0 @@
---
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)
+3 -98
View File
@@ -43,7 +43,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -57,14 +57,6 @@ jobs:
toolchain: stable toolchain: stable
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.9" --locked
- name: Install dependencies (Ubuntu only) - name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-latest' if: matrix.platform == 'ubuntu-latest'
run: | run: |
@@ -72,96 +64,19 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies - name: Install frontend dependencies
shell: bash run: pnpm install --frozen-lockfile
run: |
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
else
pnpm install
fi
- name: Generate changelog - name: Generate changelog
run: pnpm generate-changelog run: pnpm generate-changelog
- name: Build frontend (Next.js)
shell: bash
env:
NODE_OPTIONS: ${{ matrix.platform == 'macos-latest' && matrix.target == 'aarch64-apple-darwin' && '--max-old-space-size=4096' || '--max-old-space-size=2048' }}
run: |
echo "Building Next.js frontend with NODE_OPTIONS=${NODE_OPTIONS}"
pnpm build
if [ ! -d "out" ]; then
echo "Error: Frontend build failed - 'out' directory not found"
exit 1
fi
echo "Frontend build successful - 'out' directory exists"
<<<<<<< HEAD
- name: Prepare bundle resources (Windows only)
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
# Criar diretórios
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/ollama" | Out-Null
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/models/embeddings" | Out-Null
# Baixar instalador do Ollama (se não existir)
$ollamaPath = "src-tauri/bundle/ollama/OllamaSetup.exe"
if (!(Test-Path $ollamaPath)) {
Write-Host "Downloading Ollama installer..."
Invoke-WebRequest -Uri "https://ollama.com/download/OllamaSetup.exe" -OutFile $ollamaPath -UseBasicParsing
}
# Baixar modelos de embeddings (se não existirem)
$modelPath = "src-tauri/bundle/models/embeddings/all-MiniLM-L6-v2.onnx"
if (!(Test-Path $modelPath)) {
Write-Host "Downloading ONNX embedding model (~90MB, this may take a while)..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx" -OutFile $modelPath -UseBasicParsing
}
$tokenizerPath = "src-tauri/bundle/models/embeddings/tokenizer.json"
if (!(Test-Path $tokenizerPath)) {
Write-Host "Downloading tokenizer..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json" -OutFile $tokenizerPath -UseBasicParsing
}
# Baixar e extrair ONNX Runtime DLL
$ortDllPath = "src-tauri/bundle/models/embeddings/onnxruntime.dll"
if (!(Test-Path $ortDllPath)) {
$ortZipPath = "src-tauri/bundle/models/embeddings/onnxruntime.zip"
if (!(Test-Path $ortZipPath)) {
Write-Host "Downloading ONNX Runtime library (~50MB)..."
Invoke-WebRequest -Uri "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip" -OutFile $ortZipPath -UseBasicParsing
}
Write-Host "Extracting ONNX Runtime DLL..."
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($ortZipPath)
foreach ($entry in $zip.Entries) {
if ($entry.Name -eq "onnxruntime.dll") {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $ortDllPath, $true)
Write-Host "Extracted onnxruntime.dll"
break
}
}
$zip.Dispose()
Remove-Item $ortZipPath -ErrorAction SilentlyContinue
}
Write-Host "Bundle resources prepared successfully"
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
- name: Build Tauri app - name: Build Tauri app
run: | run: |
cd src-tauri cd src-tauri
cargo build --release --target ${{ matrix.target }} cargo build --release --target ${{ matrix.target }}
- name: Build Tauri bundle - name: Build Tauri bundle
shell: bash
env: env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }} TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
NODE_OPTIONS: ${{ matrix.platform == 'macos-latest' && matrix.target == 'aarch64-apple-darwin' && '--max-old-space-size=4096' || '--max-old-space-size=2048' }}
run: | run: |
cd src-tauri cd src-tauri
if [ -n "$TAURI_PRIVATE_KEY" ]; then if [ -n "$TAURI_PRIVATE_KEY" ]; then
@@ -199,7 +114,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -207,15 +122,6 @@ jobs:
node-version: '20' node-version: '20'
cache: 'pnpm' cache: 'pnpm'
- name: Install frontend dependencies
shell: bash
run: |
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
else
pnpm install
fi
- name: Get version - name: Get version
id: get_version id: get_version
run: | run: |
@@ -233,7 +139,6 @@ jobs:
- name: Extract release notes - name: Extract release notes
id: extract_notes id: extract_notes
shell: bash
run: | run: |
if [ -f "data/changelog.json" ]; then if [ -f "data/changelog.json" ]; then
NOTES=$(node -e " NOTES=$(node -e "
-11
View File
@@ -40,14 +40,3 @@ 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/
-27
View File
@@ -1,27 +0,0 @@
OllaHub Community License (v1.0)
Copyright (c) 2024 Evandro Fonseca Junior (evandrodevbr) Website: https://evandro.dev.br
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. GRANT OF LICENSE Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, publish, distribute, and sublicense copies of the Software, subject strictly to the conditions and limitations set forth below.
2. ATTRIBUTION REQUIREMENT (CREDITWARE) This license explicitly requires correct attribution as a condition of use. a) Source Code & Distribution: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. b) Public Display: Any redistribution, fork, derivative work, video content, article, social media post, or public demonstration involving this Software MUST include visible and clear credits to the original author (Evandro Fonseca Junior) and a direct link to https://evandro.dev.br. c) UI Integrity: Removal, obscuring, or modification of copyright notices, author links, metadata, or "About" screens within the Software's user interface is strictly prohibited.
3. NON-COMMERCIAL & ANTI-MONETIZATION RESTRICTIONS This license distinguishes between "Internal Use" and "Commercial Distribution." a) Prohibition of Sale: You may NOT sell, rent, lease, or sub-license the Software for a fee. You may not bundle this Software within a paid product or service without express written permission from the Copyright Holder. b) No Monetization Barriers: You may NOT use the Software as a mechanism for monetization, including but not limited to "freemium" models, paywalls, or "pay-for-speed" download gates. c) Permitted Internal Use: The use of this Software internally within a commercial organization for productivity, research, or development purposes (e.g., employees using the tool to perform their daily tasks) is EXPLICITLY PERMITTED and encouraged, provided the Software itself is not the product being sold.
4. DONATION POLICY The Software is developed and maintained through voluntary contributions. a) Official Channels Only: No third-party entity is authorized to solicit or collect donations on behalf of OllaHub or its author. b) Direct Support: All financial contributions must be directed exclusively to the official channels listed at https://evandro.dev.br. Soliciting donations for a fork or redistribution of this Software without significant and distinct added value is prohibited.
5. DISCLAIMER OF WARRANTY & LIABILITY THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
6. SPECIFIC DISCLAIMER: AI & WEB SCRAPING The Software interacts with Large Language Models (LLMs) and performs web scraping operations. By using this Software, you acknowledge and agree that: a) The authors are not responsible for the accuracy, hallucinations, or bias of content generated by LLMs. b) You are solely responsible for ensuring that your use of the web scraping features complies with the Terms of Service (ToS) of third-party websites and applicable local laws. c) You are responsible for maintaining data privacy and security when processing sensitive information.
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
+48 -173
View File
@@ -1,206 +1,81 @@
<div align="center"> <div align="center">
<h1>🧠 OllaHub</h1>
<p><strong>Local-first LLM workspace with deep research capabilities and native OS integration.</strong></p>
<p align="center"> <img src="assets/readme/hero.svg" width="100%" alt="OllaHub: desktop chat for your local Ollama models, with web search and MCP tools">
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.77.2-orange.svg?style=flat-square&logo=rust" alt="Rust"></a>
<a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-2.9.2-24C8DB.svg?style=flat-square&logo=tauri" alt="Tauri"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/Next.js-16.0.3-black.svg?style=flat-square&logo=next.js" alt="Next.js"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0-blue.svg?style=flat-square&logo=typescript" alt="TypeScript"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Custom-blue.svg?style=flat-square" alt="License"></a>
</p>
<br> ![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-555?style=flat-square)
![Tauri 2](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)
![Next.js 16](https://img.shields.io/badge/Next.js-16.0.3-000000?style=flat-square&logo=nextdotjs&logoColor=white)
![React 19](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black)
![Platforms](https://img.shields.io/badge/platforms-Windows_%7C_macOS_%7C_Linux-1447e6?style=flat-square)
</div> [Quick start](#quick-start) · [Docs](docs/) · [Issues](https://git.evandro.dev.br/evandro/OllaHub/issues)
--- </div>
## 🌟 Why OllaHub? ## What it is
OllaHub is a powerful desktop application that orchestrates local Large Language Models (LLMs) through **Ollama**, providing a private, offline-capable workspace for research, analysis, and conversation. OllaHub is a desktop chat app for the models you run with [Ollama](https://ollama.com). On first run it scans your hardware, recommends a model and downloads it. After that it chats with streaming, keeps history in a local SQLite database, and can add context from web search, MCP tools and scheduled tasks. The UI is Next.js inside a Tauri 2 window, and a Rust backend does the work.
Unlike standard chat interfaces, OllaHub executes **complex research workflows** by decomposing queries, performing parallel web scraping, and synthesizing context from multiple sources before generating responses—all while keeping your data 100% local and private. OllaHub is a client, not a model runtime. It talks to the Ollama API (default `http://localhost:11434`, configurable in Settings), tries to start Ollama when it is installed but stopped, and can download the Ollama installer when it is missing.
--- ## Why use it
## ✨ Key Features - **Local by default.** Prompts go to your own Ollama, and chats live in `ollahub.db` in the app data directory, with FTS5 full-text search. Besides downloads (models, the embedding model, updates), web search is what reaches the internet.
- **Web answers ranked on your machine.** Headless Chrome scrapes Google, Bing, Yahoo, DuckDuckGo and Startpage (order configurable), then local embeddings (`all-MiniLM-L6-v2` on ONNX Runtime) rank and prune the pages, with a BM25-like fallback when the embedding model is unavailable. A deep-research mode runs planning → searching → aggregating → validating → formulating.
- **More than a chat box.** MCP servers run as child processes configured in `mcp_config.json`, their tools can be called from the chat, and cron tasks (search and summarize, notification, custom prompt) keep running in the background.
### 🔍 Deep Research Pipeline **When not to use it:** if you want a finished, tested product (version `0.1.0`, no test suite), a model runtime of its own, or a server you can run without a desktop.
OllaHub doesn't just answer; it researches. Our multi-stage pipeline includes:
1. **Query Decomposition:** Uses the LLM to break complex queries into 3-5 atomic, search-optimized sub-queries.
2. **Parallel Scraping:** Executes concurrent searches using headless Chrome and the DuckDuckGo metadata API.
3. **Knowledge Aggregation:** Collects, deduplicates, and cleans content from multiple sources.
4. **Context Condensation:** Applies relevance scoring (ONNX embeddings) and summarization to fit context within token limits.
5. **Validation:** Verifies knowledge base sufficiency before response generation, with safety fallbacks.
### 🛡️ Local-First & Privacy-Focused ## How it works
* **Ollama Integration:** Direct subprocess management for seamless local model execution.
* **Local Semantic Search:** Powered by ONNX Runtime (`all-MiniLM-L6-v2` 384-dimensional embeddings) with no external API calls.
* **Hybrid Storage:** Blazing fast SQLite (FTS5 for full-text search) with JSON fallbacks for backward compatibility and data safety.
### 🔌 MCP (Model Context Protocol) Integration - **Frontend** (`app/`, `components/`, `hooks/`, `lib/`, `store/`): Next.js 16 with `output: 'export'`, React 19, Tailwind 4, Radix UI and Zustand. It calls the backend through Tauri `invoke` and events.
Extensible by design. Connect external tools via MCP servers: - **Backend** (`src-tauri/src/`): `lib.rs` holds the commands for chat (`chat_stream`), models, the Ollama lifecycle, sessions, MCP, search, tasks, export and the installer. `db.rs` handles SQLite, `web_scraper.rs` the search engines, `embeddings.rs` the ranking, and `scheduler.rs` the cron tasks stored in `tasks.json`.
* **Tool Discovery:** Automatic enumeration of available tools from running servers. - **Models:** list, delete and pull with progress, install a local `.gguf` file, and get a recommendation for your hardware.
* **Process Management:** Full lifecycle control (start, stop, restart) with health monitoring. - **System monitor:** CPU and RAM stats plus GPU VRAM, usage and temperature. GPU detection uses `wmic` and `nvidia-smi` on Windows and `lspci` on Linux.
* **Tool Execution:** Runtime invocation of tools with direct result injection into the LLM context. - **Updates:** the Tauri updater checks automatically. Its endpoint in `src-tauri/tauri.conf.json` points to a GitHub Releases `latest.json`.
### ⚡ Reactive & Customizable UI More notes: [`docs/updater-setup.md`](docs/updater-setup.md) (signing keys and updater) and [`docs/style-guide.md`](docs/style-guide.md) (UI spacing, radii, accessibility).
Built with **React 19, Next.js 16, and Tailwind CSS**. Features smooth animations (Framer Motion), accessible primitives (Radix UI/Shadcn), resizable panels, and lightweight state management (Zustand).
--- ## Quick start
## 🏗️ System Architecture No release is published on this repository yet, so build it locally. You need Node.js 20, pnpm 8, a stable Rust toolchain and the [Tauri 2 prerequisites](https://tauri.app/start/prerequisites/) (the versions come from the CI workflow). On Ubuntu the workflow also installs `libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf`. Ollama is needed at runtime.
<details>
<summary><b>Click to view Architecture Diagram</b></summary>
```mermaid
graph TB
subgraph Frontend["Frontend (Next.js)"]
UI[React Components]
Hooks[Custom Hooks]
Store[Zustand Store]
end
subgraph Bridge["Tauri Bridge"]
Commands[Tauri Commands]
Events[Event System]
end
subgraph Backend["Rust Core"]
Orchestrator[Orchestrator]
Scraper[Web Scraper]
DB[(SQLite + FTS5)]
Embeddings[ONNX Embeddings]
Scheduler[Task Scheduler]
MCP[MCP Manager]
end
subgraph External["External Services"]
Ollama[Ollama Subprocess]
Chrome[Headless Chrome]
MCP_Servers[MCP Servers]
end
UI --> Hooks
Hooks --> Store
Store --> Commands
Commands --> Orchestrator
Orchestrator --> Scraper
Orchestrator --> DB
Orchestrator --> Embeddings
Orchestrator --> Scheduler
Orchestrator --> MCP
Scraper --> Chrome
Orchestrator --> Ollama
MCP --> MCP_Servers
Events --> UI
style Frontend fill:#2563eb,color:#fff
style Bridge fill:#7c3aed,color:#fff
style Backend fill:#dc2626,color:#fff
style External fill:#059669,color:#fff
```
</details>
---
## 🚀 Getting Started
### Prerequisites
Before you begin, ensure you have the following installed:
* **Node.js**: `v20.x` or later
* **Rust**: `v1.77.2` or later
* **pnpm**: Latest version (`npm install -g pnpm`)
* **[Ollama](https://ollama.ai)**: Must be running locally on `http://localhost:11434`
### Installation & Development
1. **Clone the repository:**
```bash ```bash
git clone [https://github.com/evandrodevbr/OllaHub.git](https://github.com/evandrodevbr/OllaHub.git) git clone https://git.evandro.dev.br/evandro/OllaHub.git
cd OllaHub cd OllaHub
```
2. **Install dependencies:**
```bash
pnpm install pnpm install
pnpm tauri dev # starts `pnpm dev` (Next.js on http://localhost:3000) inside the Tauri window
pnpm tauri build # runs `pnpm generate-changelog && pnpm build`, then bundles the installer
pnpm lint # ESLint
pnpm bump-version # patch | minor | major, optional --commit
``` ```
The workflow [`.github/workflows/release.yml`](.github/workflows/release.yml) builds `msi` (Windows), `dmg` (macOS, Apple Silicon and Intel) and `AppImage` (Linux) on pushes to `main`, `master` and `develop`, and publishes release `v<version>` on pushes to `main` or `master`.
3. **Run in development mode:** > [!WARNING]
```bash > **Two things the app does on its own.**
pnpm tauri dev > 1. Opening the chat page pulls the small helper model **`qwen2.5:0.5b`** if it is not installed yet, used for automatic chat titles. Expect an extra download on first use.
> 2. **Closing the window only hides it.** The close request is intercepted (`prevent_close`) so scheduled tasks keep running, but there is no tray icon to bring the window back yet; the tray step is still pending in [`IMPLEMENTATION_GUIDE.md`](IMPLEMENTATION_GUIDE.md).
``` ## Project status
Early: version `0.1.0`, 47 commits between 2025-10-27 and 2025-11-27, no automated tests (`package.json` defines `dev`, `build`, `start`, `lint`, `generate-changelog` and `bump-version`). Known limitations:
*This starts the Next.js server (`localhost:3000`), builds the Rust backend, and launches the Tauri native window.* - **No tray icon yet:** closing the window hides it (see the warning above).
- **"Start with the system" does nothing yet:** the setting only toggles a stored flag; the code has a `TODO` until `tauri-plugin-autostart` is configured.
- **RAG inside `chat_stream` is not wired:** the `enable_rag` branch is commented out as `TODO`; the frontend assembles web context before the request.
- **Embeddings outside Windows:** the ONNX Runtime library is downloaded automatically only on Windows. On macOS and Linux, provide it in the app data folder or on the system path, otherwise ranking falls back to BM25-like scoring.
- **Unused leftovers:** `src-tauri/scripts/scraper.py` is not called by the Rust backend, and the 188 model files in `data/models/` are not referenced by the app code.
### Building for Production ## License
```bash No published license: all rights reserved.
pnpm tauri build
``` Built with [Tauri](https://tauri.app), [Next.js](https://nextjs.org), [Radix UI](https://www.radix-ui.com), [Tailwind CSS](https://tailwindcss.com), [Zustand](https://zustand-demo.pmnd.rs), [Lucide](https://lucide.dev), [headless_chrome](https://github.com/rust-headless-chrome/rust-headless-chrome), [ONNX Runtime](https://onnxruntime.ai) and the [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) model.
*Compiled binaries will be available in `src-tauri/target/release/bundle/` (.msi, .dmg, .app, .deb, etc.).*
---
## 💻 Tech Stack Deep Dive
<div align="center"> <div align="center">
<img src="https://www.google.com/search?q=https://skillicons.dev/icons%3Fi%3Drust,tauri,nextjs,react,ts,tailwind,sqlite" />
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div> </div>
| Layer | Technologies Used | Purpose |
| --- | --- | --- |
| **Core** | Tauri 2.9, Rust 1.77 | Native desktop environment, high-performance system I/O, concurrency (Tokio). |
| **Frontend** | Next.js 16, React 19, TS | SSR-capable UI, Zustand (State), Framer Motion (Animations), Tailwind/Radix. |
| **Database** | SQLite (rusqlite 0.31) | FTS5 virtual tables for lightning-fast full-text search and session storage. |
| **AI / ML** | Ollama, ONNX Runtime | Local LLM execution and 384-dim semantic embeddings (`all-MiniLM-L6-v2`). |
| **Scraping** | headless_chrome, scraper | Parallel web automation, DOM parsing, and readability extraction. |
---
## 🗺️ Roadmap
* [ ] **Vector Store Integration**: Transition from SQLite BLOB embeddings to a dedicated vector DB (LanceDB or Qdrant).
* [ ] **Advanced RAG**: Multi-modal retrieval supporting images, PDFs, and local documents.
* [ ] **Cloud Sync**: Optional E2E encrypted synchronization of chat sessions across devices.
* [ ] **Custom Plugin System**: Extensible architecture for user-defined research workflows.
* [ ] **Model Fine-Tuning UI**: Native interface for training custom models on personal knowledge bases.
---
## ⚖️ License & Terms
OllaHub is released under a custom license combining freeware and creditware models. See the [LICENSE](https://www.google.com/search?q=LICENSE) file for full legal terms.
**TL;DR:**
* ✅ **You may:** Use freely for personal/educational/internal use, modify, and fork the project.
* ✅ **You must:** Give visible credit to **Evandro Fonseca Junior** and link to [evandro.dev.br](https://evandro.dev.br). Keep copyright notices intact.
* ❌ **You may not:** Sell the software, bundle it in paid products, or monetize it without explicit written permission.
For commercial licensing inquiries, please contact me via [evandro.dev.br](https://evandro.dev.br).
---
<div align="center">
<p>Built by <a href="https://github.com/evandrodevbr">Evandro Fonseca Junior</a></p>
</div>
```
+246 -356
View File
@@ -3,9 +3,8 @@
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Button } from "@/components/ui/button"; 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, ScrollText, Copy, Check, Globe, 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";
@@ -14,7 +13,6 @@ 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";
@@ -26,17 +24,15 @@ 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, startTransition } from "react"; import { useState, useEffect, useRef, useCallback } 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";
import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor"; import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor";
import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat"; import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
import { executeProgressiveSearch } from "@/lib/web-search-fallback"; import { executeProgressiveSearch } from "@/lib/web-search-fallback";
import type { DebugData } from "@/components/chat/debug-console";
// @ts-expect-error - MD file import // @ts-expect-error - MD file import
import defaultFormatPrompt from "@/data/prompts/default-format.md"; import defaultFormatPrompt from "@/data/prompts/default-format.md";
@@ -46,7 +42,6 @@ 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,
@@ -116,68 +111,9 @@ export default function ChatPage() {
})); }));
}, [setMessages]); }, [setMessages]);
const { selectedModel, setSelectedModel } = useModelSelection(); const [selectedModel, setSelectedModel] = useState("");
// 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
@@ -189,9 +125,14 @@ export default function ChatPage() {
const lastSavedMessagesRef = useRef<number>(0); const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showContextDebug, setShowContextDebug] = useState(false);
const [showProcessDebug, setShowProcessDebug] = useState(false);
const [lastWebContext, setLastWebContext] = useState('');
const [lastContextSources, setLastContextSources] = useState<ScrapedContent[]>([]);
const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0); const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [debugDataMap, setDebugDataMap] = useState<Map<number, DebugData>>(new Map());
// Resetar índice quando busca é limpa ou sessão muda // Resetar índice quando busca é limpa ou sessão muda
useEffect(() => { useEffect(() => {
@@ -200,10 +141,25 @@ 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)) {
@@ -413,6 +369,13 @@ export default function ChatPage() {
chatLog.info(`Should Search: ${preprocessed.shouldSearch}`); chatLog.info(`Should Search: ${preprocessed.shouldSearch}`);
chatLog.info(`Timestamp: ${new Date().toISOString()}`); chatLog.info(`Timestamp: ${new Date().toISOString()}`);
setShowContextDebug(false);
setShowProcessDebug(false);
setLastWebContext('');
setLastContextSources([]);
setLastUserQuery(content); // Store for copy logs
setLogsCopied(false);
// Generate ID if new session (but don't create card yet - wait for title) // Generate ID if new session (but don't create card yet - wait for title)
if (!currentSessionId) { if (!currentSessionId) {
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
@@ -473,10 +436,7 @@ 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) => {
const queryStartTime = Date.now(); chatLog.info(` → Starting progressive search ${idx + 1}/${searchPlan.length}: "${query}"`);
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', {
@@ -489,22 +449,6 @@ 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,
@@ -528,47 +472,22 @@ export default function ChatPage() {
} }
); );
const queryDuration = Date.now() - queryStartTime; // Log do resultado
chatLog.info(` ✓ Progressive search ${idx + 1} complete:`);
chatLog.info(` - Success: ${fallbackResult.success}`);
chatLog.info(` - Total results: ${fallbackResult.scrapedSources.length}`);
chatLog.info(` - Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
chatLog.info(` - Attempts: ${fallbackResult.attempts.length}`);
chatLog.info(` - Used fallback: ${fallbackResult.usedFallback}`);
// Log detalhado do resultado // Log detalhado de cada resultado
chatLog.info(`\n[WebResearch] ========== QUERY ${idx + 1} RESULTS ==========`); fallbackResult.scrapedSources.forEach((result, resultIdx) => {
chatLog.info(`[WebResearch] Duration: ${queryDuration}ms`); chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`);
chatLog.info(`[WebResearch] Success: ${fallbackResult.success}`); chatLog.info(` URL: ${result.url}`);
chatLog.info(`[WebResearch] Total results: ${fallbackResult.scrapedSources.length}`); const contentLength = result.markdown?.length || 0;
chatLog.info(`[WebResearch] Total analyzed: ${fallbackResult.totalResultsAnalyzed}`); chatLog.info(` Content length: ${contentLength} chars`);
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);
@@ -608,14 +527,13 @@ 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) {
@@ -628,86 +546,60 @@ 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('\n[DeepResearch] ========== STEP 3: VALIDATION =========='); chatLog.info('\nStep 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);
const validationDuration = Date.now() - validationStart; chatLog.info(`Validation Report Length: ${validationReport.length} chars`);
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('\n[DeepResearch] ========== STEP 4: CONTEXT CONDENSATION =========='); chatLog.info('\nStep 4: Getting Curated Context (Optimized)');
// 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(`[DeepResearch] Model: ${selectedModel}`); chatLog.info(`Model Context Window: ${contextInfo.maxContextWindow} tokens`);
chatLog.info(`[DeepResearch] Max context window: ${contextInfo.maxContextWindow} tokens`); chatLog.info(`Available Tokens for KB: ${availableTokens} 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(`\n[DeepResearch] ========== CONDENSATION SUMMARY ==========`); chatLog.info(`Knowledge Base Context Length: ${knowledgeBaseContext.length} chars`);
chatLog.info(`[DeepResearch] Duration: ${condensationDuration}ms`); chatLog.info(`Condensation Method: ${optimizedResult.result.method}`);
chatLog.info(`[DeepResearch] Final context length: ${knowledgeBaseContext.length} chars`); chatLog.info(`Chunks Used: ${optimizedResult.result.chunksUsed} / ${optimizedResult.result.chunksTotal}`);
chatLog.info(`[DeepResearch] Method: ${optimizedResult.result.method}`); chatLog.info(`Compression Ratio: ${(optimizedResult.result.compressionRatio * 100).toFixed(1)}%`);
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(`${knowledgeBaseContext.substring(0, 2000)}...`); chatLog.info(`Context Preview (first 2000 chars):\n${knowledgeBaseContext.substring(0, 2000)}...`);
chatLog.info(`[DeepResearch] (truncated, ${knowledgeBaseContext.length} chars total)`);
} else { } else {
chatLog.info(knowledgeBaseContext); chatLog.info(`Full Context:\n${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(`[DeepResearch] Validation failed: ${validation.warnings.join(', ')}`); chatLog.error(`Context 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(`[DeepResearch] ⚠️ ${warning}`); chatLog.warn(`⚠️ ${warning}`);
}); });
} else { } else {
chatLog.info(`[DeepResearch] ✓ Context validation passed`); chatLog.info('✓ Context validation passed');
} }
chatLog.info(`[DeepResearch] ============================================\n`);
} else { } else {
chatLog.warn('[DeepResearch] ⚠️ Knowledge Base Context is empty!'); chatLog.warn('⚠️ Knowledge Base Context is empty!');
} }
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
// Atualizar mensagem: Processamento concluído // Atualizar mensagem: Processamento concluído
updateThinkingMessage('processing', { updateThinkingMessage('processing', {
@@ -735,6 +627,8 @@ export default function ChatPage() {
deepResearch.addToKnowledgeBase(simpleQuery, results); deepResearch.addToKnowledgeBase(simpleQuery, results);
knowledgeBaseContext = deepResearch.getCuratedContext(); knowledgeBaseContext = deepResearch.getCuratedContext();
scrapedSources = results; scrapedSources = results;
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
} }
} else { } else {
chatLog.info('Simple query returned NO_SEARCH, skipping web search'); chatLog.info('Simple query returned NO_SEARCH, skipping web search');
@@ -744,21 +638,17 @@ 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 (using local model fallback): ${errorMsg}`); chatLog.warn(`⚠️ Error in Deep Research pipeline (continuing with partial results): ${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 status: 'completed', // Marcar como completed mesmo com erro parcial
details: `Erro na pesquisa web (${errorMsg}). Usando modelo local como fallback.`, details: `Alguns erros ocorreram durante a pesquisa: ${errorMsg}. Continuando com resultados parciais...`,
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 (fallback automático) // O sistema continuará com conhecimento interno se necessário
} }
} else { } else {
if (!webSearch.isEnabled) { if (!webSearch.isEnabled) {
@@ -819,9 +709,8 @@ 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 ou erro na pesquisa) // Verificar se houve fallback (nenhum resultado relevante encontrado)
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
@@ -830,36 +719,25 @@ export default function ChatPage() {
.replace('{{userQuery}}', content); .replace('{{userQuery}}', content);
enhancedSystemPrompt = strictPrompt; enhancedSystemPrompt = strictPrompt;
} else if (usedFallback || hasWebSearchError) { } else if (usedFallback) {
// 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}
## ⚠️ FALLBACK PARA CONHECIMENTO INTERNO ## ⚠️ AVISO IMPORTANTE
Não foi possível acessar fontes web para esta consulta após múltiplas tentativas de busca.${errorContext} Não foi possível encontrar informações relevantes nas fontes web consultadas após múltiplas tentativas de busca.
**INSTRUÇÕES CRÍTICAS:** **INSTRUÇÕES:**
- Você DEVE responder usando APENAS seu conhecimento interno (treinamento do modelo) - Responda usando APENAS seu conhecimento interno (treinamento)
- Seja transparente: informe que não foi possível acessar fontes web, mas que você responderá com base no seu conhecimento - Seja honesto e diga: "Não encontrei essa informação nas fontes consultadas."
- Se tiver conhecimento sobre o tópico, compartilhe-o de forma útil e detalhada - Se tiver conhecimento sobre o tópico, compartilhe, mas deixe claro que não há fontes externas verificadas
- 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 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 quando relevante.`; Use esta data exata para referências temporais.`;
} }
// 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) {
@@ -929,9 +807,6 @@ 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`);
@@ -962,149 +837,6 @@ 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
setMessages(prev => {
const newMessages = [...prev];
const lastMsgIndex = newMessages.length - 1;
if (lastMsgIndex >= 0 && newMessages[lastMsgIndex].role === 'assistant') {
const lastMsg = newMessages[lastMsgIndex];
const debugData: DebugData = {
model: selectedModel,
timestamp: Date.now(),
latency: Date.now() - responseStart,
systemPrompt: enhancedSystemPrompt,
userQuery: content,
<<<<<<< HEAD
contextUsed: prev.filter(m => m.role !== 'system' && m.role !== 'assistant'),
webResearch: {
queries: deepResearch.state.plan || [],
enrichedQueries: deepResearch.state.enrichedQueries || undefined,
sources: currentScrapedSources,
sitesResearched: sitesResearched,
logs: deepResearch.state.logs || [],
plan: deepResearch.state.plan || [],
=======
contextUsed: messages.filter(m => m.role !== 'system'),
webResearch: {
queries: deepResearch.state.plan || [],
enrichedQueries: deepResearch.state.enrichedQueries || undefined,
sources: scrapedSources,
logs: deepResearch.state.logs,
plan: deepResearch.state.plan,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
sourceUrl: entry.sourceUrl,
title: entry.title,
content: entry.content,
})),
<<<<<<< HEAD
activeQueries: webSearch.activeQueries.length > 0 ? webSearch.activeQueries : undefined,
activeUrls: webSearch.activeUrls.length > 0 ? webSearch.activeUrls : undefined,
},
deepResearchState: deepResearch.state,
thinkingSteps: allThinkingSteps.length > 0 ? allThinkingSteps : undefined,
reasoning: reasoningSteps.length > 0 ? {
steps: reasoningSteps.map(r => `${r.stage}: ${r.input.substring(0, 100)}...`),
intermediateResults: reasoningSteps.map(r => ({
stage: r.stage,
output: r.output,
timestamp: r.timestamp,
})),
} : undefined,
finalResponse: lastMsg.content || '',
rawResponse: lastMsg.content || '',
tokenUsage: {
input: estimatedInputTokens,
output: estimatedOutputTokens,
total: estimatedInputTokens + estimatedOutputTokens,
},
=======
},
deepResearchState: deepResearch.state,
finalResponse: lastMsg.content,
rawResponse: lastMsg.content,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
};
// Armazenar debug data usando o índice da mensagem
setDebugDataMap(prevMap => {
const newMap = new Map(prevMap);
newMap.set(lastMsgIndex, debugData);
return newMap;
});
}
return newMessages;
});
// Persistir fontes na mensagem do assistente (metadata) // Persistir fontes na mensagem do assistente (metadata)
if (scrapedSources.length > 0) { if (scrapedSources.length > 0) {
// Aguardar um pouco para garantir que o sendMessage iniciou e criou a mensagem do assistente // Aguardar um pouco para garantir que o sendMessage iniciou e criou a mensagem do assistente
@@ -1266,6 +998,45 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
} }
}; };
// Copy Logs to Clipboard
const handleCopyLogs = async () => {
const logs = deepResearch.state.logs;
const lastAssistantMessage = messages.filter(m => m.role === 'assistant').pop();
let logText = `# Deep Research Debug Log\n\n`;
logText += `## User Query\n${lastUserQuery}\n\n`;
logText += `## Model\n${selectedModel}\n\n`;
logText += `## Process Logs\n\n`;
logs.forEach((log, idx) => {
logText += `### ${idx + 1}. Stage: ${log.stage.toUpperCase()}\n`;
logText += `- **Time**: ${new Date(log.timestamp).toLocaleTimeString()}\n`;
logText += `- **Input**: ${log.input.substring(0, 500)}${log.input.length > 500 ? '...' : ''}\n`;
if (log.rawOutput) {
logText += `- **Raw Output**: ${log.rawOutput.substring(0, 500)}${log.rawOutput.length > 500 ? '...' : ''}\n`;
}
if (log.parsedOutput) {
logText += `- **Parsed Output**: \`\`\`json\n${JSON.stringify(log.parsedOutput, null, 2)}\n\`\`\`\n`;
}
if (log.error) {
logText += `- **Error**: ${log.error}\n`;
}
logText += '\n';
});
logText += `## AI Response\n`;
logText += lastAssistantMessage?.content || '[No response yet]';
logText += '\n\n---\n';
logText += `Generated at: ${new Date().toISOString()}`;
try {
await navigator.clipboard.writeText(logText);
setLogsCopied(true);
setTimeout(() => setLogsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy logs:', err);
}
};
return ( return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col overflow-x-hidden"> <div className="h-screen w-full bg-background overflow-hidden flex flex-col overflow-x-hidden">
@@ -1273,7 +1044,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 pt-8"> <div className="flex-1 flex overflow-hidden">
<ResizablePanelGroup direction="horizontal" className="flex-1"> <ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel <ResizablePanel
@@ -1380,7 +1151,61 @@ 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">
<ModelSelector /> <TooltipProvider>
<Select
value={selectedModel || undefined}
onValueChange={(v) => {
if (v === "__add_model__") {
setShowDownloadDialog(true);
// Não alterar selectedModel quando abrir dialog
} else {
setSelectedModel(v);
settings.setSelectedModel(v);
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger className="h-9 min-w-0 max-w-full">
<SelectValue placeholder="Selecione um modelo..." className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw] sm:max-w-[420px]">
{models.map(m => (
<Tooltip key={m.name}>
<TooltipTrigger asChild>
<SelectItem value={m.name} className="min-w-0">
<span className="flex items-center justify-between w-full min-w-0 gap-2">
<span className="flex items-center gap-2 truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate">{m.name}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
</span>
</SelectItem>
</TooltipTrigger>
{m.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{m.name}</p>
</TooltipContent>
)}
</Tooltip>
))}
<SelectItem value="__add_model__" className="text-primary font-medium">
<span className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Adicionar modelo...
</span>
</SelectItem>
</SelectContent>
</Select>
</TooltipProvider>
</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" />
@@ -1555,7 +1380,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined} highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
highlightIndex={messageMatchIndex} highlightIndex={messageMatchIndex}
messageIndex={group.index} messageIndex={group.index}
debugData={group.index !== undefined ? debugDataMap.get(group.index) : undefined}
/> />
</div> </div>
); );
@@ -1566,6 +1390,71 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
)} )}
{/* Debug Info (Moved inside scroll) */}
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
<div className="mt-12 pt-6 border-t border-dashed border-muted/50 opacity-70 hover:opacity-100 transition-opacity">
<div className="space-y-4">
{/* Process Log Toggle */}
{deepResearch.state.logs.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowProcessDebug((prev) => !prev)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<ScrollText className="w-3 h-3" />
Debug: Processo de Pensamento
<ChevronDown className={`w-3 h-3 transition-transform ${showProcessDebug ? 'rotate-180' : ''}`} />
</button>
{showProcessDebug && (
<div className="mt-2 space-y-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 max-h-60 overflow-y-auto">
<div className="flex justify-end mb-2">
<Button variant="ghost" size="sm" onClick={handleCopyLogs} className="h-6 px-2 text-[10px]">
{logsCopied ? <Check className="w-3 h-3 mr-1" /> : <Copy className="w-3 h-3 mr-1" />} Copiar Logs
</Button>
</div>
{deepResearch.state.logs.map((log, idx) => (
<div key={idx} className="grid grid-cols-[60px_1fr] gap-2 border-b border-muted/20 pb-2 last:border-0">
<span className="text-muted-foreground uppercase text-[10px]">{log.stage}</span>
<span>{log.input ? (log.input.length > 50 ? log.input.substring(0, 50) + '...' : log.input) : '-'}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Context Debug Toggle */}
{lastWebContext && (
<div>
<button
type="button"
onClick={() => setShowContextDebug((prev) => !prev)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Globe className="w-3 h-3" />
Debug: Contexto Web ({lastContextSources.length} fontes)
<ChevronDown className={`w-3 h-3 transition-transform ${showContextDebug ? 'rotate-180' : ''}`} />
</button>
{showContextDebug && (
<div className="mt-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 space-y-3">
<div className="max-h-32 overflow-y-auto space-y-1">
{lastContextSources.map((s, i) => (
<a key={i} href={s.url} target="_blank" className="block hover:underline text-primary truncate">{s.title || s.url}</a>
))}
</div>
<div className="border-t border-muted/20 pt-2">
<p className="mb-1 font-semibold">Preview Contexto:</p>
<pre className="max-h-32 overflow-y-auto p-2 bg-background rounded border text-[10px]">{lastWebContext.substring(0, 500)}...</pre>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Spacer for Bottom Input */} {/* Spacer for Bottom Input */}
<div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} /> <div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} />
</div> </div>
@@ -1652,3 +1541,4 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
); );
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

-10
View File
@@ -376,16 +376,6 @@
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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

-25
View File
@@ -3,14 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; 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";
<<<<<<< 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",
@@ -25,10 +17,6 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "OllaHub", title: "OllaHub",
description: "Interface moderna para Ollama", description: "Interface moderna para Ollama",
icons: {
icon: "/icon.png",
apple: "/icon.png",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -47,21 +35,8 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<<<<<<< HEAD
<OllamaHealthProvider>
<DownloadProvider>
<KeyboardShortcuts />
{children}
<AppUpdaterProvider />
<GlobalDownloadStatusBar />
<ServiceInterruptionOverlay />
</DownloadProvider>
</OllamaHealthProvider>
=======
<KeyboardShortcuts />
{children} {children}
<AppUpdaterProvider /> <AppUpdaterProvider />
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+28 -179
View File
@@ -1,230 +1,80 @@
'use client'; 'use client';
<<<<<<< HEAD import { useOllamaCheck } from "@/hooks/use-ollama-check";
import { TitleBar } from "@/components/titlebar";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, startTransition, useRef } from "react";
import { useSettingsStore } from "@/store/settings-store";
import { invoke } from "@tauri-apps/api/core";
export default function Home() {
=======
import { useSetupCheck } from "@/hooks/use-setup-check";
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { InstallModal } from "@/components/modals/install-modal"; import { InstallModal } from "@/components/modals/install-modal";
import { StoppedCard } from "@/components/landing/stopped-card"; import { StoppedCard } from "@/components/landing/stopped-card";
import { WelcomeNotification } from "@/components/notifications/welcome-notification"; import { WelcomeNotification } from "@/components/notifications/welcome-notification";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, startTransition } from "react"; import { useEffect } from "react";
export default function Home() { export default function Home() {
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck(); const { status, check } = useOllamaCheck();
>>>>>>> 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 // Tentar iniciar Ollama automaticamente quando o componente montar
// Hydration check: aguardar Zustand carregar do localStorage
useEffect(() => { useEffect(() => {
setIsHydrated(true); const tryAutoStart = async () => {
}, []);
// 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 { try {
// 1. Verificação Real do Sistema (Fonte da Verdade) const { invoke } = await import("@tauri-apps/api/core");
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command'); await invoke('auto_start_ollama');
// Aguardar um pouco e verificar novamente
// Ler isSetupCompleted diretamente do store (não usar como dependência) setTimeout(() => {
const currentSetupStatus = useSettingsStore.getState().isSetupCompleted; check();
}, 2000);
if (!isOllamaIntact) { } catch (error) {
// Se o motor não existe, força o setup, não importa o que o storage diga console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
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(); // Só tentar se estiver instalado mas não rodando
}, [isHydrated, router, setSetupCompleted]); if (status === 'installed_stopped') {
tryAutoStart();
// Enquanto não hidratou ou está verificando, retornar skeleton simples
if (!isHydrated || isChecking) {
return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<div className="flex-1 flex items-center justify-center pt-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">
{!isHydrated ? 'Carregando...' : 'Verificando sistema...'}
</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
</div>
=======
// Redirecionar automaticamente se tudo estiver pronto
useEffect(() => {
if (status === 'ready') {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
// Se setup não foi marcado como completo, marcar agora
if (!setupComplete) {
localStorage.setItem("ollahub_setup_complete", "true");
}
// Redirecionar para chat imediatamente (startTransition já garante não-bloqueio)
startTransition(() => {
router.push("/chat");
});
return () => clearTimeout(timer);
} }
}, [status, router]); }, [status, check]);
const handleStart = () => { const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete"); const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) { if (setupComplete) {
startTransition(() => { router.push("/chat");
router.push("/chat");
});
} else { } else {
startTransition(() => { router.push("/setup");
router.push("/setup");
});
} }
}; };
// Mostrar loading durante verificação
if (status === 'checking') { if (status === 'checking') {
return ( return (
<div className="flex h-screen w-full items-center justify-center bg-background"> <div className="flex h-screen w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4"> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <span className="ml-2 text-muted-foreground">Verificando sistema...</span>
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</div>
</div>
</div> </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 />
{/* Layout for Ready State - Redirecionamento automático */} {/* Layout for Running State */}
{status === 'ready' && ( {status === 'running' && (
<div className="flex h-screen w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 animate-in fade-in duration-300">
<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">Tudo pronto!</span>
<span className="text-xs text-muted-foreground">Redirecionando para o chat...</span>
</div>
</div>
</div>
)}
{/* Layout for Needs Setup State */}
{status === 'needs_setup' && ollamaStatus === 'running' && (
<Hero onStart={handleStart} /> <Hero onStart={handleStart} />
)} )}
{/* Layout for Stopped State */} {/* Layout for Stopped State */}
{status === 'needs_setup' && ollamaStatus === 'installed_stopped' && ( {status === 'installed_stopped' && (
<div className="flex flex-col items-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="flex flex-col items-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tighter">OllaHub</h1> <h1 className="text-3xl font-bold tracking-tighter">OllaHub</h1>
<p className="text-muted-foreground">Detectamos o Ollama, mas ele não está rodando.</p> <p className="text-muted-foreground">Detectamos o Ollama, mas ele não está rodando.</p>
</div> </div>
<StoppedCard onCheckAgain={recheck} /> <StoppedCard onCheckAgain={check} />
</div> </div>
)} )}
{/* Layout for Not Installed State (Background + Modal) */} {/* Layout for Not Installed State (Background + Modal) */}
{status === 'needs_ollama' && ( {status === 'not_installed' && (
<> <>
<div className="opacity-20 pointer-events-none blur-sm"> <div className="opacity-20 pointer-events-none blur-sm">
<Hero onStart={() => {}} /> <Hero onStart={() => {}} />
@@ -232,11 +82,10 @@ export default function Home() {
{/* Hero is shown in background, modal is open */} {/* Hero is shown in background, modal is open */}
<InstallModal <InstallModal
open={true} open={true}
onCheckAgain={recheck} onCheckAgain={check}
/> />
</> </>
)} )}
</main> </main>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
); );
} }
+990 -1547
View File
File diff suppressed because it is too large. Load diff
+26 -19
View File
@@ -1,41 +1,48 @@
'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";
import { ModelSelector } from "@/components/setup/model-selector"; import { ModelSelector } from "@/components/setup/model-selector";
import { getRecommendation } from "@/lib/recommendation"; import { getRecommendation } from "@/lib/recommendation";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
export default function SetupPage() { export default function SetupPage() {
const { specs, loading, gpus } = useHardware(); const { specs, loading, gpus } = useHardware();
const router = useRouter(); const router = useRouter();
const [isNavigating, setIsNavigating] = useState(false);
const handleComplete = () => { const handleComplete = () => {
setIsNavigating(true);
// Save setup state if needed // Save setup state if needed
localStorage.setItem("ollahub_setup_complete", "true"); localStorage.setItem("ollahub_setup_complete", "true");
router.push("/chat");
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
router.push("/chat");
});
}; };
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return ( return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col"> <main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
<TitleBar /> <div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
<SetupWizard /> <div className="text-center space-y-2">
</div> <h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
<p className="text-muted-foreground">Vamos preparar o ambiente ideal para você.</p>
</div>
<HardwareScan specs={specs} loading={loading} />
{!loading && specs && gpus.length > 0 && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
<GpuSelector gpus={gpus} />
</div>
)}
{!loading && specs && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
<ModelSelector
recommendation={getRecommendation(specs)}
onComplete={handleComplete}
/>
</div>
)}
</div>
</main>
); );
} }
+1 -1
View File
@@ -105,7 +105,7 @@ export default function TasksPage() {
return ( return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col"> <div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar /> <TitleBar />
<div className="flex-1 overflow-y-auto pt-8"> <div className="flex-1 overflow-y-auto">
<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">
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">OllaHub</title>
<desc id="desc">OllaHub: a desktop window with a chat sidebar, a local Ollama model download in progress, an answer backed by numbered web sources, and Web and MCP toggles in the message box.</desc>
<rect width="1200" height="420" rx="24" fill="#09090B"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="170" font-size="64" font-weight="700" fill="#FAFAFA">OllaHub</text>
<text x="64" y="226" font-size="28" fill="#FAFAFA">Desktop chat for your local Ollama</text>
<text x="64" y="264" font-size="28" fill="#FAFAFA">models, with web search and MCP.</text>
<text x="64" y="322" font-size="20" fill="#9F9FA9" font-family="ui-monospace, 'SF Mono', Consolas, monospace">Tauri 2 · Rust · Next.js 16 · React 19</text>
</g>
<g id="window" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="608" y="52" width="528" height="316" rx="14" fill="#09090B" stroke="#27272A" stroke-width="2"/>
<path d="M622 52 h500 a14 14 0 0 1 14 14 v26 h-528 v-26 a14 14 0 0 1 14 -14 z" fill="#18181B"/>
<text x="628" y="79" font-size="18" fill="#9F9FA9">OllaHub</text>
<g fill="none" stroke="#9F9FA9" stroke-width="2" stroke-linecap="round">
<path d="M1052 72 h14"/>
<rect x="1078" y="65" width="14" height="14" rx="2"/>
<path d="M1106 65 l14 14 M1120 65 l-14 14"/>
</g>
<path d="M608 92 h528" stroke="#27272A" stroke-width="2"/>
<path d="M608 92 h144 v276 h-130 a14 14 0 0 1 -14 -14 z" fill="#18181B"/>
<rect x="622" y="108" width="116" height="34" rx="8" fill="#1447E6"/>
<text x="680" y="131" font-size="18" fill="#FAFAFA" text-anchor="middle">New chat</text>
<rect x="622" y="166" width="100" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="192" width="84" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="218" width="94" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="244" width="72" height="10" rx="5" fill="#27272A"/>
<text x="772" y="128" font-size="18" fill="#FAFAFA" font-family="ui-monospace, 'SF Mono', Consolas, monospace">qwen2.5:0.5b</text>
<rect x="772" y="140" width="344" height="10" rx="5" fill="#27272A"/>
<rect x="772" y="140" width="220" height="10" rx="5" fill="#1447E6"/>
<rect x="772" y="170" width="344" height="116" rx="12" fill="#201E25"/>
<rect x="790" y="190" width="290" height="10" rx="5" fill="#9F9FA9"/>
<rect x="790" y="212" width="300" height="10" rx="5" fill="#9F9FA9"/>
<rect x="790" y="234" width="180" height="10" rx="5" fill="#9F9FA9"/>
<text x="790" y="272" font-size="18" fill="#9F9FA9">Sources</text>
<g font-size="18" fill="#FAFAFA" text-anchor="middle">
<rect x="866" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="879" y="273">1</text>
<rect x="898" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="911" y="273">2</text>
<rect x="930" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="943" y="273">3</text>
</g>
<rect x="772" y="302" width="344" height="40" rx="20" fill="#18181B" stroke="#27272A" stroke-width="2"/>
<rect x="782" y="309" width="60" height="26" rx="13" fill="#27272A"/>
<text x="812" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">Web</text>
<rect x="850" y="309" width="62" height="26" rx="13" fill="#27272A"/>
<text x="881" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">MCP</text>
<circle cx="1096" cy="322" r="13" fill="#1447E6"/>
<path d="M1096 328 v-12 M1090 321 l6 -6 l6 6" fill="none" stroke="#FAFAFA" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+81 -98
View File
@@ -8,9 +8,7 @@ import { markdownRenderers } from "./markdown-renderers";
import { MessageActions } from "./message-actions"; import { MessageActions } from "./message-actions";
import { MessageBackground } from "./message-background"; import { MessageBackground } from "./message-background";
import { MessageStepThinking } from "./message-step-thinking"; import { MessageStepThinking } from "./message-step-thinking";
import { DebugConsole, type DebugData } from "./debug-console";
import type { ThinkingMessageMetadata } from "@/hooks/use-chat"; import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
import { useSettingsStore } from "@/store/settings-store";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
@@ -18,17 +16,16 @@ interface ChatMessageProps {
highlightTerm?: string; highlightTerm?: string;
highlightIndex?: number; highlightIndex?: number;
messageIndex?: number; messageIndex?: number;
debugData?: DebugData;
} }
/** /**
* Componente de mensagem do chat otimizado com React.memo * Componente de mensagem do chat otimizado com React.memo
* *
* Renderiza markdown em tempo real durante streaming para melhor UX. * Durante streaming, usa renderização simplificada (texto puro) para evitar
* Usa useMemo para otimizar re-renders e manter performance. * recálculo custoso da árvore DOM e syntax highlight a cada token.
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
*/ */
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex, debugData }: ChatMessageProps) { function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
const debugMode = useSettingsStore(state => state.debugMode);
// Verificar se é mensagem de processo de pensamento // Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata && const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' && typeof message.metadata === 'object' &&
@@ -64,9 +61,6 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
return content || message.content; return content || message.content;
}, [message.content]); }, [message.content]);
// Memoizar conteúdo markdown para otimizar re-renders durante streaming
const markdownContent = useMemo(() => processedContent, [processedContent]);
// Processar highlight separadamente (após limpeza de conteúdo) // Processar highlight separadamente (após limpeza de conteúdo)
// Desabilitar durante streaming para performance // Desabilitar durante streaming para performance
@@ -120,101 +114,91 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]); }, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
return ( return (
<> <MessageBackground
<MessageBackground role={isUser ? 'user' : 'assistant'}
role={isUser ? 'user' : 'assistant'} state={selected ? 'selected' : 'normal'}
state={selected ? 'selected' : 'normal'} className={cn(
className={cn( "cursor-default transition-colors rounded-xl",
"cursor-default transition-colors rounded-xl", selected && "bg-muted/40",
selected && "bg-muted/40", isUser ? "mb-2" : "mb-8"
isUser ? "mb-2" : "mb-8" )}
)} onClick={() => setSelected((v) => !v)}
onClick={() => setSelected((v) => !v)} tabIndex={0}
tabIndex={0} >
> <div className="flex-shrink-0 mt-0.5">
<div className="flex-shrink-0 mt-0.5"> <div className={cn(
<div className={cn( "w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm", isUser
isUser ? "bg-background text-muted-foreground"
? "bg-background text-muted-foreground" : "bg-primary/10 text-primary ring-primary/20"
: "bg-primary/10 text-primary ring-primary/20" )}>
)}> {isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
</div>
</div> </div>
</div>
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? ( <div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
// Estilo específico para mensagem do usuário (mais destaque, fonte maior) {isUser ? (
<div // Estilo específico para mensagem do usuário (mais destaque, fonte maior)
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap" <div
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined} className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
> dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
{!highlightedContent && processedContent} >
</div> {!highlightedContent && processedContent}
) : ( </div>
// Estilo Assistant ) : (
isEmpty && isStreaming ? ( // Estilo Assistant
<div className="flex items-center gap-2 text-muted-foreground py-1"> isEmpty && isStreaming ? (
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> <div className="flex items-center gap-2 text-muted-foreground py-1">
<span className="text-sm font-medium">Gerando resposta...</span> <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span>
</div>
) : isStreaming ? (
// Renderização LEVE durante streaming: texto simples com formatação básica
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight",
"relative"
)}>
<div className="whitespace-pre-wrap font-sans">
{processedContent}
</div> </div>
) : isStreaming ? ( <span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
// Renderização de markdown em tempo real durante streaming </div>
<div className={cn( ) : (
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90", // Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg", <div className={cn(
"prose-p:leading-7 prose-li:leading-7", "prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl", "prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded", "prose-p:leading-7 prose-li:leading-7",
"relative" "prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
)}> "prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
)}>
{highlightedContent ? (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
) : (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={markdownRenderers} components={markdownRenderers}
> >
{markdownContent} {processedContent}
</ReactMarkdown> </ReactMarkdown>
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" /> )}
</div> </div>
) : ( )
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:leading-7 prose-li:leading-7",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
)}>
{highlightedContent ? (
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{processedContent}
</ReactMarkdown>
)}
</div>
)
)}
</div>
{!isUser && !isStreaming && (
<div className={cn(
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
"group-hover:opacity-100",
selected && "opacity-100"
)}>
<MessageActions content={message.content} role={message.role} />
</div>
)} )}
</MessageBackground> </div>
{!isUser && !isStreaming && debugMode && debugData && (
<DebugConsole data={debugData} /> {!isUser && !isStreaming && (
<div className={cn(
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
"group-hover:opacity-100",
selected && "opacity-100"
)}>
<MessageActions content={message.content} role={message.role} />
</div>
)} )}
</> </MessageBackground>
); );
} }
@@ -243,8 +227,7 @@ export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProp
prevProps.message.role === nextProps.message.role && prevProps.message.role === nextProps.message.role &&
prevProps.highlightTerm === nextProps.highlightTerm && prevProps.highlightTerm === nextProps.highlightTerm &&
prevProps.highlightIndex === nextProps.highlightIndex && prevProps.highlightIndex === nextProps.highlightIndex &&
prevProps.messageIndex === nextProps.messageIndex && prevProps.messageIndex === nextProps.messageIndex
prevProps.debugData === nextProps.debugData
); );
}); });
-521
View File
@@ -1,521 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
ChevronDown,
ChevronUp,
Copy,
Check,
Brain,
Search,
Settings,
FileText,
Globe,
Clock,
Database,
Code2
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
<<<<<<< HEAD
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
=======
import { Message } from '@/hooks/use-chat';
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
import type { DeepResearchState } from '@/hooks/use-deep-research';
import type { ScrapedContent } from '@/services/webSearch';
export interface DebugData {
model: string;
timestamp: number;
latency?: number;
systemPrompt: string;
userQuery: string;
contextUsed: Message[];
webResearch?: {
queries: string[];
enrichedQueries?: {
literal: string[];
semantic: string[];
related: string[];
expanded: string[];
contextual: string[];
};
sources: ScrapedContent[];
<<<<<<< HEAD
sitesResearched?: Array<{
url: string;
title: string;
status: string;
source: string;
duration?: number;
contentLength: number;
scrapedAt: number;
}>;
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
logs: Array<{
stage: string;
timestamp: number;
input: string;
rawOutput?: string;
parsedOutput?: any;
error?: string;
}>;
plan?: string[];
knowledgeBase?: Array<{
sourceUrl: string;
title: string;
content: string;
}>;
<<<<<<< HEAD
activeQueries?: Array<{
query: string;
source: string;
round?: number;
startedAt: number;
}>;
activeUrls?: Array<{
url: string;
title?: string;
status: string;
source?: string;
startedAt: number;
duration?: number;
}>;
};
deepResearchState?: DeepResearchState;
thinkingSteps?: ThinkingMessageMetadata[];
reasoning?: {
steps?: string[];
intermediateResults?: any[];
};
finalResponse: string;
rawResponse: string;
tokenUsage?: {
input: number;
output: number;
total: number;
=======
};
deepResearchState?: DeepResearchState;
finalResponse: string;
rawResponse?: string;
tokenUsage?: {
input: number;
output: number;
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
};
}
interface DebugConsoleProps {
data: DebugData;
}
export function DebugConsole({ data }: DebugConsoleProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [copied, setCopied] = useState(false);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const copyFullDump = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const formatTimestamp = (ts: number) => {
return new Date(ts).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatDuration = (ms?: number) => {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const isExpanded = (section: string) => expandedSections.has(section);
return (
<div className="mt-6 border-t border-border/50 pt-4">
<div className="bg-muted/30 rounded-lg border border-border/50 overflow-hidden font-mono">
{/* Header Bar */}
<div className="bg-muted/50 border-b border-border/50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">Debug Console</span>
</div>
<Badge variant="outline" className="text-xs font-mono">
{data.model || 'N/A'}
</Badge>
{data.latency !== undefined && (
<Badge variant="outline" className="text-xs font-mono">
<Clock className="w-3 h-3 mr-1" />
{formatDuration(data.latency)}
</Badge>
)}
{data.tokenUsage && (
<Badge variant="outline" className="text-xs font-mono">
Tokens: {data.tokenUsage.input}/{data.tokenUsage.output}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={copyFullDump}
className="h-7 px-2 text-xs"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copiado
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copiar Dump (JSON)
</>
)}
</Button>
</div>
{/* Sections */}
<div className="divide-y divide-border/50">
{/* Section A: Contexto & Memória */}
<Section
id="context"
title="🧠 Contexto & Memória (Input)"
icon={<Brain className="w-4 h-4" />}
isExpanded={isExpanded('context')}
onToggle={() => toggleSection('context')}
>
<div className="space-y-4 p-4">
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
System Prompt
</h4>
<pre className="text-xs bg-background/50 p-3 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words">
{data.systemPrompt}
</pre>
<p className="text-[10px] text-muted-foreground mt-1">
Injetado em: {formatTimestamp(data.timestamp)}
</p>
</div>
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Histórico da Sessão ({data.contextUsed.length} mensagens)
</h4>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground mb-2">
Expandir JSON
</summary>
<pre className="bg-background/50 p-3 rounded border border-border/30 overflow-x-auto">
{JSON.stringify(data.contextUsed, null, 2)}
</pre>
</details>
</div>
</div>
</Section>
{/* Section B: Deep Research */}
{data.webResearch && (
<Section
id="research"
title="🔎 Deep Research (Processamento)"
icon={<Search className="w-4 h-4" />}
isExpanded={isExpanded('research')}
onToggle={() => toggleSection('research')}
>
<div className="space-y-4 p-4">
{/* Pipeline Status */}
<div>
<h4 className="text-xs font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Status do Pipeline
</h4>
<div className="space-y-3">
{/* Decomposition */}
{data.webResearch.plan && data.webResearch.plan.length > 0 && (
<div className="border-l-2 border-primary/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span className="text-xs font-medium">Decomposition</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-1">
{data.webResearch.plan.map((query, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-muted-foreground/50">{idx + 1}.</span>
<span>{query}</span>
</div>
))}
</div>
</div>
)}
{/* Query Expansion */}
{data.webResearch.enrichedQueries && (
<div className="border-l-2 border-blue-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<span className="text-xs font-medium">Query Expansion</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-2">
{data.webResearch.queries.map((original, idx) => {
const eq = data.webResearch?.enrichedQueries;
const allEnriched = eq ? [
...(eq.literal || []),
...(eq.semantic || []),
...(eq.related || []),
...(eq.expanded || []),
...(eq.contextual || []),
] : [];
return (
<div key={idx} className="space-y-1">
<div className="text-muted-foreground/70">
<span className="font-medium">Original:</span> {original}
</div>
{allEnriched.length > 0 && (
<div className="text-muted-foreground space-y-1">
<span className="font-medium">Enriquecidas ({allEnriched.length}):</span>
<div className="ml-2 space-y-0.5">
{allEnriched.slice(0, 5).map((q, qIdx) => (
<div key={qIdx} className="text-[10px]">• {q}</div>
))}
{allEnriched.length > 5 && (
<div className="text-[10px] text-muted-foreground/70">
... e mais {allEnriched.length - 5}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Web Crawling */}
{data.webResearch.sources && data.webResearch.sources.length > 0 && (
<div className="border-l-2 border-yellow-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
<span className="text-xs font-medium">
Web Crawling ({data.webResearch.sources.length} fontes)
</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-2 max-h-60 overflow-y-auto">
{data.webResearch.sources.map((source, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 bg-background/30 rounded border border-border/20">
<div className="flex-shrink-0 mt-0.5">
{source.content && source.content.length > 0 ? (
<span className="text-green-500">✓</span>
) : (
<span className="text-red-500">✗</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{source.title || source.url}</span>
{source.cached && (
<Badge variant="outline" className="text-[10px] px-1">
Cached
</Badge>
)}
</div>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-primary hover:underline truncate block"
>
{source.url}
</a>
{source.content && (
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2">
{source.content.substring(0, 150)}...
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Knowledge Base */}
{data.webResearch.knowledgeBase && data.webResearch.knowledgeBase.length > 0 && (
<div className="border-l-2 border-purple-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-purple-500 rounded-full" />
<span className="text-xs font-medium">Knowledge Base</span>
</div>
<div className="text-xs text-muted-foreground ml-4">
<Badge variant="secondary" className="text-[10px]">
{data.webResearch.knowledgeBase.length} chunks extraídos
</Badge>
</div>
</div>
)}
</div>
</div>
{/* Research Logs */}
{data.webResearch.logs && data.webResearch.logs.length > 0 && (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Logs Internos ({data.webResearch.logs.length} entradas)
</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{data.webResearch.logs.map((log, idx) => (
<div key={idx} className="text-xs bg-background/30 p-2 rounded border border-border/20">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-[10px] px-1">
{log.stage}
</Badge>
<span className="text-[10px] text-muted-foreground">
{formatTimestamp(log.timestamp)}
</span>
</div>
<div className="text-muted-foreground">
<div className="mb-1">
<span className="font-medium">Input:</span> {log.input}
</div>
{log.rawOutput && (
<div className="mb-1">
<span className="font-medium">Output:</span>{' '}
<span className="text-[10px]">{log.rawOutput.substring(0, 200)}...</span>
</div>
)}
{log.error && (
<div className="text-red-500">
<span className="font-medium">Erro:</span> {log.error}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</Section>
)}
{/* Section C: Dados Processados */}
{data.deepResearchState && (
<Section
id="processed"
title="⚙️ Dados Processados (Raciocínio)"
icon={<Settings className="w-4 h-4" />}
isExpanded={isExpanded('processed')}
onToggle={() => toggleSection('processed')}
>
<div className="space-y-4 p-4">
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Estado do Deep Research
</h4>
<div className="text-xs space-y-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Step:</span>
<Badge variant="outline">{data.deepResearchState.step}</Badge>
</div>
{data.deepResearchState.validationReport && (
<div>
<span className="text-muted-foreground font-medium">Validation Report:</span>
<pre className="mt-1 bg-background/50 p-2 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words text-[10px]">
{data.deepResearchState.validationReport}
</pre>
</div>
)}
{data.deepResearchState.error && (
<div className="text-red-500">
<span className="font-medium">Erro:</span> {data.deepResearchState.error}
</div>
)}
</div>
</div>
</div>
</Section>
)}
{/* Section D: Resposta Raw */}
<Section
id="response"
title="📤 Resposta Raw (Output)"
icon={<FileText className="w-4 h-4" />}
isExpanded={isExpanded('response')}
onToggle={() => toggleSection('response')}
>
<div className="p-4">
<pre className="text-xs bg-background/50 p-3 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
{data.rawResponse || data.finalResponse}
</pre>
</div>
</Section>
</div>
</div>
</div>
);
}
interface SectionProps {
id: string;
title: string;
icon: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function Section({ id, title, icon, isExpanded, onToggle, children }: SectionProps) {
return (
<div>
<button
onClick={onToggle}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors text-left"
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{title}</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="bg-background/20">
{children}
</div>
)}
</div>
);
}
+2 -23
View File
@@ -18,8 +18,6 @@ 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;
@@ -107,31 +105,13 @@ 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-4 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-2 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="pt-2 border-t border-muted/30"> <div className="flex flex-wrap gap-2 pt-1">
<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" />
@@ -141,7 +121,6 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
</button> </button>
</div> </div>
))} ))}
</div>
</div> </div>
)} )}
-317
View File
@@ -1,317 +0,0 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, Search, DownloadCloud, Cpu, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useModelSelection } from '@/hooks/use-model-selection';
import { useDownloadContext } from '@/contexts/download-context';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
export function ModelSelector() {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const { myModels, availableModels, selectedModel, setSelectedModel, installedVariantsByFamily, refreshModels } = useModelSelection();
const { startDownload, state: downloadState } = useDownloadContext();
const { toast } = useToast();
// Track selected variant for each model in the catalog
const [selectedVariants, setSelectedVariants] = React.useState<Record<string, string>>({});
const handleDownload = async (e: React.MouseEvent, modelName: string) => {
e.stopPropagation();
try {
await startDownload(modelName);
toast({
title: "Download Iniciado",
description: `Baixando ${modelName}...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
const handleSmartDownload = async () => {
const term = searchQuery.trim();
if (!term) return;
try {
await startDownload(term);
setOpen(false);
setSearchQuery("");
toast({
title: "Download Iniciado",
description: `Baixando ${term} da biblioteca Ollama...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
React.useEffect(() => {
if (downloadState?.isSuccess) {
refreshModels();
}
}, [downloadState?.isSuccess, refreshModels]);
const filteredMyModels = React.useMemo(() => {
if (!searchQuery) return myModels;
return myModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [myModels, searchQuery]);
const filteredAvailableModels = React.useMemo(() => {
if (!searchQuery) return availableModels;
return availableModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [availableModels, searchQuery]);
// Helper to check if a model is currently selected (partial match for variants)
const isSelected = (modelName: string) => {
if (!selectedModel) return false;
return selectedModel.startsWith(modelName) || selectedModel === modelName;
};
// Helper to extract tag/parameters from model name
const getVariantDisplay = (name: string) => {
if (name.includes(':')) {
return name.split(':')[1];
}
return 'latest';
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[260px] justify-between"
>
{selectedModel ? (
<span className="flex items-center gap-2 truncate">
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate">{selectedModel}</span>
</span>
) : (
"Selecione um modelo..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="flex flex-col">
{/* Search Input */}
<div className="flex items-center border-b border-border/50 px-3 bg-muted/30">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Buscar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery && filteredAvailableModels.length === 0) {
handleSmartDownload();
}
}}
/>
</div>
<Tabs defaultValue="installed" className="w-full">
<TabsList className="w-full rounded-none border-b bg-transparent p-0">
<TabsTrigger
value="installed"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Instalados
</TabsTrigger>
<TabsTrigger
value="available"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Catálogo
</TabsTrigger>
</TabsList>
<TabsContent value="installed" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredMyModels.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchQuery ? (
<span>Nenhum modelo instalado encontrado.</span>
) : (
<span>Nenhum modelo instalado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredMyModels.map((modelDef) => {
const family = modelDef.model;
const installedTags = installedVariantsByFamily[family] || [];
const isFamilySelected = selectedModel ? (selectedModel === family || selectedModel.startsWith(family + ":")) : false;
return (
<div
key={family}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate flex items-center gap-2">
{modelDef.model}
{isFamilySelected && (
<Check className="h-3 w-3 text-primary animate-in fade-in zoom-in" />
)}
</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-1">
{installedTags.map((fullTag) => {
const tag = getVariantDisplay(fullTag);
const isExactSelected = selectedModel === fullTag;
// Find variant def for size info if available
const variantDef = modelDef.variants.find(v => v.name === fullTag);
return (
<Badge
key={fullTag}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isExactSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={variantDef ? `Tamanho: ${variantDef.size}` : undefined}
onClick={(e) => {
e.stopPropagation();
setSelectedModel(fullTag);
setOpen(false);
}}
>
{tag}
</Badge>
);
})}
</div>
</div>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="available" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredAvailableModels.length === 0 ? (
<div className="py-6 text-center text-sm">
{searchQuery ? (
<div className="flex flex-col items-center gap-2">
<p className="text-muted-foreground">Modelo não encontrado no catálogo.</p>
<Button
variant="outline"
className="h-auto py-2 px-4 border-dashed border-zinc-500/50 hover:border-primary hover:bg-primary/10"
onClick={handleSmartDownload}
>
<DownloadCloud className="mr-2 h-4 w-4" />
<span>Baixar <span className="font-bold ml-1">{searchQuery}</span></span>
</Button>
</div>
) : (
<span className="text-muted-foreground">Nenhum modelo encontrado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredAvailableModels.map((modelDef) => {
// Determine active variant for this item
const activeVariantName = selectedVariants[modelDef.model] || modelDef.variants[0]?.name;
const activeVariant = modelDef.variants.find(v => v.name === activeVariantName) || modelDef.variants[0];
return (
<div
key={modelDef.model}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate">{modelDef.model}</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => handleDownload(e, activeVariant.name)}
disabled={downloadState.isDownloading && downloadState.currentModel === activeVariant.name}
title={`Baixar ${activeVariant.name}`}
>
{downloadState.isDownloading && downloadState.currentModel === activeVariant.name ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<DownloadCloud className="h-4 w-4" />
)}
</Button>
</div>
{/* Variants Pills */}
<div className="flex flex-wrap gap-1.5 mt-1">
{modelDef.variants.map((variant) => {
const isVariantSelected = activeVariant.name === variant.name;
return (
<Badge
key={variant.name}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isVariantSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={`Tamanho em disco: ${variant.size}`}
onClick={(e) => {
e.stopPropagation();
setSelectedVariants(prev => ({
...prev,
[modelDef.model]: variant.name
}));
}}
>
{getVariantDisplay(variant.name)}
</Badge>
);
})}
</div>
</div>
)})}
</div>
)}
</TabsContent>
</Tabs>
</div>
</PopoverContent>
</Popover>
);
}
-107
View File
@@ -1,107 +0,0 @@
'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>
);
}
@@ -1,70 +0,0 @@
'use client';
import { Loader2, CheckCircle2, Globe, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ActiveQuery } from '@/hooks/use-web-search-events';
interface SearchQueriesMonitorProps {
queries: ActiveQuery[];
className?: string;
}
export function SearchQueriesMonitor({ queries, className }: SearchQueriesMonitorProps) {
if (queries.length === 0) return null;
const getSourceColor = (source: string) => {
switch (source) {
case 'brave':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
case 'searxng':
return 'bg-green-500/10 text-green-600 border-green-500/20';
case 'duckduckgo':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
default:
return 'bg-muted text-muted-foreground border-muted';
}
};
const getSourceLabel = (source: string) => {
switch (source) {
case 'brave':
return 'Brave';
case 'searxng':
return 'SearXNG';
case 'duckduckgo':
return 'DuckDuckGo';
default:
return source;
}
};
return (
<div className={cn('space-y-2', className)}>
<div className="text-xs font-medium text-muted-foreground mb-2">
Consultas Ativas ({queries.length})
</div>
{queries.map((query, idx) => (
<div
key={`${query.query}-${query.source}-${idx}`}
className="flex items-center gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
>
<Loader2 className="w-3 h-3 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs truncate">{query.query}</span>
<Badge variant="outline" className={cn('text-xs', getSourceColor(query.source))}>
{getSourceLabel(query.source)}
</Badge>
{query.round && (
<Badge variant="outline" className="text-xs">
Round {query.round}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
);
}
-80
View File
@@ -1,80 +0,0 @@
'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>
);
}
-101
View File
@@ -1,101 +0,0 @@
'use client';
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
export function KeyboardShortcuts() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// CTRL+SHIFT+D para abrir devtools
if (e.ctrlKey && e.shiftKey && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
e.stopPropagation();
invoke('toggle_devtools').catch(console.error);
return;
}
// Bloquear F5 (refresh)
if (e.key === 'F5') {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+R (refresh)
if (e.ctrlKey && (e.key === 'r' || e.key === 'R') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+R (hard refresh)
if (e.ctrlKey && e.shiftKey && (e.key === 'r' || e.key === 'R')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+I (devtools)
if (e.ctrlKey && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+J (console)
if (e.ctrlKey && e.shiftKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+U (view source)
if (e.ctrlKey && (e.key === 'u' || e.key === 'U') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+DEL (clear storage)
if (e.ctrlKey && e.shiftKey && e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+W (fechar aba - mas permitir fechar janela com o comportamento customizado do Tauri)
// Não bloqueamos CTRL+W porque o Tauri já trata isso no on_window_event
// Bloquear CTRL+N (nova janela)
if (e.ctrlKey && (e.key === 'n' || e.key === 'N') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+N (nova janela anônima)
if (e.ctrlKey && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+T (nova aba)
if (e.ctrlKey && (e.key === 't' || e.key === 'T') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
};
// Adicionar listener com capture para interceptar antes de outros handlers
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, []);
return null;
}
+8 -33
View File
@@ -87,7 +87,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
const handleInstall = async () => { const handleInstall = async () => {
try { try {
await downloadState.handleInstall(); await downloadState.handleInstall();
// Toast será mostrado automaticamente via eventos de progresso toast({
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',
@@ -141,10 +144,8 @@ 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 && !isInstalling) { if (!isDownloading && !hasDownloaded) {
return null; return null;
} }
@@ -161,18 +162,7 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
<Progress value={downloadState.downloadProgress} className="h-2" /> <Progress value={downloadState.downloadProgress} className="h-2" />
</> </>
)} )}
{isInstalling && ( {hasDownloaded && !isDownloading && (
<>
<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">
@@ -180,20 +170,7 @@ 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 automática. Clique em "Instalar Ollama" para iniciar a instalação.
</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>
@@ -206,9 +183,7 @@ 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 data-tauri-drag-region className="cursor-move select-none"> <DialogTitle>Ollama não encontrado</DialogTitle>
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,11 +67,3 @@ export function WelcomeNotification() {
-8
View File
@@ -269,11 +269,3 @@ export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps)
} }
+5 -27
View File
@@ -43,33 +43,11 @@ export function HardwareDashboard() {
if (isLoading || !stats) { if (isLoading || !stats) {
return ( return (
<div className="space-y-4"> <Card>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <CardContent className="py-8 text-center text-muted-foreground">
{/* Skeleton Cards */} Carregando estatísticas do sistema...
{[1, 2, 3, 4].map((i) => ( </CardContent>
<Card key={i}> </Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="h-4 w-16 bg-muted animate-pulse rounded" />
<div className="h-5 w-12 bg-muted animate-pulse rounded" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="h-2 w-full bg-muted animate-pulse rounded" />
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Carregando estatísticas do sistema...</span>
</div>
</CardContent>
</Card>
</div>
); );
} }
@@ -1,169 +0,0 @@
'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>
);
}
@@ -1,899 +0,0 @@
'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>
);
}
@@ -1,244 +0,0 @@
'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>
);
}
@@ -1,209 +0,0 @@
'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>
);
}
@@ -1,881 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
CheckCircle2,
XCircle,
Loader2,
ArrowLeft,
ArrowRight,
AlertCircle,
ExternalLink,
Download,
Settings,
Play,
Square,
X
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { cn } from '@/lib/utils';
interface SearxngInstallWizardProps {
onComplete: () => void;
onCancel: () => void;
initialUrl?: string;
}
type Step = 0 | 1 | 2 | 3 | 4 | 5;
interface SystemRequirements {
status: 'pass' | 'fail' | 'warn';
os: string;
arch: string;
ram_mb: number;
disk_gb: number;
cpu_cores: number;
kernel_version?: string;
issues: string[];
warnings: string[];
}
interface InstallProgressEvent {
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
step?: number;
step_name?: string;
total_steps?: number;
progress: number;
message?: string;
log_line?: string;
}
interface ValidationResult {
status: 'pass' | 'fail' | 'partial';
tests: Array<{
name: string;
status: 'pass' | 'fail' | 'warn';
message?: string;
}>;
docker_version?: string;
}
export function SearxngInstallWizard({ onComplete, onCancel, initialUrl = 'http://localhost:8080' }: SearxngInstallWizardProps) {
const [currentStep, setCurrentStep] = useState<Step>(0);
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [url, setUrl] = useState(initialUrl);
const [port, setPort] = useState('8080');
const [autoStart, setAutoStart] = useState(true);
const [installProgress, setInstallProgress] = useState<string>('');
const [isInstalling, setIsInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
currentStep: number;
totalSteps: number;
progress: number;
currentStepName: string;
logs: string[];
}>({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [verificationStatus, setVerificationStatus] = useState<'checking' | 'success' | 'error' | null>(null);
const [verificationError, setVerificationError] = useState<string | null>(null);
// Step 0: Verificar Requisitos
useEffect(() => {
if (currentStep === 0) {
checkRequirements();
}
}, [currentStep]);
// Step 1: Verificar Docker
useEffect(() => {
if (currentStep === 1) {
checkDocker();
}
}, [currentStep]);
// Listener de eventos de progresso Docker
useEffect(() => {
let unlistenPromise: Promise<() => void> | null = null;
if (isInstallingDocker) {
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
const data = event.payload;
switch (data.event_type) {
case 'step_start':
setDockerInstallProgress(prev => ({
...prev,
currentStep: data.step || 0,
totalSteps: data.total_steps || prev.totalSteps,
currentStepName: data.step_name || '',
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'log':
if (data.log_line) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.log_line!],
}));
}
break;
case 'step_complete':
setDockerInstallProgress(prev => ({
...prev,
progress: data.progress,
}));
if (data.message) {
setDockerInstallProgress(prev => ({
...prev,
logs: [...prev.logs.slice(-49), data.message!],
}));
}
break;
case 'error':
setInstallError(data.message || 'Erro desconhecido');
setIsInstallingDocker(false);
break;
case 'complete':
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
setIsInstallingDocker(false);
// Validar instalação após conclusão
setTimeout(() => {
validateDocker();
}, 1000);
break;
}
});
}
return () => {
if (unlistenPromise) {
unlistenPromise.then(fn => fn());
}
};
}, [isInstallingDocker]);
const checkRequirements = async () => {
setIsCheckingRequirements(true);
try {
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
setRequirements(reqs);
} catch (error) {
console.error('Error checking requirements:', error);
setRequirements({
status: 'fail',
os: 'unknown',
arch: 'unknown',
ram_mb: 0,
disk_gb: 0,
cpu_cores: 0,
issues: ['Erro ao verificar requisitos do sistema'],
warnings: [],
});
} finally {
setIsCheckingRequirements(false);
}
};
const checkDocker = async () => {
setIsChecking(true);
try {
const available = await invoke<boolean>('check_docker_available');
setDockerAvailable(available);
if (available) {
const running = await invoke<boolean>('check_docker_running');
setDockerRunning(running);
}
} catch (error) {
console.error('Error checking Docker:', error);
setDockerAvailable(false);
setDockerRunning(false);
} finally {
setIsChecking(false);
}
};
const handleNext = () => {
if (currentStep < 5) {
setCurrentStep((prev) => (prev + 1) as Step);
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep((prev) => (prev - 1) as Step);
}
};
const handleInstallDocker = async () => {
setIsInstallingDocker(true);
setInstallError(null);
setDockerInstallProgress({
currentStep: 0,
totalSteps: 0,
progress: 0,
currentStepName: '',
logs: [],
});
try {
await invoke('install_docker');
} catch (error) {
console.error('Docker installation error:', error);
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker');
setIsInstallingDocker(false);
}
};
const validateDocker = async () => {
setIsValidating(true);
setValidationResult(null);
try {
const result = await invoke<ValidationResult>('validate_docker_installation');
setValidationResult(result);
if (result.status === 'pass' || result.status === 'partial') {
// Docker validado, atualizar estado
setDockerAvailable(true);
setDockerRunning(true);
}
} catch (error) {
console.error('Validation error:', error);
setValidationResult({
status: 'fail',
tests: [{
name: 'Validação',
status: 'fail',
message: error instanceof Error ? error.message : 'Erro ao validar Docker',
}],
});
} finally {
setIsValidating(false);
}
};
const handleInstall = async () => {
setIsInstalling(true);
setInstallError(null);
setInstallProgress('Iniciando instalação...');
try {
setInstallProgress('Baixando imagem SearXNG...');
await invoke('install_searxng', { url, port: parseInt(port, 10) });
setInstallProgress('Criando container...');
// Installation is handled by install_searxng command
setInstallProgress('Iniciando serviços...');
await invoke('start_searxng');
setInstallProgress('Instalação concluída!');
setTimeout(() => {
handleNext();
}, 1000);
} catch (error) {
console.error('Installation error:', error);
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação');
} finally {
setIsInstalling(false);
}
};
const handleVerify = async () => {
setVerificationStatus('checking');
setVerificationError(null);
try {
const isAvailable = await invoke<boolean>('check_searxng_status', { url });
if (isAvailable) {
setVerificationStatus('success');
setTimeout(() => {
onComplete();
}, 1500);
} else {
setVerificationStatus('error');
setVerificationError('SearXNG não está respondendo. Verifique se o container está rodando.');
}
} catch (error) {
setVerificationStatus('error');
setVerificationError(error instanceof Error ? error.message : 'Erro ao verificar SearXNG');
}
};
const renderStep = () => {
switch (currentStep) {
case 0:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação de Requisitos do Sistema</h3>
<p className="text-sm text-muted-foreground">
Verificando se seu sistema atende aos requisitos para instalação do Docker.
</p>
</div>
{isCheckingRequirements ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground">Verificando requisitos...</span>
</div>
) : requirements ? (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-4">
<span className="font-medium text-lg">Status Geral</span>
{requirements.status === 'pass' ? (
<Badge className="bg-green-500">Compatível</Badge>
) : requirements.status === 'warn' ? (
<Badge className="bg-yellow-500">Avisos</Badge>
) : (
<Badge className="bg-red-500">Incompatível</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<p className="text-xs text-muted-foreground">Sistema Operacional</p>
<p className="font-medium">{requirements.os} ({requirements.arch})</p>
</div>
<div>
<p className="text-xs text-muted-foreground">RAM</p>
<p className="font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Disco Livre</p>
<p className="font-medium">{requirements.disk_gb} GB</p>
</div>
<div>
<p className="text-xs text-muted-foreground">CPU Cores</p>
<p className="font-medium">{requirements.cpu_cores}</p>
</div>
</div>
{requirements.kernel_version && (
<div className="mt-4">
<p className="text-xs text-muted-foreground">Versão do Kernel</p>
<p className="font-medium">{requirements.kernel_version}</p>
</div>
)}
</div>
{requirements.issues.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">Problemas Encontrados:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{requirements.issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
</div>
</AlertDescription>
</Alert>
)}
{requirements.warnings.length > 0 && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">Avisos:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{requirements.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</div>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={checkRequirements} disabled={isCheckingRequirements}>
Verificar Novamente
</Button>
<Button
onClick={handleNext}
disabled={requirements.status === 'fail'}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
) : null}
</div>
);
case 1:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação de Docker</h3>
<p className="text-sm text-muted-foreground">
SearXNG requer Docker para funcionar. Vamos verificar se está instalado e rodando.
</p>
</div>
{isChecking ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="ml-3 text-sm text-muted-foreground">Verificando Docker...</span>
</div>
) : (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Docker Instalado</span>
{dockerAvailable === null ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : dockerAvailable ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div>
{dockerAvailable === false && (
<p className="text-xs text-muted-foreground mt-2">
Docker não está instalado. Você precisa instalá-lo primeiro.
</p>
)}
</div>
{dockerAvailable && (
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Docker Rodando</span>
{dockerRunning === null ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : dockerRunning ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<XCircle className="w-5 h-5 text-red-500" />
)}
</div>
{dockerRunning === false && (
<p className="text-xs text-muted-foreground mt-2">
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
</p>
)}
</div>
)}
{dockerAvailable === false && (
<Alert>
<AlertCircle className="w-4 h-4" />
<AlertDescription>
<div className="space-y-2">
<p>Docker não está instalado. Você pode instalá-lo agora ou manualmente.</p>
<div className="flex gap-2">
<Button
variant="default"
size="sm"
onClick={handleInstallDocker}
disabled={isInstallingDocker}
>
{isInstallingDocker ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Instalando...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Instalar Docker Agora
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
window.open('https://www.docker.com/products/docker-desktop', '_blank');
}}
>
<ExternalLink className="w-4 h-4 mr-2" />
Instalar Manualmente
</Button>
</div>
</div>
</AlertDescription>
</Alert>
)}
{isInstallingDocker && (
<div className="space-y-4 mt-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
<span>{dockerInstallProgress.progress}%</span>
</div>
<Progress value={dockerInstallProgress.progress} />
</div>
{dockerInstallProgress.logs.length > 0 && (
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto">
{dockerInstallProgress.logs.map((log, idx) => (
<div key={idx}>{log}</div>
))}
</div>
)}
</div>
)}
{validationResult && (
<div className="space-y-2 mt-4">
<h4 className="font-medium">Resultados da Validação:</h4>
{validationResult.tests.map((test, idx) => (
<div key={idx} className="flex items-center justify-between p-2 rounded border bg-muted/30">
<span className="text-sm">{test.name}</span>
{test.status === 'pass' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : test.status === 'warn' ? (
<AlertCircle className="w-4 h-4 text-yellow-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
</div>
))}
{validationResult.docker_version && (
<p className="text-xs text-muted-foreground">
Docker {validationResult.docker_version} instalado
</p>
)}
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={checkDocker} disabled={isChecking}>
Verificar Novamente
</Button>
{validationResult && (validationResult.status === 'pass' || validationResult.status === 'partial') && (
<Button
onClick={handleNext}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{!validationResult && dockerAvailable && dockerRunning && (
<Button
onClick={handleNext}
className="ml-auto"
>
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{!dockerAvailable && !isInstallingDocker && (
<Button
onClick={handleNext}
variant="outline"
className="ml-auto"
>
Pular Instalação Docker
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>
</div>
)}
</div>
);
case 2:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Configuração</h3>
<p className="text-sm text-muted-foreground">
Configure a URL e porta onde SearXNG será acessível.
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="searxng-url">URL do SearXNG</Label>
<Input
id="searxng-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="http://localhost:8080"
/>
<p className="text-xs text-muted-foreground">
URL completa onde o SearXNG estará acessível
</p>
</div>
<div className="space-y-2">
<Label htmlFor="searxng-port">Porta</Label>
<Input
id="searxng-port"
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="8080"
min="1024"
max="65535"
/>
<p className="text-xs text-muted-foreground">
Porta que será usada pelo container (padrão: 8080)
</p>
</div>
<div className="flex items-center space-x-2 p-4 rounded-lg border bg-muted/30">
<Checkbox
id="auto-start"
checked={autoStart}
onCheckedChange={(checked) => setAutoStart(checked === true)}
/>
<Label htmlFor="auto-start" className="cursor-pointer">
Iniciar automaticamente com o OllaHub
</Label>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
<Button onClick={handleNext} className="ml-auto">
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
);
case 3:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Instalação do SearXNG</h3>
<p className="text-sm text-muted-foreground">
Instalando e configurando SearXNG. Isso pode levar alguns minutos.
</p>
</div>
{isInstalling ? (
<div className="space-y-4">
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium">{installProgress}</p>
<div className="mt-2 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary animate-pulse" style={{ width: '60%' }} />
</div>
</div>
</div>
</div>
</div>
) : installError ? (
<Alert variant="destructive">
<XCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Erro durante instalação</p>
<p className="text-sm">{installError}</p>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="mt-3"
>
Tentar Novamente
</Button>
</AlertDescription>
</Alert>
) : (
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium">Instalação concluída!</p>
<p className="text-sm text-muted-foreground">
SearXNG foi instalado com sucesso.
</p>
</div>
</div>
</div>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack} disabled={isInstalling}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
{!isInstalling && !installError && (
<Button onClick={handleNext} className="ml-auto">
Continuar
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
{isInstalling && (
<Button variant="outline" onClick={onCancel} className="ml-auto">
Cancelar
</Button>
)}
</div>
</div>
);
case 5:
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Verificação</h3>
<p className="text-sm text-muted-foreground">
Verificando se SearXNG está funcionando corretamente.
</p>
</div>
{verificationStatus === null && (
<div className="p-4 rounded-lg border bg-muted/30 text-center">
<p className="text-sm text-muted-foreground mb-4">
Clique em "Verificar" para testar a conexão com SearXNG.
</p>
<Button onClick={handleVerify} size="lg">
<Play className="w-4 h-4 mr-2" />
Verificar
</Button>
</div>
)}
{verificationStatus === 'checking' && (
<div className="p-4 rounded-lg border bg-muted/30">
<div className="flex items-center justify-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<span>Verificando conexão...</span>
</div>
</div>
)}
{verificationStatus === 'success' && (
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
<div className="flex items-center gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium">SearXNG está disponível!</p>
<p className="text-sm text-muted-foreground">
A conexão foi estabelecida com sucesso.
</p>
</div>
</div>
</div>
)}
{verificationStatus === 'error' && (
<Alert variant="destructive">
<XCircle className="w-4 h-4" />
<AlertDescription>
<p className="font-medium mb-2">Falha na verificação</p>
<p className="text-sm mb-3">{verificationError || 'SearXNG não está respondendo'}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleVerify}>
Tentar Novamente
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await invoke('start_searxng');
setTimeout(handleVerify, 2000);
} catch (error) {
console.error('Error starting SearXNG:', error);
}
}}
>
<Play className="w-4 h-4 mr-2" />
Iniciar Container
</Button>
</div>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button variant="outline" onClick={handleBack} disabled={verificationStatus === 'checking'}>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Button>
{verificationStatus === 'success' && (
<Button onClick={onComplete} className="ml-auto">
Concluir
<CheckCircle2 className="w-4 h-4 ml-2" />
</Button>
)}
</div>
</div>
);
default:
return null;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-2xl relative">
<Button
variant="ghost"
size="icon"
onClick={onCancel}
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
aria-label="Fechar"
disabled={isInstalling || isInstallingDocker}
>
<X className="w-4 h-4" />
</Button>
<CardHeader>
<CardTitle>SearXNG - Instalação</CardTitle>
<CardDescription>
Passo {currentStep + 1} de 6
</CardDescription>
</CardHeader>
<CardContent>
{renderStep()}
</CardContent>
</Card>
</div>
);
}
-186
View File
@@ -1,186 +0,0 @@
'use client';
import { CheckCircle2, Shield, Infinity, Globe, Settings, ArrowRight, X, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface SearxngOnboardingProps {
onInstall: () => void;
onSkip: () => void;
onLearnMore?: () => void;
}
export function SearxngOnboarding({ onInstall, onSkip, onLearnMore }: SearxngOnboardingProps) {
const benefits = [
{
icon: Infinity,
title: 'Sem limites de queries',
description: 'Enquanto Brave API oferece 2000 queries/mês, SearXNG é ilimitado',
color: 'text-green-500',
},
{
icon: Shield,
title: 'Privacidade total',
description: 'Sem rastreamento, sem cookies, sem logs. Seus dados ficam privados',
color: 'text-blue-500',
},
{
icon: Globe,
title: 'Agrega múltiplos motores',
description: 'Combina resultados de Google, Bing, DuckDuckGo e outros em uma única busca',
color: 'text-purple-500',
},
{
icon: Settings,
title: 'Controle total',
description: 'Configure motores, filtros e personalizações conforme sua necessidade',
color: 'text-orange-500',
},
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto relative">
<Button
variant="ghost"
size="icon"
onClick={onSkip}
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
aria-label="Fechar"
>
<X className="w-4 h-4" />
</Button>
<CardHeader className="text-center space-y-4 pb-6">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Globe className="w-8 h-8 text-primary" />
</div>
<CardTitle className="text-2xl sm:text-3xl">SearXNG Self-Hosted</CardTitle>
<CardDescription className="text-base">
Busca Ilimitada e Privada
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Por que SearXNG? */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Por que SearXNG?
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{benefits.map((benefit, idx) => {
const Icon = benefit.icon;
return (
<div
key={idx}
className="p-4 rounded-lg border border-muted bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start gap-3">
<Icon className={cn('w-5 h-5 mt-0.5 flex-shrink-0', benefit.color)} />
<div className="space-y-1 flex-1">
<h4 className="font-medium text-sm">{benefit.title}</h4>
<p className="text-xs text-muted-foreground">{benefit.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Como Funciona */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
Como Funciona
</h3>
<div className="p-4 rounded-lg border border-muted bg-muted/30">
<div className="flex items-center justify-center gap-2 sm:gap-4 flex-wrap">
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-blue-500/10 border border-blue-500/20 text-sm font-medium">
Brave API
</div>
<span className="text-xs text-muted-foreground">Tier 1</span>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground" />
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-green-500/10 border border-green-500/20 text-sm font-medium">
SearXNG
</div>
<span className="text-xs text-muted-foreground">Tier 2</span>
</div>
<ArrowRight className="w-5 h-5 text-muted-foreground" />
<div className="flex flex-col items-center gap-2">
<div className="px-4 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-sm font-medium">
DuckDuckGo
</div>
<span className="text-xs text-muted-foreground">Tier 3</span>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4 text-center">
SearXNG é usado como fallback quando Brave API esgota sua quota mensal (2000 queries).
É completamente opcional e não afeta o uso normal do sistema.
</p>
</div>
</div>
{/* Requisitos */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Settings className="w-5 h-5 text-orange-500" />
Requisitos
</h3>
<div className="space-y-2">
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Docker instalado e rodando</p>
<p className="text-xs text-muted-foreground">
O SearXNG roda em um container Docker. Se não tiver Docker, você pode instalá-lo durante o processo.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">~200MB de RAM</p>
<p className="text-xs text-muted-foreground">
O container SearXNG consome aproximadamente 200MB de memória RAM.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Porta 8080 disponível</p>
<p className="text-xs text-muted-foreground">
Por padrão, SearXNG usa a porta 8080. Você pode configurar uma porta diferente se necessário.
</p>
</div>
</div>
</div>
</div>
{/* Ações */}
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t">
<Button onClick={onInstall} className="flex-1" size="lg">
<ArrowRight className="w-4 h-4 mr-2" />
Instalar Agora
</Button>
{onLearnMore && (
<Button onClick={onLearnMore} variant="outline" className="flex-1" size="lg">
<ExternalLink className="w-4 h-4 mr-2" />
Saiba Mais
</Button>
)}
<Button onClick={onSkip} variant="ghost" className="flex-1" size="lg">
<X className="w-4 h-4 mr-2" />
Pular por Enquanto
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
-152
View File
@@ -1,152 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { Progress } from '@/components/ui/progress';
interface LogLine {
line: string;
type: 'stdout' | 'stderr';
progress?: number;
}
interface InstallationTerminalProps {
onComplete?: () => void;
onError?: (error: string) => void;
}
export function InstallationTerminal({ onComplete, onError }: InstallationTerminalProps) {
const [logs, setLogs] = useState<LogLine[]>([]);
const [progress, setProgress] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const lastProgressRef = useRef<number>(0);
// Auto-scroll para última linha
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
// Escutar eventos de log
useEffect(() => {
const unlistenPromise = listen<LogLine>('install-log', (event) => {
const logLine = event.payload;
setLogs(prev => [...prev, logLine]);
// Atualizar progresso se disponível
if (logLine.progress !== undefined) {
setProgress(logLine.progress);
lastProgressRef.current = logLine.progress;
} else {
// Tentar extrair progresso da linha de texto
const extractedProgress = extractProgressFromText(logLine.line);
if (extractedProgress !== null) {
setProgress(extractedProgress);
lastProgressRef.current = extractedProgress;
}
}
// Verificar se instalação foi concluída
const lineLower = logLine.line.toLowerCase();
if (lineLower.includes('success') || lineLower.includes('completed') || lineLower.includes('finished')) {
setProgress(100);
if (onComplete) {
setTimeout(() => onComplete(), 500);
}
}
// Verificar se houve erro
if (logLine.type === 'stderr' && (lineLower.includes('error') || lineLower.includes('failed'))) {
if (onError) {
onError(logLine.line);
}
}
});
return () => {
unlistenPromise.then(unlisten => unlisten());
};
}, [onComplete, onError]);
return (
<div className="space-y-2 sm:space-y-3">
<div className="bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden max-h-[50vh] flex flex-col">
<div className="px-2 sm:px-3 py-1.5 sm:py-2 bg-zinc-900 border-b border-zinc-800 flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-red-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-yellow-500"></div>
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-green-500"></div>
<span className="text-[10px] sm:text-xs text-zinc-400 ml-1 sm:ml-2">Terminal</span>
</div>
{progress > 0 && (
<span className="text-[10px] sm:text-xs text-zinc-400 font-mono shrink-0">{progress}%</span>
)}
</div>
<div
ref={scrollRef}
className="h-48 sm:h-64 md:h-80 flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 font-mono text-[10px] sm:text-xs"
style={{
scrollbarWidth: 'thin',
scrollbarColor: '#3f3f46 #18181b',
scrollBehavior: 'smooth'
}}
>
{logs.length === 0 ? (
<div className="text-zinc-500 text-[10px] sm:text-xs">Aguardando logs...</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={`
${log.type === 'stderr' ? 'text-red-400' : 'text-green-400'}
whitespace-pre-wrap
break-words
`}
>
{log.line}
</div>
))
)}
</div>
</div>
{progress > 0 && (
<div className="space-y-1">
<Progress value={progress} className="h-1.5 sm:h-2" />
<div className="text-[10px] sm:text-xs text-muted-foreground text-center">
{progress < 100 ? `Instalando... ${progress}%` : 'Instalação concluída'}
</div>
</div>
)}
</div>
);
}
/// Extrai progresso percentual de uma linha de texto
function extractProgressFromText(line: string): number | null {
// Procurar por padrão "Progress: n%" ou "n%"
const percentMatch = line.match(/(\d+)%/);
if (percentMatch) {
const num = parseInt(percentMatch[1], 10);
if (num >= 0 && num <= 100) {
return num;
}
}
// Mapear keywords para progresso aproximado
const lineLower = line.toLowerCase();
if (lineLower.includes('downloading')) {
return 25;
} else if (lineLower.includes('installing')) {
return 50;
} else if (lineLower.includes('shim') || lineLower.includes('creating')) {
return 75;
} else if (lineLower.includes('finished') || lineLower.includes('completed') || lineLower.includes('success')) {
return 100;
}
return null;
}
-425
View File
@@ -1,425 +0,0 @@
'use client';
import { useEffect, useMemo, useState, useCallback, startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useDownloadContext } from '@/contexts/download-context';
import { Search, Download, CheckCircle2, AlertCircle, Loader2, Sparkles, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ModelInfo {
id: string;
name: string;
size: string;
minRam: number;
description?: string;
}
// Lista expandida de modelos populares
const POPULAR_MODELS: ModelInfo[] = [
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '1.3GB', minRam: 4, description: 'Leve e rápido, ideal para iniciantes' },
{ id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2.0GB', minRam: 8, description: 'Equilíbrio perfeito entre velocidade e qualidade' },
{ id: 'llama3.1:8b', name: 'Llama 3.1 8B', size: '4.7GB', minRam: 16, description: 'Padrão da indústria, alta capacidade' },
{ id: 'mistral:latest', name: 'Mistral 7B', size: '4.1GB', minRam: 16, description: 'Excelente para raciocínio e código' },
{ id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5.4GB', minRam: 12, description: 'Modelo avançado do Google' },
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', size: '4.5GB', minRam: 16, description: 'Multilíngue e versátil' },
{ id: 'phi3:mini', name: 'Phi-3 Mini', size: '2.3GB', minRam: 8, description: 'Compacto e eficiente da Microsoft' },
{ id: 'deepseek-coder:6.7b', name: 'DeepSeek Coder 6.7B', size: '4.2GB', minRam: 16, description: 'Especializado em programação' },
];
interface ModelDownloadStepProps {
onNext: () => void;
}
export function ModelDownloadStep({ onNext }: ModelDownloadStepProps) {
const { state, checkSetupState } = useSetupWizard();
const { state: downloadState, startDownload } = useDownloadContext();
const [searchQuery, setSearchQuery] = useState('');
const [customModelInput, setCustomModelInput] = useState('');
const [activeModelTag, setActiveModelTag] = useState<string | null>(null);
const [installedModels, setInstalledModels] = useState<Set<string>>(new Set());
const [customModelInstalled, setCustomModelInstalled] = useState(false);
// Verificar modelos instalados ao montar
useEffect(() => {
const checkAllModels = async () => {
const installed = new Set<string>();
// Verificar todos os modelos em paralelo
const checks = POPULAR_MODELS.map(async (model) => {
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: model.id });
if (isInstalled) {
installed.add(model.id);
}
} catch (error) {
console.error(`Erro ao verificar ${model.id}:`, error);
}
});
await Promise.all(checks);
startTransition(() => {
setInstalledModels(installed);
});
};
checkAllModels();
}, []);
// Verificar modelo customizado quando o usuário digita
useEffect(() => {
const checkCustomModel = async () => {
const modelTag = customModelInput.trim();
if (!modelTag) {
setCustomModelInstalled(false);
return;
}
try {
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: modelTag });
setCustomModelInstalled(isInstalled);
} catch (error) {
console.error(`Erro ao verificar modelo customizado ${modelTag}:`, error);
setCustomModelInstalled(false);
}
};
// Debounce: aguardar 500ms após o usuário parar de digitar
const timer = setTimeout(checkCustomModel, 500);
return () => clearTimeout(timer);
}, [customModelInput]);
// Filtrar modelos baseado na pesquisa
const filteredModels = useMemo(() => {
if (!searchQuery.trim()) return POPULAR_MODELS;
const query = searchQuery.toLowerCase();
return POPULAR_MODELS.filter(
model =>
model.name.toLowerCase().includes(query) ||
model.id.toLowerCase().includes(query) ||
model.description?.toLowerCase().includes(query)
);
}, [searchQuery]);
// Progresso do download ativo
const activeProgress = useMemo(() => {
if (!downloadState.isDownloading || downloadState.currentModel !== activeModelTag) {
return null;
}
return {
percent: downloadState.progress.percent,
downloaded: downloadState.progress.downloadedBytes,
total: downloadState.progress.totalBytes,
downloadedFormatted: downloadState.progress.downloadedFormatted,
totalFormatted: downloadState.progress.totalFormatted,
status: downloadState.progress.status,
speed: downloadState.progress.speed,
};
}, [downloadState, activeModelTag]);
// Atualizar lista de instalados e limpar activeModelTag quando download completar
useEffect(() => {
if (downloadState.isSuccess && activeModelTag) {
const modelTag = activeModelTag;
// Adicionar modelo à lista de instalados usando startTransition para evitar cascading renders
startTransition(() => {
setInstalledModels(prev => new Set([...prev, modelTag]));
// Verificar também o modelo customizado se for o caso
if (modelTag === customModelInput.trim()) {
setCustomModelInstalled(true);
}
});
// Limpar activeModelTag após um pequeno delay para garantir que o estado foi atualizado
const timer = setTimeout(() => {
setActiveModelTag(null);
}, 100);
return () => clearTimeout(timer);
}
}, [downloadState.isSuccess, activeModelTag, customModelInput]);
// Verificar estado do setup quando download completar
useEffect(() => {
if (downloadState.isSuccess || (!downloadState.isDownloading && downloadState.progress.percent === 100 && downloadState.progress.status === 'success')) {
checkSetupState().catch(console.error);
}
}, [downloadState.isSuccess, downloadState.isDownloading, downloadState.progress.percent, downloadState.progress.status, checkSetupState]);
const handleModelDownload = useCallback(async (modelTag: string) => {
if (downloadState.isDownloading) {
console.warn('Download já em andamento');
return;
}
setActiveModelTag(modelTag);
try {
await startDownload(modelTag);
} catch (error) {
console.error('Erro ao iniciar download:', error);
setActiveModelTag(null);
}
}, [downloadState.isDownloading, startDownload]);
const handleCustomDownload = useCallback(async () => {
const modelTag = customModelInput.trim();
if (!modelTag) return;
await handleModelDownload(modelTag);
setCustomModelInput('');
}, [customModelInput, handleModelDownload]);
if (state.modelDownloaded) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Modelo Instalado</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Modelo {state.modelName || 'padrão'} pronto para uso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Download concluído com sucesso!</p>
<p className="text-xs text-emerald-300 mt-1">Avançando para o próximo passo...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-6xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl max-h-[calc(100vh-20rem)] flex flex-col">
<CardHeader className="p-6 sm:p-8 flex-shrink-0">
<CardTitle className="text-xl font-bold text-white flex items-center gap-2 mb-1">
<Download className="w-6 h-6 shrink-0" />
<span>Download do Modelo</span>
</CardTitle>
<CardDescription className="text-zinc-400 text-xs">
Escolha um modelo e/ou informe uma tag custom para baixar.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-4 sm:pb-6 pt-0 space-y-4 flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col md:flex-row gap-3 mb-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Pesquisar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-10 text-sm"
/>
</div>
<div className="flex gap-2 md:w-1/3">
<Input
type="text"
placeholder="Tag custom (ex: deepseek-coder:6.7b)"
value={customModelInput}
onChange={(e) => setCustomModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCustomDownload();
}
}}
disabled={downloadState.isDownloading}
className="flex-1 h-10 text-sm"
/>
<Button
onClick={handleCustomDownload}
disabled={!customModelInput.trim() || downloadState.isDownloading || customModelInstalled}
variant={customModelInstalled ? 'outline' : 'default'}
className={cn(
"gap-2 h-10 px-4 text-sm",
customModelInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
)}
>
{downloadState.isDownloading && activeModelTag === customModelInput.trim() ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Baixando
</>
) : customModelInstalled ? (
<>
<CheckCircle2 className="w-4 h-4" />
Já Instalado
</>
) : (
<>
<Download className="w-4 h-4" />
Baixar
</>
)}
</Button>
</div>
</div>
{/* Lista de Modelos Recomendados */}
<div className="space-y-4">
<div className="flex items-center gap-2 mt-2 mb-2">
<Sparkles className="w-4 h-4 text-primary" />
<h3 className="text-xs font-semibold">Modelos Recomendados</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 min-h-[300px] max-h-[450px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-zinc-800 custom-scrollbar">
{filteredModels.length === 0 ? (
<div className="col-span-1 sm:col-span-2 text-center py-12 text-sm text-muted-foreground">
Nenhum modelo encontrado para &ldquo;{searchQuery}&rdquo;
</div>
) : (
filteredModels.map((model) => {
const isActive = activeModelTag === model.id;
const isDownloading = downloadState.isDownloading && isActive;
const progress = isActive ? activeProgress : null;
const isInstalled = installedModels.has(model.id);
return (
<Card
key={model.id}
className={cn(
'relative border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900 transition-all',
isDownloading && 'border-emerald-500/50',
isInstalled && !isDownloading && 'border-emerald-500/30 bg-emerald-500/5'
)}
>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-bold text-sm truncate">{model.name}</h4>
{isInstalled && (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-[10px] px-1.5 py-0">
<CheckCircle2 className="w-3 h-3 mr-1" />
Instalado
</Badge>
)}
</div>
<p className="hidden md:block text-[11px] text-muted-foreground mt-1 line-clamp-1">
{model.description || ''}
</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px] sm:text-xs">
{model.size}
</Badge>
</div>
<div className="flex items-center justify-between text-[10px] sm:text-xs text-muted-foreground gap-2">
<span className="truncate">Mín. {model.minRam}GB RAM</span>
<code className="text-[9px] sm:text-[10px] bg-muted px-1 sm:px-1.5 py-0.5 rounded shrink-0 truncate max-w-[120px] sm:max-w-none">
{model.id}
</code>
</div>
{isDownloading && progress && (
<div className="space-y-1 pt-2 border-t border-zinc-800/50">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{progress.status || 'Baixando...'}
</span>
{progress.speed && (
<span className="font-mono text-[10px] text-muted-foreground">
{progress.speed}
</span>
)}
</div>
<Progress
value={progress.total > 0 && progress.percent !== null ? progress.percent : undefined}
className="h-1"
/>
{progress.total > 0 && (
<div className="flex justify-between text-[10px] text-muted-foreground/70">
<span>
{progress.downloadedFormatted} / {progress.totalFormatted}
</span>
<span>{progress.percent ?? 0}%</span>
</div>
)}
</div>
)}
<Button
onClick={() => handleModelDownload(model.id)}
disabled={downloadState.isDownloading || isInstalled}
variant={isInstalled ? 'outline' : isDownloading ? 'outline' : 'default'}
className={cn(
"w-full gap-2 h-8 text-xs",
isInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
)}
>
{isDownloading ? (
<>
<Loader2 className="w-3 h-3 animate-spin" />
<span className="hidden sm:inline">Baixando...</span>
</>
) : isInstalled ? (
<>
<CheckCircle2 className="w-3 h-3" />
<span className="hidden sm:inline">Já Instalado</span>
</>
) : (
<>
<Download className="w-3 h-3" />
<span className="hidden sm:inline">Baixar</span>
</>
)}
</Button>
</div>
</Card>
);
})
)}
</div>
</div>
{/* Mensagens de Erro */}
{state.lastError && (
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900/50">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-red-900 dark:text-red-100">Erro no download</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1 break-words">{state.lastError}</p>
</div>
</div>
)}
{/* SLIM FOOTER NAVIGATION */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 mt-4 border-t border-zinc-800 bg-zinc-950/50 backdrop-blur-sm flex-shrink-0">
<div className="text-xs text-zinc-500">
<span>Selecione um modelo ou pule para configurar depois.</span>
</div>
<div className="flex gap-3 w-full sm:w-auto">
<Button
variant="ghost"
onClick={onNext}
className="text-zinc-400 hover:text-white hover:bg-zinc-800 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Pular etapa
</Button>
<Button
onClick={onNext}
className="bg-white text-black hover:bg-zinc-200 px-8 flex-1 sm:flex-initial"
disabled={downloadState.isDownloading}
>
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</CardContent>
<CardFooter className="p-6 sm:p-8 pt-0 border-t-0 flex-shrink-0 hidden">
{/* Footer vazio - mantido para compatibilidade, mas conteúdo movido para CardContent */}
</CardFooter>
</Card>
);
}
+15 -41
View File
@@ -8,7 +8,6 @@ import { RECOMMENDED_MODELS, ModelRecommendation } from "@/lib/recommendation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { startTransition } from "react";
interface ModelSelectorProps { interface ModelSelectorProps {
recommendation: ModelRecommendation; recommendation: ModelRecommendation;
@@ -31,7 +30,6 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState(""); const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null); const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0]; const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
@@ -82,11 +80,10 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay // Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") { if (info.percent === 100 || info.status === "success") {
setTimeout(async () => { setTimeout(() => {
setIsInstalled(true); setIsInstalled(true);
setIsDownloading(false); setIsDownloading(false);
// Verificar instalação de forma assíncrona sem bloquear checkInstallation(selectedModelId);
checkInstallation(selectedModelId).catch(console.error);
}, 500); }, 500);
} }
} }
@@ -106,30 +103,20 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
} }
}; };
const handleNavigate = () => {
setIsNavigating(true);
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
onComplete();
});
};
const handleDownload = async () => { const handleDownload = async () => {
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
setIsDownloading(true); setIsDownloading(true);
setDownloadProgress(0); setDownloadProgress(0);
setDownloadStatus("Iniciando download..."); setDownloadStatus("Iniciando download...");
try {
// Chamar Tauri em background (não bloquear) await invoke('pull_model', { name: selectedModelId });
invoke('pull_model', { name: selectedModelId }) // O status será atualizado pelo listener quando o download completar
.catch((error) => { // Não precisamos setar aqui pois o listener já faz isso
console.error("Download failed", error); } catch (error) {
setDownloadStatus("Falha no download. Tente novamente."); console.error("Download failed", error);
setDownloadProgress(0); setDownloadStatus("Falha no download. Tente novamente.");
setIsDownloading(false); setDownloadProgress(0);
}); setIsDownloading(false);
// O status será atualizado pelo listener quando o download completar }
// Não precisamos aguardar aqui pois o listener já faz isso
}; };
return ( return (
@@ -205,22 +192,9 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
</CardContent> </CardContent>
<CardFooter> <CardFooter>
{isInstalled ? ( {isInstalled ? (
<Button <Button className="w-full h-12 text-lg" onClick={onComplete}>
className="w-full h-12 text-lg" Ir para o Chat
onClick={handleNavigate} <Play className="ml-2 w-5 h-5" />
disabled={isNavigating}
>
{isNavigating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Inicializando...
</>
) : (
<>
Ir para o Chat
<Play className="ml-2 w-5 h-5" />
</>
)}
</Button> </Button>
) : ( ) : (
<Button <Button
-638
View File
@@ -1,638 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useOllamaDownload } from '@/hooks/use-ollama-download';
import { useOllamaCheck } from '@/hooks/use-ollama-check';
import { useOperatingSystem } from '@/hooks/use-operating-system';
import { InstallationTerminal } from './installation-terminal';
import { Loader2, CheckCircle2, AlertCircle, Download, RefreshCw, Shield, Clock, WifiOff, ArrowRight } from 'lucide-react';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function formatTime(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
type InstallErrorType = 'ERR_NO_PRIVILEGE' | 'ERR_DOWNLOAD_FAIL' | 'ERR_TIMEOUT' | 'ERR_INSTALL_FAILED';
function parseInstallError(error: string): InstallErrorType {
const errorLower = error.toLowerCase();
// Verificar privilégios/permissões
if (
errorLower.includes('privilege') ||
errorLower.includes('permission') ||
errorLower.includes('elevated') ||
errorLower.includes('uac') ||
errorLower.includes('administrator') ||
errorLower.includes('access denied') ||
errorLower.includes('acesso negado')
) {
return 'ERR_NO_PRIVILEGE';
}
// Verificar erros de download/rede
if (
errorLower.includes('download') ||
errorLower.includes('network') ||
errorLower.includes('connection') ||
errorLower.includes('failed to download') ||
errorLower.includes('falha ao baixar') ||
errorLower.includes('conexão') ||
errorLower.includes('timeout') && (errorLower.includes('download') || errorLower.includes('conexão'))
) {
return 'ERR_DOWNLOAD_FAIL';
}
// Verificar timeout
if (
errorLower.includes('timeout') ||
errorLower.includes('90 segundos') ||
errorLower.includes('timed out') ||
errorLower.includes('tempo esgotado')
) {
return 'ERR_TIMEOUT';
}
return 'ERR_INSTALL_FAILED';
}
interface ErrorDisplayProps {
errorType: InstallErrorType;
errorMessage: string;
onRetry: () => void;
isRetrying: boolean;
}
function ErrorDisplay({ errorType, errorMessage, onRetry, isRetrying }: ErrorDisplayProps) {
switch (errorType) {
case 'ERR_NO_PRIVILEGE':
return (
<Card className="border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-amber-900 dark:text-amber-100">
Permissões Administrativas Necessárias
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-amber-700 dark:text-amber-300 mt-1 sm:mt-2">
O Windows solicitou permissões de administrador para instalar o Ollama.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<p className="text-xs sm:text-sm font-medium text-foreground mb-2">O que fazer:</p>
<ol className="list-decimal list-inside space-y-1 text-xs sm:text-sm text-muted-foreground">
<li>Verifique se apareceu um prompt do Windows pedindo permissões</li>
<li>Clique em &quot;Sim&quot; ou &quot;Allow&quot; no prompt de UAC (User Account Control)</li>
<li>Aguarde alguns segundos para a instalação continuar</li>
<li>Se o prompt não apareceu, tente executar o OllaHub como administrador</li>
</ol>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
<p className="text-xs text-muted-foreground text-center">
{errorMessage}
</p>
</CardContent>
</Card>
);
case 'ERR_DOWNLOAD_FAIL':
return (
<Card className="border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-red-900 dark:text-red-100">
Erro ao Baixar Instalador
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 sm:mt-2">
Não foi possível baixar o instalador do Ollama. Verifique sua conexão com a internet.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-xs sm:text-sm text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Tentando Novamente...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Tentar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
case 'ERR_TIMEOUT':
return (
<Card className="border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/20">
<CardHeader className="pb-3 sm:pb-6">
<div className="flex items-start gap-2 sm:gap-3">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600 dark:text-orange-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm sm:text-base text-orange-900 dark:text-orange-100">
Instalação Demorando Mais do Que o Esperado
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-orange-700 dark:text-orange-300 mt-1 sm:mt-2">
A instalação pode ainda estar em andamento em segundo plano.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-orange-200 dark:border-orange-800">
<p className="text-xs sm:text-sm text-muted-foreground mb-2 break-words">
O processo de instalação pode levar alguns minutos. Se o Ollama estiver sendo instalado,
você pode verificar o status manualmente.
</p>
<p className="text-[10px] sm:text-xs text-muted-foreground break-words">
{errorMessage}
</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</CardContent>
</Card>
);
default:
return (
<div className="flex items-start gap-2 p-3 sm:p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-2 sm:space-y-3 min-w-0">
<div>
<p className="text-sm sm:text-base font-medium text-red-900 dark:text-red-100">Erro na instalação</p>
<p className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 break-words">{errorMessage}</p>
</div>
<Button
onClick={onRetry}
disabled={isRetrying}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
</div>
</div>
);
}
}
type InstallStepState = 'IDLE' | 'INSTALLING' | 'VERIFYING' | 'SUCCESS';
interface OllamaInstallStepProps {
onNext: () => void;
}
export function OllamaInstallStep({ onNext }: OllamaInstallStepProps) {
const { state, installOllama, checkChocolatey, installChocolatey, installOllamaViaChoco, checkSetupState } = useSetupWizard();
const { os: detectedOS } = useOperatingSystem();
const downloadState = useOllamaDownload();
const ollamaCheck = useOllamaCheck();
const [stepState, setStepState] = useState<InstallStepState>('IDLE');
const [useChocolatey, setUseChocolatey] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const installationInProgressRef = useRef(false);
const hasCheckedInitialRef = useRef(false);
// Função de polling ativo para aguardar serviço ficar disponível
const waitForService = async (maxAttempts = 15): Promise<boolean> => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
return true;
}
} catch (error) {
console.warn(`Tentativa ${attempt}/${maxAttempts} falhou:`, error);
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
throw new Error('Ollama não ficou disponível após 30 segundos');
};
// Verificação inicial no mount: se Ollama já estiver instalado, ir direto para SUCCESS
useEffect(() => {
if (hasCheckedInitialRef.current) return;
const checkInitial = async () => {
if (stepState === 'IDLE') {
try {
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (isIntact) {
// Se a integridade está OK, atualizar estado do wizard e ir para SUCCESS
await checkSetupState();
setStepState('SUCCESS');
}
} catch {
// Silenciosamente ignorar - usuário ainda não instalou
}
}
hasCheckedInitialRef.current = true;
};
checkInitial();
}, [stepState, checkSetupState]);
// Sincronizar estado do polling com wizard state e atualizar FSM
useEffect(() => {
if (ollamaCheck.status === 'running' && !state.ollamaRunning) {
// Polling detectou que Ollama está rodando, atualizar wizard state
checkSetupState().catch(console.error);
}
// Atualizar FSM baseado no status
if (stepState === 'VERIFYING' && ollamaCheck.status === 'running' && state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
ollamaCheck.stopPolling();
}
}, [ollamaCheck.status, state.ollamaRunning, state.ollamaInstalled, checkSetupState, stepState, ollamaCheck]);
const handleManualRetry = async () => {
setIsRetrying(true);
try {
await checkSetupState();
// Se já estiver instalado, ir para SUCCESS
if (state.ollamaInstalled && state.ollamaRunning) {
setStepState('SUCCESS');
} else {
// Iniciar verificação manual
setStepState('VERIFYING');
ollamaCheck.startPolling();
}
} catch (error) {
console.error('Erro ao verificar novamente:', error);
} finally {
setIsRetrying(false);
}
};
const handleCheckExisting = async () => {
setStepState('VERIFYING');
ollamaCheck.startPolling();
await checkSetupState();
};
const handleInstall = async () => {
if (installationInProgressRef.current) {
return; // Evitar múltiplas instalações simultâneas
}
installationInProgressRef.current = true;
setStepState('INSTALLING');
setShowTerminal(true);
try {
// No Windows, verificar se Chocolatey está disponível
if (detectedOS === 'windows') {
const hasChoco = await checkChocolatey();
if (hasChoco) {
setUseChocolatey(true);
// Instalar Ollama via Chocolatey
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} else {
// Tentar instalar Chocolatey primeiro
try {
await installChocolatey();
setUseChocolatey(true);
await installOllamaViaChoco();
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
installationInProgressRef.current = false;
return;
} catch (error) {
console.warn('Falha ao usar Chocolatey, usando instalação direta:', error);
setUseChocolatey(false);
setShowTerminal(false);
}
}
}
// Fallback: instalação direta
if (!downloadState.filePath && detectedOS) {
await downloadState.handleDownload(detectedOS);
}
if (downloadState.filePath) {
await installOllama(downloadState.filePath);
} else {
await installOllama();
}
// Após instalação, fazer polling ativo
setStepState('VERIFYING');
await waitForService(15); // 15 tentativas = 30 segundos
// Se chegou aqui, Ollama está funcional
setStepState('SUCCESS');
await checkSetupState(); // Atualizar estado do wizard
} catch (error) {
console.error('Erro ao aguardar serviço:', error);
setStepState('IDLE');
} finally {
installationInProgressRef.current = false;
}
};
// Estado SUCCESS: mostrar mensagem de sucesso e botão Continuar
if (stepState === 'SUCCESS' || (state.ollamaInstalled && state.ollamaRunning)) {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
<span>Instalação Concluída</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Ollama está instalado e rodando com sucesso.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-100">Ollama Service is Running</p>
<p className="text-xs text-emerald-300 mt-1">Pronto para continuar com o download de modelos.</p>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={onNext} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
Continuar <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</CardFooter>
</Card>
);
}
// Estado IDLE: mostrar card informativo
if (stepState === 'IDLE') {
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
<Download className="w-6 h-6 shrink-0" />
<span>Ollama Engine</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
O OllaHub requer o motor Ollama para executar IAs localmente.
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
<p className="text-sm text-zinc-400 leading-relaxed">
O Ollama é uma ferramenta open-source que permite executar modelos de linguagem grandes (LLMs) diretamente no seu computador, sem necessidade de conexão com a internet.
</p>
</CardContent>
<CardFooter className="flex flex-col sm:flex-row justify-end gap-4 p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button onClick={handleCheckExisting} variant="outline" className="w-full sm:w-auto h-12 border-zinc-700 text-zinc-300 hover:bg-zinc-800">
Já tenho o Ollama instalado
</Button>
<Button onClick={handleInstall} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
<Download className="w-4 h-4 mr-2" />
Instalar Ollama
</Button>
</CardFooter>
</Card>
);
}
const errorType = state.lastError ? parseInstallError(state.lastError) : null;
const showRetryButton = state.lastError || state.progressStatus === 'error';
const showManualRetry = showRetryButton && state.lastError;
const hasTerminal = showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey);
return (
<Card className={`w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl ${hasTerminal ? 'max-h-[calc(100vh-16rem)] flex flex-col' : ''}`}>
<CardHeader className={`p-6 sm:p-8 ${hasTerminal ? 'flex-shrink-0' : ''}`}>
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{stepState === 'INSTALLING' && <Loader2 className="w-6 h-6 animate-spin" />}
{stepState === 'VERIFYING' && <Loader2 className="w-6 h-6 animate-spin" />}
{(state.lastError || state.progressStatus === 'error') && <AlertCircle className="w-6 h-6 text-red-500" />}
<span>
{stepState === 'INSTALLING' ? 'Instalando Ollama...' :
stepState === 'VERIFYING' ? 'Verificando Instalação...' :
'Instalação em Progresso'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
Acompanhe o progresso da configuração do ambiente.
</CardDescription>
</CardHeader>
<CardContent className={`px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6 ${hasTerminal ? 'flex-1 min-h-0 overflow-y-auto' : ''}`}>
{/* Terminal integrado para instalação via Chocolatey */}
{(showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey)) && (
<InstallationTerminal
onComplete={() => {
setShowTerminal(false);
}}
onError={(error) => {
console.error('Erro na instalação:', error);
}}
/>
)}
{/* Progresso para instalação direta (fallback) */}
{state.progressMessage && !showTerminal && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{state.progressMessage}</span>
{ollamaCheck.isChecking && (
<span className="text-xs text-muted-foreground">
Verificando... {formatTime(ollamaCheck.elapsedTime)}
</span>
)}
</div>
{state.progressStatus === 'installing_ollama' && (
<Progress value={undefined} className="h-2" />
)}
</div>
)}
{/* Feedback do polling em background (estado VERIFYING) */}
{stepState === 'VERIFYING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">
Instalação concluída. Aguardando inicialização do serviço...
</span>
</div>
)}
{/* Display de erro granular */}
{state.lastError && errorType && (
<ErrorDisplay
errorType={errorType}
errorMessage={state.lastError}
onRetry={handleManualRetry}
isRetrying={isRetrying}
/>
)}
{/* Botão de retry manual quando não há erro específico */}
{showRetryButton && !showManualRetry && (
<Button
onClick={handleManualRetry}
disabled={isRetrying || stepState === 'INSTALLING'}
variant="outline"
className="w-full"
>
{isRetrying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Novamente
</>
)}
</Button>
)}
{downloadState.isDownloading && (
<div className="space-y-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-2">
{downloadState.status === 'verifying' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
{downloadState.status === 'verifying'
? 'Verificando integridade...'
: 'Baixando...'}
</span>
<span className="font-mono">{downloadState.speed || '0 MB/s'}</span>
</div>
<Progress
value={downloadState.downloadProgress}
className="h-2 w-full transition-all duration-300"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
<span>
{formatBytes(downloadState.downloadedBytes)} / {formatBytes(downloadState.totalBytes)}
</span>
<span>{downloadState.downloadProgress.toFixed(1)}%</span>
</div>
{downloadState.eta > 0 && downloadState.status === 'downloading' && (
<div className="text-xs text-muted-foreground text-center">
Tempo restante: {formatTime(downloadState.eta)}
</div>
)}
</div>
)}
{/* Estado INSTALLING: mostrar progresso */}
{stepState === 'INSTALLING' && (
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span className="break-words">Instalando Ollama... Isso pode levar alguns minutos.</span>
</div>
)}
</CardContent>
{/* Footer opcional se necessário */}
</Card>
);
}
-173
View File
@@ -1,173 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { useSettingsStore } from '@/store/settings-store';
import { useRouter } from 'next/navigation';
import { CheckCircle2, ArrowRight, AlertCircle, Sparkles, Loader2 } from 'lucide-react';
import { startTransition } from 'react';
import { invoke } from '@tauri-apps/api/core';
export function SetupCompleteStep() {
const { state, goToStep } = useSetupWizard();
const { setSetupCompleted } = useSettingsStore();
const router = useRouter();
const [integrityError, setIntegrityError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(true);
useEffect(() => {
const verifyAndComplete = async () => {
try {
setIsVerifying(true);
setIntegrityError(null);
// Verificar integridade ANTES de marcar como completo
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar silenciosamente para o passo de instalação do Ollama
setIsVerifying(false);
goToStep(2);
return;
}
// Só definir flag e redirecionar se Ollama estiver íntegro
setSetupCompleted(true);
setIsVerifying(false);
const timer = setTimeout(() => {
startTransition(() => {
router.replace('/chat');
});
}, 2000);
return () => clearTimeout(timer);
} catch {
// Em caso de erro na verificação, redirecionar para instalação
setIsVerifying(false);
goToStep(2);
}
};
verifyAndComplete();
}, [setSetupCompleted, router, goToStep]);
const handleGoToChat = async () => {
try {
// Verificar integridade antes de redirecionar manualmente também
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
if (!isOllamaIntact) {
// Redirecionar para o passo de instalação do Ollama
goToStep(2);
return;
}
setSetupCompleted(true);
startTransition(() => {
router.replace('/chat');
});
} catch {
// Em caso de erro na verificação, redirecionar para instalação
goToStep(2);
}
};
const handleGoToInstallation = () => {
goToStep(2);
};
return (
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
<CardHeader className="p-6 sm:p-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
{isVerifying ? (
<Loader2 className="w-6 h-6 animate-spin shrink-0" />
) : integrityError ? (
<AlertCircle className="w-6 h-6 text-red-500 shrink-0" />
) : (
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
)}
<span>
{isVerifying ? 'Verificando Integridade' :
integrityError ? 'Erro na Configuração' :
'Configuração Completa'}
</span>
</CardTitle>
<CardDescription className="text-zinc-400 mb-8 text-base">
{isVerifying ? 'Validando instalação do Ollama...' :
integrityError ? 'Encontramos um problema.' :
'Tudo pronto para começar a usar o OllaHub.'}
</CardDescription>
</CardHeader>
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
{isVerifying ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-zinc-900/50 flex items-center justify-center border border-zinc-800">
<CheckCircle2 className="w-8 h-8 text-zinc-700 animate-pulse" />
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Estamos verificando se o serviço Ollama está respondendo corretamente antes de liberar o acesso.
</p>
</div>
) : integrityError ? (
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
<div className="w-16 h-16 rounded-full bg-red-950/20 flex items-center justify-center border border-red-900/50">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<div className="text-center space-y-2 max-w-md">
<h3 className="font-semibold text-red-400">Falha na Verificação</h3>
<p className="text-sm text-zinc-400">
{integrityError}
</p>
</div>
</div>
) : (
<>
<div className="flex items-start gap-4 p-6 bg-emerald-950/10 rounded-xl border border-emerald-900/20">
<div className="p-3 bg-emerald-500/10 rounded-lg shrink-0">
<Sparkles className="w-6 h-6 text-emerald-500" />
</div>
<div className="space-y-1">
<h3 className="font-semibold text-emerald-400 text-lg">Ambiente Configurado</h3>
<p className="text-emerald-200/70 text-sm leading-relaxed">
Seu assistente de IA local está pronto. Você pode começar a conversar, gerar códigos ou analisar documentos agora mesmo.
</p>
</div>
</div>
<div className="space-y-3 p-4 bg-zinc-900/50 rounded-xl border border-zinc-800/50">
<p className="text-sm font-medium text-zinc-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
Resumo da Instalação:
</p>
<ul className="text-sm text-zinc-500 space-y-2 pl-6 list-disc marker:text-zinc-700">
<li>Hardware compatível detectado</li>
{state.ollamaInstalled && <li>Ollama Engine instalado e ativo</li>}
{state.modelDownloaded && (
<li>Modelo de IA <strong>{state.modelName || 'padrão'}</strong> baixado</li>
)}
</ul>
</div>
</>
)}
</CardContent>
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
<Button
onClick={integrityError ? handleGoToInstallation : handleGoToChat}
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
disabled={isVerifying}
>
{isVerifying ? 'Verificando...' : integrityError ? 'Instalar Ollama' : 'Ir para o Chat'}
{!isVerifying && !integrityError && <ArrowRight className="w-4 h-4 ml-2" />}
</Button>
</CardFooter>
</Card>
);
}
-120
View File
@@ -1,120 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useSetupWizard } from '@/hooks/use-setup-wizard';
import { SystemCheckStep } from './system-check-step';
import { OllamaInstallStep } from './ollama-install-step';
import { ModelDownloadStep } from './model-download-step';
import { SetupCompleteStep } from './setup-complete-step';
import { CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
const steps = [
{ id: 1, title: 'Verificação do Sistema', description: 'Analisando hardware' },
{ id: 2, title: 'Instalação do Ollama', description: 'Configurando engine' },
{ id: 3, title: 'Download do Modelo', description: 'Baixando modelo padrão' },
{ id: 4, title: 'Conclusão', description: 'Tudo pronto!' },
];
export function SetupWizard() {
const { state, checkSetupState, goToStep, nextStep } = useSetupWizard();
useEffect(() => {
checkSetupState();
}, [checkSetupState]);
const handleNext = () => {
nextStep();
};
const renderStep = () => {
switch (state.currentStep) {
case 1:
return <SystemCheckStep onNext={handleNext} />;
case 2:
return <OllamaInstallStep onNext={handleNext} />;
case 3:
return <ModelDownloadStep onNext={handleNext} />;
case 4:
return <SetupCompleteStep />;
default:
return <SystemCheckStep onNext={handleNext} />;
}
};
const containerWidth = state.currentStep === 3 ? 'max-w-6xl' : 'max-w-2xl';
return (
<div className="h-screen max-h-screen w-full bg-background flex flex-col items-center overflow-y-auto p-4 sm:p-6 lg:p-8">
<div className={`w-full ${containerWidth} flex-shrink-0 transition-all duration-500 ease-in-out space-y-4 sm:space-y-6 lg:space-y-8 py-4`}>
<div className="text-center space-y-2 flex-shrink-0">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
<p className="text-sm sm:text-base text-muted-foreground">
Vamos preparar o ambiente ideal para você começar a usar o OllaHub.
</p>
</div>
<div className={`w-full ${containerWidth} mx-auto mb-20 sm:mb-24 md:mb-28 px-2 sm:px-0 transition-all duration-500 ease-in-out flex-shrink-0 relative z-0`}>
<div className="flex items-center justify-between relative">
{steps.map((step, index) => {
const isCompleted = state.completedSteps.includes(step.id);
const isCurrent = state.currentStep === step.id;
const isFuture = !isCompleted && !isCurrent;
const canNavigate = step.id <= state.currentStep;
const maxReachableStep = Math.max(state.currentStep, ...state.completedSteps);
const isClickable = canNavigate || step.id === maxReachableStep + 1;
const isLineActive = state.currentStep > index + 1 || state.completedSteps.includes(step.id);
return (
<div key={step.id} className={cn("contents")}>
{/* STEP CIRCLE & LABEL */}
<div className="flex flex-col items-center relative z-0 group pb-0 md:pb-0">
<div
onClick={() => isClickable && step.id !== state.currentStep && goToStep(step.id)}
className={cn(
"w-10 h-10 sm:w-12 sm:h-12 rounded-full flex items-center justify-center border-2 transition-all duration-300 relative z-0",
// Estética dos Círculos
isCompleted && "bg-emerald-500 border-emerald-500 text-white",
isCurrent && "bg-zinc-900 border-white text-white shadow-[0_0_10px_rgba(255,255,255,0.5)]",
isFuture && "bg-zinc-900 border-zinc-800 text-zinc-500",
// Interatividade
isClickable ? "cursor-pointer" : "cursor-default"
)}
>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 sm:w-6 sm:h-6" />
) : (
<span className="text-sm sm:text-base font-bold">{step.id}</span>
)}
</div>
{/* Rótulos (Labels) - Desktop Only */}
<div className="hidden md:flex absolute top-14 flex-col items-center w-32 text-center z-10">
<span className={cn(
"text-sm font-medium transition-colors duration-300",
isCurrent ? "text-white" : "text-muted-foreground"
)}>
{step.title}
</span>
</div>
</div>
{/* LINHA CONECTORA (Entre passos) */}
{index < steps.length - 1 && (
<div className={cn(
"flex-1 h-[2px] mx-2 sm:mx-4 rounded transition-colors duration-500 relative z-0",
isLineActive ? "bg-emerald-500" : "bg-zinc-800"
)} />
)}
</div>
);
})}
</div>
</div>
<div className="w-full flex-shrink-0 relative z-10">
{renderStep()}
</div>
</div>
</div>
);
}
-136
View File
@@ -1,136 +0,0 @@
'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>
);
}
+1 -36
View File
@@ -4,12 +4,9 @@ import { useEffect, useState } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { Minus, Square, X } from 'lucide-react'; import { Minus, Square, X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSystemHealth } from '@/hooks/use-system-health';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export function TitleBar() { export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const health = useSystemHealth();
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -80,49 +77,17 @@ 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-[9999] shrink-0 fixed top-0 left-0 right-0" className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-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
className="flex items-center gap-2 px-4 h-full flex-1" className="flex items-center gap-2 px-4 h-full flex-1"
data-tauri-drag-region data-tauri-drag-region
> >
<img
src="/icon.png"
alt="OllaHub"
className="w-5 h-5 shrink-0"
draggable={false}
/>
<span className="text-sm font-medium text-sidebar-foreground"> <span className="text-sm font-medium text-sidebar-foreground">
OllaHub OllaHub
</span> </span>
{/* Indicador de saúde do sistema */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"w-2 h-2 rounded-full shrink-0",
health.status === 'healthy' && "bg-green-500",
health.status === 'warning' && "bg-yellow-500",
health.status === 'critical' && "bg-red-500"
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>{health.message}</p>
<p className="text-muted-foreground mt-1">
RAM: {health.ram_percent.toFixed(1)}% | CPU: {health.cpu_percent.toFixed(1)}%
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
{/* Controles de janela */} {/* Controles de janela */}
-46
View File
@@ -1,46 +0,0 @@
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 }
+2 -8
View File
@@ -38,14 +38,9 @@ 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 top-8 left-0 right-0 bottom-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 inset-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}
/> />
) )
@@ -65,10 +60,9 @@ 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-[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", "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",
className className
)} )}
style={{ pointerEvents: 'auto' }}
{...props} {...props}
> >
{children} {children}
-31
View File
@@ -1,31 +0,0 @@
"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 }
@@ -1,67 +0,0 @@
'use client';
import { useState } from 'react';
import { useOllamaHealth } from '@/contexts/ollama-health-context';
import { useSettingsStore } from '@/store/settings-store';
import { Button } from '@/components/ui/button';
import { AlertTriangle, RefreshCw, Power } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
export function ServiceInterruptionOverlay() {
const { isOllamaRunning, isChecking, checkNow } = useOllamaHealth();
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
const [starting, setStarting] = useState(false);
const tryStartOllama = async () => {
setStarting(true);
try {
await invoke<boolean>('auto_start_ollama');
await checkNow();
} catch {}
setStarting(false);
};
if (isOllamaRunning || !isSetupCompleted) return null;
return (
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-6">
<div className="max-w-md w-full border rounded-lg bg-zinc-900 text-white p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="w-6 h-6 text-yellow-400" />
<h2 className="text-lg font-semibold">Conexão com Ollama Perdida</h2>
</div>
<p className="text-sm text-zinc-300 mb-4">
O serviço Ollama não está respondendo. Verifique se ele está aberto.
</p>
<div className="flex items-center gap-2 mb-6">
{isChecking ? (
<span className="text-xs text-zinc-400 flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin" /> Tentando reconectar...
</span>
) : (
<Button variant="ghost" size="sm" onClick={checkNow}>
<RefreshCw className="w-4 h-4" /> Verificar novamente
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button onClick={tryStartOllama} disabled={starting} className="flex-1">
{starting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" /> Iniciando Ollama...
</>
) : (
<>
<Power className="w-4 h-4" /> Tentar abrir Ollama
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={checkNow}>
Testar novamente
</Button>
</div>
</div>
</div>
);
}
-117
View File
@@ -1,117 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
-346
View File
@@ -1,346 +0,0 @@
'use client';
import { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
// Helper para formatar bytes em formato legível (KB, MB, GB)
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
// Helper para formatar velocidade em formato legível
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
return `${parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Helper para parsear string formatada (ex: "552 MB") para bytes
function parseFormattedBytes(formatted: string): number {
if (!formatted || typeof formatted !== 'string') return 0;
const match = formatted.match(/([\d.]+)\s*(B|KB|MB|GB)/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multiplier =
unit === 'GB' ? 1024 * 1024 * 1024 :
unit === 'MB' ? 1024 * 1024 :
unit === 'KB' ? 1024 : 1;
return value * multiplier;
}
interface DownloadProgress {
percent: number | null; // null = indeterminado
downloadedBytes: number; // em bytes
totalBytes: number; // em bytes
downloadedFormatted: string; // "552 MB"
totalFormatted: string; // "1.2 GB"
speed: string; // "25 MB/s" (calculado no frontend)
status: string;
}
interface DownloadState {
isDownloading: boolean;
isSuccess: boolean; // NOVO: indica se download foi concluído com sucesso
progress: DownloadProgress;
currentModel: string | null;
error: string | null;
isCancelling: boolean;
}
interface DownloadContextType {
state: DownloadState;
startDownload: (modelName: string) => Promise<void>;
cancelDownload: () => void;
clearError: () => void;
}
const DownloadContext = createContext<DownloadContextType | undefined>(undefined);
export function DownloadProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<DownloadState>({
isDownloading: false,
isSuccess: false,
progress: {
percent: null,
downloadedBytes: 0,
totalBytes: 0,
downloadedFormatted: '0 B',
totalFormatted: '0 B',
speed: '0 B/s',
status: '',
},
currentModel: null,
error: null,
isCancelling: false,
});
const abortControllerRef = useRef<AbortController | null>(null);
const unlistenRef = useRef<(() => void) | null>(null);
const isCancellingRef = useRef(false);
const lastDownloadedBytesRef = useRef<number>(0);
const lastUpdateTimeRef = useRef<number>(0);
// Sincronizar ref com estado
useEffect(() => {
isCancellingRef.current = state.isCancelling;
}, [state.isCancelling]);
// Escutar eventos de progresso do download
useEffect(() => {
if (!state.isDownloading) {
// Limpar listener quando não estiver baixando
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
return;
}
const setupListener = async () => {
const unlisten = await listen<string>('download-progress', (event) => {
if (isCancellingRef.current) return;
try {
const data = JSON.parse(event.payload);
const status = data.status || '';
// Usar percent do backend diretamente (já calculado corretamente)
// Se não estiver disponível e tivermos completed/total, calcular
let percent: number | null = data.percent !== undefined ? data.percent : null;
// Parsear valores formatados do backend para bytes
let downloadedBytes = 0;
let totalBytes = 0;
if (data.downloaded) {
downloadedBytes = typeof data.downloaded === 'string'
? parseFormattedBytes(data.downloaded)
: data.downloaded;
}
if (data.total) {
totalBytes = typeof data.total === 'string'
? parseFormattedBytes(data.total)
: data.total;
}
// Se percent não veio do backend mas temos valores brutos, calcular
if (percent === null && totalBytes > 0 && downloadedBytes > 0) {
percent = Math.round((downloadedBytes / totalBytes) * 100);
}
// Se total for 0 ou indefinido, percent fica null (indeterminado)
if (totalBytes === 0) {
percent = null;
}
// Calcular velocidade em tempo real usando refs
const now = Date.now();
let calculatedSpeed = '0 B/s';
if (downloadedBytes > 0) {
// Primeira atualização: inicializar timestamp
if (lastUpdateTimeRef.current === 0) {
lastUpdateTimeRef.current = now;
lastDownloadedBytesRef.current = downloadedBytes;
// Usar velocidade do backend se disponível, senão 0
calculatedSpeed = data.speed || '0 B/s';
} else {
const deltaTime = (now - lastUpdateTimeRef.current) / 1000; // em segundos
if (deltaTime > 0 && downloadedBytes > lastDownloadedBytesRef.current) {
const deltaBytes = downloadedBytes - lastDownloadedBytesRef.current;
const bytesPerSec = deltaBytes / deltaTime;
calculatedSpeed = formatSpeed(bytesPerSec);
} else {
// Sem mudança ou tempo inválido, manter velocidade anterior ou do backend
calculatedSpeed = data.speed || '0 B/s';
}
// Atualizar refs para próximo cálculo
lastDownloadedBytesRef.current = downloadedBytes;
lastUpdateTimeRef.current = now;
}
}
// Formatar valores para exibição
const downloadedFormatted = downloadedBytes > 0 ? formatBytes(downloadedBytes) : (data.downloaded || '0 B');
const totalFormatted = totalBytes > 0 ? formatBytes(totalBytes) : (data.total || '0 B');
// Determinar se é sucesso
const isSuccess = status === 'success' || percent === 100;
setState(prev => ({
...prev,
isSuccess: isSuccess,
isDownloading: !isSuccess,
progress: {
percent: percent !== null ? percent : prev.progress.percent,
downloadedBytes: downloadedBytes || prev.progress.downloadedBytes,
totalBytes: totalBytes || prev.progress.totalBytes,
downloadedFormatted: downloadedFormatted,
totalFormatted: totalFormatted,
speed: calculatedSpeed,
status: status || prev.progress.status,
},
isCancelling: false,
}));
if (isSuccess) {
// Não resetar progresso - manter em 100% para feedback visual
setState(prev => ({
...prev,
isDownloading: false,
isSuccess: true,
progress: {
...prev.progress,
percent: 100,
},
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
}
} catch (error) {
console.error('Erro ao processar evento de progresso:', error);
}
});
unlistenRef.current = unlisten;
};
setupListener();
return () => {
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
};
}, [state.isDownloading, state.isCancelling]);
const startDownload = useCallback(async (modelName: string) => {
if (state.isDownloading) {
console.warn('Download já em andamento');
return;
}
// Resetar refs para cálculo de velocidade
lastDownloadedBytesRef.current = 0;
lastUpdateTimeRef.current = Date.now();
setState({
isDownloading: true,
isSuccess: false,
progress: {
percent: null,
downloadedBytes: 0,
totalBytes: 0,
downloadedFormatted: '0 B',
totalFormatted: '0 B',
speed: '0 B/s',
status: 'Iniciando download...',
},
currentModel: modelName,
error: null,
isCancelling: false,
});
abortControllerRef.current = new AbortController();
try {
await invoke('download_default_model_command', { modelName });
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Erro desconhecido durante download';
setState(prev => ({
...prev,
isDownloading: false,
isSuccess: false,
error: errorMsg,
isCancelling: false,
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
}
}, [state.isDownloading]);
const cancelDownload = useCallback(async () => {
if (!state.isDownloading) return;
setState(prev => ({
...prev,
isCancelling: true,
}));
try {
await invoke('cancel_download');
} catch (error) {
console.error('Falha ao cancelar no backend:', error);
}
setState(prev => ({
...prev,
isDownloading: false,
isCancelling: false,
progress: {
...prev.progress,
status: 'Cancelado',
},
}));
if (unlistenRef.current) {
unlistenRef.current();
unlistenRef.current = null;
}
// Limpar AbortController local
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}, [state.isDownloading]);
const clearError = useCallback(() => {
setState(prev => ({
...prev,
error: null,
}));
}, []);
return (
<DownloadContext.Provider
value={{
state,
startDownload,
cancelDownload,
clearError,
}}
>
{children}
</DownloadContext.Provider>
);
}
export function useDownloadContext() {
const context = useContext(DownloadContext);
if (context === undefined) {
throw new Error('useDownloadContext deve ser usado dentro de DownloadProvider');
}
return context;
}
-92
View File
@@ -1,92 +0,0 @@
'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;
}
-5
View File
@@ -1,5 +0,0 @@
{
"version": "0.1.0",
"generatedAt": "2025-11-28T03:22:48.992Z",
"releases": []
}
-31
View File
@@ -1,31 +0,0 @@
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
+10 -21
View File
@@ -49,14 +49,6 @@ 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);
@@ -95,31 +87,28 @@ export function useAppUpdater(): AppUpdaterState {
const update = await checkForUpdate(); const update = await checkForUpdate();
if (update?.available) { if (update?.available) {
// Escutar progresso do download
const unlisten = await update.on('download-progress', (progress: any) => {
setDownloadProgress(progress.progress || 0);
});
// Baixar e instalar atualização // Baixar e instalar atualização
// O progresso pode ser monitorado via eventos do Tauri se necessário
await update.downloadAndInstall(); await update.downloadAndInstall();
// Após downloadAndInstall(), o app será reiniciado automaticamente unlisten();
// Não é necessário chamar installAndRestart() que não existe na API
// Reiniciar aplicativo após instalação
await update.installAndRestart();
} }
} catch (err) { } catch (err) {
console.error('Failed to install update:', err); console.error('Failed to install update:', err);
setError(err instanceof Error ? err.message : 'Erro ao instalar atualização'); setError(err instanceof Error ? err.message : 'Erro ao instalar atualização');
setIsDownloading(false); setIsDownloading(false);
setDownloadProgress(0);
} }
}, [updateAvailable]); }, [updateAvailable]);
// 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)
+1 -21
View File
@@ -15,8 +15,7 @@ export function useAutoLabelingModel() {
const checkAndDownload = async () => { const checkAndDownload = async () => {
try { try {
// O comando Tauri é check_if_model_installed_command, mas o Tauri remove o sufixo _command const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
const installed = await invoke<boolean>('check_if_model_installed_command', { name: TARGET_MODEL });
if (installed) { if (installed) {
setIsReady(true); setIsReady(true);
return; return;
@@ -50,25 +49,6 @@ 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);
} }
+16 -9
View File
@@ -37,17 +37,24 @@ export function useBatchVerify() {
const callLLM = async (model: string, prompt: string, json = false) => { const callLLM = async (model: string, prompt: string, json = false) => {
try { try {
const { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const response = await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt, body: JSON.stringify({
options: { model,
temperature: 0.3, prompt,
num_predict: 1024, stream: false,
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;
+45 -18
View File
@@ -254,15 +254,29 @@ 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 { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/chat', {
let title = await invoke<string>('generate_chat_completion', { method: 'POST',
model: TITLE_MODEL, headers: { 'Content-Type': 'application/json' },
messages: [ body: JSON.stringify({
{ 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.' }, model: TITLE_MODEL,
{ role: 'user', content: userMessage } messages: [
], { role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
options: undefined, { role: 'user', content: userMessage }
],
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) {
@@ -306,17 +320,30 @@ export function useChatStorage() {
content: removeMetadataNoise(m.content), content: removeMetadataNoise(m.content),
})); }));
const { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/chat', {
const rawTitle = await invoke<string>('generate_chat_completion', { method: 'POST',
model: TITLE_MODEL, headers: { 'Content-Type': 'application/json' },
messages: [ body: JSON.stringify({
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' }, model: TITLE_MODEL,
messages: [
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
...sanitizedMessages ...sanitizedMessages
], ],
options: undefined, stream: false,
}),
}); });
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}`);
}
let title = removeMetadataNoise(rawTitle?.trim() || ""); const data = await response.json();
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) {
@@ -365,7 +392,7 @@ export function useChatStorage() {
const keywords = Array.isArray(msg.metadata.keywords) const keywords = Array.isArray(msg.metadata.keywords)
? msg.metadata.keywords ? msg.metadata.keywords
: []; : [];
memoryContext.push(...keywords.filter((k: unknown) => typeof k === 'string')); memoryContext.push(...keywords.filter(k => typeof k === 'string'));
} }
}); });
const uniqueMemoryContext = Array.from(new Set(memoryContext)); const uniqueMemoryContext = Array.from(new Set(memoryContext));
@@ -430,7 +457,7 @@ export function useChatStorage() {
const keywords = Array.isArray(msg.metadata.keywords) const keywords = Array.isArray(msg.metadata.keywords)
? msg.metadata.keywords ? msg.metadata.keywords
: []; : [];
memoryContext.push(...keywords.filter((k: unknown) => typeof k === 'string')); memoryContext.push(...keywords.filter(k => typeof k === 'string'));
} }
}); });
// Deduplicate // Deduplicate
+3 -16
View File
@@ -26,8 +26,6 @@ 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 {
@@ -530,21 +528,10 @@ export function useChat() {
} }
}; };
const stop = async () => { const stop = () => {
try { if (abortControllerRef.current) {
// Cancelar processamento de scraping (prioridade alta) abortControllerRef.current.abort();
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;
} }
}; };
+72 -97
View File
@@ -23,7 +23,7 @@ export interface DeepResearchLog {
timestamp: number; timestamp: number;
input: string; input: string;
rawOutput?: string; rawOutput?: string;
parsedOutput?: unknown; parsedOutput?: any;
error?: string; error?: string;
} }
@@ -47,7 +47,7 @@ function logStep(message: string) {
log.info(message); log.info(message);
} }
function logData(label: string, data: unknown) { function logData(label: string, data: any) {
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: unknown) {
} }
} }
function logError(message: string, error?: unknown) { function logError(message: string, error?: any) {
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 ``` ... ```)
const cleaned = raw let 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: unknown; let parsed: any;
try { try {
parsed = JSON.parse(cleaned); parsed = JSON.parse(cleaned);
logStep('Direct JSON parse successful'); logStep('Direct JSON parse successful');
} catch { } catch (e) {
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 { } catch (e2) {
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 as unknown[]).filter(item => typeof item === 'string' && (item as string).trim().length > 0) as string[]; const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0);
logSuccess(`Parsed as plain array: ${result.length} queries`); logSuccess(`Parsed as plain array: ${result.length} queries`);
return result; return result;
} }
@@ -113,19 +113,17 @@ 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) {
const obj = parsed as Record<string, unknown>; if (parsed[key] && Array.isArray(parsed[key])) {
const val = obj[key]; const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
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 as Record<string, unknown>)) { for (const val of Object.values(parsed)) {
if (Array.isArray(val) && val.length > 0 && typeof (val as unknown[])[0] === 'string') { if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[]; const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
logSuccess(`Found queries in object value: ${result.length} queries`); logSuccess(`Found queries in object value: ${result.length} queries`);
return result; return result;
} }
@@ -153,19 +151,29 @@ export function useDeepResearch() {
logData('Prompt length', `${prompt.length} chars`); logData('Prompt length', `${prompt.length} chars`);
try { try {
const { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const response = await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt, body: JSON.stringify({
options: { model,
temperature: 0.2, prompt,
num_predict: 2048, stream: false,
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');
}
logSuccess(`LLM response received (${response?.length || 0} chars)`); const data = await response.json();
return response; logSuccess(`LLM response received (${data.response?.length || 0} chars)`);
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);
@@ -187,7 +195,7 @@ export function useDeepResearch() {
setState(s => ({ ...s, context })); setState(s => ({ ...s, context }));
return context; return context;
} catch (error) { } catch (error) {
log.warn(`Contextual analysis failed, continuing without context: ${error instanceof Error ? error.message : String(error)}`); log.warn('Contextual analysis failed, continuing without context:', error);
return null; return null;
} }
}, []); }, []);
@@ -211,7 +219,7 @@ export function useDeepResearch() {
setState(s => ({ ...s, enrichedQueries: enriched })); setState(s => ({ ...s, enrichedQueries: enriched }));
return enriched; return enriched;
} catch (error) { } catch (error) {
log.warn(`Query enrichment failed, continuing without enrichment: ${error instanceof Error ? error.message : String(error)}`); log.warn('Query enrichment failed, continuing without enrichment:', error);
return null; return null;
} }
}, []); }, []);
@@ -247,41 +255,40 @@ 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);
if (isSmallModel) { const isRelevant = plan.some(q =>
logStep('Safety Check bypassed for small/experimental model'); keywords.some(k => q.toLowerCase().includes(k))
);
logData('Plan is relevant', isRelevant);
if (!isRelevant && keywords.length > 0) {
logError('Safety Check Failed: Plan is irrelevant to query');
logData('Generated plan', plan);
logStep('Falling back to original query');
addLog({
stage: 'decomposition',
timestamp: Date.now(),
input: prompt,
rawOutput: response,
parsedOutput: plan,
error: 'Safety Check Failed: Plan irrelevant to query'
});
plan = [query];
} else { } else {
const isRelevant = plan.some(q => logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
keywords.some(k => q.toLowerCase().includes(k)) addLog({
); stage: 'decomposition',
logData('Plan is relevant', isRelevant); timestamp: Date.now(),
if (!isRelevant && keywords.length > 0) { input: prompt,
logError('Safety Check Failed: Plan is irrelevant to query'); rawOutput: response,
logData('Generated plan', plan); parsedOutput: plan,
logStep('Falling back to original query'); error: parseError
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;
@@ -296,63 +303,31 @@ 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[]) => {
logSection('ADDING TO KNOWLEDGE BASE'); logStep(`--- 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 || src.content || '', content: src.markdown,
timestamp: Date.now(), timestamp: Date.now(),
})); }));
// Log detalhado de cada fonte
sources.forEach((src, idx) => { sources.forEach((src, idx) => {
logStep(`\n--- Source ${idx + 1}/${sources.length} ---`); logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
logData('Title', src.title); const contentLength = src.markdown?.length || 0;
logData('URL', src.url); logData(` Content length`, `${contentLength} chars`);
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) {
// Log preview mais detalhado const preview = src.markdown.substring(0, 300);
const preview = src.markdown.substring(0, 500); logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
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 {
logError('No content extracted from this source'); logStep(` ⚠️ No content extracted`);
}
// 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,
+5 -2
View File
@@ -10,8 +10,11 @@ export function useHardware() {
async function fetchSpecs() { async function fetchSpecs() {
try { try {
const data = await invoke<SystemSpecs>('get_system_specs'); const data = await invoke<SystemSpecs>('get_system_specs');
setSpecs(data); // Simulate a small delay for the "scanning" effect
setLoading(false); setTimeout(() => {
setSpecs(data);
setLoading(false);
}, 1500);
} catch (error) { } catch (error) {
console.error("Failed to get system specs:", error); console.error("Failed to get system specs:", error);
setLoading(false); setLoading(false);
-102
View File
@@ -1,102 +0,0 @@
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,
};
}
+18 -192
View File
@@ -1,224 +1,50 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect } 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';
interface UseOllamaCheckReturn { export function useOllamaCheck() {
status: OllamaStatus; const [status, setStatus] = useState<OllamaStatus>('checking');
isChecking: boolean;
check: () => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
reset: () => void;
elapsedTime: number;
}
const INITIAL_INTERVAL = 2000; // 2 segundos const check = async () => {
const MAX_INTERVAL = 30000; // 30 segundos setStatus('checking');
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');
const newStatus = result.status as OllamaStatus; setStatus(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 robusta como fallback // Se falhar, tentar verificação individual 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 false; // Continua polling (não instalado ainda) return;
} }
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 false; // Continua polling (instalado mas não rodando) return;
} }
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
} }
} }
}, []);
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,
}; };
}
useEffect(() => {
check();
}, []);
return { status, check };
}
+26 -184
View File
@@ -12,13 +12,6 @@ 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() {
@@ -31,13 +24,6 @@ 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);
@@ -45,71 +31,17 @@ export function useOllamaDownload() {
// Escutar eventos de progresso do download // Escutar eventos de progresso do download
useEffect(() => { useEffect(() => {
const unlistenDownloadPromise = listen('installer-download-progress', (event: any) => { const unlistenPromise = 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: statusText, downloadStatus: data.status || '',
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 () => {
unlistenDownloadPromise.then(unlisten => unlisten()); unlistenPromise.then(unlisten => unlisten());
unlistenInstallPromise.then(unlisten => unlisten());
unlistenReadyPromise.then(unlisten => unlisten());
}; };
}, []); }, []);
@@ -121,18 +53,6 @@ export function useOllamaDownload() {
downloadError: null, downloadError: null,
filePath: null, filePath: null,
isInstalled: false, isInstalled: false,
isChecking: false,
checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
try { try {
@@ -146,18 +66,6 @@ export function useOllamaDownload() {
downloadError: null, downloadError: null,
filePath: existingPath, filePath: existingPath,
isInstalled: false, isInstalled: false,
isChecking: false,
checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'completed',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
return; return;
} }
@@ -171,18 +79,6 @@ export function useOllamaDownload() {
downloadError: null, downloadError: null,
filePath, filePath,
isInstalled: false, isInstalled: false,
isChecking: false,
checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'completed',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
} catch (error) { } catch (error) {
setState({ setState({
@@ -192,18 +88,6 @@ export function useOllamaDownload() {
downloadError: error instanceof Error ? error.message : 'Erro desconhecido durante download', downloadError: error instanceof Error ? error.message : 'Erro desconhecido durante download',
filePath: null, filePath: null,
isInstalled: false, isInstalled: false,
isChecking: false,
checkStatus: '',
<<<<<<< HEAD
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}); });
} }
}; };
@@ -217,69 +101,35 @@ export function useOllamaDownload() {
return; return;
} }
<<<<<<< HEAD try {
// Atualizar estado para mostrar que a instalação está começando await runInstaller(state.filePath);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
isInstalling: true, isInstalled: true,
installStatus: 'Iniciando instalação silenciosa...', }));
downloadError: null,
})); // Iniciar verificação automática em segundo plano
startAutoCheck();
// Chamar Tauri em background (não bloquear) } catch (error) {
runInstaller(state.filePath) setState(prev => ({
.then(() => { ...prev,
// Após iniciar a instalação, começar a verificação automática após um delay downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
setTimeout(() => { }));
startAutoCheck(); }
}, 2000);
})
=======
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
setState(prev => ({
...prev,
isInstalled: true, // Marcar como instalado imediatamente
}));
// Iniciar verificação automática em segundo plano imediatamente
startAutoCheck();
// Chamar Tauri em background (não bloquear)
runInstaller(state.filePath)
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
.catch((error) => {
setState(prev => ({
...prev,
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
<<<<<<< HEAD
isInstalling: false,
installStatus: null,
=======
isInstalled: false, // Reverter se falhar
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
}));
});
}; };
const checkOllama = async (): Promise<boolean> => { const checkOllama = async (): Promise<boolean> => {
try { try {
// Usar verificação robusta multi-camada (PATH, caminhos absolutos, HTTP) const installed = await invoke<boolean>('check_ollama_installed');
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command'); if (!installed) {
return isInstalledAndRunning;
} catch (error) {
console.error('Erro ao verificar Ollama:', error);
// 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; return false;
} }
const running = await invoke<boolean>('check_ollama_running');
return running;
} catch (error) {
console.error('Erro ao verificar Ollama:', error);
return false;
} }
}; };
@@ -376,7 +226,6 @@ export function useOllamaDownload() {
filePath: existingPath, filePath: existingPath,
downloadProgress: 100, downloadProgress: 100,
downloadStatus: 'Download já concluído', downloadStatus: 'Download já concluído',
status: 'completed',
})); }));
} }
}; };
@@ -392,13 +241,6 @@ export function useOllamaDownload() {
isInstalled: false, isInstalled: false,
isChecking: false, isChecking: false,
checkStatus: '', checkStatus: '',
installStatus: null,
isInstalling: false,
speed: '0 MB/s',
downloadedBytes: 0,
totalBytes: 0,
eta: 0,
status: 'downloading',
}); });
}; };
-8
View File
@@ -56,11 +56,3 @@ export function useOperatingSystem() {
+15 -9
View File
@@ -1,5 +1,4 @@
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.
@@ -41,16 +40,23 @@ 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 invoke<string>('generate_chat_completion', { const response = await fetch('http://localhost:11434/api/chat', {
model, method: 'POST',
messages: [ headers: { 'Content-Type': 'application/json' },
{ role: 'system', content: PROMPT_GEN_SYSTEM }, body: JSON.stringify({
{ role: 'user', content: userGoal } model,
], messages: [
options: undefined, { role: 'system', content: PROMPT_GEN_SYSTEM },
{ 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');
return response || ""; const data = await response.json();
return data.message?.content || "";
} catch (error) { } catch (error) {
console.error("Prompt generation failed:", error); console.error("Prompt generation failed:", error);
throw error; throw error;
+19 -8
View File
@@ -1,5 +1,4 @@
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
@@ -34,15 +33,27 @@ Pergunta do usuário: "${userInput}"
Query de busca (ou NO_SEARCH):`; Query de busca (ou NO_SEARCH):`;
const generatedText = await invoke<string>('generate_completion', { const response = await fetch('http://localhost:11434/api/generate', {
model, method: 'POST',
prompt, headers: { 'Content-Type': 'application/json' },
options: { body: JSON.stringify({
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas model,
num_predict: 50, // Limitar tokens para resposta rápida prompt,
}, 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
-130
View File
@@ -1,130 +0,0 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useOllamaCheck, type OllamaStatus } from './use-ollama-check';
export type SetupStatus = 'checking' | 'ready' | 'needs_setup' | 'needs_ollama';
export function useSetupCheck() {
const { status: ollamaStatus, check: checkOllama } = useOllamaCheck();
const [setupStatus, setSetupStatus] = useState<SetupStatus>('checking');
const [checkingStep, setCheckingStep] = useState<string>('Verificando Ollama...');
const [hasModels, setHasModels] = useState(false);
const [modelsCount, setModelsCount] = useState(0);
const checkModels = async (): Promise<boolean> => {
try {
setCheckingStep('Verificando modelos instalados...');
const models = await invoke<Array<{ name: string; size: string; id: string; modified_at: string }>>('list_local_models');
const count = models.length;
setModelsCount(count);
const hasAny = count > 0;
setHasModels(hasAny);
return hasAny;
} catch (error) {
console.error('Failed to check models:', error);
// Em caso de erro, assumir que não há modelos (seguro)
setHasModels(false);
setModelsCount(0);
return false;
}
};
const determineSetupStatus = async (ollamaStatus: OllamaStatus): Promise<SetupStatus> => {
// Se Ollama não está instalado, precisa instalar
if (ollamaStatus === 'not_installed') {
return 'needs_ollama';
}
// Se Ollama está instalado mas parado, tentar iniciar primeiro
if (ollamaStatus === 'installed_stopped') {
try {
setCheckingStep('Iniciando Ollama...');
await invoke('auto_start_ollama');
// Aguardar um pouco para o servidor iniciar
await new Promise(resolve => setTimeout(resolve, 2000));
<<<<<<< HEAD
// Verificar integridade completa (instalação + serviço rodando)
try {
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
if (!isInstalledAndRunning) {
// Ainda não está rodando ou instalação está corrompida
return 'needs_setup';
}
} catch (verifyError) {
// Fallback para verificação simples se o comando robusto falhar
console.warn('verify_ollama_integrity failed, using fallback:', verifyError);
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
return 'needs_setup';
}
}
=======
// Verificar novamente se está rodando
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
// Ainda não está rodando, precisa de intervenção do usuário
return 'needs_setup';
}
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
// Agora está rodando, verificar modelos
const hasAnyModels = await checkModels();
return hasAnyModels ? 'ready' : 'needs_setup';
} catch (error) {
console.error('Failed to auto-start Ollama:', error);
return 'needs_setup';
}
}
// Se Ollama está rodando, verificar modelos
if (ollamaStatus === 'running') {
const hasAnyModels = await checkModels();
return hasAnyModels ? 'ready' : 'needs_setup';
}
// Estado checking ou qualquer outro caso
return 'checking';
};
useEffect(() => {
const performCheck = async () => {
if (ollamaStatus === 'checking') {
setSetupStatus('checking');
setCheckingStep('Verificando Ollama...');
return;
}
const status = await determineSetupStatus(ollamaStatus);
setSetupStatus(status);
if (status === 'ready') {
setCheckingStep('Tudo pronto!');
} else if (status === 'needs_setup') {
setCheckingStep('Configuração necessária');
} else if (status === 'needs_ollama') {
setCheckingStep('Ollama não instalado');
}
};
performCheck();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ollamaStatus]);
const recheck = async () => {
setSetupStatus('checking');
setCheckingStep('Verificando Ollama...');
await checkOllama();
// O useEffect vai processar o novo status
};
return {
status: setupStatus,
ollamaStatus,
hasModels,
modelsCount,
checkingStep,
recheck,
};
}
-366
View File
@@ -1,366 +0,0 @@
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,
};
}
-56
View File
@@ -1,56 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useToast } from '@/hooks/use-toast';
export type HealthStatus = 'healthy' | 'warning' | 'critical';
export interface SystemHealth {
status: HealthStatus;
ram_percent: number;
cpu_percent: number;
message: string;
}
export function useSystemHealth() {
const [health, setHealth] = useState<SystemHealth>({
status: 'healthy',
ram_percent: 0,
cpu_percent: 0,
message: 'Sistema saudável',
});
const [hasShownCriticalToast, setHasShownCriticalToast] = useState(false);
const { toast } = useToast();
useEffect(() => {
const unlistenPromise = listen<SystemHealth>('system-state-change', (event) => {
const newHealth = event.payload;
setHealth(newHealth);
// Mostrar toast apenas quando entrar em modo Critical (uma vez)
if (newHealth.status === 'critical' && !hasShownCriticalToast) {
toast({
title: 'Modo de Economia Ativado',
description: 'Recursos limitados para manter estabilidade. Algumas funcionalidades podem estar temporariamente indisponíveis.',
variant: 'destructive',
<<<<<<< HEAD
=======
duration: 5000,
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
});
setHasShownCriticalToast(true);
} else if (newHealth.status !== 'critical') {
// Reset flag quando sair do modo crítico
setHasShownCriticalToast(false);
}
});
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, [hasShownCriticalToast, toast]);
return health;
}
-161
View File
@@ -1,161 +0,0 @@
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,
};
}
+3 -42
View File
@@ -1,10 +1,8 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback } 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' | 'cancelled'; export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
export interface WebSearchState { export interface WebSearchState {
isEnabled: boolean; isEnabled: boolean;
@@ -12,8 +10,6 @@ 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 = {
@@ -22,16 +18,12 @@ 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') {
@@ -44,15 +36,6 @@ 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
*/ */
@@ -192,7 +175,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 para usar fallback de modelo local):', errorMessage); console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
@@ -202,7 +185,6 @@ 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 [];
} }
}, []); }, []);
@@ -236,22 +218,6 @@ 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
*/ */
@@ -269,19 +235,14 @@ 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
}; };
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

+21 -11
View File
@@ -108,16 +108,27 @@ 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 { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const rawResponse = await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt, body: JSON.stringify({
options: { model,
temperature: 0.3, prompt,
num_predict: 600, stream: false,
}, 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, '')
@@ -145,7 +156,7 @@ export async function extractKeyFacts(
entities: f.entities || [], entities: f.entities || [],
})); }));
} catch (error) { } catch (error) {
chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}: ${error instanceof Error ? error.message : String(error)}`); chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}:`, error);
// Fallback: criar fato básico do título // Fallback: criar fato básico do título
return [{ return [{
fact: entry.title, fact: entry.title,
@@ -262,7 +273,7 @@ export async function unifyContext(
// Criar texto unificado // Criar texto unificado
const unifiedSections: string[] = []; const unifiedSections: string[] = [];
const sourceMap = new Map<string, { id: string; title: string; url: string; factsCount: number }>(); const sourceMap = new Map<string, { title: string; url: string; factsCount: number }>();
// Agrupar por fonte // Agrupar por fonte
for (const entry of entries) { for (const entry of entries) {
@@ -270,7 +281,6 @@ export async function unifyContext(
if (entryFacts.length > 0) { if (entryFacts.length > 0) {
sourceMap.set(entry.id, { sourceMap.set(entry.id, {
id: entry.id,
title: entry.title, title: entry.title,
url: entry.sourceUrl, url: entry.sourceUrl,
factsCount: entryFacts.length, factsCount: entryFacts.length,
+22 -54
View File
@@ -154,17 +154,27 @@ export async function analyzeQueryContext(
try { try {
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query); const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
const { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const rawResponse = await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt, body: JSON.stringify({
options: { model,
temperature: 0.3, prompt,
num_predict: 800, stream: false,
format: 'json', options: {
}, 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, '')
@@ -178,7 +188,7 @@ export async function analyzeQueryContext(
jsonText = jsonMatch[0]; jsonText = jsonMatch[0];
} }
const analysis = tryParseJsonWithRepair(jsonText) as Partial<QueryContext>; const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
// Validar e preencher campos obrigatórios // Validar e preencher campos obrigatórios
const context: QueryContext = { const context: QueryContext = {
@@ -196,11 +206,11 @@ export async function analyzeQueryContext(
complexity: analysis.complexity || 'moderate', complexity: analysis.complexity || 'moderate',
}; };
chatLog.info(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`); chatLog.success(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`);
return context; return context;
} catch (error) { } catch (error) {
chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto: ${error instanceof Error ? error.message : String(error)}`); chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto:`, error);
// Fallback: análise básica usando heurísticas // Fallback: análise básica usando heurísticas
return createFallbackContext(query); return createFallbackContext(query);
@@ -264,45 +274,3 @@ function createFallbackContext(query: string): QueryContext {
}; };
} }
function repairJson(text: string): string {
let s = text.trim();
s = s.replace(/,\s*([}\]])/g, '$1');
let inString = false;
let escape = false;
let braceCount = 0;
let bracketCount = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (escape) { escape = false; continue; }
if (c === '\\') { escape = true; continue; }
if (c === '"') { inString = !inString; continue; }
if (!inString) {
if (c === '{') braceCount++;
else if (c === '}') braceCount--;
else if (c === '[') bracketCount++;
else if (c === ']') bracketCount--;
}
}
if (braceCount > 0) s += '}'.repeat(braceCount);
if (bracketCount > 0) s += ']'.repeat(bracketCount);
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
if (lastClose > 0) {
const after = s.slice(lastClose + 1);
if (/\S/.test(after)) {
s = s.slice(0, lastClose + 1);
}
}
return s;
}
function tryParseJsonWithRepair(text: string): unknown {
try { return JSON.parse(text); } catch {}
const repaired = repairJson(text);
try { return JSON.parse(repaired); } catch {}
const lastClose = Math.max(repaired.lastIndexOf('}'), repaired.lastIndexOf(']'));
if (lastClose > 0) {
const truncated = repaired.slice(0, lastClose + 1);
return JSON.parse(truncated);
}
throw new Error('Invalid JSON');
}
-626
View File
@@ -1,626 +0,0 @@
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;
}
+4 -8
View File
@@ -53,15 +53,14 @@ 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, expectedSha256?: string): Promise<string> { export async function downloadInstaller(os: OS): 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) {
@@ -88,12 +87,9 @@ export async function getDownloadedInstallerPath(os: OS): Promise<string | null>
/** /**
* Executa o instalador baixado * Executa o instalador baixado
*/ */
export async function runInstaller(filePath: string, expectedSha256?: string): Promise<void> { export async function runInstaller(filePath: string): Promise<void> {
try { try {
await invoke('run_installer', { await invoke('run_installer', { filePath });
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}`);
} }
+2 -79
View File
@@ -3,7 +3,6 @@ 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;
@@ -25,13 +24,12 @@ export interface CondensationOptions {
context?: QueryContext; // Contexto analisado para ranqueamento semântico context?: QueryContext; // Contexto analisado para ranqueamento semântico
} }
const DEFAULT_OPTIONS: Omit<Required<CondensationOptions>, 'context'> & { context?: QueryContext } = { const DEFAULT_OPTIONS: Required<CondensationOptions> = {
maxTokens: 12000, maxTokens: 12000,
minRelevanceScore: 0.1, minRelevanceScore: 0.1,
autoSummarize: true, autoSummarize: true,
summarizeThreshold: 5000, summarizeThreshold: 5000,
fallbackToSummarization: true, fallbackToSummarization: true,
context: undefined,
}; };
/** /**
@@ -80,21 +78,9 @@ 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,
@@ -110,9 +96,6 @@ 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[] = [];
@@ -122,17 +105,10 @@ 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,
autoSummarize: opts.autoSummarize,
}); });
// Atribuir sourceIndex e metadados aos chunks // Atribuir sourceIndex e metadados aos chunks
@@ -143,19 +119,6 @@ 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,
@@ -164,18 +127,11 @@ 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,
@@ -183,19 +139,6 @@ 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';
@@ -253,26 +196,6 @@ 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(),
+8 -4
View File
@@ -29,12 +29,16 @@ 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 { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/show', {
const data = await invoke<any>('get_ollama_model_info', { method: 'POST',
modelName, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: modelName,
}),
}); });
if (data) { if (response.ok) {
const data = await response.json();
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto // Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
// Mapear tamanhos conhecidos de modelos // Mapear tamanhos conhecidos de modelos
-142
View File
@@ -1,142 +0,0 @@
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))
);
}
+21 -10
View File
@@ -118,16 +118,27 @@ 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 { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const rawResponse = await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt, body: JSON.stringify({
options: { model,
temperature: 0.5, // Temperatura média para criatividade controlada prompt,
num_predict: 1000, // Espaço suficiente para múltiplas queries stream: false,
}, 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, '')
@@ -165,7 +176,7 @@ export async function enrichQueries(
result.expanded.length + result.expanded.length +
result.contextual.length; result.contextual.length;
chatLog.info( chatLog.success(
`[QueryEnricher] ${totalQueries} queries geradas: ` + `[QueryEnricher] ${totalQueries} queries geradas: ` +
`${result.literal.length} literal, ${result.semantic.length} semântica, ` + `${result.literal.length} literal, ${result.semantic.length} semântica, ` +
`${result.related.length} relacionadas, ${result.expanded.length} expandidas, ` + `${result.related.length} relacionadas, ${result.expanded.length} expandidas, ` +
@@ -174,7 +185,7 @@ export async function enrichQueries(
return result; return result;
} catch (error) { } catch (error) {
chatLog.error(`[QueryEnricher] Erro ao enriquecer queries: ${error instanceof Error ? error.message : String(error)}`); chatLog.error(`[QueryEnricher] Erro ao enriquecer queries:`, error);
// Fallback: usar apenas query original // Fallback: usar apenas query original
return createFallbackEnrichment(originalQuery, context); return createFallbackEnrichment(originalQuery, context);
-8
View File
@@ -211,11 +211,3 @@ export function formatSearchLog(log: SearchLog): string {
return lines.join('\n'); return lines.join('\n');
} }
-8
View File
@@ -38,11 +38,3 @@ export const deepResearchLog = {
-31
View File
@@ -13,34 +13,3 @@ 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 -43
View File
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: FallbackConfig = {
maxRounds: 4, maxRounds: 4,
maxResultsPerRound: 10, maxResultsPerRound: 10,
maxTotalResults: 40, maxTotalResults: 40,
minRelevanceScore: 0.1, minRelevanceScore: 0.3,
enableQueryExpansion: true, enableQueryExpansion: true,
initialTimeout: TIMEOUT_CONFIG.initialTimeout, initialTimeout: TIMEOUT_CONFIG.initialTimeout,
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery, maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
@@ -100,35 +100,39 @@ Instruções:
Query expandida:`; Query expandida:`;
const { invoke } = await import('@tauri-apps/api/core'); const response = await fetch('http://localhost:11434/api/generate', {
const expanded = (await invoke<string>('generate_completion', { method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
prompt: expansionPrompt, body: JSON.stringify({
options: { model,
temperature: 0.5, prompt: expansionPrompt,
num_predict: 100, stream: false,
}, options: {
})) temperature: 0.5,
.replace(/^["']|["']$/g, '') num_predict: 100,
.replace(/\.$/, '') },
.trim(); }),
});
const sanitized = expanded if (!response.ok) {
.replace(/<think>[\s\S]*?<\/think>/gi, '') chatLog.warn('Failed to expand query, using original');
.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;
} }
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`); const data = await response.json();
return sanitized; const expanded = (data.response || '').trim()
.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);
return originalQuery; return originalQuery;
} }
} }
@@ -151,7 +155,7 @@ export function calculateRelevanceScoreForFallback(
// Contar matches de palavras-chave // Contar matches de palavras-chave
let matches = 0; let matches = 0;
const totalWords = queryWords.length; let totalWords = queryWords.length;
for (const word of queryWords) { for (const word of queryWords) {
if (combinedText.includes(word)) { if (combinedText.includes(word)) {
@@ -279,7 +283,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;
const allResults: ScrapedContent[] = []; let 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;
@@ -333,8 +337,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();
const roundResults: ScrapedContent[] = []; let roundResults: ScrapedContent[] = [];
const roundQueries: string[] = []; let 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) => {
@@ -347,13 +351,8 @@ 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);
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return { query: finalQuery, results: [] }; return { query: finalQuery, results: [] };
} }
}); });
@@ -368,7 +367,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 (_err) { } catch (error) {
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 =>
@@ -407,15 +406,13 @@ 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) => {
if (semanticSimilarity && finalConfig.context) { // Usar contexto para cálculo de relevância se disponível
return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title); if (finalConfig.context) {
// Importar função dinamicamente para evitar circular dependency
const { calculateSemanticSimilarity } = require('./relevance-scorer');
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
} }
return sum + calculateRelevanceScoreForFallback(r, originalQuery); return sum + calculateRelevanceScoreForFallback(r, originalQuery);
}, 0) / roundResults.length }, 0) / roundResults.length
@@ -482,3 +479,4 @@ export async function executeProgressiveSearch(
usedFallback: true, usedFallback: true,
}; };
} }
+1 -45
View File
@@ -5,60 +5,16 @@ 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
config.module.rules.push({ config.module.rules.push({
test: /\.md$/, test: /\.md$/,
use: { use: 'raw-loader',
loader: 'raw-loader',
options: {
esModule: false,
},
},
}); });
// 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
// Turbopack ainda não suporta raw-loader, então usaremos webpack para builds // Turbopack ainda não suporta raw-loader, então usaremos webpack para builds
turbopack: {}, turbopack: {},
// Aumenta timeout para builds grandes
staticPageGenerationTimeout: 300,
}; };
export default nextConfig; export default nextConfig;
+3 -8
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build --webpack", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"generate-changelog": "node scripts/generate-changelog.js", "generate-changelog": "node scripts/generate-changelog.js",
@@ -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.1.1", "next": "16.0.3",
"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,15 +51,10 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.0.3",
"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"
}
} }
} }
+55 -59
View File
@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
mdast-util-to-hast: '>=13.2.1'
importers: importers:
.: .:
@@ -75,8 +72,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.1.1 specifier: 16.0.3
version: 16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected]) version: 16.0.3(@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])
@@ -127,8 +124,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.1.1 specifier: 16.0.3
version: 16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]) version: 16.0.3(@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])
@@ -456,56 +453,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.1.1': '@next/env@16.0.3':
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
'@next/eslint-plugin-next@16.1.1': '@next/eslint-plugin-next@16.0.3':
resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==} resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
'@next/swc-darwin-arm64@16.1.1': '@next/swc-darwin-arm64@16.0.3':
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==} resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.1.1': '@next/swc-darwin-x64@16.0.3':
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==} resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.1.1': '@next/swc-linux-arm64-gnu@16.0.3':
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==} resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-arm64-musl@16.1.1': '@next/swc-linux-arm64-musl@16.0.3':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@next/swc-linux-x64-gnu@16.1.1': '@next/swc-linux-x64-gnu@16.0.3':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-linux-x64-musl@16.1.1': '@next/swc-linux-x64-musl@16.0.3':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@next/swc-win32-arm64-msvc@16.1.1': '@next/swc-win32-arm64-msvc@16.0.3':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.1.1': '@next/swc-win32-x64-msvc@16.0.3':
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==} resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -1773,8 +1770,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.1.1: eslint-config-next@16.0.3:
resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==} resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
peerDependencies: peerDependencies:
eslint: '>=9.0.0' eslint: '>=9.0.0'
typescript: '>=3.3.1' typescript: '>=3.3.1'
@@ -2470,8 +2467,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
[email protected].1: [email protected].0:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@@ -2623,8 +2620,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.1.1: next@16.0.3:
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==} resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3602,34 +3599,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1 '@tybys/wasm-util': 0.10.1
optional: true optional: true
'@next/env@16.1.1': {} '@next/env@16.0.3': {}
'@next/eslint-plugin-next@16.1.1': '@next/eslint-plugin-next@16.0.3':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.1.1': '@next/swc-darwin-arm64@16.0.3':
optional: true optional: true
'@next/swc-darwin-x64@16.1.1': '@next/swc-darwin-x64@16.0.3':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.1.1': '@next/swc-linux-arm64-gnu@16.0.3':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.1.1': '@next/swc-linux-arm64-musl@16.0.3':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.1.1': '@next/swc-linux-x64-gnu@16.0.3':
optional: true optional: true
'@next/swc-linux-x64-musl@16.1.1': '@next/swc-linux-x64-musl@16.0.3':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.1.1': '@next/swc-win32-arm64-msvc@16.0.3':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.1.1': '@next/swc-win32-x64-msvc@16.0.3':
optional: true optional: true
'@nodelib/[email protected]': '@nodelib/[email protected]':
@@ -4951,9 +4948,9 @@ snapshots:
[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]): eslint-config-next@16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/eslint-plugin-next': 16.1.1 '@next/eslint-plugin-next': 16.0.3
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]))
@@ -5806,7 +5803,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].1: [email protected].0:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
@@ -6071,25 +6068,24 @@ 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.1.1(@babel/[email protected])([email protected]([email protected]))([email protected]): next@16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.1.1 '@next/env': 16.0.3
'@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.1.1 '@next/swc-darwin-arm64': 16.0.3
'@next/swc-darwin-x64': 16.1.1 '@next/swc-darwin-x64': 16.0.3
'@next/swc-linux-arm64-gnu': 16.1.1 '@next/swc-linux-arm64-gnu': 16.0.3
'@next/swc-linux-arm64-musl': 16.1.1 '@next/swc-linux-arm64-musl': 16.0.3
'@next/swc-linux-x64-gnu': 16.1.1 '@next/swc-linux-x64-gnu': 16.0.3
'@next/swc-linux-x64-musl': 16.1.1 '@next/swc-linux-x64-musl': 16.0.3
'@next/swc-win32-arm64-msvc': 16.1.1 '@next/swc-win32-arm64-msvc': 16.0.3
'@next/swc-win32-x64-msvc': 16.1.1 '@next/swc-win32-x64-msvc': 16.0.3
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -6243,7 +6239,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.1 mdast-util-to-hast: 13.2.0
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
@@ -6348,7 +6344,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.1 mdast-util-to-hast: 13.2.0
unified: 11.0.5 unified: 11.0.5
vfile: 6.0.3 vfile: 6.0.3
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

-67
View File
@@ -1,67 +0,0 @@
# OllaHub Recommended SearXNG Configuration (2025)
# Optimized for resilience against 403 Forbidden and Rate Limiting
# General Settings
use_default_settings: true
general:
debug: false
instance_name: "OllaHub Search"
# Search Engine Settings
search:
safe_search: 0
autocomplete: ""
# CRITICAL: Adjust ban times to avoid long lockouts
ban_time_on_fail: 5
max_ban_time_on_fail: 120
suspended_times:
SearxEngineAccessDenied: 0 # Disable suspension on 403 (Access Denied)
SearxEngineCaptcha: 0 # Disable suspension on Captcha
SearxEngineTooManyRequests: 3600 # Keep suspension for 429
# Rate Limiter (Disable for self-hosted single user)
limiter:
enabled: false
# Server Configuration
server:
port: 8080
bind_address: "0.0.0.0"
secret_key: "ollahub-secret-key-change-me" # Change this!
# Proxy configuration (Crucial for proper IP forwarding)
method: "GET"
image_proxy: true
# Engine Configuration
engines:
- name: google
engine: google
shortcut: g
disabled: false
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
disabled: false
- name: bing
engine: bing
shortcut: b
disabled: false
- name: wikipedia
engine: wikipedia
shortcut: w
disabled: false
# Outgoing Requests
outgoing:
# Request timeout
request_timeout: 10.0
# Max request timeout
max_request_timeout: 20.0
# User Agent (SearXNG default rotates, but we can enforce generic if needed)
# useragent_suffix: ""
# Proxy Support (Optional - Uncomment if using proxies)
# proxies:
# all:
# - http://user:pass@proxy_ip:port
# - http://user:pass@proxy_ip2:port
+191
View File
@@ -0,0 +1,191 @@
/**
* 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));
}
}
+63 -103
View File
@@ -4,9 +4,11 @@ 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 {
@@ -14,7 +16,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 {
@@ -56,6 +58,7 @@ 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() {
@@ -197,9 +200,8 @@ 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: Record<string, unknown> | undefined = undefined; let rustConfig: any = 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,
@@ -211,10 +213,6 @@ 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,
},
}; };
} }
@@ -224,7 +222,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);
@@ -257,44 +255,51 @@ 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;
let rustConfig: Record<string, unknown> | undefined = undefined; // Priorizar motores usando circuit breaker
if (searchConfig) { engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
const settings = useSettingsStore.getState();
rustConfig = { // Filtrar motores não disponíveis (circuit breaker aberto)
max_concurrent_tabs: searchConfig.maxConcurrentTabs, const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder);
total_sources_limit: searchConfig.totalSourcesLimit,
categories: searchConfig.categories.map(cat => ({ if (availableEngines.length === 0) {
id: cat.id, console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`);
name: cat.name, // Se todos estão abertos, tentar mesmo assim (pode ser temporário)
base_sites: cat.baseSites, engineOrder = engineOrder;
enabled: cat.enabled, } else {
})), engineOrder = availableEngines;
user_custom_sites: searchConfig.userCustomSites, if (availableEngines.length < engineOrder.length) {
excluded_domains: searchConfig.excludedDomains, console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`);
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 detalhado da tentativa // Log da tentativa
const searchStartTime = Date.now(); console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
console.log(`\n[WebSearch] ========== METADATA SEARCH START ==========`); console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
console.log(`[WebSearch] Query: "${query}"`); console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`);
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;
@@ -310,7 +315,7 @@ class WebSearchService {
query: query.trim(), query: query.trim(),
limit, limit,
search_config: rustConfig, search_config: rustConfig,
engine_order: undefined, // Não usado mais, mantido para compatibilidade engineOrder: engineOrder, // Passar ordem de motores
}); });
const result = await withTimeout( const result = await withTimeout(
@@ -318,25 +323,16 @@ 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`);
// Log detalhado dos resultados // Registrar sucesso no circuit breaker para cada motor usado
console.log(`\n[WebSearch] ========== METADATA SEARCH RESULTS ==========`); // (assumindo que o primeiro motor disponível foi usado)
console.log(`[WebSearch] Duration: ${duration}ms`); if (engineOrder.length > 0) {
console.log(`[WebSearch] Results count: ${result?.length ?? 0}`); const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem
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) {
@@ -344,6 +340,12 @@ 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;
} }
}, },
@@ -358,7 +360,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}`); console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`);
} }
return results; return results;
} }
@@ -382,8 +384,9 @@ 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 // Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores
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
@@ -392,6 +395,7 @@ 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 [];
} }
} }
@@ -497,13 +501,6 @@ 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,
@@ -516,14 +513,7 @@ 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: [] };
} }
@@ -531,8 +521,6 @@ 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(
@@ -540,34 +528,6 @@ 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)
+317 -490
View File
File diff suppressed because it is too large. Load diff
+3 -35
View File
@@ -16,16 +16,6 @@ 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"
@@ -40,7 +30,10 @@ 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"
@@ -55,11 +48,6 @@ 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
@@ -67,23 +55,3 @@ base64 = "0.21"
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
+1 -444
View File
@@ -1,446 +1,3 @@
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() {
// Criar estrutura de diretórios para bundle tauri_build::build()
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.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more