Compare commits
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)
|
|
||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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/
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[Quick start](#quick-start) · [Docs](docs/) · [Issues](https://git.evandro.dev.br/evandro/OllaHub/issues)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
## What it is
|
||||||
|
|
||||||
## 🌟 Why OllaHub?
|
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.
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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.
|
## Why use it
|
||||||
|
|
||||||
---
|
- **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.
|
||||||
|
|
||||||
## ✨ Key Features
|
**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.
|
||||||
|
|
||||||
### 🔍 Deep Research Pipeline
|
## How it works
|
||||||
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
|
- **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.
|
||||||
* **Ollama Integration:** Direct subprocess management for seamless local model execution.
|
- **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`.
|
||||||
* **Local Semantic Search:** Powered by ONNX Runtime (`all-MiniLM-L6-v2` 384-dimensional embeddings) with no external API calls.
|
- **Models:** list, delete and pull with progress, install a local `.gguf` file, and get a recommendation for your hardware.
|
||||||
* **Hybrid Storage:** Blazing fast SQLite (FTS5 for full-text search) with JSON fallbacks for backward compatibility and data safety.
|
- **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.
|
||||||
|
- **Updates:** the Tauri updater checks automatically. Its endpoint in `src-tauri/tauri.conf.json` points to a GitHub Releases `latest.json`.
|
||||||
|
|
||||||
### 🔌 MCP (Model Context Protocol) Integration
|
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).
|
||||||
Extensible by design. Connect external tools via MCP servers:
|
|
||||||
* **Tool Discovery:** Automatic enumeration of available tools from running servers.
|
|
||||||
* **Process Management:** Full lifecycle control (start, stop, restart) with health monitoring.
|
|
||||||
* **Tool Execution:** Runtime invocation of tools with direct result injection into the LLM context.
|
|
||||||
|
|
||||||
### ⚡ Reactive & Customizable UI
|
## Quick start
|
||||||
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).
|
|
||||||
|
|
||||||
---
|
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.
|
||||||
|
|
||||||
## 🏗️ System Architecture
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
```
|
|
||||||
@@ -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,46 +472,21 @@ 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 ==========`);
|
|
||||||
chatLog.info(`[WebResearch] Duration: ${queryDuration}ms`);
|
|
||||||
chatLog.info(`[WebResearch] Success: ${fallbackResult.success}`);
|
|
||||||
chatLog.info(`[WebResearch] Total results: ${fallbackResult.scrapedSources.length}`);
|
|
||||||
chatLog.info(`[WebResearch] Total analyzed: ${fallbackResult.totalResultsAnalyzed}`);
|
|
||||||
chatLog.info(`[WebResearch] Attempts: ${fallbackResult.attempts.length}`);
|
|
||||||
chatLog.info(`[WebResearch] Used fallback: ${fallbackResult.usedFallback}`);
|
|
||||||
|
|
||||||
// Log detalhado de cada tentativa
|
|
||||||
fallbackResult.attempts.forEach((attempt, attemptIdx) => {
|
|
||||||
chatLog.info(`\n Attempt ${attemptIdx + 1}:`);
|
|
||||||
chatLog.info(` Round: ${attempt.round}`);
|
|
||||||
chatLog.info(` Strategy: ${attempt.strategy || 'N/A'}`);
|
|
||||||
chatLog.info(` Query: "${attempt.query}"`);
|
|
||||||
chatLog.info(` Results: ${attempt.results.length}`);
|
|
||||||
chatLog.info(` Relevance score: ${attempt.relevanceScore.toFixed(3)}`);
|
|
||||||
chatLog.info(` Duration: ${attempt.duration}ms`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log detalhado de cada resultado extraído
|
|
||||||
if (fallbackResult.scrapedSources.length > 0) {
|
|
||||||
chatLog.info(`\n[WebResearch] Extracted Sources:`);
|
|
||||||
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
fallbackResult.scrapedSources.forEach((result, resultIdx) => {
|
||||||
chatLog.info(`\n [${resultIdx + 1}] ${result.title}`);
|
chatLog.info(` → Result ${resultIdx + 1}: ${result.title}`);
|
||||||
chatLog.info(` URL: ${result.url}`);
|
chatLog.info(` URL: ${result.url}`);
|
||||||
const contentLength = result.markdown?.length || result.content?.length || 0;
|
const contentLength = result.markdown?.length || 0;
|
||||||
const snippetLength = result.snippet?.length || 0;
|
chatLog.info(` Content length: ${contentLength} chars`);
|
||||||
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) {
|
||||||
@@ -613,8 +532,7 @@ export default function ChatPage() {
|
|||||||
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
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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 {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 136 KiB |
@@ -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}
|
{children}
|
||||||
<AppUpdaterProvider />
|
<AppUpdaterProvider />
|
||||||
<GlobalDownloadStatusBar />
|
|
||||||
<ServiceInterruptionOverlay />
|
|
||||||
</DownloadProvider>
|
|
||||||
</OllamaHealthProvider>
|
|
||||||
=======
|
|
||||||
<KeyboardShortcuts />
|
|
||||||
{children}
|
|
||||||
<AppUpdaterProvider />
|
|
||||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
// Tentar iniciar Ollama automaticamente quando o componente montar
|
||||||
const hasCheckedRef = useRef(false);
|
|
||||||
const isRedirectingRef = useRef(false);
|
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// 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");
|
|
||||||
}
|
}
|
||||||
|
}, [status, check]);
|
||||||
// Redirecionar para chat imediatamente (startTransition já garante não-bloqueio)
|
|
||||||
startTransition(() => {
|
|
||||||
router.push("/chat");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [status, router]);
|
|
||||||
|
|
||||||
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" />
|
||||||
<div className="flex flex-col items-center gap-1">
|
<span className="ml-2 text-muted-foreground">Verificando sistema...</span>
|
||||||
<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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -23,22 +22,15 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useSettingsStore } from '@/store/settings-store';
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
|
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
|
||||||
import { SearxngOnboarding } from '@/components/settings/searxng-onboarding';
|
import { HardwareDashboard } from '@/components/settings/HardwareDashboard';
|
||||||
import { SearxngInstallWizard } from '@/components/settings/searxng-install-wizard';
|
|
||||||
import { DockerSettingsPanel } from '@/components/settings/docker-settings-panel';
|
|
||||||
const HardwareDashboard = lazy(() => import('@/components/settings/HardwareDashboard').then(m => ({ default: m.HardwareDashboard })));
|
|
||||||
import { RuntimeSettingsPanel } from '@/components/settings/runtime-settings-panel';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
|
||||||
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw, Settings, Square } from 'lucide-react';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
||||||
import { TitleBar } from '@/components/titlebar';
|
import { TitleBar } from '@/components/titlebar';
|
||||||
import { useAppUpdater } from '@/hooks/use-app-updater';
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { useAppUpdater } from '@/hooks/use-app-updater';
|
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
|
||||||
|
|
||||||
interface Model {
|
interface Model {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -51,8 +43,6 @@ export default function SettingsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
|
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
|
||||||
const [showSearxngOnboarding, setShowSearxngOnboarding] = useState(false);
|
|
||||||
const [showSearxngWizard, setShowSearxngWizard] = useState(false);
|
|
||||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
const [availableModels, setAvailableModels] = useState<Model[]>([]);
|
||||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||||
@@ -65,11 +55,6 @@ export default function SettingsPage() {
|
|||||||
const [recentLogs, setRecentLogs] = useState<string[]>([]);
|
const [recentLogs, setRecentLogs] = useState<string[]>([]);
|
||||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||||
const [appVersion, setAppVersion] = useState<string>('');
|
const [appVersion, setAppVersion] = useState<string>('');
|
||||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
|
||||||
|
|
||||||
const updater = useAppUpdater();
|
|
||||||
const [appVersion, setAppVersion] = useState<string>('');
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
|
||||||
|
|
||||||
const updater = useAppUpdater();
|
const updater = useAppUpdater();
|
||||||
|
|
||||||
@@ -83,11 +68,6 @@ export default function SettingsPage() {
|
|||||||
getVersion()
|
getVersion()
|
||||||
.then(setAppVersion)
|
.then(setAppVersion)
|
||||||
.catch(() => setAppVersion('0.1.0'));
|
.catch(() => setAppVersion('0.1.0'));
|
||||||
|
|
||||||
// Load app version
|
|
||||||
getVersion()
|
|
||||||
.then(setAppVersion)
|
|
||||||
.catch(() => setAppVersion('0.1.0'));
|
|
||||||
}, [settings.ollamaUrl]);
|
}, [settings.ollamaUrl]);
|
||||||
|
|
||||||
// Load logs periodically
|
// Load logs periodically
|
||||||
@@ -105,56 +85,20 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadLogs();
|
loadLogs();
|
||||||
const logsInterval = setInterval(loadLogs, 5000); // Refresh logs every 5 seconds
|
const interval = setInterval(loadLogs, 5000); // Refresh every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
// Periodic model check (fallback)
|
|
||||||
const modelsInterval = setInterval(loadModels, 30000); // Refresh models every 30 seconds
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(logsInterval);
|
|
||||||
clearInterval(modelsInterval);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Listen for model download completion
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: (() => void) | undefined;
|
|
||||||
|
|
||||||
const setupListener = async () => {
|
|
||||||
unlisten = await listen<string>('download-progress', (event) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(event.payload);
|
|
||||||
if (payload.status === 'success') {
|
|
||||||
loadModels();
|
|
||||||
toast({
|
|
||||||
title: 'Novo modelo detectado',
|
|
||||||
description: 'A lista de modelos foi atualizada.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing download progress:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setupListener();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (unlisten) unlisten();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadModels = async () => {
|
const loadModels = async () => {
|
||||||
setIsLoadingModels(true);
|
setIsLoadingModels(true);
|
||||||
try {
|
try {
|
||||||
const models = await invoke<Array<{ name: string; size?: number; modified_at?: string; digest?: string }>>('get_ollama_tags');
|
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
|
||||||
// Mapear para o formato esperado pela interface Model
|
if (response.ok) {
|
||||||
const mappedModels: Model[] = (models || []).map(model => ({
|
const data = await response.json();
|
||||||
name: model.name,
|
setAvailableModels(data.models || []);
|
||||||
size: model.size ?? 0,
|
} else {
|
||||||
modified_at: model.modified_at ?? '',
|
setAvailableModels([]);
|
||||||
}));
|
}
|
||||||
setAvailableModels(mappedModels);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load models:', error);
|
console.error('Failed to load models:', error);
|
||||||
setAvailableModels([]);
|
setAvailableModels([]);
|
||||||
@@ -178,8 +122,11 @@ export default function SettingsPage() {
|
|||||||
setConnectionStatus('idle');
|
setConnectionStatus('idle');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar conexão tentando listar modelos
|
const response = await fetch(`${settings.ollamaUrl}/api/tags`, {
|
||||||
await invoke('get_ollama_tags');
|
method: 'HEAD',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
setConnectionStatus('success');
|
setConnectionStatus('success');
|
||||||
toast({
|
toast({
|
||||||
title: 'Conexão bem-sucedida',
|
title: 'Conexão bem-sucedida',
|
||||||
@@ -187,6 +134,14 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
// Reload models after successful connection
|
// Reload models after successful connection
|
||||||
loadModels();
|
loadModels();
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
toast({
|
||||||
|
title: 'Erro de conexão',
|
||||||
|
description: `Status: ${response.status}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setConnectionStatus('error');
|
setConnectionStatus('error');
|
||||||
toast({
|
toast({
|
||||||
@@ -275,7 +230,7 @@ export default function SettingsPage() {
|
|||||||
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 py-4 sm:py-6 md:py-8 px-3 sm:px-4 md:px-6 max-w-4xl w-full overflow-x-hidden">
|
<div className="container mx-auto py-4 sm:py-6 md:py-8 px-3 sm:px-4 md:px-6 max-w-4xl 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">
|
||||||
@@ -297,8 +252,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" value={activeTab} onValueChange={setActiveTab} className="space-y-4 sm:space-y-6">
|
<Tabs defaultValue="overview" className="space-y-4 sm:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-7 gap-1 sm:gap-2 h-auto">
|
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-1 sm:gap-2 h-auto">
|
||||||
<TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
<TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
Visão Geral
|
Visão Geral
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -311,9 +266,6 @@ export default function SettingsPage() {
|
|||||||
<TabsTrigger value="sources" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
<TabsTrigger value="sources" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
Sources
|
Sources
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="searxng" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
|
||||||
SearXNG
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="tasks" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
<TabsTrigger value="tasks" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
Tasks
|
Tasks
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -332,62 +284,13 @@ export default function SettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<HardwareDashboard />
|
<HardwareDashboard />
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<HardwareDashboard />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab: General */}
|
{/* Tab: General */}
|
||||||
<TabsContent value="general" className="space-y-6">
|
<TabsContent value="general" className="space-y-6">
|
||||||
<RuntimeSettingsPanel />
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Conexão Ollama</CardTitle>
|
<CardTitle>Conexão Ollama</CardTitle>
|
||||||
@@ -426,19 +329,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="model-select">Modelo Padrão</Label>
|
<Label htmlFor="model-select">Modelo Padrão</Label>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={loadModels}
|
|
||||||
disabled={isLoadingModels}
|
|
||||||
title="Atualizar lista de modelos"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${isLoadingModels ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isLoadingModels ? (
|
{isLoadingModels ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -657,10 +548,96 @@ export default function SettingsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Motores de Busca</CardTitle>
|
<CardTitle>Motores de Busca</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure o comportamento da busca web
|
Configure a ordem e comportamento dos motores de busca
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Ordem dos Motores</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Arraste para reordenar. O sistema tentará cada motor na ordem especificada até encontrar resultados suficientes.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 border rounded-lg p-4">
|
||||||
|
{settings.webSearch.engineOrder.map((engine, index) => (
|
||||||
|
<div
|
||||||
|
key={engine}
|
||||||
|
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium capitalize">{engine}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{index > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newOrder = [...settings.webSearch.engineOrder];
|
||||||
|
[newOrder[index], newOrder[index - 1]] = [newOrder[index - 1], newOrder[index]];
|
||||||
|
settings.setWebSearchEngineOrder(newOrder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{index < settings.webSearch.engineOrder.length - 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newOrder = [...settings.webSearch.engineOrder];
|
||||||
|
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||||
|
settings.setWebSearchEngineOrder(newOrder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3 rotate-180" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="min-results-per-engine">Mínimo de Resultados por Motor</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="min-results-per-engine"
|
||||||
|
type="number"
|
||||||
|
value={settings.webSearch.minResultsPerEngine}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value || '1', 10);
|
||||||
|
const clamped = Math.min(10, Math.max(1, isNaN(v) ? 1 : v));
|
||||||
|
settings.setWebSearchMinResultsPerEngine(clamped);
|
||||||
|
}}
|
||||||
|
className="w-24"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
disabled={!settings.webSearch.enabled}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Slider
|
||||||
|
value={[settings.webSearch.minResultsPerEngine]}
|
||||||
|
onValueChange={([value]) => settings.setWebSearchMinResultsPerEngine(value)}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
disabled={!settings.webSearch.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Número mínimo de resultados para considerar um motor como bem-sucedido. Se um motor retornar menos resultados, o sistema tentará o próximo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -701,171 +678,6 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card: SearXNG Self-Hosted */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>SearXNG Self-Hosted</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Busca ilimitada e privada via SearXNG self-hosted (opcional)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5 flex-1">
|
|
||||||
<Label htmlFor="searxng-enabled">Ativar SearXNG Self-Hosted</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Use SearXNG como fonte de busca adicional (requer Docker)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="searxng-enabled"
|
|
||||||
checked={settings.webSearch.searxng?.enabled ?? false}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
settings.setSearxngEnabled(checked);
|
|
||||||
if (checked && (settings.webSearch.searxng?.status ?? 'unknown') === 'unknown') {
|
|
||||||
// Show onboarding on first enable
|
|
||||||
setShowSearxngOnboarding(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!settings.webSearch.enabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(settings.webSearch.searxng?.enabled ?? false) && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'checking' && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">Verificando...</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'available' && (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
|
||||||
<Badge variant="outline" className="text-green-600 border-green-500">
|
|
||||||
Disponível
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'unavailable' && (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
|
||||||
<Badge variant="outline" className="text-red-600 border-red-500">
|
|
||||||
Indisponível
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'unknown' && (
|
|
||||||
<Badge variant="outline">Não verificado</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
}}
|
|
||||||
disabled={(settings.webSearch.searxng?.status ?? 'unknown') === 'checking'}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3 h-3 mr-1" />
|
|
||||||
Verificar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="searxng-url">URL do SearXNG</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="searxng-url"
|
|
||||||
value={settings.webSearch.searxng?.url ?? 'http://localhost:8080'}
|
|
||||||
onChange={(e) => settings.setSearxngUrl(e.target.value)}
|
|
||||||
placeholder="http://localhost:8080"
|
|
||||||
disabled={(settings.webSearch.searxng?.status ?? 'unknown') === 'checking'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
URL completa onde o SearXNG está acessível
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if ((settings.webSearch.searxng?.status ?? 'unknown') === 'unknown') {
|
|
||||||
setShowSearxngOnboarding(true);
|
|
||||||
} else {
|
|
||||||
setShowSearxngWizard(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Configurar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const isRunning = (settings.webSearch.searxng?.status ?? 'unknown') === 'available';
|
|
||||||
if (isRunning) {
|
|
||||||
await invoke('stop_searxng');
|
|
||||||
toast({
|
|
||||||
title: 'SearXNG parado',
|
|
||||||
description: 'Container foi parado com sucesso',
|
|
||||||
});
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
} else {
|
|
||||||
await invoke('start_searxng');
|
|
||||||
toast({
|
|
||||||
title: 'SearXNG iniciado',
|
|
||||||
description: 'Container foi iniciado com sucesso',
|
|
||||||
});
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Erro',
|
|
||||||
description: error instanceof Error ? error.message : 'Falha ao gerenciar container',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'available' ? (
|
|
||||||
<>
|
|
||||||
<Square className="w-4 h-4 mr-2" />
|
|
||||||
Parar
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Power className="w-4 h-4 mr-2" />
|
|
||||||
Iniciar
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
window.open('https://docs.searxng.org/', '_blank');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
Saiba Mais
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab: Sources & Knowledge */}
|
{/* Tab: Sources & Knowledge */}
|
||||||
@@ -1118,195 +930,6 @@ export default function SettingsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Tab: SearXNG Integration */}
|
|
||||||
<TabsContent value="searxng" className="space-y-3 sm:space-y-4 pb-4 sm:pb-6">
|
|
||||||
{/* Docker Settings Panel */}
|
|
||||||
<DockerSettingsPanel />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2 sm:pb-4">
|
|
||||||
<CardTitle className="text-lg sm:text-xl">SearXNG Self-Hosted</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
|
||||||
Configure e gerencie sua instância SearXNG self-hosted para buscas privadas e ilimitadas
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-3">
|
|
||||||
<div className="space-y-0.5 sm:space-y-1 flex-1 min-w-0">
|
|
||||||
<Label className="text-sm sm:text-base">Habilitar SearXNG</Label>
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
|
||||||
Use SearXNG self-hosted como fonte de busca prioritária
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Switch
|
|
||||||
id="searxng-enabled-tab"
|
|
||||||
checked={settings.webSearch.searxng?.enabled ?? false}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
settings.setSearxngEnabled(checked);
|
|
||||||
if (checked && (settings.webSearch.searxng?.status ?? 'unknown') === 'unknown') {
|
|
||||||
setShowSearxngOnboarding(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!settings.webSearch.enabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(settings.webSearch.searxng?.enabled ?? false) && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
||||||
<Label className="text-sm sm:text-base">Status</Label>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'checking' && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-xs sm:text-sm text-muted-foreground">Verificando...</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'available' && (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
|
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm text-green-600 border-green-500 whitespace-nowrap">
|
|
||||||
Disponível
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'unavailable' && (
|
|
||||||
<>
|
|
||||||
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm text-red-600 border-red-500 whitespace-nowrap">
|
|
||||||
Indisponível
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'unknown' && (
|
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm whitespace-nowrap">Não verificado</Badge>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
}}
|
|
||||||
disabled={(settings.webSearch.searxng?.status ?? 'unknown') === 'checking'}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
|
||||||
<span className="hidden xs:inline">Verificar</span>
|
|
||||||
<span className="xs:hidden">Atualizar</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="searxng-url-tab" className="text-sm sm:text-base">URL do SearXNG</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="searxng-url-tab"
|
|
||||||
value={settings.webSearch.searxng?.url ?? 'http://localhost:8080'}
|
|
||||||
onChange={(e) => settings.setSearxngUrl(e.target.value)}
|
|
||||||
placeholder="http://localhost:8080"
|
|
||||||
disabled={(settings.webSearch.searxng?.status ?? 'unknown') === 'checking'}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
URL completa onde o SearXNG está acessível
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col xs:flex-row gap-2 pt-1 sm:pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if ((settings.webSearch.searxng?.status ?? 'unknown') === 'unknown') {
|
|
||||||
setShowSearxngOnboarding(true);
|
|
||||||
} else {
|
|
||||||
setShowSearxngWizard(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 xs:flex-initial text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Settings className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
|
||||||
Configurar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const isRunning = (settings.webSearch.searxng?.status ?? 'unknown') === 'available';
|
|
||||||
if (isRunning) {
|
|
||||||
await invoke('stop_searxng');
|
|
||||||
toast({
|
|
||||||
title: 'SearXNG parado',
|
|
||||||
description: 'Container foi parado com sucesso',
|
|
||||||
});
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
} else {
|
|
||||||
await invoke('start_searxng');
|
|
||||||
toast({
|
|
||||||
title: 'SearXNG iniciado',
|
|
||||||
description: 'Container foi iniciado com sucesso',
|
|
||||||
});
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: 'Erro',
|
|
||||||
description: error instanceof Error ? error.message : 'Falha ao gerenciar container',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1 xs:flex-initial text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
{(settings.webSearch.searxng?.status ?? 'unknown') === 'available' ? (
|
|
||||||
<>
|
|
||||||
<Square className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
|
||||||
Parar
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Power className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
|
||||||
Iniciar
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
window.open('https://docs.searxng.org/', '_blank');
|
|
||||||
}}
|
|
||||||
className="flex-1 xs:flex-initial text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
|
||||||
<span className="hidden sm:inline">Saiba Mais</span>
|
|
||||||
<span className="sm:hidden">Docs</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!(settings.webSearch.searxng?.enabled ?? false) && (
|
|
||||||
<div className="text-center py-4 sm:py-6 text-muted-foreground px-2">
|
|
||||||
<p className="text-xs sm:text-sm">
|
|
||||||
Habilite o SearXNG acima para começar a usar buscas privadas e ilimitadas.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-2">
|
|
||||||
Você também pode configurar o SearXNG na aba "Web Search".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Tab: Tasks & Scheduler */}
|
{/* Tab: Tasks & Scheduler */}
|
||||||
<TabsContent value="tasks" className="space-y-6">
|
<TabsContent value="tasks" className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1483,149 +1106,6 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Debug & Desenvolvimento</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Ferramentas de debug e introspecção técnica
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="debug-mode">Modo Debug (God-Mode)</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Exibe console completo de introspecção técnica para cada resposta da IA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="debug-mode"
|
|
||||||
checked={settings.debugMode}
|
|
||||||
onCheckedChange={settings.setDebugMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{settings.debugMode && (
|
|
||||||
<div className="p-3 bg-muted/50 rounded-lg border border-border/50">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
O console de debug mostrará: System Prompt, Histórico da Sessão, Deep Research Pipeline,
|
|
||||||
Dados Processados e Resposta Raw para cada mensagem do assistente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Atualizações</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure verificação automática de atualizações
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="auto-check-updates">Verificar Atualizações Automaticamente</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
O app verificará novas versões periodicamente
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="auto-check-updates"
|
|
||||||
checked={settings.autoCheckUpdates}
|
|
||||||
onCheckedChange={settings.setAutoCheckUpdates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Versão Atual</span>
|
|
||||||
<span className="font-mono font-medium">
|
|
||||||
{appVersion || 'Carregando...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{updater.updateAvailable && (
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Nova Versão Disponível</span>
|
|
||||||
<span className="font-mono font-medium text-primary">
|
|
||||||
{updater.updateInfo?.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
await updater.checkUpdate();
|
|
||||||
if (updater.updateAvailable) {
|
|
||||||
toast({
|
|
||||||
title: 'Atualização disponível',
|
|
||||||
description: `Versão ${updater.updateInfo?.version} está disponível para download.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: 'Você está atualizado',
|
|
||||||
description: 'Não há novas atualizações disponíveis.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
disabled={updater.isChecking}
|
|
||||||
>
|
|
||||||
{updater.isChecking ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Verificando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Verificar Atualizações
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{updater.error && (
|
|
||||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
|
||||||
<p className="text-sm text-destructive">{updater.error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Debug & Desenvolvimento</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Ferramentas de debug e introspecção técnica
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label htmlFor="debug-mode">Modo Debug (God-Mode)</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Exibe console completo de introspecção técnica para cada resposta da IA
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="debug-mode"
|
|
||||||
checked={settings.debugMode}
|
|
||||||
onCheckedChange={settings.setDebugMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{settings.debugMode && (
|
|
||||||
<div className="p-3 bg-muted/50 rounded-lg border border-border/50">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
O console de debug mostrará: System Prompt, Histórico da Sessão, Deep Research Pipeline,
|
|
||||||
Dados Processados e Resposta Raw para cada mensagem do assistente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Atualizações</CardTitle>
|
<CardTitle>Atualizações</CardTitle>
|
||||||
@@ -1745,43 +1225,6 @@ export default function SettingsPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SearXNG Onboarding Modal */}
|
|
||||||
{showSearxngOnboarding && (
|
|
||||||
<SearxngOnboarding
|
|
||||||
onInstall={() => {
|
|
||||||
setShowSearxngOnboarding(false);
|
|
||||||
setShowSearxngWizard(true);
|
|
||||||
}}
|
|
||||||
onSkip={() => {
|
|
||||||
setShowSearxngOnboarding(false);
|
|
||||||
settings.setSearxngEnabled(false);
|
|
||||||
}}
|
|
||||||
onLearnMore={() => {
|
|
||||||
window.open('https://docs.searxng.org/', '_blank');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SearXNG Install Wizard Modal */}
|
|
||||||
{showSearxngWizard && (
|
|
||||||
<SearxngInstallWizard
|
|
||||||
onComplete={async () => {
|
|
||||||
setShowSearxngWizard(false);
|
|
||||||
settings.setSearxngEnabled(true);
|
|
||||||
settings.setSearxngStatus('available');
|
|
||||||
await settings.checkSearxngStatus();
|
|
||||||
toast({
|
|
||||||
title: 'SearXNG configurado',
|
|
||||||
description: 'SearXNG foi instalado e configurado com sucesso!',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowSearxngWizard(false);
|
|
||||||
}}
|
|
||||||
initialUrl={settings.webSearch.searxng?.url ?? 'http://localhost:8080'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
// Usar startTransition para navegação não-bloqueante
|
|
||||||
startTransition(() => {
|
|
||||||
router.push("/chat");
|
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">
|
||||||
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 |
@@ -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' &&
|
||||||
@@ -65,9 +62,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
|
||||||
const highlightedContent = useMemo(() => {
|
const highlightedContent = useMemo(() => {
|
||||||
@@ -120,7 +114,6 @@ 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'}
|
||||||
@@ -160,21 +153,16 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
|
|||||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||||
</div>
|
</div>
|
||||||
) : isStreaming ? (
|
) : isStreaming ? (
|
||||||
// Renderização de markdown em tempo real durante streaming
|
// 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(
|
<div className={cn(
|
||||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
"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-headings:font-semibold prose-headings:tracking-tight",
|
||||||
"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",
|
|
||||||
"relative"
|
"relative"
|
||||||
)}>
|
)}>
|
||||||
<ReactMarkdown
|
<div className="whitespace-pre-wrap font-sans">
|
||||||
remarkPlugins={[remarkGfm]}
|
{processedContent}
|
||||||
components={markdownRenderers}
|
</div>
|
||||||
>
|
|
||||||
{markdownContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -211,10 +199,6 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</MessageBackground>
|
</MessageBackground>
|
||||||
{!isUser && !isStreaming && debugMode && debugData && (
|
|
||||||
<DebugConsole data={debugData} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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" />
|
||||||
@@ -142,7 +122,6 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metadata.error && (
|
{metadata.error && (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -269,11 +269,3 @@ export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -43,33 +43,11 @@ export function HardwareDashboard() {
|
|||||||
|
|
||||||
if (isLoading || !stats) {
|
if (isLoading || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{/* Skeleton Cards */}
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<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>
|
<Card>
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
<div className="flex items-center justify-center gap-2">
|
Carregando estatísticas do sistema...
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 “{searchQuery}”
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredModels.map((model) => {
|
|
||||||
const isActive = activeModelTag === model.id;
|
|
||||||
const isDownloading = downloadState.isDownloading && isActive;
|
|
||||||
const progress = isActive ? activeProgress : null;
|
|
||||||
const isInstalled = installedModels.has(model.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={model.id}
|
|
||||||
className={cn(
|
|
||||||
'relative border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900 transition-all',
|
|
||||||
isDownloading && 'border-emerald-500/50',
|
|
||||||
isInstalled && !isDownloading && 'border-emerald-500/30 bg-emerald-500/5'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-bold text-sm truncate">{model.name}</h4>
|
|
||||||
{isInstalled && (
|
|
||||||
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-[10px] px-1.5 py-0">
|
|
||||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
|
||||||
Instalado
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="hidden md:block text-[11px] text-muted-foreground mt-1 line-clamp-1">
|
|
||||||
{model.description || ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="shrink-0 text-[10px] sm:text-xs">
|
|
||||||
{model.size}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-[10px] sm:text-xs text-muted-foreground gap-2">
|
|
||||||
<span className="truncate">Mín. {model.minRam}GB RAM</span>
|
|
||||||
<code className="text-[9px] sm:text-[10px] bg-muted px-1 sm:px-1.5 py-0.5 rounded shrink-0 truncate max-w-[120px] sm:max-w-none">
|
|
||||||
{model.id}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDownloading && progress && (
|
|
||||||
<div className="space-y-1 pt-2 border-t border-zinc-800/50">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{progress.status || 'Baixando...'}
|
|
||||||
</span>
|
|
||||||
{progress.speed && (
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
{progress.speed}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={progress.total > 0 && progress.percent !== null ? progress.percent : undefined}
|
|
||||||
className="h-1"
|
|
||||||
/>
|
|
||||||
{progress.total > 0 && (
|
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground/70">
|
|
||||||
<span>
|
|
||||||
{progress.downloadedFormatted} / {progress.totalFormatted}
|
|
||||||
</span>
|
|
||||||
<span>{progress.percent ?? 0}%</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => handleModelDownload(model.id)}
|
|
||||||
disabled={downloadState.isDownloading || isInstalled}
|
|
||||||
variant={isInstalled ? 'outline' : isDownloading ? 'outline' : 'default'}
|
|
||||||
className={cn(
|
|
||||||
"w-full gap-2 h-8 text-xs",
|
|
||||||
isInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isDownloading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
<span className="hidden sm:inline">Baixando...</span>
|
|
||||||
</>
|
|
||||||
) : isInstalled ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
|
||||||
<span className="hidden sm:inline">Já Instalado</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
<span className="hidden sm:inline">Baixar</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mensagens de Erro */}
|
|
||||||
{state.lastError && (
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900/50">
|
|
||||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
|
||||||
<p className="text-xs text-red-700 dark:text-red-300 mt-1 break-words">{state.lastError}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SLIM FOOTER NAVIGATION */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 mt-4 border-t border-zinc-800 bg-zinc-950/50 backdrop-blur-sm flex-shrink-0">
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
<span>Selecione um modelo ou pule para configurar depois.</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 w-full sm:w-auto">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onNext}
|
|
||||||
className="text-zinc-400 hover:text-white hover:bg-zinc-800 flex-1 sm:flex-initial"
|
|
||||||
disabled={downloadState.isDownloading}
|
|
||||||
>
|
|
||||||
Pular etapa
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="bg-white text-black hover:bg-zinc-200 px-8 flex-1 sm:flex-initial"
|
|
||||||
disabled={downloadState.isDownloading}
|
|
||||||
>
|
|
||||||
Continuar <ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="p-6 sm:p-8 pt-0 border-t-0 flex-shrink-0 hidden">
|
|
||||||
{/* Footer vazio - mantido para compatibilidade, mas conteúdo movido para CardContent */}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
} catch (error) {
|
||||||
console.error("Download failed", error);
|
console.error("Download failed", error);
|
||||||
setDownloadStatus("Falha no download. Tente novamente.");
|
setDownloadStatus("Falha no download. Tente novamente.");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
setIsDownloading(false);
|
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"
|
|
||||||
onClick={handleNavigate}
|
|
||||||
disabled={isNavigating}
|
|
||||||
>
|
|
||||||
{isNavigating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
||||||
Inicializando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Ir para o Chat
|
Ir para o Chat
|
||||||
<Play className="ml-2 w-5 h-5" />
|
<Play className="ml-2 w-5 h-5" />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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 "Sim" ou "Allow" no prompt de UAC (User Account Control)</li>
|
|
||||||
<li>Aguarde alguns segundos para a instalação continuar</li>
|
|
||||||
<li>Se o prompt não apareceu, tente executar o OllaHub como administrador</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={onRetry}
|
|
||||||
disabled={isRetrying}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Verificando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Verificar Novamente
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'ERR_DOWNLOAD_FAIL':
|
|
||||||
return (
|
|
||||||
<Card className="border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20">
|
|
||||||
<CardHeader className="pb-3 sm:pb-6">
|
|
||||||
<div className="flex items-start gap-2 sm:gap-3">
|
|
||||||
<WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-sm sm:text-base text-red-900 dark:text-red-100">
|
|
||||||
Erro ao Baixar Instalador
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 sm:mt-2">
|
|
||||||
Não foi possível baixar o instalador do Ollama. Verifique sua conexão com a internet.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
|
||||||
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-red-200 dark:border-red-800">
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={onRetry}
|
|
||||||
disabled={isRetrying}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Tentando Novamente...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Tentar Novamente
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'ERR_TIMEOUT':
|
|
||||||
return (
|
|
||||||
<Card className="border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/20">
|
|
||||||
<CardHeader className="pb-3 sm:pb-6">
|
|
||||||
<div className="flex items-start gap-2 sm:gap-3">
|
|
||||||
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600 dark:text-orange-400 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-sm sm:text-base text-orange-900 dark:text-orange-100">
|
|
||||||
Instalação Demorando Mais do Que o Esperado
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs sm:text-sm text-orange-700 dark:text-orange-300 mt-1 sm:mt-2">
|
|
||||||
A instalação pode ainda estar em andamento em segundo plano.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
|
||||||
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground mb-2 break-words">
|
|
||||||
O processo de instalação pode levar alguns minutos. Se o Ollama estiver sendo instalado,
|
|
||||||
você pode verificar o status manualmente.
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground break-words">
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={onRetry}
|
|
||||||
disabled={isRetrying}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Verificando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Verificar Novamente
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-2 p-3 sm:p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
|
|
||||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1 space-y-2 sm:space-y-3 min-w-0">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm sm:text-base font-medium text-red-900 dark:text-red-100">Erro na instalação</p>
|
|
||||||
<p className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 break-words">{errorMessage}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={onRetry}
|
|
||||||
disabled={isRetrying}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Verificando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Verificar Novamente
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type InstallStepState = 'IDLE' | 'INSTALLING' | 'VERIFYING' | 'SUCCESS';
|
|
||||||
|
|
||||||
interface OllamaInstallStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OllamaInstallStep({ onNext }: OllamaInstallStepProps) {
|
|
||||||
const { state, installOllama, checkChocolatey, installChocolatey, installOllamaViaChoco, checkSetupState } = useSetupWizard();
|
|
||||||
const { os: detectedOS } = useOperatingSystem();
|
|
||||||
const downloadState = useOllamaDownload();
|
|
||||||
const ollamaCheck = useOllamaCheck();
|
|
||||||
const [stepState, setStepState] = useState<InstallStepState>('IDLE');
|
|
||||||
const [useChocolatey, setUseChocolatey] = useState(false);
|
|
||||||
const [showTerminal, setShowTerminal] = useState(false);
|
|
||||||
const [isRetrying, setIsRetrying] = useState(false);
|
|
||||||
|
|
||||||
const installationInProgressRef = useRef(false);
|
|
||||||
const hasCheckedInitialRef = useRef(false);
|
|
||||||
|
|
||||||
// Função de polling ativo para aguardar serviço ficar disponível
|
|
||||||
const waitForService = async (maxAttempts = 15): Promise<boolean> => {
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
|
||||||
if (isIntact) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Tentativa ${attempt}/${maxAttempts} falhou:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Ollama não ficou disponível após 30 segundos');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verificação inicial no mount: se Ollama já estiver instalado, ir direto para SUCCESS
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasCheckedInitialRef.current) return;
|
|
||||||
|
|
||||||
const checkInitial = async () => {
|
|
||||||
if (stepState === 'IDLE') {
|
|
||||||
try {
|
|
||||||
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
|
||||||
if (isIntact) {
|
|
||||||
// Se a integridade está OK, atualizar estado do wizard e ir para SUCCESS
|
|
||||||
await checkSetupState();
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silenciosamente ignorar - usuário ainda não instalou
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasCheckedInitialRef.current = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
checkInitial();
|
|
||||||
}, [stepState, checkSetupState]);
|
|
||||||
|
|
||||||
// Sincronizar estado do polling com wizard state e atualizar FSM
|
|
||||||
useEffect(() => {
|
|
||||||
if (ollamaCheck.status === 'running' && !state.ollamaRunning) {
|
|
||||||
// Polling detectou que Ollama está rodando, atualizar wizard state
|
|
||||||
checkSetupState().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualizar FSM baseado no status
|
|
||||||
if (stepState === 'VERIFYING' && ollamaCheck.status === 'running' && state.ollamaInstalled && state.ollamaRunning) {
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
ollamaCheck.stopPolling();
|
|
||||||
}
|
|
||||||
}, [ollamaCheck.status, state.ollamaRunning, state.ollamaInstalled, checkSetupState, stepState, ollamaCheck]);
|
|
||||||
|
|
||||||
const handleManualRetry = async () => {
|
|
||||||
setIsRetrying(true);
|
|
||||||
try {
|
|
||||||
await checkSetupState();
|
|
||||||
|
|
||||||
// Se já estiver instalado, ir para SUCCESS
|
|
||||||
if (state.ollamaInstalled && state.ollamaRunning) {
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
} else {
|
|
||||||
// Iniciar verificação manual
|
|
||||||
setStepState('VERIFYING');
|
|
||||||
ollamaCheck.startPolling();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao verificar novamente:', error);
|
|
||||||
} finally {
|
|
||||||
setIsRetrying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckExisting = async () => {
|
|
||||||
setStepState('VERIFYING');
|
|
||||||
ollamaCheck.startPolling();
|
|
||||||
await checkSetupState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInstall = async () => {
|
|
||||||
if (installationInProgressRef.current) {
|
|
||||||
return; // Evitar múltiplas instalações simultâneas
|
|
||||||
}
|
|
||||||
|
|
||||||
installationInProgressRef.current = true;
|
|
||||||
setStepState('INSTALLING');
|
|
||||||
setShowTerminal(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// No Windows, verificar se Chocolatey está disponível
|
|
||||||
if (detectedOS === 'windows') {
|
|
||||||
const hasChoco = await checkChocolatey();
|
|
||||||
if (hasChoco) {
|
|
||||||
setUseChocolatey(true);
|
|
||||||
// Instalar Ollama via Chocolatey
|
|
||||||
await installOllamaViaChoco();
|
|
||||||
// Após instalação, fazer polling ativo
|
|
||||||
setStepState('VERIFYING');
|
|
||||||
await waitForService(15); // 15 tentativas = 30 segundos
|
|
||||||
// Se chegou aqui, Ollama está funcional
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
await checkSetupState(); // Atualizar estado do wizard
|
|
||||||
installationInProgressRef.current = false;
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// Tentar instalar Chocolatey primeiro
|
|
||||||
try {
|
|
||||||
await installChocolatey();
|
|
||||||
setUseChocolatey(true);
|
|
||||||
await installOllamaViaChoco();
|
|
||||||
// Após instalação, fazer polling ativo
|
|
||||||
setStepState('VERIFYING');
|
|
||||||
await waitForService(15); // 15 tentativas = 30 segundos
|
|
||||||
// Se chegou aqui, Ollama está funcional
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
await checkSetupState(); // Atualizar estado do wizard
|
|
||||||
installationInProgressRef.current = false;
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Falha ao usar Chocolatey, usando instalação direta:', error);
|
|
||||||
setUseChocolatey(false);
|
|
||||||
setShowTerminal(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: instalação direta
|
|
||||||
if (!downloadState.filePath && detectedOS) {
|
|
||||||
await downloadState.handleDownload(detectedOS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadState.filePath) {
|
|
||||||
await installOllama(downloadState.filePath);
|
|
||||||
} else {
|
|
||||||
await installOllama();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Após instalação, fazer polling ativo
|
|
||||||
setStepState('VERIFYING');
|
|
||||||
await waitForService(15); // 15 tentativas = 30 segundos
|
|
||||||
// Se chegou aqui, Ollama está funcional
|
|
||||||
setStepState('SUCCESS');
|
|
||||||
await checkSetupState(); // Atualizar estado do wizard
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao aguardar serviço:', error);
|
|
||||||
setStepState('IDLE');
|
|
||||||
} finally {
|
|
||||||
installationInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Estado SUCCESS: mostrar mensagem de sucesso e botão Continuar
|
|
||||||
if (stepState === 'SUCCESS' || (state.ollamaInstalled && state.ollamaRunning)) {
|
|
||||||
return (
|
|
||||||
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
|
||||||
<CardHeader className="p-6 sm:p-8">
|
|
||||||
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
|
|
||||||
<span>Instalação Concluída</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-zinc-400 mb-8 text-base">
|
|
||||||
Ollama está instalado e rodando com sucesso.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
|
||||||
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-emerald-100">Ollama Service is Running</p>
|
|
||||||
<p className="text-xs text-emerald-300 mt-1">Pronto para continuar com o download de modelos.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
|
||||||
<Button onClick={onNext} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
|
|
||||||
Continuar <ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estado IDLE: mostrar card informativo
|
|
||||||
if (stepState === 'IDLE') {
|
|
||||||
return (
|
|
||||||
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
|
||||||
<CardHeader className="p-6 sm:p-8">
|
|
||||||
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
|
||||||
<Download className="w-6 h-6 shrink-0" />
|
|
||||||
<span>Ollama Engine</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-zinc-400 mb-8 text-base">
|
|
||||||
O OllaHub requer o motor Ollama para executar IAs localmente.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
|
||||||
<p className="text-sm text-zinc-400 leading-relaxed">
|
|
||||||
O Ollama é uma ferramenta open-source que permite executar modelos de linguagem grandes (LLMs) diretamente no seu computador, sem necessidade de conexão com a internet.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col sm:flex-row justify-end gap-4 p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
|
||||||
<Button onClick={handleCheckExisting} variant="outline" className="w-full sm:w-auto h-12 border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
|
||||||
Já tenho o Ollama instalado
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleInstall} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Instalar Ollama
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorType = state.lastError ? parseInstallError(state.lastError) : null;
|
|
||||||
const showRetryButton = state.lastError || state.progressStatus === 'error';
|
|
||||||
const showManualRetry = showRetryButton && state.lastError;
|
|
||||||
const hasTerminal = showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={`w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl ${hasTerminal ? 'max-h-[calc(100vh-16rem)] flex flex-col' : ''}`}>
|
|
||||||
<CardHeader className={`p-6 sm:p-8 ${hasTerminal ? 'flex-shrink-0' : ''}`}>
|
|
||||||
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
|
||||||
{stepState === 'INSTALLING' && <Loader2 className="w-6 h-6 animate-spin" />}
|
|
||||||
{stepState === 'VERIFYING' && <Loader2 className="w-6 h-6 animate-spin" />}
|
|
||||||
{(state.lastError || state.progressStatus === 'error') && <AlertCircle className="w-6 h-6 text-red-500" />}
|
|
||||||
<span>
|
|
||||||
{stepState === 'INSTALLING' ? 'Instalando Ollama...' :
|
|
||||||
stepState === 'VERIFYING' ? 'Verificando Instalação...' :
|
|
||||||
'Instalação em Progresso'}
|
|
||||||
</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-zinc-400 mb-8 text-base">
|
|
||||||
Acompanhe o progresso da configuração do ambiente.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className={`px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6 ${hasTerminal ? 'flex-1 min-h-0 overflow-y-auto' : ''}`}>
|
|
||||||
{/* Terminal integrado para instalação via Chocolatey */}
|
|
||||||
{(showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey)) && (
|
|
||||||
<InstallationTerminal
|
|
||||||
onComplete={() => {
|
|
||||||
setShowTerminal(false);
|
|
||||||
}}
|
|
||||||
onError={(error) => {
|
|
||||||
console.error('Erro na instalação:', error);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progresso para instalação direta (fallback) */}
|
|
||||||
{state.progressMessage && !showTerminal && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{state.progressMessage}</span>
|
|
||||||
{ollamaCheck.isChecking && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Verificando... {formatTime(ollamaCheck.elapsedTime)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{state.progressStatus === 'installing_ollama' && (
|
|
||||||
<Progress value={undefined} className="h-2" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Feedback do polling em background (estado VERIFYING) */}
|
|
||||||
{stepState === 'VERIFYING' && (
|
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
|
||||||
<span className="break-words">
|
|
||||||
Instalação concluída. Aguardando inicialização do serviço...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Display de erro granular */}
|
|
||||||
{state.lastError && errorType && (
|
|
||||||
<ErrorDisplay
|
|
||||||
errorType={errorType}
|
|
||||||
errorMessage={state.lastError}
|
|
||||||
onRetry={handleManualRetry}
|
|
||||||
isRetrying={isRetrying}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Botão de retry manual quando não há erro específico */}
|
|
||||||
{showRetryButton && !showManualRetry && (
|
|
||||||
<Button
|
|
||||||
onClick={handleManualRetry}
|
|
||||||
disabled={isRetrying || stepState === 'INSTALLING'}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isRetrying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Verificando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
Verificar Novamente
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{downloadState.isDownloading && (
|
|
||||||
<div className="space-y-2 w-full">
|
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{downloadState.status === 'verifying' ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
{downloadState.status === 'verifying'
|
|
||||||
? 'Verificando integridade...'
|
|
||||||
: 'Baixando...'}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono">{downloadState.speed || '0 MB/s'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Progress
|
|
||||||
value={downloadState.downloadProgress}
|
|
||||||
className="h-2 w-full transition-all duration-300"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
|
|
||||||
<span>
|
|
||||||
{formatBytes(downloadState.downloadedBytes)} / {formatBytes(downloadState.totalBytes)}
|
|
||||||
</span>
|
|
||||||
<span>{downloadState.downloadProgress.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{downloadState.eta > 0 && downloadState.status === 'downloading' && (
|
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
|
||||||
Tempo restante: {formatTime(downloadState.eta)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Estado INSTALLING: mostrar progresso */}
|
|
||||||
{stepState === 'INSTALLING' && (
|
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
|
||||||
<span className="break-words">Instalando Ollama... Isso pode levar alguns minutos.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
{/* Footer opcional se necessário */}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"generatedAt": "2025-11-28T03:22:48.992Z",
|
|
||||||
"releases": []
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
format: json ? 'json' : undefined,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
num_predict: 1024,
|
num_predict: 1024,
|
||||||
format: json ? 'json' : undefined,
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
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;
|
||||||
|
|||||||
@@ -254,16 +254,30 @@ 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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model: TITLE_MODEL,
|
model: TITLE_MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
||||||
{ role: 'user', content: userMessage }
|
{ role: 'user', content: userMessage }
|
||||||
],
|
],
|
||||||
options: undefined,
|
stream: false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// If model not available, use fallback
|
||||||
|
if (response.status === 404 || response.status >= 500) {
|
||||||
|
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let title = data.message?.content?.trim() || "";
|
||||||
|
|
||||||
// If empty from start, use fallback
|
// If empty from start, use fallback
|
||||||
if (!title || title.length === 0) {
|
if (!title || title.length === 0) {
|
||||||
throw new Error("Empty title returned from model");
|
throw new Error("Empty title returned from model");
|
||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model: TITLE_MODEL,
|
model: TITLE_MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
{ 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,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
let title = removeMetadataNoise(rawTitle?.trim() || "");
|
if (!response.ok) {
|
||||||
|
// If model not found or unavailable, throw to trigger fallback
|
||||||
|
if (response.status === 404 || response.status >= 500) {
|
||||||
|
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
// For other errors, use fallback
|
||||||
|
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let title = removeMetadataNoise(data.message?.content?.trim() || "");
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
format: json ? 'json' : undefined,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
num_predict: 2048,
|
num_predict: 2048,
|
||||||
format: json ? 'json' : undefined,
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
logSuccess(`LLM response received (${response?.length || 0} chars)`);
|
if (!response.ok) {
|
||||||
return response;
|
logError(`LLM API returned status ${response.status}`);
|
||||||
|
throw new Error('Failed to call LLM');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logSuccess(`LLM response received (${data.response?.length || 0} chars)`);
|
||||||
|
return data.response;
|
||||||
} 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,17 +255,16 @@ 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) {
|
|
||||||
logStep('Safety Check bypassed for small/experimental model');
|
|
||||||
} else {
|
|
||||||
const isRelevant = plan.some(q =>
|
const isRelevant = plan.some(q =>
|
||||||
keywords.some(k => q.toLowerCase().includes(k))
|
keywords.some(k => q.toLowerCase().includes(k))
|
||||||
);
|
);
|
||||||
logData('Plan is relevant', isRelevant);
|
logData('Plan is relevant', isRelevant);
|
||||||
|
|
||||||
if (!isRelevant && keywords.length > 0) {
|
if (!isRelevant && keywords.length > 0) {
|
||||||
logError('Safety Check Failed: Plan is irrelevant to query');
|
logError('Safety Check Failed: Plan is irrelevant to query');
|
||||||
logData('Generated plan', plan);
|
logData('Generated plan', plan);
|
||||||
@@ -271,8 +278,8 @@ export function useDeepResearch() {
|
|||||||
error: 'Safety Check Failed: Plan irrelevant to query'
|
error: 'Safety Check Failed: Plan irrelevant to query'
|
||||||
});
|
});
|
||||||
plan = [query];
|
plan = [query];
|
||||||
}
|
} else {
|
||||||
}
|
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
||||||
addLog({
|
addLog({
|
||||||
stage: 'decomposition',
|
stage: 'decomposition',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -281,7 +288,7 @@ export function useDeepResearch() {
|
|||||||
parsedOutput: plan,
|
parsedOutput: plan,
|
||||||
error: parseError
|
error: parseError
|
||||||
});
|
});
|
||||||
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
}
|
||||||
|
|
||||||
setState(s => ({ ...s, plan }));
|
setState(s => ({ ...s, plan }));
|
||||||
return plan;
|
return plan;
|
||||||
@@ -296,64 +303,32 @@ 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,
|
||||||
knowledgeBase: [...s.knowledgeBase, ...entries]
|
knowledgeBase: [...s.knowledgeBase, ...entries]
|
||||||
|
|||||||
@@ -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');
|
||||||
|
// Simulate a small delay for the "scanning" effect
|
||||||
|
setTimeout(() => {
|
||||||
setSpecs(data);
|
setSpecs(data);
|
||||||
setLoading(false);
|
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);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
check();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPolling = useCallback(() => {
|
return { status, check };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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,70 +101,36 @@ 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,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Chamar Tauri em background (não bloquear)
|
// Iniciar verificação automática em segundo plano
|
||||||
runInstaller(state.filePath)
|
|
||||||
.then(() => {
|
|
||||||
// Após iniciar a instalação, começar a verificação automática após um delay
|
|
||||||
setTimeout(() => {
|
|
||||||
startAutoCheck();
|
startAutoCheck();
|
||||||
}, 2000);
|
} catch (error) {
|
||||||
})
|
|
||||||
=======
|
|
||||||
// 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 => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
||||||
<<<<<<< HEAD
|
|
||||||
isInstalling: false,
|
|
||||||
installStatus: null,
|
|
||||||
=======
|
|
||||||
isInstalled: false, // Reverter se falhar
|
|
||||||
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
|
||||||
}));
|
}));
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkOllama = async (): Promise<boolean> => {
|
const checkOllama = async (): Promise<boolean> => {
|
||||||
try {
|
|
||||||
// Usar verificação robusta multi-camada (PATH, caminhos absolutos, HTTP)
|
|
||||||
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
|
||||||
return isInstalledAndRunning;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro ao verificar Ollama:', error);
|
|
||||||
// Fallback para verificação simples se o comando robusto falhar
|
|
||||||
try {
|
try {
|
||||||
const installed = await invoke<boolean>('check_ollama_installed');
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = await invoke<boolean>('check_ollama_running');
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
return running;
|
return running;
|
||||||
} catch (fallbackError) {
|
} catch (error) {
|
||||||
console.error('Fallback check also failed:', fallbackError);
|
console.error('Erro ao verificar Ollama:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAutoCheck = () => {
|
const startAutoCheck = () => {
|
||||||
@@ -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',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,3 @@ export function useOperatingSystem() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
{ role: 'system', content: PROMPT_GEN_SYSTEM },
|
||||||
{ role: 'user', content: userGoal }
|
{ role: 'user', content: userGoal }
|
||||||
],
|
],
|
||||||
options: undefined,
|
stream: false, // We want the full prompt at once for this utility
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return response || "";
|
if (!response.ok) throw new Error('Failed to generate prompt');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
||||||
num_predict: 50, // Limitar tokens para resposta rápida
|
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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 114 KiB |
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
num_predict: 600,
|
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.3,
|
temperature: 0.3, // Baixa temperatura para análise mais determinística
|
||||||
num_predict: 800,
|
num_predict: 800, // Espaço suficiente para JSON completo
|
||||||
format: 'json',
|
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const rawResponse = (data.response || '').trim();
|
||||||
|
|
||||||
// Limpar resposta (remover markdown code blocks se houver)
|
// 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');
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -111,9 +97,6 @@ export function condenseKnowledgeBase(
|
|||||||
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[] = [];
|
||||||
const processedEntries: Array<{
|
const processedEntries: Array<{
|
||||||
@@ -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,
|
||||||
@@ -165,17 +128,10 @@ 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,
|
||||||
@@ -184,19 +140,6 @@ export function condenseKnowledgeBase(
|
|||||||
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';
|
||||||
let context = '';
|
let context = '';
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.5, // Temperatura média para criatividade controlada
|
temperature: 0.5, // Temperatura média para criatividade controlada
|
||||||
num_predict: 1000, // Espaço suficiente para múltiplas queries
|
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);
|
||||||
|
|||||||
@@ -211,11 +211,3 @@ export function formatSearchLog(log: SearchLog): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +38,3 @@ export const deepResearchLog = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
model,
|
model,
|
||||||
prompt: expansionPrompt,
|
prompt: expansionPrompt,
|
||||||
|
stream: false,
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
num_predict: 100,
|
num_predict: 100,
|
||||||
},
|
},
|
||||||
}))
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
chatLog.warn('Failed to expand query, using original');
|
||||||
|
return originalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const expanded = (data.response || '').trim()
|
||||||
.replace(/^["']|["']$/g, '')
|
.replace(/^["']|["']$/g, '')
|
||||||
.replace(/\.$/, '')
|
.replace(/\.$/, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const sanitized = expanded
|
if (expanded.length < 3) {
|
||||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
||||||
.replace(/^\s*```[a-zA-Z]*\s*/g, '')
|
|
||||||
.replace(/\s*```\s*$/g, '')
|
|
||||||
.replace(/^(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*/i, '')
|
|
||||||
.trim()
|
|
||||||
.replace(/[\r\n]+/g, ' ');
|
|
||||||
|
|
||||||
if (sanitized.length < 3 || sanitized.length > 150 || /[<>]/.test(sanitized)) {
|
|
||||||
return originalQuery;
|
return originalQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
|
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
|
||||||
return sanitized;
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 114 KiB |
@@ -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
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,10 +255,30 @@ 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
|
||||||
let rustConfig: Record<string, unknown> | undefined = undefined;
|
|
||||||
if (searchConfig) {
|
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
|
const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
||||||
|
let engineOrder = settings.webSearch.engineOrder || defaultEngines;
|
||||||
|
|
||||||
|
// Priorizar motores usando circuit breaker
|
||||||
|
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
|
||||||
|
|
||||||
|
// Filtrar motores não disponíveis (circuit breaker aberto)
|
||||||
|
const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder);
|
||||||
|
|
||||||
|
if (availableEngines.length === 0) {
|
||||||
|
console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`);
|
||||||
|
// Se todos estão abertos, tentar mesmo assim (pode ser temporário)
|
||||||
|
engineOrder = engineOrder;
|
||||||
|
} else {
|
||||||
|
engineOrder = availableEngines;
|
||||||
|
if (availableEngines.length < engineOrder.length) {
|
||||||
|
console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rustConfig: any = undefined;
|
||||||
|
if (searchConfig) {
|
||||||
rustConfig = {
|
rustConfig = {
|
||||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||||
@@ -272,29 +290,16 @@ 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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
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()
|
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())
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |