Compare commits
26
Commits
No files matched your search
@@ -0,0 +1,569 @@
|
|||||||
|
---
|
||||||
|
name: Integração llama.cpp nativo e download Hugging Face
|
||||||
|
overview: Implementar download direto de modelos GGUF do Hugging Face, integração com llama.cpp nativo via Rust, listagem de modelos locais e UI experimental para alternar entre Ollama e llama.cpp nativo.
|
||||||
|
todos:
|
||||||
|
- id: hf-download-module
|
||||||
|
content: Criar módulo huggingface_download.rs com download_gguf_model() e streaming de progresso
|
||||||
|
status: pending
|
||||||
|
- id: hf-list-models
|
||||||
|
content: Implementar list_huggingface_gguf_models() com cache de metadados (TTL 1h)
|
||||||
|
status: pending
|
||||||
|
- id: hf-validation
|
||||||
|
content: Adicionar validação SHA256 obrigatória quando disponível, retry logic com resume e rate limiting (429)
|
||||||
|
status: pending
|
||||||
|
- id: hf-rate-limiting
|
||||||
|
content: Implementar tratamento de rate limiting com Retry-After header e backoff exponencial
|
||||||
|
status: pending
|
||||||
|
- id: llama-cpp-deps
|
||||||
|
content: Adicionar llama_cpp 0.4 no Cargo.toml com features condicionais por OS + feature flag dev-no-llama
|
||||||
|
status: pending
|
||||||
|
- id: llama-deps-parsing
|
||||||
|
content: Adicionar dependências gguf 0.1 e lru 0.12 para parsing de metadados e cache LRU
|
||||||
|
status: pending
|
||||||
|
- id: llama-state
|
||||||
|
content: Criar LlamaCppState com LRU cache (limite 3 modelos) para prevenir memory leaks
|
||||||
|
status: pending
|
||||||
|
- id: models-dir-structure
|
||||||
|
content: Implementar get_models_dir() com estrutura hierárquica cross-platform (repo_owner/repo_name/quantization)
|
||||||
|
status: pending
|
||||||
|
- id: llama-chat-stream
|
||||||
|
content: Implementar chat_stream_native() com streaming de tokens via llama-cpp-rs
|
||||||
|
status: pending
|
||||||
|
- id: llama-params
|
||||||
|
content: Adicionar configuração de parâmetros (n_gpu_layers, n_ctx, threads) no settings store
|
||||||
|
status: pending
|
||||||
|
- id: local-scan
|
||||||
|
content: Implementar list_local_gguf_models() com scan recursivo e parsing de metadados via crate gguf
|
||||||
|
status: pending
|
||||||
|
- id: local-cache
|
||||||
|
content: Adicionar cache de metadados de modelos locais para evitar re-scan frequente
|
||||||
|
status: pending
|
||||||
|
- id: quantization-regex
|
||||||
|
content: "Implementar regex corrigido para extração de quantização: [._-](Q\\d+_?[KM]?_?[MS]?|[FI]P?\\d+)[._-]"
|
||||||
|
status: pending
|
||||||
|
- id: vram-detection
|
||||||
|
content: Adicionar detecção de VRAM disponível via sysinfo antes de carregar modelos grandes
|
||||||
|
status: pending
|
||||||
|
- id: concurrent-downloads
|
||||||
|
content: Implementar semáforo para limitar downloads paralelos a 2 simultâneos
|
||||||
|
status: pending
|
||||||
|
- id: ui-runtime-toggle
|
||||||
|
content: Criar toggle de runtime (Ollama/llama.cpp) nas configurações com indicador visual
|
||||||
|
status: pending
|
||||||
|
- id: ui-hf-selector
|
||||||
|
content: Implementar seletor de modelos Hugging Face com busca e filtros de quantização
|
||||||
|
status: pending
|
||||||
|
- id: ui-integration
|
||||||
|
content: Integrar runtime selection no use-chat.ts para alternar entre chat_stream e chat_stream_native
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plano de Implementação: Integração llama.cpp e Download Hugging Face
|
||||||
|
|
||||||
|
## Contexto Arquitetural
|
||||||
|
|
||||||
|
O projeto atualmente usa Ollama como runtime de modelos via HTTP (`http://localhost:11434`). O chat streaming é feito através de `chat_stream` que chama `OllamaClient`. Modelos são baixados via `pull_model` que usa a API do Ollama. Existe suporte básico para instalar modelos GGUF locais via `install_gguf_model`.
|
||||||
|
|
||||||
|
**Decisão arquitetural:** Implementar runtime dual (Ollama + llama.cpp nativo) com abstração no backend Rust para permitir alternância transparente no frontend.
|
||||||
|
|
||||||
|
## Estrutura de Armazenamento
|
||||||
|
|
||||||
|
- **Modelos Ollama:** `~/.ollama/models` (Linux/Mac) ou `%USERPROFILE%\.ollama\models` (Windows)
|
||||||
|
- **Modelos llama.cpp nativo:** Estrutura hierárquica em `{app_data_dir}/models/gguf/{repo_owner}/{repo_name}/{quantization}/model.gguf`
|
||||||
|
- Exemplo: `~/.local/share/OllaHub/models/gguf/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/Q4_K_M/mistral-7b-instruct-v0.2.Q4_K_M.gguf`
|
||||||
|
- **Cache de metadados:** `{app_data_dir}/cache/huggingface_models.json`
|
||||||
|
- **Cross-platform paths:**
|
||||||
|
- Windows: `%APPDATA%\Roaming\com.ollahub.app\models\gguf`
|
||||||
|
- Linux: `~/.local/share/com.ollahub.app/models/gguf`
|
||||||
|
- macOS: `~/Library/Application Support/com.ollahub.app/models/gguf`
|
||||||
|
|
||||||
|
## Fase 1: Backend - Download Direto Hugging Face
|
||||||
|
|
||||||
|
### 1.1 Módulo de Download (`src-tauri/src/huggingface_download.rs`)
|
||||||
|
|
||||||
|
**Contrato técnico:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct HuggingFaceModel {
|
||||||
|
pub id: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub quantization: String, // Q4_K_M, Q5_K_S, etc.
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_gguf_model(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
model_id: String,
|
||||||
|
filename: String,
|
||||||
|
quantization_filter: Option<String>,
|
||||||
|
window: Option<Window>,
|
||||||
|
) -> Result<PathBuf, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementação:**
|
||||||
|
|
||||||
|
- Usar `reqwest` com streaming (já presente no `Cargo.toml`)
|
||||||
|
- API Hugging Face: `https://huggingface.co/api/models/{model_id}/tree/main`
|
||||||
|
- Download direto: `https://huggingface.co/{model_id}/resolve/main/{filename}`
|
||||||
|
- Emitir eventos `download-progress` (reutilizar estrutura existente de `pull_model_impl_window`)
|
||||||
|
- **Validação SHA256 obrigatória** quando disponível no Hugging Face (usar `sha2` já presente)
|
||||||
|
- Retry logic com backoff exponencial (3 tentativas)
|
||||||
|
- **Rate limiting:** Tratar 429 Too Many Requests com `Retry-After` header
|
||||||
|
- **Resume de download:** Verificar arquivo parcial e continuar de onde parou
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar `src-tauri/src/huggingface_download.rs`
|
||||||
|
- Adicionar comando `download_gguf_model` em `lib.rs`
|
||||||
|
|
||||||
|
### 1.2 Busca de Modelos (`list_huggingface_gguf_models`)
|
||||||
|
|
||||||
|
**Contrato:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn list_huggingface_gguf_models(
|
||||||
|
query: Option<String>,
|
||||||
|
quantization: Option<String>,
|
||||||
|
) -> Result<Vec<HuggingFaceModel>, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementação:**
|
||||||
|
|
||||||
|
- Usar API pública do Hugging Face: `https://huggingface.co/api/models?search={query}&filter=gguf`
|
||||||
|
- Filtrar por arquivos `.gguf` no tree
|
||||||
|
- Cache local com TTL de 1 hora (evitar requests repetidos)
|
||||||
|
- **Extrair quantização do filename** (regex corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`)
|
||||||
|
- Suporta: `Q4_K_M`, `Q5_0`, `F16`, `FP32`, etc.
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Adicionar função em `huggingface_download.rs`
|
||||||
|
- Comando `list_huggingface_gguf_models` em `lib.rs`
|
||||||
|
|
||||||
|
### 1.3 Cache de Metadados
|
||||||
|
|
||||||
|
**Estrutura:**
|
||||||
|
|
||||||
|
- Arquivo JSON em `{app_data_dir}/cache/huggingface_models.json`
|
||||||
|
- Estrutura: `{ "models": [...], "last_updated": "ISO8601" }`
|
||||||
|
- Atualizar cache apenas se `last_updated` > 1 hora
|
||||||
|
|
||||||
|
## Fase 2: Backend - Integração llama.cpp
|
||||||
|
|
||||||
|
### 2.1 Dependências (`Cargo.toml`)
|
||||||
|
|
||||||
|
**Correção crítica:** Usar `llama_cpp` (edgenai) versão 0.4 em vez de `llama-cpp-rs` 0.2
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
default = ["llama-cpp"]
|
||||||
|
llama-cpp = ["dep:llama_cpp"]
|
||||||
|
dev-no-llama = [] # Skip llama.cpp em dev builds (builds 30-45 min podem ser lentos)
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
llama_cpp = { version = "0.4", optional = true }
|
||||||
|
|
||||||
|
# Features condicionais por OS (Metal/CUDA são mutuamente exclusivos)
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
llama_cpp = { version = "0.4", features = ["metal"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||||
|
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
|
||||||
|
|
||||||
|
# Dependência para parsing de metadados GGUF
|
||||||
|
gguf = "0.1"
|
||||||
|
lru = "0.12" # Para LRU cache de modelos carregados
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build considerations:**
|
||||||
|
|
||||||
|
- `llama_cpp` compila `llama.cpp` via build script
|
||||||
|
- **Tempo de build:** 30-45 min em máquinas mais fracas (não 10-15 min)
|
||||||
|
- Feature flag `dev-no-llama` permite builds rápidos em desenvolvimento
|
||||||
|
- Requer CMake/Ninja instalados (documentar no README)
|
||||||
|
|
||||||
|
### 2.2 Gerenciamento de Estado (`AppState`)
|
||||||
|
|
||||||
|
**Estrutura (com LRU cache para prevenir memory leaks):**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use lru::LruCache;
|
||||||
|
|
||||||
|
pub struct LlamaCppState {
|
||||||
|
models: Arc<Mutex<LruCache<String, LlamaModel>>>, // Limite de 3 modelos
|
||||||
|
default_params: LlamaParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlamaCppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
models: Arc::new(Mutex::new(LruCache::new(3.try_into().unwrap()))),
|
||||||
|
default_params: LlamaParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LlamaModel {
|
||||||
|
path: PathBuf,
|
||||||
|
context: Option<LlamaContext>, // Lazy loading
|
||||||
|
metadata: ModelMetadata,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar `src-tauri/src/llama_cpp_state.rs`
|
||||||
|
- Registrar state em `main()` via `.manage()`
|
||||||
|
|
||||||
|
### 2.3 Comando de Chat Streaming (`chat_stream_native`)
|
||||||
|
|
||||||
|
**Contrato:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[command]
|
||||||
|
async fn chat_stream_native(
|
||||||
|
window: Window,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
session_id: Option<String>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
model_path: String,
|
||||||
|
system_prompt: Option<String>,
|
||||||
|
params: Option<LlamaParams>,
|
||||||
|
) -> Result<String, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementação:**
|
||||||
|
|
||||||
|
- Carregar modelo lazy (verificar se já está em `AppState`)
|
||||||
|
- Usar `llama-cpp-rs` para streaming de tokens
|
||||||
|
- Emitir eventos `chat-token` (mesmo formato que `chat_stream` atual)
|
||||||
|
- Reutilizar lógica de salvamento em DB existente
|
||||||
|
- Parâmetros: `n_gpu_layers`, `n_ctx`, `threads` (configuráveis via settings)
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar `src-tauri/src/llama_cpp_chat.rs`
|
||||||
|
- Comando em `lib.rs`
|
||||||
|
- Integrar com `ProcessingState` existente
|
||||||
|
|
||||||
|
### 2.4 Configuração de Parâmetros
|
||||||
|
|
||||||
|
**Settings store (frontend):**
|
||||||
|
|
||||||
|
- Adicionar `llamaCppParams` em `settings-store.ts`:
|
||||||
|
```typescript
|
||||||
|
llamaCppParams: {
|
||||||
|
nGpuLayers: number;
|
||||||
|
nCtx: number;
|
||||||
|
threads: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
|
||||||
|
- Struct `LlamaParams` serializável
|
||||||
|
- Valores padrão: `n_gpu_layers: 0` (CPU), `n_ctx: 4096`, `threads: auto`
|
||||||
|
|
||||||
|
## Fase 3: Backend - Listagem Local
|
||||||
|
|
||||||
|
### 3.1 Scan de Arquivos GGUF (`list_local_gguf_models`)
|
||||||
|
|
||||||
|
**Contrato:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[command]
|
||||||
|
fn list_local_gguf_models(app_handle: AppHandle) -> Result<Vec<LocalGgufModel>, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementação:**
|
||||||
|
|
||||||
|
- Scan recursivo em `{app_data_dir}/models/gguf/` (estrutura hierárquica)
|
||||||
|
- Usar `walkdir` (já presente)
|
||||||
|
- **Parsing de metadados GGUF usando crate `gguf`:**
|
||||||
|
```rust
|
||||||
|
use gguf::GGUFFile;
|
||||||
|
|
||||||
|
pub fn extract_gguf_metadata(path: &Path) -> Result<GgufMetadata, String> {
|
||||||
|
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||||
|
let gguf = GGUFFile::parse(file).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(GgufMetadata {
|
||||||
|
architecture: gguf.header.metadata.get("general.architecture"),
|
||||||
|
quantization: gguf.header.metadata.get("general.quantization_version"),
|
||||||
|
parameter_count: gguf.header.metadata.get("general.parameter_count"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Extrair metadados:
|
||||||
|
- Tamanho do arquivo
|
||||||
|
- Quantização do filename (regex corrigido)
|
||||||
|
- **Arquitetura via parsing de header GGUF** (não inferir do filename)
|
||||||
|
- Cache de metadados para evitar re-scan frequente
|
||||||
|
|
||||||
|
**Estrutura de retorno:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct LocalGgufModel {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub quantization: Option<String>,
|
||||||
|
pub architecture: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Função em `lib.rs` ou módulo separado `src-tauri/src/local_models.rs`
|
||||||
|
|
||||||
|
### 3.2 Detecção Automática
|
||||||
|
|
||||||
|
- Ao baixar modelo do Hugging Face, adicionar à lista local automaticamente
|
||||||
|
- Verificar integridade (tamanho, extensão) antes de listar
|
||||||
|
|
||||||
|
## Fase 4: Frontend - UI Experimental
|
||||||
|
|
||||||
|
### 4.1 Settings Store - Runtime Selection
|
||||||
|
|
||||||
|
**Modificações em `store/settings-store.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
runtime: 'ollama' | 'llama-cpp';
|
||||||
|
llamaCppParams: { nGpuLayers: number; nCtx: number; threads: number };
|
||||||
|
setRuntime: (runtime: 'ollama' | 'llama-cpp') => void;
|
||||||
|
setLlamaCppParams: (params: Partial<LlamaCppParams>) => void;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Toggle de Runtime (`components/settings/`)
|
||||||
|
|
||||||
|
**Componente:**
|
||||||
|
|
||||||
|
- Toggle switch "Usar llama.cpp nativo"
|
||||||
|
- **Indicador visual melhorado** com ícone + tooltip:
|
||||||
|
```tsx
|
||||||
|
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'}>
|
||||||
|
{runtime === 'ollama' ? (
|
||||||
|
<>🦙 Ollama</>
|
||||||
|
) : (
|
||||||
|
<>⚡ llama.cpp (nativo)</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipContent>
|
||||||
|
{runtime === 'ollama'
|
||||||
|
? 'Usando Ollama como runtime (requer servidor externo)'
|
||||||
|
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Desabilitar se nenhum modelo GGUF local disponível
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar ou modificar `components/settings/runtime-settings-panel.tsx`
|
||||||
|
|
||||||
|
### 4.3 Seletor de Modelos Hugging Face
|
||||||
|
|
||||||
|
**Componente:**
|
||||||
|
|
||||||
|
- Input de busca
|
||||||
|
- Lista de modelos com filtros de quantização (Q4, Q5, Q8)
|
||||||
|
- Botão de download com progresso
|
||||||
|
- Integrar com `DownloadContext` existente
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar `components/chat/huggingface-model-selector.tsx`
|
||||||
|
- Integrar em `components/chat/model-selector.tsx`
|
||||||
|
|
||||||
|
### 4.4 Página de Comparação
|
||||||
|
|
||||||
|
**Componente experimental:**
|
||||||
|
|
||||||
|
- Side-by-side: Ollama vs llama.cpp
|
||||||
|
- Mesmo prompt, comparar latência e qualidade
|
||||||
|
- Métricas: tokens/s, tempo total, uso de VRAM
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Criar `app/benchmark/page.tsx` (opcional, pode ser feature flag)
|
||||||
|
|
||||||
|
### 4.5 Integração no Chat
|
||||||
|
|
||||||
|
**Modificações em `hooks/use-chat.ts`:**
|
||||||
|
|
||||||
|
- Verificar `runtime` do settings store
|
||||||
|
- Chamar `chat_stream` ou `chat_stream_native` conforme runtime
|
||||||
|
- Manter compatibilidade com código existente
|
||||||
|
|
||||||
|
**Arquivos:**
|
||||||
|
|
||||||
|
- Modificar `hooks/use-chat.ts`
|
||||||
|
- Atualizar `app/chat/page.tsx` se necessário
|
||||||
|
|
||||||
|
## Decisões Técnicas
|
||||||
|
|
||||||
|
### Abordagem Ingênua vs Sênior
|
||||||
|
|
||||||
|
| Aspecto | Ingênua | Sênior (Escolhida) |
|
||||||
|
|
||||||
|
|---------|---------|-------------------|
|
||||||
|
|
||||||
|
| Download | Polling de status | Streaming com eventos Tauri |
|
||||||
|
|
||||||
|
| Estado de Modelos | Sem cache | **LRU cache com limite de 3 modelos** |
|
||||||
|
|
||||||
|
| Integração Chat | Duplicar código | Abstração via enum Runtime |
|
||||||
|
|
||||||
|
| Validação | Apenas tamanho | **SHA256 obrigatório quando disponível** + tamanho + **parsing header GGUF** |
|
||||||
|
|
||||||
|
| Build llama.cpp | Sempre compilar | **Feature flags condicionais por OS + dev-no-llama** |
|
||||||
|
|
||||||
|
| Rate Limiting | Sem tratamento | **Backoff + Retry-After header** |
|
||||||
|
|
||||||
|
| Parsing Metadata | Inferir do filename | **Crate `gguf` para parsing correto** |
|
||||||
|
|
||||||
|
### Trade-offs
|
||||||
|
|
||||||
|
- **Performance:** llama.cpp nativo pode ser 10-30% mais rápido que Ollama (sem overhead HTTP)
|
||||||
|
- **Manutenção:** Duplicar lógica de chat aumenta complexidade; abstração Runtime reduz duplicação
|
||||||
|
- **VRAM:** llama.cpp permite controle fino de `n_gpu_layers`; Ollama gerencia automaticamente
|
||||||
|
|
||||||
|
### Modos de Falha
|
||||||
|
|
||||||
|
1. **Gargalo VRAM:** Modelos grandes podem não carregar; implementar fallback para CPU
|
||||||
|
|
||||||
|
- **Solução:** Usar `sysinfo` crate para detectar VRAM disponível antes de carregar
|
||||||
|
|
||||||
|
2. **Download interrompido:** Retry logic com resume (verificar se arquivo parcial existe)
|
||||||
|
3. **Build llama.cpp falha:** Feature flag `dev-no-llama` para desabilitar em desenvolvimento
|
||||||
|
4. **Latência de rede:** Cache agressivo de metadados do Hugging Face
|
||||||
|
5. **Rate limiting Hugging Face:** Tratar 429 com `Retry-After` header
|
||||||
|
6. **Concurrent downloads:** Limitar a 2 downloads paralelos com semáforo
|
||||||
|
7. **Memory leaks:** LRU cache com limite de 3 modelos previne crescimento indefinido
|
||||||
|
|
||||||
|
### Antipadrões a Evitar
|
||||||
|
|
||||||
|
- Não fazer polling de status de download (usar eventos)
|
||||||
|
- Não carregar todos os modelos na inicialização (lazy loading)
|
||||||
|
- Não duplicar lógica de streaming (abstrair Runtime)
|
||||||
|
- Não hardcodar paths (usar `app_data_dir`)
|
||||||
|
|
||||||
|
## Ordem de Implementação (Reordenada)
|
||||||
|
|
||||||
|
1. **Estrutura de diretórios e AppState** (fundação)
|
||||||
|
|
||||||
|
- Criar `get_models_dir()` com paths cross-platform
|
||||||
|
- Implementar `LlamaCppState` com LRU cache
|
||||||
|
|
||||||
|
2. **Listagem local de GGUF** (valida estrutura)
|
||||||
|
|
||||||
|
- `list_local_gguf_models()` com parsing via crate `gguf`
|
||||||
|
- Cache de metadados
|
||||||
|
|
||||||
|
3. **Download Hugging Face** (adiciona modelos)
|
||||||
|
|
||||||
|
- `download_gguf_model()` com SHA256 obrigatório
|
||||||
|
- Rate limiting e retry logic
|
||||||
|
|
||||||
|
4. **Parsing de metadados GGUF** (enriquece listagem)
|
||||||
|
|
||||||
|
- Integrar crate `gguf` para extrair arquitetura
|
||||||
|
|
||||||
|
5. **Integração llama.cpp** (runtime)
|
||||||
|
|
||||||
|
- `chat_stream_native()` com lazy loading
|
||||||
|
- Detecção de VRAM via `sysinfo`
|
||||||
|
|
||||||
|
6. **UI experimental** (camada final)
|
||||||
|
|
||||||
|
- Toggle de runtime com indicador melhorado
|
||||||
|
- Integração no `use-chat.ts`
|
||||||
|
|
||||||
|
## Validação
|
||||||
|
|
||||||
|
**Comandos de teste (Windows PowerShell):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Verificar modelos baixados
|
||||||
|
Get-ChildItem "$env:APPDATA\OllaHub\models\gguf" -Recurse -Filter "*.gguf"
|
||||||
|
|
||||||
|
# Testar download (via Tauri command)
|
||||||
|
# Usar DevTools do app para invocar: download_gguf_model
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comandos de teste (Linux Bash):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar modelos baixados
|
||||||
|
find ~/.local/share/com.ollahub.app/models/gguf -name "*.gguf"
|
||||||
|
|
||||||
|
# Testar build llama.cpp (com feature flag)
|
||||||
|
cargo build --features llama-cpp # Build completo
|
||||||
|
cargo build --no-default-features --features dev-no-llama # Build rápido (dev)
|
||||||
|
|
||||||
|
# Verificar VRAM disponível (se NVIDIA)
|
||||||
|
nvidia-smi --query-gpu=memory.free --format=csv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Correções Críticas Aplicadas
|
||||||
|
|
||||||
|
### 1. Crate llama.cpp
|
||||||
|
|
||||||
|
- ✅ Alterado de `llama-cpp-rs = "0.2"` para `llama_cpp = "0.4"` (edgenai, mais estável)
|
||||||
|
|
||||||
|
### 2. Feature Flags
|
||||||
|
|
||||||
|
- ✅ Features condicionais por OS (Metal/CUDA mutuamente exclusivos)
|
||||||
|
- ✅ Feature flag `dev-no-llama` para builds rápidos em desenvolvimento
|
||||||
|
|
||||||
|
### 3. Parsing de Metadados
|
||||||
|
|
||||||
|
- ✅ Adicionada dependência `gguf = "0.1"` para parsing correto de headers GGUF
|
||||||
|
- ✅ Removida inferência de arquitetura do filename
|
||||||
|
|
||||||
|
### 4. Memory Management
|
||||||
|
|
||||||
|
- ✅ Substituído `HashMap` por `LruCache` com limite de 3 modelos
|
||||||
|
|
||||||
|
### 5. Validação SHA256
|
||||||
|
|
||||||
|
- ✅ Tornada obrigatória quando disponível no Hugging Face
|
||||||
|
|
||||||
|
### 6. Rate Limiting
|
||||||
|
|
||||||
|
- ✅ Implementado tratamento de 429 com `Retry-After` header
|
||||||
|
|
||||||
|
### 7. Estrutura de Diretórios
|
||||||
|
|
||||||
|
- ✅ Hierárquica: `{repo_owner}/{repo_name}/{quantization}/model.gguf`
|
||||||
|
|
||||||
|
### 8. Regex de Quantização
|
||||||
|
|
||||||
|
- ✅ Corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`
|
||||||
|
|
||||||
|
### 9. Cross-Platform Paths
|
||||||
|
|
||||||
|
- ✅ Validação de `app_data_dir` para Windows/Linux/macOS
|
||||||
|
|
||||||
|
### 10. Indicador de Runtime
|
||||||
|
|
||||||
|
- ✅ Melhorado com ícone + tooltip explicativo
|
||||||
|
|
||||||
|
### 11. Ordem de Implementação
|
||||||
|
|
||||||
|
- ✅ Reordenada considerando dependências técnicas
|
||||||
|
|
||||||
|
### 12. Riscos Adicionais
|
||||||
|
|
||||||
|
- ✅ VRAM detection via `sysinfo`
|
||||||
|
- ✅ Limite de downloads paralelos (semáforo)
|
||||||
|
- ✅ Documentação de requisitos (CMake/Ninja)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
TAURI_VERSION: "2.9.4"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: 'windows-latest'
|
||||||
|
target: 'x86_64-pc-windows-msvc'
|
||||||
|
bundle: 'msi'
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
target: 'aarch64-apple-darwin'
|
||||||
|
bundle: 'dmg'
|
||||||
|
- platform: 'macos-latest'
|
||||||
|
target: 'x86_64-apple-darwin'
|
||||||
|
bundle: 'dmg'
|
||||||
|
- platform: 'ubuntu-latest'
|
||||||
|
target: 'x86_64-unknown-linux-gnu'
|
||||||
|
bundle: 'appimage'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
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)
|
||||||
|
if: matrix.platform == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f pnpm-lock.yaml ]; then
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
else
|
||||||
|
pnpm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 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
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Build Tauri bundle
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
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: |
|
||||||
|
cd src-tauri
|
||||||
|
if [ -n "$TAURI_PRIVATE_KEY" ]; then
|
||||||
|
echo "$TAURI_PRIVATE_KEY" > /tmp/private.key
|
||||||
|
cargo tauri build --target ${{ matrix.target }} --config '{"updater":{"active":true}}'
|
||||||
|
rm /tmp/private.key
|
||||||
|
else
|
||||||
|
cargo tauri build --target ${{ matrix.target }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.platform }}-${{ matrix.target }}
|
||||||
|
path: |
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.${{ matrix.bundle }}
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage
|
||||||
|
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: build
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
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
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Generate changelog for release
|
||||||
|
run: pnpm generate-changelog
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Extract release notes
|
||||||
|
id: extract_notes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f "data/changelog.json" ]; then
|
||||||
|
NOTES=$(node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
try {
|
||||||
|
const changelog = JSON.parse(fs.readFileSync('data/changelog.json', 'utf-8'));
|
||||||
|
const latestRelease = changelog.releases[0];
|
||||||
|
if (latestRelease) {
|
||||||
|
let notes = '## ' + (latestRelease.version === 'unreleased' ? 'Próxima Versão' : 'v' + latestRelease.version) + '\\n\\n';
|
||||||
|
notes += '**Data:** ' + latestRelease.date + '\\n\\n';
|
||||||
|
notes += '### Mudanças\\n\\n';
|
||||||
|
latestRelease.commits.forEach(commit => {
|
||||||
|
const icon = commit.type === 'feat' ? '✨' : '🐛';
|
||||||
|
notes += '- ' + icon + ' **' + (commit.type === 'feat' ? 'Nova Funcionalidade' : 'Correção') + ':** ' + commit.message + '\\n';
|
||||||
|
});
|
||||||
|
console.log(notes);
|
||||||
|
} else {
|
||||||
|
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||||
|
}
|
||||||
|
")
|
||||||
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "notes=## Release ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.get_version.outputs.tag }}
|
||||||
|
name: Release ${{ steps.get_version.outputs.tag }}
|
||||||
|
body: |
|
||||||
|
${{ steps.extract_notes.outputs.notes }}
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
Baixe o instalador para sua plataforma nos assets abaixo.
|
||||||
|
draft: false
|
||||||
|
files: artifacts/**/*
|
||||||
|
generate_release_notes: false
|
||||||
|
|
||||||
+12
@@ -39,3 +39,15 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
~/.tauri/ollahub.key
|
||||||
|
|
||||||
|
# tauri build artifacts
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/bundle/
|
||||||
|
*.exe
|
||||||
|
*.msi
|
||||||
|
*.AppImage
|
||||||
|
*.deb
|
||||||
|
*.dmg
|
||||||
|
OllamaSetup.exe
|
||||||
|
src-tauri/bundle/ollama/
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
## 📋 Análise
|
|
||||||
**Solicitação**: Corrigir falhas de conexão do browser headless e o erro de compilação `Send`, tornar o scraper resiliente com retry e ampliar resultados de busca.
|
|
||||||
**Objetivo real**: Garantir estabilidade contínua do backend de scraping, mesmo quando o Chrome headless cai, evitando violações de `Send` e melhorando a cobertura de fontes.
|
|
||||||
|
|
||||||
## 🔍 Contexto Identificado
|
|
||||||
### Arquivos Examinados
|
|
||||||
- `src-tauri/src/lib.rs:1176–1193` get_or_create_browser: cria/reutiliza `Browser` singleton.
|
|
||||||
- `src-tauri/src/lib.rs:1247–1277` search_web_metadata: comando Tauri que exige `Future + Send`.
|
|
||||||
- `src-tauri/src/web_scraper.rs:136–240` search_duckduckgo_metadata: parsing HTML, previously segurava `Html` atravessando `await`.
|
|
||||||
- `src-tauri/src/web_scraper.rs:469–538` search_and_scrape_with_config: coordena scraping paralelo e coleta resultados.
|
|
||||||
- `src-tauri/src/web_scraper.rs:605–736` fetch_and_convert_sync: cria aba, navega, extrai HTML e usa Readability.
|
|
||||||
- `src-tauri/src/lib.rs:1306–1399` force_kill_browser: encerra processos Chrome headless.
|
|
||||||
|
|
||||||
### Padrões Detectados
|
|
||||||
- **Singleton Browser** via `State<BrowserState>` com `Arc<Browser>`.
|
|
||||||
- **Concorrência controlada** com `Semaphore` e `tokio::spawn_blocking` para operações de Chrome.
|
|
||||||
- **Fallbacks**: limpeza de URL, filtros anti-ads, Readability + fallback por parágrafos.
|
|
||||||
|
|
||||||
### Dependências Mapeadas
|
|
||||||
```
|
|
||||||
web_scraper (modificado)
|
|
||||||
├─ headless_chrome::Browser/Tab
|
|
||||||
├─ reqwest (HTTP)
|
|
||||||
├─ scraper (Html, Selector) [não Send]
|
|
||||||
└─ readability, html2text
|
|
||||||
lib (modificado)
|
|
||||||
└─ tauri commands (Future + Send obrigatório)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Abordagens Viáveis
|
|
||||||
|
|
||||||
### Abordagem A: Verificação ativa de Browser + Retry cooperativo
|
|
||||||
**Conceito**: Validar liveness do `Browser` antes de uso; se cair durante scraping, recriar e repetir uma passada de URLs com limite de tentativas.
|
|
||||||
```
|
|
||||||
request → get_or_create_browser (is_alive?) → spawn scraping
|
|
||||||
↳ erro conn fechada → reset + recreate → retry restante (1x)
|
|
||||||
```
|
|
||||||
**Vantagens**: ✓ Simples de integrar, ✓ Minimiza impacto, ✓ Boa resiliência
|
|
||||||
**Desvantagens**: ✗ Retry global pode repetir algumas URLs
|
|
||||||
**Complexidade**: O(n) | Tempo: 3–4h | Risco: Médio
|
|
||||||
|
|
||||||
### Abordagem B: Pool/Auto-heal por task
|
|
||||||
**Conceito**: Cada task recupera o browser ao falhar e re-executa só sua URL.
|
|
||||||
```
|
|
||||||
spawn_blocking(url) → new_tab → erro conn → sinaliza reset → reobtem browser → reprocessa url
|
|
||||||
```
|
|
||||||
**Vantagens**: ✓ Isolamento por URL
|
|
||||||
**Desvantagens**: ✗ Coordenação de estado entre threads, ✗ Maior complexidade
|
|
||||||
**Complexidade**: O(n) | Tempo: 5–6h | Risco: Médio-Alto
|
|
||||||
|
|
||||||
## 🏆 Recomendação
|
|
||||||
**Abordagem A** porque:
|
|
||||||
- Alinha com o padrão singleton já existente.
|
|
||||||
- Menor acoplamento entre tarefas e estado global.
|
|
||||||
- Implementação direta com menor risco de condições de corrida.
|
|
||||||
|
|
||||||
## ⚠️ Riscos & Mitigações
|
|
||||||
| Risco | Prob | Impacto | Mitigação |
|
|
||||||
|-------|------|---------|-----------|
|
|
||||||
| Loop de retry infinito | Baixa | Médio | Limitar a 1–2 tentativas, backoff curto |
|
|
||||||
| Recriação concorrente do Browser | Média | Médio | Guardar lock no `BrowserState` e checar antes de recriar |
|
|
||||||
| Demasiadas abas simultâneas | Média | Médio | Reduzir `max_concurrent_tabs` ao detectar falha |
|
|
||||||
| DDG bloqueios 429 | Média | Baixo | Rotação User-Agent e backoff |
|
|
||||||
|
|
||||||
## 📝 Plano de Execução
|
|
||||||
1. Ajustes `Send` (consolidação)
|
|
||||||
- Isolar parsing `scraper::Html` em escopo síncrono que retorna `Vec<SearchResultMetadata>` (já aplicado em parte na função; revisar para todos os caminhos).
|
|
||||||
- Garantir que nenhum `Html`/`Selector` vive além do `await` nos métodos `search_*`.
|
|
||||||
- Verificar pontos: `src-tauri/src/web_scraper.rs:136–240` e demais que usem `Html`.
|
|
||||||
|
|
||||||
2. Browser Resurrection
|
|
||||||
- Adicionar `fn is_browser_alive(browser: &Browser) -> bool` em `web_scraper.rs` ou `lib.rs` (ex.: tentar `new_tab()` e descartar; ou `get_tabs()`; se falhar → false).
|
|
||||||
- Alterar `get_or_create_browser` (`src-tauri/src/lib.rs:1176–1193`) para: se `Some(browser)` e `!is_alive(browser)` → `reset` e `create_browser()`.
|
|
||||||
- Em `force_kill_browser` (`src-tauri/src/lib.rs:1306–1399`): após kill, setar `BrowserState` para `None` via lock (ou orientar UI a chamar `reset_browser` antes do kill), garantindo recriação no próximo uso.
|
|
||||||
|
|
||||||
3. Retry resiliente no Scraper
|
|
||||||
- Envolver `fetch_and_convert_sync` com camada de retry (máx. 2 tentativas) na orquestração async (`search_and_scrape_with_config` em `src-tauri/src/web_scraper.rs:469–538`).
|
|
||||||
- Se erro contiver "underlying connection is closed"/timeout global do browser:
|
|
||||||
- Executar `reset_browser` e `get_or_create_browser` para novo `Arc<Browser)`.
|
|
||||||
- Reprocessar as URLs que falharam apenas uma vez com o novo browser.
|
|
||||||
- Reduzir temporariamente `max_concurrent_tabs` para 3 ao detectar queda, evitando sobrecarga imediata.
|
|
||||||
|
|
||||||
4. Paginação no DuckDuckGo
|
|
||||||
- Em `search_duckduckgo` (`src-tauri/src/web_scraper.rs:78–133`): suportar paginação via parâmetro `s=<offset>` até atingir `limit` (30–50).
|
|
||||||
- Extrair links por página, deduplicar, respeitar `excluded_domains`/ads.
|
|
||||||
|
|
||||||
5. Telemetria e Logs
|
|
||||||
- Logar eventos de recreação de browser, tentativas de retry e motivos.
|
|
||||||
- Contadores: quedas de browser, tentativas, tempo médio por URL.
|
|
||||||
|
|
||||||
6. Testes/Validação
|
|
||||||
- Caso 1: Forçar `force_kill_browser` durante scraping → validar que a próxima chamada recria o browser e conclui.
|
|
||||||
- Caso 2: Simular DDG com >= 40 resultados → paginar e coletar sem violar `Send`.
|
|
||||||
- Caso 3: Stress com `max_concurrent_tabs=5` → se cair, reduzir e recuperar.
|
|
||||||
|
|
||||||
## Key Changes (Resumo de Implementação)
|
|
||||||
- `lib.rs:1176–1193`: `get_or_create_browser` verifica liveness; recria se morto.
|
|
||||||
- `lib.rs:1306–1399`: sincronia com `reset_browser` ao matar processos.
|
|
||||||
- `web_scraper.rs:469–538`: retry global (1x) e recriação de browser ao detectar conexão fechada; ajuste dinamicamente `Semaphore` na segunda passada.
|
|
||||||
- `web_scraper.rs:78–133`: paginação DDG com `s=` offset.
|
|
||||||
- `web_scraper.rs:136–240`: parsing isolado; garantir que `Html` é dropado antes do `await`.
|
|
||||||
|
|
||||||
## Entregáveis
|
|
||||||
- Código robusto em `web_scraper.rs` e `lib.rs` com:
|
|
||||||
- Correção total do `Send`.
|
|
||||||
- Resurreição automática do browser.
|
|
||||||
- Retry controlado por erro de conexão.
|
|
||||||
- Paginação para 30–50 resultados.
|
|
||||||
- Logs melhorados e validação manual automática.
|
|
||||||
|
|
||||||
## ❓ Aguardando Aprovação
|
|
||||||
- [ ] Posso prosseguir com as mudanças propostas?
|
|
||||||
- [ ] Preferências de `max_concurrent_tabs` e `limit` padrão (sugestão: 5 e 40)?
|
|
||||||
- [ ] Deseja manter `force_kill_browser` como utilitário avançado, com alerta na UI para reiniciar o scraper?
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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,36 +1,206 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
<h1>🧠 OllaHub</h1>
|
||||||
|
<p><strong>Local-first LLM workspace with deep research capabilities and native OS integration.</strong></p>
|
||||||
|
|
||||||
## Getting Started
|
<p align="center">
|
||||||
|
<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>
|
||||||
|
|
||||||
First, run the development server:
|
<br>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Why OllaHub?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
### 🔍 Deep Research Pipeline
|
||||||
|
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
|
||||||
|
* **Ollama Integration:** Direct subprocess management for seamless local model execution.
|
||||||
|
* **Local Semantic Search:** Powered by ONNX Runtime (`all-MiniLM-L6-v2` 384-dimensional embeddings) with no external API calls.
|
||||||
|
* **Hybrid Storage:** Blazing fast SQLite (FTS5 for full-text search) with JSON fallbacks for backward compatibility and data safety.
|
||||||
|
|
||||||
|
### 🔌 MCP (Model Context Protocol) Integration
|
||||||
|
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
|
||||||
|
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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 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
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
</details>
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
---
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## 🚀 Getting Started
|
||||||
|
|
||||||
## Learn More
|
### Prerequisites
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
Before you begin, ensure you have the following installed:
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
* **Node.js**: `v20.x` or later
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
* **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`
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
### Installation & Development
|
||||||
|
|
||||||
## Deploy on Vercel
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone [https://github.com/evandrodevbr/OllaHub.git](https://github.com/evandrodevbr/OllaHub.git)
|
||||||
|
cd OllaHub
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
3. **Run in development mode:**
|
||||||
|
```bash
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
*This starts the Next.js server (`localhost:3000`), builds the Rust backend, and launches the Tauri native window.*
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm tauri build
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
*Compiled binaries will be available in `src-tauri/target/release/bundle/` (.msi, .dmg, .app, .deb, etc.).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 Tech Stack Deep Dive
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://www.google.com/search?q=https://skillicons.dev/icons%3Fi%3Drust,tauri,nextjs,react,ts,tailwind,sqlite" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
```
|
||||||
+587
-245
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -376,6 +376,16 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility class para ocultar scrollbar */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
|
}
|
||||||
|
|
||||||
/* Reasoning Chain animations */
|
/* Reasoning Chain animations */
|
||||||
@keyframes reasoning-fade-in {
|
@keyframes reasoning-fade-in {
|
||||||
from {
|
from {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
@@ -2,6 +2,15 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
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 { 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",
|
||||||
@@ -16,6 +25,10 @@ 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({
|
||||||
@@ -34,7 +47,21 @@ export default function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<OllamaHealthProvider>
|
||||||
|
<DownloadProvider>
|
||||||
|
<KeyboardShortcuts />
|
||||||
{children}
|
{children}
|
||||||
|
<AppUpdaterProvider />
|
||||||
|
<GlobalDownloadStatusBar />
|
||||||
|
<ServiceInterruptionOverlay />
|
||||||
|
</DownloadProvider>
|
||||||
|
</OllamaHealthProvider>
|
||||||
|
=======
|
||||||
|
<KeyboardShortcuts />
|
||||||
|
{children}
|
||||||
|
<AppUpdaterProvider />
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+175
-24
@@ -1,80 +1,230 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useOllamaCheck } from "@/hooks/use-ollama-check";
|
<<<<<<< HEAD
|
||||||
|
import { TitleBar } from "@/components/titlebar";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, startTransition, useRef } from "react";
|
||||||
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
=======
|
||||||
|
import { useSetupCheck } from "@/hooks/use-setup-check";
|
||||||
import { Hero } from "@/components/landing/hero";
|
import { 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 } from "react";
|
import { useEffect, startTransition } from "react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { status, check } = useOllamaCheck();
|
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { setSetupCompleted } = useSettingsStore();
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
// Tentar iniciar Ollama automaticamente quando o componente montar
|
// Refs para evitar loops e execuções múltiplas
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const isRedirectingRef = useRef(false);
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Hydration check: aguardar Zustand carregar do localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tryAutoStart = async () => {
|
setIsHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Guarda de roteamento: verificação de integridade do Ollama + flag persistente
|
||||||
|
// Prioridade: Integridade do Sistema > Flag de Setup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHydrated) return;
|
||||||
|
if (hasCheckedRef.current) return; // Proteção contra múltiplas execuções
|
||||||
|
|
||||||
|
const performCheck = async () => {
|
||||||
|
if (isRedirectingRef.current) return; // Evitar múltiplos redirecionamentos
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { invoke } = await import("@tauri-apps/api/core");
|
// 1. Verificação Real do Sistema (Fonte da Verdade)
|
||||||
await invoke('auto_start_ollama');
|
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
// Aguardar um pouco e verificar novamente
|
|
||||||
setTimeout(() => {
|
// Ler isSetupCompleted diretamente do store (não usar como dependência)
|
||||||
check();
|
const currentSetupStatus = useSettingsStore.getState().isSetupCompleted;
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
if (!isOllamaIntact) {
|
||||||
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
|
// Se o motor não existe, força o setup, não importa o que o storage diga
|
||||||
|
console.warn("Integridade do Ollama falhou. Redirecionando para Setup.");
|
||||||
|
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
// Resetar flag de setup se Ollama não está íntegro (ANTES de redirecionar)
|
||||||
|
setSetupCompleted(false);
|
||||||
|
|
||||||
|
// Pequeno delay para garantir que o estado seja persistido no localStorage
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificação de Fluxo de UI (apenas se Ollama está OK)
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
|
||||||
|
if (currentSetupStatus) {
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/chat');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Falha crítica no check de inicialização", e);
|
||||||
|
// Fallback seguro: sempre ir para setup em caso de erro
|
||||||
|
if (!isRedirectingRef.current) {
|
||||||
|
isRedirectingRef.current = true;
|
||||||
|
setSetupCompleted(false);
|
||||||
|
|
||||||
|
// Pequeno delay para garantir que o estado seja persistido no localStorage
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/setup');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
hasCheckedRef.current = true; // Marcar como executado
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Só tentar se estiver instalado mas não rodando
|
performCheck();
|
||||||
if (status === 'installed_stopped') {
|
}, [isHydrated, router, setSetupCompleted]);
|
||||||
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" />
|
||||||
<span className="ml-2 text-muted-foreground">Verificando sistema...</span>
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Após verificação, mostrar loading de redirecionamento (não deve aparecer por muito tempo)
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex-1 flex items-center justify-center pt-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<span className="text-sm font-medium text-foreground">Redirecionando...</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
=======
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
||||||
{/* Notificação de boas-vindas */}
|
{/* Notificação de boas-vindas */}
|
||||||
<WelcomeNotification />
|
<WelcomeNotification />
|
||||||
|
|
||||||
{/* Layout for Running State */}
|
{/* Layout for Ready State - Redirecionamento automático */}
|
||||||
{status === 'running' && (
|
{status === 'ready' && (
|
||||||
|
<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 === 'installed_stopped' && (
|
{status === 'needs_setup' && ollamaStatus === '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={check} />
|
<StoppedCard onCheckAgain={recheck} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Layout for Not Installed State (Background + Modal) */}
|
{/* Layout for Not Installed State (Background + Modal) */}
|
||||||
{status === 'not_installed' && (
|
{status === 'needs_ollama' && (
|
||||||
<>
|
<>
|
||||||
<div className="opacity-20 pointer-events-none blur-sm">
|
<div className="opacity-20 pointer-events-none blur-sm">
|
||||||
<Hero onStart={() => {}} />
|
<Hero onStart={() => {}} />
|
||||||
@@ -82,10 +232,11 @@ 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={check}
|
onCheckAgain={recheck}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+761
-115
File diff suppressed because it is too large.
Load diff
+17
-24
@@ -1,48 +1,41 @@
|
|||||||
'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 (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
|
<TitleBar />
|
||||||
<div className="text-center space-y-2">
|
<SetupWizard />
|
||||||
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -105,7 +105,7 @@ export default function TasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto pt-8">
|
||||||
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
||||||
<div className="mb-4 sm:mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { UpdateDialog } from '@/components/modals/update-dialog';
|
||||||
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export function AppUpdaterProvider() {
|
||||||
|
const { updateAvailable, checkUpdate } = useAppUpdater();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [hasChecked, setHasChecked] = useState(false);
|
||||||
|
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoCheckEnabled || hasChecked) return;
|
||||||
|
|
||||||
|
// Verificar após um delay para não interferir no carregamento inicial
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkUpdate().then(() => {
|
||||||
|
setHasChecked(true);
|
||||||
|
});
|
||||||
|
}, 5000); // 5 segundos após o app carregar
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [autoCheckEnabled, hasChecked, checkUpdate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (updateAvailable && !isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [updateAvailable, isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdateDialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Message } from "@/hooks/use-chat";
|
import { Message } from "@/hooks/use-chat";
|
||||||
import { Bot, User } from "lucide-react";
|
import { Bot, User } from "lucide-react";
|
||||||
@@ -8,14 +8,27 @@ 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;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
highlightTerm?: string;
|
||||||
|
highlightIndex?: number;
|
||||||
|
messageIndex?: number;
|
||||||
|
debugData?: DebugData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
|
/**
|
||||||
|
* Componente de mensagem do chat otimizado com React.memo
|
||||||
|
*
|
||||||
|
* Renderiza markdown em tempo real durante streaming para melhor UX.
|
||||||
|
* Usa useMemo para otimizar re-renders e manter performance.
|
||||||
|
*/
|
||||||
|
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex, debugData }: 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' &&
|
||||||
@@ -52,7 +65,62 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
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)
|
||||||
|
// Desabilitar durante streaming para performance
|
||||||
|
const highlightedContent = useMemo(() => {
|
||||||
|
// Não processar highlight durante streaming
|
||||||
|
if (isStreaming) return null;
|
||||||
|
|
||||||
|
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
|
||||||
|
return null; // Não há highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = highlightTerm.trim();
|
||||||
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||||
|
const content = processedContent;
|
||||||
|
|
||||||
|
// Encontrar todos os matches
|
||||||
|
const matches: Array<{ start: number; end: number; text: string }> = [];
|
||||||
|
let match;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
matches.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
text: match[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
// Construir conteúdo com highlights
|
||||||
|
let highlighted = '';
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
matches.forEach((match, idx) => {
|
||||||
|
// Adicionar texto antes do match
|
||||||
|
highlighted += content.substring(lastIndex, match.start);
|
||||||
|
|
||||||
|
// Adicionar match com highlight (laranja para atual, amarelo para outros)
|
||||||
|
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
|
||||||
|
const className = isCurrent
|
||||||
|
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
|
||||||
|
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
|
||||||
|
|
||||||
|
highlighted += `<mark class="${className}">${match.text}</mark>`;
|
||||||
|
lastIndex = match.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar texto restante
|
||||||
|
highlighted += content.substring(lastIndex);
|
||||||
|
return highlighted;
|
||||||
|
}, [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'}
|
||||||
@@ -75,11 +143,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden min-w-0 pt-1">
|
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
||||||
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
|
<div
|
||||||
{processedContent}
|
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
|
||||||
|
>
|
||||||
|
{!highlightedContent && processedContent}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Estilo Assistant
|
// Estilo Assistant
|
||||||
@@ -88,22 +159,42 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isStreaming ? (
|
||||||
|
// Renderização de markdown em tempo real durante streaming
|
||||||
<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-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||||
"prose-p:leading-7 prose-li:leading-7",
|
"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-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||||
isStreaming && "relative"
|
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded",
|
||||||
|
"relative"
|
||||||
)}>
|
)}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={markdownRenderers}
|
||||||
|
>
|
||||||
|
{markdownContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
|
||||||
|
<div className={cn(
|
||||||
|
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||||
|
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||||
|
"prose-p:leading-7 prose-li:leading-7",
|
||||||
|
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||||
|
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
|
||||||
|
)}>
|
||||||
|
{highlightedContent ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||||
|
) : (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={markdownRenderers}
|
components={markdownRenderers}
|
||||||
>
|
>
|
||||||
{processedContent}
|
{processedContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
{isStreaming && !isEmpty && (
|
|
||||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -120,8 +211,42 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</MessageBackground>
|
</MessageBackground>
|
||||||
|
{!isUser && !isStreaming && debugMode && debugData && (
|
||||||
|
<DebugConsole data={debugData} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatMessage com React.memo para evitar re-renders desnecessários
|
||||||
|
*
|
||||||
|
* Comparação customizada:
|
||||||
|
* - Compara conteúdo da mensagem
|
||||||
|
* - Compara estado de streaming
|
||||||
|
* - Compara termo de highlight e índice
|
||||||
|
*/
|
||||||
|
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
|
||||||
|
// Se streaming mudou, sempre re-renderizar
|
||||||
|
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durante streaming, só re-renderizar se conteúdo mudou
|
||||||
|
if (nextProps.isStreaming) {
|
||||||
|
return prevProps.message.content === nextProps.message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quando não está em streaming, comparar todas as props relevantes
|
||||||
|
return (
|
||||||
|
prevProps.message.content === nextProps.message.content &&
|
||||||
|
prevProps.message.role === nextProps.message.role &&
|
||||||
|
prevProps.highlightTerm === nextProps.highlightTerm &&
|
||||||
|
prevProps.highlightIndex === nextProps.highlightIndex &&
|
||||||
|
prevProps.messageIndex === nextProps.messageIndex &&
|
||||||
|
prevProps.debugData === nextProps.debugData
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar displayName para debugging
|
||||||
|
ChatMessage.displayName = 'ChatMessage';
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
'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,6 +18,8 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||||
|
import { SearchQueriesMonitor } from './search-queries-monitor';
|
||||||
|
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
|
||||||
|
|
||||||
interface MessageStepThinkingProps {
|
interface MessageStepThinkingProps {
|
||||||
metadata: ThinkingMessageMetadata;
|
metadata: ThinkingMessageMetadata;
|
||||||
@@ -105,13 +107,31 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
|||||||
|
|
||||||
{/* Conteúdo Expandido */}
|
{/* Conteúdo Expandido */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-4 text-sm text-muted-foreground animate-in fade-in">
|
||||||
{metadata.details && (
|
{metadata.details && (
|
||||||
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Monitoramento em Tempo Real - Queries */}
|
||||||
|
{metadata.activeQueries && metadata.activeQueries.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<SearchQueriesMonitor queries={metadata.activeQueries} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monitoramento em Tempo Real - URLs */}
|
||||||
|
{metadata.activeUrls && metadata.activeUrls.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<ScrapingUrlsMonitor urls={metadata.activeUrls} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{metadata.sources && metadata.sources.length > 0 && (
|
{metadata.sources && metadata.sources.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 pt-1">
|
<div className="pt-2 border-t border-muted/30">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Fontes Encontradas ({metadata.sources.length})
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{metadata.sources.map((source, idx) => (
|
{metadata.sources.map((source, idx) => (
|
||||||
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||||
@@ -122,6 +142,7 @@ export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metadata.error && (
|
{metadata.error && (
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Check, ChevronsUpDown, Search, DownloadCloud, Cpu, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { useModelSelection } from '@/hooks/use-model-selection';
|
||||||
|
import { useDownloadContext } from '@/contexts/download-context';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
export function ModelSelector() {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
const { myModels, availableModels, selectedModel, setSelectedModel, installedVariantsByFamily, refreshModels } = useModelSelection();
|
||||||
|
const { startDownload, state: downloadState } = useDownloadContext();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Track selected variant for each model in the catalog
|
||||||
|
const [selectedVariants, setSelectedVariants] = React.useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const handleDownload = async (e: React.MouseEvent, modelName: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await startDownload(modelName);
|
||||||
|
toast({
|
||||||
|
title: "Download Iniciado",
|
||||||
|
description: `Baixando ${modelName}...`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro",
|
||||||
|
description: "Falha ao iniciar download.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartDownload = async () => {
|
||||||
|
const term = searchQuery.trim();
|
||||||
|
if (!term) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startDownload(term);
|
||||||
|
setOpen(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
toast({
|
||||||
|
title: "Download Iniciado",
|
||||||
|
description: `Baixando ${term} da biblioteca Ollama...`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erro",
|
||||||
|
description: "Falha ao iniciar download.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (downloadState?.isSuccess) {
|
||||||
|
refreshModels();
|
||||||
|
}
|
||||||
|
}, [downloadState?.isSuccess, refreshModels]);
|
||||||
|
|
||||||
|
const filteredMyModels = React.useMemo(() => {
|
||||||
|
if (!searchQuery) return myModels;
|
||||||
|
return myModels.filter(m =>
|
||||||
|
m.model.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [myModels, searchQuery]);
|
||||||
|
|
||||||
|
const filteredAvailableModels = React.useMemo(() => {
|
||||||
|
if (!searchQuery) return availableModels;
|
||||||
|
return availableModels.filter(m =>
|
||||||
|
m.model.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [availableModels, searchQuery]);
|
||||||
|
|
||||||
|
// Helper to check if a model is currently selected (partial match for variants)
|
||||||
|
const isSelected = (modelName: string) => {
|
||||||
|
if (!selectedModel) return false;
|
||||||
|
return selectedModel.startsWith(modelName) || selectedModel === modelName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to extract tag/parameters from model name
|
||||||
|
const getVariantDisplay = (name: string) => {
|
||||||
|
if (name.includes(':')) {
|
||||||
|
return name.split(':')[1];
|
||||||
|
}
|
||||||
|
return 'latest';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[260px] justify-between"
|
||||||
|
>
|
||||||
|
{selectedModel ? (
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{selectedModel}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Selecione um modelo..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="flex items-center border-b border-border/50 px-3 bg-muted/30">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<input
|
||||||
|
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Buscar modelos..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && searchQuery && filteredAvailableModels.length === 0) {
|
||||||
|
handleSmartDownload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="installed" className="w-full">
|
||||||
|
<TabsList className="w-full rounded-none border-b bg-transparent p-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="installed"
|
||||||
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
|
||||||
|
>
|
||||||
|
Instalados
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="available"
|
||||||
|
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
|
||||||
|
>
|
||||||
|
Catálogo
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="installed" className="m-0 max-h-[400px] overflow-y-auto p-2">
|
||||||
|
{filteredMyModels.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{searchQuery ? (
|
||||||
|
<span>Nenhum modelo instalado encontrado.</span>
|
||||||
|
) : (
|
||||||
|
<span>Nenhum modelo instalado.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filteredMyModels.map((modelDef) => {
|
||||||
|
const family = modelDef.model;
|
||||||
|
const installedTags = installedVariantsByFamily[family] || [];
|
||||||
|
const isFamilySelected = selectedModel ? (selectedModel === family || selectedModel.startsWith(family + ":")) : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={family}
|
||||||
|
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between w-full mb-2">
|
||||||
|
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
|
||||||
|
<span className="font-bold text-base truncate flex items-center gap-2">
|
||||||
|
{modelDef.model}
|
||||||
|
{isFamilySelected && (
|
||||||
|
<Check className="h-3 w-3 text-primary animate-in fade-in zoom-in" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
|
||||||
|
{modelDef.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{installedTags.map((fullTag) => {
|
||||||
|
const tag = getVariantDisplay(fullTag);
|
||||||
|
const isExactSelected = selectedModel === fullTag;
|
||||||
|
// Find variant def for size info if available
|
||||||
|
const variantDef = modelDef.variants.find(v => v.name === fullTag);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={fullTag}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
|
||||||
|
isExactSelected
|
||||||
|
? "bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
title={variantDef ? `Tamanho: ${variantDef.size}` : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedModel(fullTag);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="available" className="m-0 max-h-[400px] overflow-y-auto p-2">
|
||||||
|
{filteredAvailableModels.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
{searchQuery ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<p className="text-muted-foreground">Modelo não encontrado no catálogo.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto py-2 px-4 border-dashed border-zinc-500/50 hover:border-primary hover:bg-primary/10"
|
||||||
|
onClick={handleSmartDownload}
|
||||||
|
>
|
||||||
|
<DownloadCloud className="mr-2 h-4 w-4" />
|
||||||
|
<span>Baixar <span className="font-bold ml-1">{searchQuery}</span></span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Nenhum modelo encontrado.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filteredAvailableModels.map((modelDef) => {
|
||||||
|
// Determine active variant for this item
|
||||||
|
const activeVariantName = selectedVariants[modelDef.model] || modelDef.variants[0]?.name;
|
||||||
|
const activeVariant = modelDef.variants.find(v => v.name === activeVariantName) || modelDef.variants[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={modelDef.model}
|
||||||
|
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between w-full mb-2">
|
||||||
|
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
|
||||||
|
<span className="font-bold text-base truncate">{modelDef.model}</span>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
|
||||||
|
{modelDef.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => handleDownload(e, activeVariant.name)}
|
||||||
|
disabled={downloadState.isDownloading && downloadState.currentModel === activeVariant.name}
|
||||||
|
title={`Baixar ${activeVariant.name}`}
|
||||||
|
>
|
||||||
|
{downloadState.isDownloading && downloadState.currentModel === activeVariant.name ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<DownloadCloud className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variants Pills */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{modelDef.variants.map((variant) => {
|
||||||
|
const isVariantSelected = activeVariant.name === variant.name;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={variant.name}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
|
||||||
|
isVariantSelected
|
||||||
|
? "bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
title={`Tamanho em disco: ${variant.size}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedVariants(prev => ({
|
||||||
|
...prev,
|
||||||
|
[modelDef.model]: variant.name
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getVariantDisplay(variant.name)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, CheckCircle2, XCircle, ExternalLink, Globe } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ActiveUrl } from '@/hooks/use-web-search-events';
|
||||||
|
|
||||||
|
interface ScrapingUrlsMonitorProps {
|
||||||
|
urls: ActiveUrl[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrapingUrlsMonitor({ urls, className }: ScrapingUrlsMonitorProps) {
|
||||||
|
if (urls.length === 0) return null;
|
||||||
|
|
||||||
|
const getFaviconUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: ActiveUrl['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'started':
|
||||||
|
return <Loader2 className="w-3 h-3 animate-spin text-primary" />;
|
||||||
|
case 'completed':
|
||||||
|
case 'cached':
|
||||||
|
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <XCircle className="w-3 h-3 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return <Globe className="w-3 h-3 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceBadge = (source?: string) => {
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
static: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
|
||||||
|
nodriver: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
|
||||||
|
playwright: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
|
||||||
|
cached: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn('text-xs', colors[source] || 'bg-muted')}>
|
||||||
|
{source}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Sites Sendo Acessados ({urls.length})
|
||||||
|
</div>
|
||||||
|
{urls.map((url, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${url.url}-${idx}`}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{getStatusIcon(url.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<img
|
||||||
|
src={getFaviconUrl(url.url)}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<a
|
||||||
|
href={url.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{url.title || url.url}
|
||||||
|
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
{getSourceBadge(url.source)}
|
||||||
|
{url.duration && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{url.duration}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{url.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, CheckCircle2, Globe, Search } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ActiveQuery } from '@/hooks/use-web-search-events';
|
||||||
|
|
||||||
|
interface SearchQueriesMonitorProps {
|
||||||
|
queries: ActiveQuery[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchQueriesMonitor({ queries, className }: SearchQueriesMonitorProps) {
|
||||||
|
if (queries.length === 0) return null;
|
||||||
|
|
||||||
|
const getSourceColor = (source: string) => {
|
||||||
|
switch (source) {
|
||||||
|
case 'brave':
|
||||||
|
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
|
||||||
|
case 'searxng':
|
||||||
|
return 'bg-green-500/10 text-green-600 border-green-500/20';
|
||||||
|
case 'duckduckgo':
|
||||||
|
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
|
||||||
|
default:
|
||||||
|
return 'bg-muted text-muted-foreground border-muted';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceLabel = (source: string) => {
|
||||||
|
switch (source) {
|
||||||
|
case 'brave':
|
||||||
|
return 'Brave';
|
||||||
|
case 'searxng':
|
||||||
|
return 'SearXNG';
|
||||||
|
case 'duckduckgo':
|
||||||
|
return 'DuckDuckGo';
|
||||||
|
default:
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Consultas Ativas ({queries.length})
|
||||||
|
</div>
|
||||||
|
{queries.map((query, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${query.query}-${query.source}-${idx}`}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-primary flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs truncate">{query.query}</span>
|
||||||
|
<Badge variant="outline" className={cn('text-xs', getSourceColor(query.source))}>
|
||||||
|
{getSourceLabel(query.source)}
|
||||||
|
</Badge>
|
||||||
|
{query.round && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Round {query.round}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
|
import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ interface SidebarListProps {
|
|||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
isSearching?: boolean;
|
isSearching?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
@@ -58,9 +60,11 @@ export function SidebarList({
|
|||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSearch,
|
onSearch,
|
||||||
isSearching = false
|
isSearching = false,
|
||||||
|
searchQuery = '',
|
||||||
|
onNavigateMatch
|
||||||
}: SidebarListProps) {
|
}: SidebarListProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [localSearchInput, setLocalSearchInput] = useState('');
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const DEBOUNCE_MS = 300;
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ export function SidebarList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Se query vazia, limpar busca imediatamente
|
// Se query vazia, limpar busca imediatamente
|
||||||
if (!searchQuery.trim()) {
|
if (!localSearchInput.trim()) {
|
||||||
if (onSearch) {
|
if (onSearch) {
|
||||||
onSearch('');
|
onSearch('');
|
||||||
}
|
}
|
||||||
@@ -80,8 +84,8 @@ export function SidebarList({
|
|||||||
|
|
||||||
// Debounce: aguardar antes de buscar
|
// Debounce: aguardar antes de buscar
|
||||||
searchTimeoutRef.current = setTimeout(() => {
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
if (onSearch && searchQuery.trim().length >= 2) {
|
if (onSearch && localSearchInput.trim().length >= 2) {
|
||||||
onSearch(searchQuery.trim());
|
onSearch(localSearchInput.trim());
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
@@ -91,10 +95,10 @@ export function SidebarList({
|
|||||||
clearTimeout(searchTimeoutRef.current);
|
clearTimeout(searchTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchQuery, onSearch]);
|
}, [localSearchInput, onSearch]);
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setSearchQuery('');
|
setLocalSearchInput('');
|
||||||
if (onSearch) {
|
if (onSearch) {
|
||||||
onSearch('');
|
onSearch('');
|
||||||
}
|
}
|
||||||
@@ -111,14 +115,14 @@ export function SidebarList({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar conversas..."
|
placeholder="Buscar conversas..."
|
||||||
value={searchQuery}
|
value={localSearchInput}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setLocalSearchInput(e.target.value)}
|
||||||
className="pl-8 pr-8 h-9 text-sm"
|
className="pl-8 pr-8 h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
||||||
)}
|
)}
|
||||||
{searchQuery && !isSearching && (
|
{localSearchInput && !isSearching && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearSearch}
|
onClick={handleClearSearch}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -190,15 +194,54 @@ export function SidebarList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Texto do card */}
|
{/* Texto do card */}
|
||||||
<div className="sidebar-chat-item-text">
|
<div className="sidebar-chat-item-text flex-1 min-w-0">
|
||||||
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
||||||
{session.title}
|
{session.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||||
{formatDate(session.updated_at)}
|
{formatDate(session.updated_at)}
|
||||||
</p>
|
</p>
|
||||||
|
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||||
|
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
|
||||||
|
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
|
||||||
|
<div className="flex flex-col gap-0.5 mr-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigateMatch(session.id, 'prev');
|
||||||
|
}}
|
||||||
|
aria-label="Ocorrência anterior"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onNavigateMatch(session.id, 'next');
|
||||||
|
}}
|
||||||
|
aria-label="Próxima ocorrência"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Botão de deletar - aparece no hover */}
|
{/* Botão de deletar - aparece no hover */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useDownloadContext } from '@/contexts/download-context';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function GlobalDownloadStatusBar() {
|
||||||
|
const { state, cancelDownload } = useDownloadContext();
|
||||||
|
|
||||||
|
if (!state.isDownloading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { progress, currentModel, isCancelling } = state;
|
||||||
|
const modelDisplayName = currentModel || 'Modelo';
|
||||||
|
const percent = progress.percent ?? 0;
|
||||||
|
const hasSizeInfo = progress.totalBytes > 0 && progress.downloadedBytes > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-0 left-0 right-0 z-50',
|
||||||
|
'bg-background/95 backdrop-blur-sm border-t border-border',
|
||||||
|
'shadow-lg',
|
||||||
|
'animate-in slide-in-from-bottom duration-300',
|
||||||
|
'transition-all'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-sm font-medium text-foreground truncate">
|
||||||
|
{isCancelling ? (
|
||||||
|
'Cancelando download...'
|
||||||
|
) : (
|
||||||
|
`Baixando Modelo: ${modelDisplayName} (${percent}%)`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{progress.speed && !isCancelling && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
|
||||||
|
{progress.speed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={percent}
|
||||||
|
className="h-1.5 mb-1.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasSizeInfo && (
|
||||||
|
<div className="flex justify-between items-center text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
|
||||||
|
<span>
|
||||||
|
{progress.downloadedFormatted} / {progress.totalFormatted}
|
||||||
|
</span>
|
||||||
|
{progress.status && (
|
||||||
|
<span className="normal-case">{progress.status}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={cancelDownload}
|
||||||
|
disabled={isCancelling}
|
||||||
|
className="shrink-0 h-8 w-8"
|
||||||
|
aria-label="Cancelar download"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'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,10 +87,7 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
try {
|
try {
|
||||||
await downloadState.handleInstall();
|
await downloadState.handleInstall();
|
||||||
toast({
|
// Toast será mostrado automaticamente via eventos de progresso
|
||||||
title: 'Instalador iniciado',
|
|
||||||
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Erro ao executar instalador',
|
title: 'Erro ao executar instalador',
|
||||||
@@ -144,8 +141,10 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
const isActive = activeTab === os;
|
const isActive = activeTab === os;
|
||||||
const isDownloading = downloadState.isDownloading && isActive;
|
const isDownloading = downloadState.isDownloading && isActive;
|
||||||
const hasDownloaded = downloadState.filePath !== null && isActive;
|
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||||
|
const isInstalling = downloadState.isInstalling && isActive;
|
||||||
|
const installStatus = downloadState.installStatus;
|
||||||
|
|
||||||
if (!isDownloading && !hasDownloaded) {
|
if (!isDownloading && !hasDownloaded && !isInstalling) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +161,18 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<Progress value={downloadState.downloadProgress} className="h-2" />
|
<Progress value={downloadState.downloadProgress} className="h-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasDownloaded && !isDownloading && (
|
{isInstalling && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{installStatus || 'Instalando...'}
|
||||||
|
</span>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<Progress value={undefined} className="h-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasDownloaded && !isDownloading && !isInstalling && (
|
||||||
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
<div className="text-sm flex-1">
|
<div className="text-sm flex-1">
|
||||||
@@ -170,7 +180,20 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
Download concluído
|
Download concluído
|
||||||
</p>
|
</p>
|
||||||
<p className="text-green-700 dark:text-green-300">
|
<p className="text-green-700 dark:text-green-300">
|
||||||
Clique em "Instalar Ollama" para iniciar a instalação.
|
Clique em "Instalar Ollama" para iniciar a instalação automática.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{downloadState.isInstalled && !isInstalling && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
|
<div className="text-sm flex-1">
|
||||||
|
<p className="font-medium text-green-900 dark:text-green-100">
|
||||||
|
Instalação concluída!
|
||||||
|
</p>
|
||||||
|
<p className="text-green-700 dark:text-green-300">
|
||||||
|
{installStatus || 'Ollama foi instalado com sucesso.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +206,9 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<Dialog open={open}>
|
<Dialog open={open}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
<DialogTitle data-tauri-drag-region className="cursor-move select-none">
|
||||||
|
Ollama não encontrado
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||||
Baixe e instale o Ollama usando os passos abaixo.
|
Baixe e instale o Ollama usando os passos abaixo.
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Download, Loader2, X, Sparkles } from 'lucide-react';
|
||||||
|
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
interface UpdateDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateDialog({ open, onOpenChange }: UpdateDialogProps) {
|
||||||
|
const {
|
||||||
|
currentVersion,
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
isChecking,
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
error,
|
||||||
|
installUpdate,
|
||||||
|
} = useAppUpdater();
|
||||||
|
|
||||||
|
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||||
|
const setAutoCheckUpdates = useSettingsStore((state) => state.setAutoCheckUpdates);
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (dontShowAgain) {
|
||||||
|
setAutoCheckUpdates(false);
|
||||||
|
}
|
||||||
|
await installUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (dontShowAgain) {
|
||||||
|
setAutoCheckUpdates(false);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!updateAvailable && !isChecking) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open && updateAvailable} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Atualização Disponível
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Uma nova versão do OllaHub está disponível para download.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">{currentVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Nova Versão</span>
|
||||||
|
<span className="font-mono font-medium text-primary">
|
||||||
|
{updateInfo?.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{updateInfo?.body && (
|
||||||
|
<div className="p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||||
|
{updateInfo.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Baixando atualização...</span>
|
||||||
|
<span className="font-medium">{downloadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={downloadProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="dont-show-updates"
|
||||||
|
checked={dontShowAgain}
|
||||||
|
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="dont-show-updates"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Não verificar atualizações automaticamente
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
Depois
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={isDownloading || isChecking}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Baixando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Atualizar Agora
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -67,3 +67,11 @@ export function WelcomeNotification() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -269,3 +269,11 @@ export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +43,33 @@ export function HardwareDashboard() {
|
|||||||
|
|
||||||
if (isLoading || !stats) {
|
if (isLoading || !stats) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardContent className="py-8 text-center text-muted-foreground">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
Carregando estatísticas do sistema...
|
{/* 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Carregando estatísticas do sistema...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ExternalLink, Copy, Check, Terminal } from 'lucide-react';
|
||||||
|
import { ProblemSolution } from '@/lib/docker-problems';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DockerCommandsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
problem: ProblemSolution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerCommandsModal({ open, onOpenChange, problem }: DockerCommandsModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (!problem) return null;
|
||||||
|
|
||||||
|
const handleCopyCommand = async (command: string, index: number) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopiedIndex(index);
|
||||||
|
toast({
|
||||||
|
title: 'Comando copiado',
|
||||||
|
description: 'Comando copiado para a área de transferência',
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Erro ao copiar',
|
||||||
|
description: 'Não foi possível copiar o comando',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('open_url', { url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening URL:', error);
|
||||||
|
// Fallback para window.open se Tauri falhar
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-4 sm:p-6">
|
||||||
|
<DialogHeader className="flex-shrink-0 pb-2 sm:pb-4">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-sm sm:text-base md:text-lg">
|
||||||
|
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||||
|
<span className="break-words">{problem.problem}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
{problem.description && (
|
||||||
|
<DialogDescription className="text-xs sm:text-sm mt-1 sm:mt-2">
|
||||||
|
{problem.description}
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 min-h-0 pr-1 -mr-1">
|
||||||
|
{/* Links de Documentação */}
|
||||||
|
{problem.links.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2 sm:pb-3">
|
||||||
|
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
Documentação Oficial
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
|
Links para documentação oficial sobre este problema
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{problem.links.map((link, idx) => (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-xs sm:text-sm h-9 sm:h-10"
|
||||||
|
onClick={() => handleOpenLink(link.url)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 sm:w-4 sm:h-4 mr-2 shrink-0" />
|
||||||
|
<span className="truncate">{link.title}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comandos */}
|
||||||
|
{problem.commands && problem.commands.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2 sm:pb-3">
|
||||||
|
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
||||||
|
<Terminal className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
Comandos
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
|
Execute estes comandos no terminal para resolver o problema
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
{problem.commands.map((command, idx) => (
|
||||||
|
<div key={idx} className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="text-xs sm:text-sm font-medium flex-1 break-words">{command.title}</h4>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleCopyCommand(command.code, idx)}
|
||||||
|
className="h-7 sm:h-8 shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{copiedIndex === idx ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 mr-1 text-green-500" />
|
||||||
|
<span className="hidden sm:inline">Copiado</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3 h-3 mr-1" />
|
||||||
|
<span className="hidden sm:inline">Copiar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{command.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{command.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="bg-muted p-2 sm:p-3 md:p-4 rounded-lg overflow-x-auto text-xs sm:text-sm">
|
||||||
|
<code className="font-mono whitespace-pre-wrap break-all">{command.code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mensagem quando não há comandos nem links */}
|
||||||
|
{problem.links.length === 0 && (!problem.commands || problem.commands.length === 0) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4 sm:py-6 text-center text-muted-foreground">
|
||||||
|
<p className="text-xs sm:text-sm">Nenhuma solução disponível para este problema.</p>
|
||||||
|
<p className="text-xs mt-2">
|
||||||
|
Consulte a documentação oficial do Docker para mais informações.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Download,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Settings,
|
||||||
|
Wrench,
|
||||||
|
Server,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
Shield,
|
||||||
|
Zap,
|
||||||
|
Terminal
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { SearxngInstallWizard } from './searxng-install-wizard';
|
||||||
|
import { DockerCommandsModal } from './docker-commands-modal';
|
||||||
|
import { findProblemSolution, ProblemSolution } from '@/lib/docker-problems';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SystemRequirements {
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
ram_mb: number;
|
||||||
|
disk_gb: number;
|
||||||
|
cpu_cores: number;
|
||||||
|
kernel_version?: string;
|
||||||
|
issues: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallProgressEvent {
|
||||||
|
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
|
||||||
|
step?: number;
|
||||||
|
step_name?: string;
|
||||||
|
total_steps?: number;
|
||||||
|
progress: number;
|
||||||
|
message?: string;
|
||||||
|
log_line?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FixProgressEvent {
|
||||||
|
event_type: 'start' | 'progress' | 'complete' | 'error';
|
||||||
|
message: string;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
status: 'pass' | 'fail' | 'partial';
|
||||||
|
tests: Array<{
|
||||||
|
name: string;
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
docker_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerSettingsPanel() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
|
||||||
|
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
|
||||||
|
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
|
||||||
|
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
|
||||||
|
const [isFixing, setIsFixing] = useState(false);
|
||||||
|
const [fixProgress, setFixProgress] = useState<{ message: string; progress: number } | null>(null);
|
||||||
|
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
progress: number;
|
||||||
|
currentStepName: string;
|
||||||
|
logs: string[];
|
||||||
|
}>({
|
||||||
|
currentStep: 0,
|
||||||
|
totalSteps: 0,
|
||||||
|
progress: 0,
|
||||||
|
currentStepName: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [showInstallWizard, setShowInstallWizard] = useState(false);
|
||||||
|
const [showCommandsModal, setShowCommandsModal] = useState(false);
|
||||||
|
const [selectedProblem, setSelectedProblem] = useState<ProblemSolution | null>(null);
|
||||||
|
|
||||||
|
// Verificar Docker ao montar
|
||||||
|
useEffect(() => {
|
||||||
|
checkDocker();
|
||||||
|
checkRequirements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listener de eventos de progresso Docker
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenPromise: Promise<() => void> | null = null;
|
||||||
|
|
||||||
|
if (isInstallingDocker) {
|
||||||
|
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
|
||||||
|
const data = event.payload;
|
||||||
|
|
||||||
|
switch (data.event_type) {
|
||||||
|
case 'step_start':
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: data.step || 0,
|
||||||
|
totalSteps: data.total_steps || prev.totalSteps,
|
||||||
|
currentStepName: data.step_name || '',
|
||||||
|
progress: data.progress,
|
||||||
|
}));
|
||||||
|
if (data.message) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.message!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'log':
|
||||||
|
if (data.log_line) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.log_line!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'step_complete':
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
progress: data.progress,
|
||||||
|
}));
|
||||||
|
if (data.message) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.message!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
toast({
|
||||||
|
title: 'Erro na instalação',
|
||||||
|
description: data.message || 'Erro desconhecido',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
toast({
|
||||||
|
title: 'Docker instalado',
|
||||||
|
description: 'Docker foi instalado com sucesso!',
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
validateDocker();
|
||||||
|
checkDocker();
|
||||||
|
}, 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenPromise) {
|
||||||
|
unlistenPromise.then(fn => fn());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isInstallingDocker, toast]);
|
||||||
|
|
||||||
|
// Listener de eventos de correção
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenPromise: Promise<() => void> | null = null;
|
||||||
|
|
||||||
|
if (isFixing) {
|
||||||
|
unlistenPromise = listen<FixProgressEvent>('docker-fix-progress', (event) => {
|
||||||
|
const data = event.payload;
|
||||||
|
|
||||||
|
switch (data.event_type) {
|
||||||
|
case 'start':
|
||||||
|
setFixProgress({ message: data.message, progress: 0 });
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
setFixProgress({ message: data.message, progress: data.progress || 0 });
|
||||||
|
break;
|
||||||
|
case 'complete':
|
||||||
|
setFixProgress({ message: data.message, progress: 100 });
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsFixing(false);
|
||||||
|
setFixProgress(null);
|
||||||
|
checkRequirements();
|
||||||
|
toast({
|
||||||
|
title: 'Correção concluída',
|
||||||
|
description: data.message,
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
setIsFixing(false);
|
||||||
|
setFixProgress(null);
|
||||||
|
toast({
|
||||||
|
title: 'Erro na correção',
|
||||||
|
description: data.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenPromise) {
|
||||||
|
unlistenPromise.then(fn => fn());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isFixing, toast]);
|
||||||
|
|
||||||
|
const checkDocker = async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const available = await invoke<boolean>('check_docker_available');
|
||||||
|
setDockerAvailable(available);
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
const running = await invoke<boolean>('check_docker_running');
|
||||||
|
setDockerRunning(running);
|
||||||
|
} else {
|
||||||
|
setDockerRunning(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking Docker:', error);
|
||||||
|
setDockerAvailable(false);
|
||||||
|
setDockerRunning(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkRequirements = async () => {
|
||||||
|
setIsCheckingRequirements(true);
|
||||||
|
try {
|
||||||
|
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
|
||||||
|
setRequirements(reqs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking requirements:', error);
|
||||||
|
setRequirements({
|
||||||
|
status: 'fail',
|
||||||
|
os: 'unknown',
|
||||||
|
arch: 'unknown',
|
||||||
|
ram_mb: 0,
|
||||||
|
disk_gb: 0,
|
||||||
|
cpu_cores: 0,
|
||||||
|
issues: ['Erro ao verificar requisitos do sistema'],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingRequirements(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFixRequirements = async () => {
|
||||||
|
if (!requirements || requirements.issues.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Nada para corrigir',
|
||||||
|
description: 'Não há problemas encontrados que possam ser corrigidos automaticamente',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFixing(true);
|
||||||
|
setFixProgress({ message: 'Preparando correção...', progress: 0 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('fix_docker_requirements', {
|
||||||
|
os: requirements.os,
|
||||||
|
issues: requirements.issues,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fixing requirements:', error);
|
||||||
|
setIsFixing(false);
|
||||||
|
setFixProgress(null);
|
||||||
|
toast({
|
||||||
|
title: 'Erro na correção',
|
||||||
|
description: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallDocker = async () => {
|
||||||
|
setIsInstallingDocker(true);
|
||||||
|
setDockerInstallProgress({
|
||||||
|
currentStep: 0,
|
||||||
|
totalSteps: 0,
|
||||||
|
progress: 0,
|
||||||
|
currentStepName: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('install_docker');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Docker installation error:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro na instalação',
|
||||||
|
description: error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDocker = async () => {
|
||||||
|
setIsValidating(true);
|
||||||
|
setValidationResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<ValidationResult>('validate_docker_installation');
|
||||||
|
setValidationResult(result);
|
||||||
|
|
||||||
|
if (result.status === 'pass' || result.status === 'partial') {
|
||||||
|
setDockerAvailable(true);
|
||||||
|
setDockerRunning(true);
|
||||||
|
toast({
|
||||||
|
title: 'Docker validado',
|
||||||
|
description: 'Docker está funcionando corretamente',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Validação falhou',
|
||||||
|
description: 'Alguns testes de validação falharam',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation error:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro na validação',
|
||||||
|
description: error instanceof Error ? error.message : 'Erro ao validar Docker',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getStatusIcon = (status: 'pass' | 'fail' | 'warn') => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pass':
|
||||||
|
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||||
|
case 'fail':
|
||||||
|
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||||
|
case 'warn':
|
||||||
|
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status do Docker */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Status do Docker</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={checkDocker}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("w-4 h-4 mr-1", isChecking && "animate-spin")} />
|
||||||
|
Atualizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Verifique se Docker está instalado e rodando
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isChecking ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">Verificando...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className={cn(
|
||||||
|
"p-4 rounded-lg border-2 transition-colors",
|
||||||
|
dockerAvailable
|
||||||
|
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
|
||||||
|
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Instalação</span>
|
||||||
|
{dockerAvailable ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
dockerAvailable
|
||||||
|
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
|
||||||
|
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dockerAvailable ? 'Instalado' : 'Não Instalado'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"p-4 rounded-lg border-2 transition-colors",
|
||||||
|
dockerRunning
|
||||||
|
? "border-green-500/50 bg-green-500/10 dark:bg-green-500/20"
|
||||||
|
: "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Serviço</span>
|
||||||
|
{dockerRunning ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
dockerRunning
|
||||||
|
? "text-green-700 dark:text-green-300 border-green-600 dark:border-green-400 bg-green-100 dark:bg-green-500/20"
|
||||||
|
: "text-red-700 dark:text-red-300 border-red-600 dark:border-red-400 bg-red-100 dark:bg-red-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dockerRunning ? 'Rodando' : 'Parado'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dockerAvailable === false && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Docker não está instalado. Instale-o para usar SearXNG.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dockerAvailable && !dockerRunning && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Requisitos do Sistema */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Requisitos do Sistema</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{requirements && requirements.issues.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFixRequirements}
|
||||||
|
disabled={isFixing || isCheckingRequirements}
|
||||||
|
>
|
||||||
|
<Wrench className="w-4 h-4 mr-1" />
|
||||||
|
Corrigir
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={checkRequirements}
|
||||||
|
disabled={isCheckingRequirements}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("w-4 h-4 mr-1", isCheckingRequirements && "animate-spin")} />
|
||||||
|
Verificar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Verifique se seu sistema atende aos requisitos para Docker
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isCheckingRequirements ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary mr-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">Verificando requisitos...</span>
|
||||||
|
</div>
|
||||||
|
) : requirements ? (
|
||||||
|
<>
|
||||||
|
{/* Status Geral */}
|
||||||
|
<div className={cn(
|
||||||
|
"p-4 rounded-lg border-2 flex items-center justify-between",
|
||||||
|
requirements.status === 'pass' && "border-green-500/50 bg-green-500/10 dark:bg-green-500/20",
|
||||||
|
requirements.status === 'warn' && "border-yellow-500/50 bg-yellow-500/10 dark:bg-yellow-500/20",
|
||||||
|
requirements.status === 'fail' && "border-red-500/50 bg-red-500/10 dark:bg-red-500/20"
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getStatusIcon(requirements.status)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground">Status Geral</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{requirements.status === 'pass' && 'Sistema compatível'}
|
||||||
|
{requirements.status === 'warn' && 'Avisos encontrados'}
|
||||||
|
{requirements.status === 'fail' && 'Problemas encontrados'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={cn(
|
||||||
|
requirements.status === 'pass' && "bg-green-500",
|
||||||
|
requirements.status === 'warn' && "bg-yellow-500",
|
||||||
|
requirements.status === 'fail' && "bg-red-500"
|
||||||
|
)}>
|
||||||
|
{requirements.status === 'pass' && 'Compatível'}
|
||||||
|
{requirements.status === 'warn' && 'Avisos'}
|
||||||
|
{requirements.status === 'fail' && 'Incompatível'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Especificações do Sistema */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MemoryStick className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">RAM</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<HardDrive className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Disco Livre</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{requirements.disk_gb} GB</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">CPU Cores</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{requirements.cpu_cores}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Server className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Sistema</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{requirements.os} ({requirements.arch})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progresso de Correção */}
|
||||||
|
{isFixing && fixProgress && (
|
||||||
|
<div className="space-y-2 p-4 rounded-lg border bg-blue-50 border-blue-200">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{fixProgress.message}</span>
|
||||||
|
<span>{fixProgress.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={fixProgress.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Problemas */}
|
||||||
|
{requirements.issues.length > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium mb-2">Problemas Encontrados:</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{requirements.issues.map((issue, idx) => {
|
||||||
|
const solution = findProblemSolution(issue, requirements.os);
|
||||||
|
return (
|
||||||
|
<li key={idx} className="flex items-start justify-between gap-2">
|
||||||
|
<span className="flex-1">{issue}</span>
|
||||||
|
{solution && (
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{solution.commands && solution.commands.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProblem(solution);
|
||||||
|
setShowCommandsModal(true);
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3 h-3 mr-1" />
|
||||||
|
Comandos
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{solution.links.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (solution.links[0]) {
|
||||||
|
try {
|
||||||
|
await invoke('open_url', { url: solution.links[0].url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening URL:', error);
|
||||||
|
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avisos */}
|
||||||
|
{requirements.warnings.length > 0 && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium mb-2">Avisos:</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{requirements.warnings.map((warning, idx) => {
|
||||||
|
const solution = findProblemSolution(warning, requirements.os);
|
||||||
|
return (
|
||||||
|
<li key={idx} className="flex items-start justify-between gap-2">
|
||||||
|
<span className="flex-1">{warning}</span>
|
||||||
|
{solution && (
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{solution.commands && solution.commands.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProblem(solution);
|
||||||
|
setShowCommandsModal(true);
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3 h-3 mr-1" />
|
||||||
|
Comandos
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{solution.links.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (solution.links[0]) {
|
||||||
|
try {
|
||||||
|
await invoke('open_url', { url: solution.links[0].url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening URL:', error);
|
||||||
|
window.open(solution.links[0].url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Docs
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Instalação Docker */}
|
||||||
|
{dockerAvailable === false && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Instalar Docker</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Instale Docker automaticamente ou manualmente
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
window.open('https://www.docker.com/products/docker-desktop', '_blank');
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Instalação Manual
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleInstallDocker}
|
||||||
|
disabled={isInstallingDocker || requirements?.status === 'fail'}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isInstallingDocker ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Instalando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Agora
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstallingDocker && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
|
||||||
|
<span className="text-muted-foreground">{dockerInstallProgress.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={dockerInstallProgress.progress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dockerInstallProgress.logs.length > 0 && (
|
||||||
|
<div className="bg-black text-green-400 font-mono text-xs p-3 rounded-lg h-40 overflow-y-auto">
|
||||||
|
{dockerInstallProgress.logs.map((log, idx) => (
|
||||||
|
<div key={idx}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validação */}
|
||||||
|
{dockerAvailable && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-primary" />
|
||||||
|
<CardTitle className="text-lg">Validação</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={validateDocker}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isValidating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
Validando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
Validar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Execute testes para verificar se Docker está funcionando corretamente
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{validationResult ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{validationResult.tests.map((test, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-3 rounded-lg border",
|
||||||
|
test.status === 'pass' && "bg-green-50 border-green-200",
|
||||||
|
test.status === 'warn' && "bg-yellow-50 border-yellow-200",
|
||||||
|
test.status === 'fail' && "bg-red-50 border-red-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{test.name}</p>
|
||||||
|
{test.message && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{test.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{test.status === 'pass' ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||||
|
) : test.status === 'warn' ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{validationResult.docker_version && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Docker {validationResult.docker_version} instalado
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Clique em "Validar" para executar os testes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão para configurar SearXNG */}
|
||||||
|
{dockerAvailable && dockerRunning && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={() => setShowInstallWizard(true)}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5 mr-2" />
|
||||||
|
Configurar SearXNG
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInstallWizard && (
|
||||||
|
<SearxngInstallWizard
|
||||||
|
onComplete={() => {
|
||||||
|
setShowInstallWizard(false);
|
||||||
|
checkDocker();
|
||||||
|
toast({
|
||||||
|
title: 'SearXNG configurado',
|
||||||
|
description: 'SearXNG foi instalado e configurado com sucesso!',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowInstallWizard(false)}
|
||||||
|
initialUrl="http://localhost:8080"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DockerCommandsModal
|
||||||
|
open={showCommandsModal}
|
||||||
|
onOpenChange={setShowCommandsModal}
|
||||||
|
problem={selectedProblem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Zap, Cpu } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LocalGgufModel {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
quantization?: string;
|
||||||
|
architecture?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeSettingsPanel() {
|
||||||
|
const { runtime, llamaCppParams, setRuntime, setLlamaCppParams } = useSettingsStore();
|
||||||
|
const [localModels, setLocalModels] = useState<LocalGgufModel[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLocalModels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLocalModels = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const models = await invoke<LocalGgufModel[]>('list_local_gguf_models');
|
||||||
|
setLocalModels(models);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load local GGUF models:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasLocalModels = localModels.length > 0;
|
||||||
|
const isLlamaCppAvailable = hasLocalModels;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Runtime de Modelos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Escolha entre Ollama (servidor externo) ou llama.cpp nativo (mais rápido, sem servidor)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Toggle de Runtime */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="runtime-toggle">Usar llama.cpp nativo</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{runtime === 'ollama'
|
||||||
|
? 'Usando Ollama como runtime (requer servidor externo)'
|
||||||
|
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'} className="gap-1">
|
||||||
|
{runtime === 'ollama' ? (
|
||||||
|
<>
|
||||||
|
<span>🦙</span> Ollama
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
llama.cpp
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{runtime === 'ollama'
|
||||||
|
? 'Usando Ollama como runtime (requer servidor externo)'
|
||||||
|
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Switch
|
||||||
|
id="runtime-toggle"
|
||||||
|
checked={runtime === 'llama-cpp'}
|
||||||
|
onCheckedChange={(checked) => setRuntime(checked ? 'llama-cpp' : 'ollama')}
|
||||||
|
disabled={!isLlamaCppAvailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLlamaCppAvailable && (
|
||||||
|
<div className="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p>
|
||||||
|
Nenhum modelo GGUF local encontrado. Baixe modelos do Hugging Face para usar llama.cpp nativo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">Carregando modelos locais...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLocalModels && (
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<p className="text-sm font-medium mb-2">
|
||||||
|
Modelos GGUF locais encontrados: {localModels.length}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{localModels.slice(0, 5).map((model, idx) => (
|
||||||
|
<div key={idx} className="text-xs text-muted-foreground">
|
||||||
|
• {model.name} {model.quantization && `(${model.quantization})`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{localModels.length > 5 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
... e mais {localModels.length - 5} modelo(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configurações do llama.cpp */}
|
||||||
|
{runtime === 'llama-cpp' && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<div>
|
||||||
|
<Label>Camadas GPU (n_gpu_layers)</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Número de camadas para carregar na GPU. 0 = CPU apenas
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Slider
|
||||||
|
value={[llamaCppParams.nGpuLayers]}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
setLlamaCppParams({ nGpuLayers: value })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={llamaCppParams.nGpuLayers}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLlamaCppParams({ nGpuLayers: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
className="w-20"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tamanho do Contexto (n_ctx)</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Tamanho máximo do contexto em tokens
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Slider
|
||||||
|
value={[llamaCppParams.nCtx]}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
setLlamaCppParams({ nCtx: value })
|
||||||
|
}
|
||||||
|
min={512}
|
||||||
|
max={32768}
|
||||||
|
step={512}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={llamaCppParams.nCtx}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLlamaCppParams({ nCtx: parseInt(e.target.value) || 4096 })
|
||||||
|
}
|
||||||
|
className="w-24"
|
||||||
|
min={512}
|
||||||
|
max={32768}
|
||||||
|
step={512}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Threads</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Número de threads (0 = automático)
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={llamaCppParams.threads || 0}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLlamaCppParams({ threads: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
className="w-32"
|
||||||
|
min={0}
|
||||||
|
max={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Temperatura</Label>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Controla a aleatoriedade das respostas (0.0 - 2.0)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Slider
|
||||||
|
value={[llamaCppParams.temperature]}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
setLlamaCppParams({ temperature: value })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={llamaCppParams.temperature}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLlamaCppParams({ temperature: parseFloat(e.target.value) || 0.7 })
|
||||||
|
}
|
||||||
|
className="w-20"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { Loader2, Save, Search, Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SearchConfig {
|
||||||
|
region: string;
|
||||||
|
safe_search: string;
|
||||||
|
max_results: number;
|
||||||
|
enabled: boolean;
|
||||||
|
log_search_content: boolean;
|
||||||
|
retention_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchSettingsPanel() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [config, setConfig] = useState<SearchConfig | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const loadedConfig = await invoke<SearchConfig>('load_search_config_command');
|
||||||
|
setConfig(loadedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Search config:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error loading configuration',
|
||||||
|
description: String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await invoke('save_search_config_command', { config });
|
||||||
|
toast({
|
||||||
|
title: 'Configuration saved',
|
||||||
|
description: 'Search settings have been updated successfully.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save Search config:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error saving configuration',
|
||||||
|
description: String(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="text-center p-8 text-muted-foreground">
|
||||||
|
Failed to load configuration.
|
||||||
|
<Button variant="outline" className="ml-4" onClick={loadConfig}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Search className="h-5 w-5" />
|
||||||
|
DuckDuckGo Search Integration
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure anonymous web search settings provided by DuckDuckGo.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) => setConfig({ ...config, enabled: checked })}
|
||||||
|
id="search-enabled"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="search-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="region">Region</Label>
|
||||||
|
<Select
|
||||||
|
value={config.region}
|
||||||
|
onValueChange={(val) => setConfig({ ...config, region: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="region">
|
||||||
|
<SelectValue placeholder="Select region" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="wt-wt">Global (wt-wt)</SelectItem>
|
||||||
|
<SelectItem value="us-en">United States (us-en)</SelectItem>
|
||||||
|
<SelectItem value="uk-en">United Kingdom (uk-en)</SelectItem>
|
||||||
|
<SelectItem value="br-pt">Brazil (br-pt)</SelectItem>
|
||||||
|
<SelectItem value="de-de">Germany (de-de)</SelectItem>
|
||||||
|
<SelectItem value="fr-fr">France (fr-fr)</SelectItem>
|
||||||
|
<SelectItem value="es-es">Spain (es-es)</SelectItem>
|
||||||
|
<SelectItem value="it-it">Italy (it-it)</SelectItem>
|
||||||
|
<SelectItem value="jp-jp">Japan (jp-jp)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="safe-search" className="flex items-center gap-2">
|
||||||
|
<Shield className="h-3 w-3" /> Safe Search
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.safe_search}
|
||||||
|
onValueChange={(val) => setConfig({ ...config, safe_search: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="safe-search">
|
||||||
|
<SelectValue placeholder="Select safe search level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="on">Strict (On)</SelectItem>
|
||||||
|
<SelectItem value="moderate">Moderate</SelectItem>
|
||||||
|
<SelectItem value="off">Off</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-results">Max Results</Label>
|
||||||
|
<Input
|
||||||
|
id="max-results"
|
||||||
|
type="number"
|
||||||
|
value={config.max_results}
|
||||||
|
onChange={(e) => setConfig({ ...config, max_results: parseInt(e.target.value) || 10 })}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retention">Log Retention (days)</Label>
|
||||||
|
<Input
|
||||||
|
id="retention"
|
||||||
|
type="number"
|
||||||
|
value={config.retention_days}
|
||||||
|
onChange={(e) => setConfig({ ...config, retention_days: parseInt(e.target.value) || 30 })}
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-sm font-medium">Privacy</h3>
|
||||||
|
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm">Log Search Content</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Store query text in local history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.log_search_content}
|
||||||
|
onCheckedChange={(checked) => setConfig({ ...config, log_search_content: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Download,
|
||||||
|
Settings,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SearxngInstallWizardProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
initialUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 0 | 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
interface SystemRequirements {
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
ram_mb: number;
|
||||||
|
disk_gb: number;
|
||||||
|
cpu_cores: number;
|
||||||
|
kernel_version?: string;
|
||||||
|
issues: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallProgressEvent {
|
||||||
|
event_type: 'step_start' | 'log' | 'step_complete' | 'error' | 'complete';
|
||||||
|
step?: number;
|
||||||
|
step_name?: string;
|
||||||
|
total_steps?: number;
|
||||||
|
progress: number;
|
||||||
|
message?: string;
|
||||||
|
log_line?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
status: 'pass' | 'fail' | 'partial';
|
||||||
|
tests: Array<{
|
||||||
|
name: string;
|
||||||
|
status: 'pass' | 'fail' | 'warn';
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
docker_version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearxngInstallWizard({ onComplete, onCancel, initialUrl = 'http://localhost:8080' }: SearxngInstallWizardProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState<Step>(0);
|
||||||
|
const [requirements, setRequirements] = useState<SystemRequirements | null>(null);
|
||||||
|
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
|
||||||
|
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null);
|
||||||
|
const [dockerRunning, setDockerRunning] = useState<boolean | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [url, setUrl] = useState(initialUrl);
|
||||||
|
const [port, setPort] = useState('8080');
|
||||||
|
const [autoStart, setAutoStart] = useState(true);
|
||||||
|
const [installProgress, setInstallProgress] = useState<string>('');
|
||||||
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
|
const [dockerInstallProgress, setDockerInstallProgress] = useState<{
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
progress: number;
|
||||||
|
currentStepName: string;
|
||||||
|
logs: string[];
|
||||||
|
}>({
|
||||||
|
currentStep: 0,
|
||||||
|
totalSteps: 0,
|
||||||
|
progress: 0,
|
||||||
|
currentStepName: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
const [isInstallingDocker, setIsInstallingDocker] = useState(false);
|
||||||
|
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [verificationStatus, setVerificationStatus] = useState<'checking' | 'success' | 'error' | null>(null);
|
||||||
|
const [verificationError, setVerificationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Step 0: Verificar Requisitos
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 0) {
|
||||||
|
checkRequirements();
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
// Step 1: Verificar Docker
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 1) {
|
||||||
|
checkDocker();
|
||||||
|
}
|
||||||
|
}, [currentStep]);
|
||||||
|
|
||||||
|
// Listener de eventos de progresso Docker
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenPromise: Promise<() => void> | null = null;
|
||||||
|
|
||||||
|
if (isInstallingDocker) {
|
||||||
|
unlistenPromise = listen<InstallProgressEvent>('docker-install-progress', (event) => {
|
||||||
|
const data = event.payload;
|
||||||
|
|
||||||
|
switch (data.event_type) {
|
||||||
|
case 'step_start':
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: data.step || 0,
|
||||||
|
totalSteps: data.total_steps || prev.totalSteps,
|
||||||
|
currentStepName: data.step_name || '',
|
||||||
|
progress: data.progress,
|
||||||
|
}));
|
||||||
|
if (data.message) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.message!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'log':
|
||||||
|
if (data.log_line) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.log_line!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'step_complete':
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
progress: data.progress,
|
||||||
|
}));
|
||||||
|
if (data.message) {
|
||||||
|
setDockerInstallProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
logs: [...prev.logs.slice(-49), data.message!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
setInstallError(data.message || 'Erro desconhecido');
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'complete':
|
||||||
|
setDockerInstallProgress(prev => ({ ...prev, progress: 100 }));
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
// Validar instalação após conclusão
|
||||||
|
setTimeout(() => {
|
||||||
|
validateDocker();
|
||||||
|
}, 1000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenPromise) {
|
||||||
|
unlistenPromise.then(fn => fn());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isInstallingDocker]);
|
||||||
|
|
||||||
|
const checkRequirements = async () => {
|
||||||
|
setIsCheckingRequirements(true);
|
||||||
|
try {
|
||||||
|
const reqs = await invoke<SystemRequirements>('check_docker_requirements');
|
||||||
|
setRequirements(reqs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking requirements:', error);
|
||||||
|
setRequirements({
|
||||||
|
status: 'fail',
|
||||||
|
os: 'unknown',
|
||||||
|
arch: 'unknown',
|
||||||
|
ram_mb: 0,
|
||||||
|
disk_gb: 0,
|
||||||
|
cpu_cores: 0,
|
||||||
|
issues: ['Erro ao verificar requisitos do sistema'],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingRequirements(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDocker = async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const available = await invoke<boolean>('check_docker_available');
|
||||||
|
setDockerAvailable(available);
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
const running = await invoke<boolean>('check_docker_running');
|
||||||
|
setDockerRunning(running);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking Docker:', error);
|
||||||
|
setDockerAvailable(false);
|
||||||
|
setDockerRunning(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < 5) {
|
||||||
|
setCurrentStep((prev) => (prev + 1) as Step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setCurrentStep((prev) => (prev - 1) as Step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallDocker = async () => {
|
||||||
|
setIsInstallingDocker(true);
|
||||||
|
setInstallError(null);
|
||||||
|
setDockerInstallProgress({
|
||||||
|
currentStep: 0,
|
||||||
|
totalSteps: 0,
|
||||||
|
progress: 0,
|
||||||
|
currentStepName: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('install_docker');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Docker installation error:', error);
|
||||||
|
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação do Docker');
|
||||||
|
setIsInstallingDocker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDocker = async () => {
|
||||||
|
setIsValidating(true);
|
||||||
|
setValidationResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<ValidationResult>('validate_docker_installation');
|
||||||
|
setValidationResult(result);
|
||||||
|
|
||||||
|
if (result.status === 'pass' || result.status === 'partial') {
|
||||||
|
// Docker validado, atualizar estado
|
||||||
|
setDockerAvailable(true);
|
||||||
|
setDockerRunning(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation error:', error);
|
||||||
|
setValidationResult({
|
||||||
|
status: 'fail',
|
||||||
|
tests: [{
|
||||||
|
name: 'Validação',
|
||||||
|
status: 'fail',
|
||||||
|
message: error instanceof Error ? error.message : 'Erro ao validar Docker',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
setIsInstalling(true);
|
||||||
|
setInstallError(null);
|
||||||
|
setInstallProgress('Iniciando instalação...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
setInstallProgress('Baixando imagem SearXNG...');
|
||||||
|
await invoke('install_searxng', { url, port: parseInt(port, 10) });
|
||||||
|
|
||||||
|
setInstallProgress('Criando container...');
|
||||||
|
// Installation is handled by install_searxng command
|
||||||
|
|
||||||
|
setInstallProgress('Iniciando serviços...');
|
||||||
|
await invoke('start_searxng');
|
||||||
|
|
||||||
|
setInstallProgress('Instalação concluída!');
|
||||||
|
setTimeout(() => {
|
||||||
|
handleNext();
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Installation error:', error);
|
||||||
|
setInstallError(error instanceof Error ? error.message : 'Erro desconhecido durante instalação');
|
||||||
|
} finally {
|
||||||
|
setIsInstalling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
setVerificationStatus('checking');
|
||||||
|
setVerificationError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAvailable = await invoke<boolean>('check_searxng_status', { url });
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
setVerificationStatus('success');
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
setVerificationStatus('error');
|
||||||
|
setVerificationError('SearXNG não está respondendo. Verifique se o container está rodando.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setVerificationStatus('error');
|
||||||
|
setVerificationError(error instanceof Error ? error.message : 'Erro ao verificar SearXNG');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 0:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Verificação de Requisitos do Sistema</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Verificando se seu sistema atende aos requisitos para instalação do Docker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCheckingRequirements ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<span className="ml-3 text-sm text-muted-foreground">Verificando requisitos...</span>
|
||||||
|
</div>
|
||||||
|
) : requirements ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="font-medium text-lg">Status Geral</span>
|
||||||
|
{requirements.status === 'pass' ? (
|
||||||
|
<Badge className="bg-green-500">Compatível</Badge>
|
||||||
|
) : requirements.status === 'warn' ? (
|
||||||
|
<Badge className="bg-yellow-500">Avisos</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500">Incompatível</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Sistema Operacional</p>
|
||||||
|
<p className="font-medium">{requirements.os} ({requirements.arch})</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">RAM</p>
|
||||||
|
<p className="font-medium">{(requirements.ram_mb / 1024).toFixed(1)} GB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Disco Livre</p>
|
||||||
|
<p className="font-medium">{requirements.disk_gb} GB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">CPU Cores</p>
|
||||||
|
<p className="font-medium">{requirements.cpu_cores}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requirements.kernel_version && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Versão do Kernel</p>
|
||||||
|
<p className="font-medium">{requirements.kernel_version}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requirements.issues.length > 0 && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">Problemas Encontrados:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
{requirements.issues.map((issue, idx) => (
|
||||||
|
<li key={idx}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{requirements.warnings.length > 0 && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">Avisos:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
{requirements.warnings.map((warning, idx) => (
|
||||||
|
<li key={idx}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={checkRequirements} disabled={isCheckingRequirements}>
|
||||||
|
Verificar Novamente
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={requirements.status === 'fail'}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Verificação de Docker</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
SearXNG requer Docker para funcionar. Vamos verificar se está instalado e rodando.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isChecking ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
<span className="ml-3 text-sm text-muted-foreground">Verificando Docker...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">Docker Instalado</span>
|
||||||
|
{dockerAvailable === null ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : dockerAvailable ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dockerAvailable === false && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Docker não está instalado. Você precisa instalá-lo primeiro.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dockerAvailable && (
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium">Docker Rodando</span>
|
||||||
|
{dockerRunning === null ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : dockerRunning ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dockerRunning === false && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Docker está instalado mas não está rodando. Inicie o Docker Desktop.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dockerAvailable === false && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Docker não está instalado. Você pode instalá-lo agora ou manualmente.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstallDocker}
|
||||||
|
disabled={isInstallingDocker}
|
||||||
|
>
|
||||||
|
{isInstallingDocker ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Instalando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Docker Agora
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
window.open('https://www.docker.com/products/docker-desktop', '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Manualmente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInstallingDocker && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{dockerInstallProgress.currentStepName || 'Instalando Docker...'}</span>
|
||||||
|
<span>{dockerInstallProgress.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={dockerInstallProgress.progress} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dockerInstallProgress.logs.length > 0 && (
|
||||||
|
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg h-48 overflow-y-auto">
|
||||||
|
{dockerInstallProgress.logs.map((log, idx) => (
|
||||||
|
<div key={idx}>{log}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationResult && (
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<h4 className="font-medium">Resultados da Validação:</h4>
|
||||||
|
{validationResult.tests.map((test, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-2 rounded border bg-muted/30">
|
||||||
|
<span className="text-sm">{test.name}</span>
|
||||||
|
{test.status === 'pass' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
) : test.status === 'warn' ? (
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{validationResult.docker_version && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Docker {validationResult.docker_version} instalado
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={checkDocker} disabled={isChecking}>
|
||||||
|
Verificar Novamente
|
||||||
|
</Button>
|
||||||
|
{validationResult && (validationResult.status === 'pass' || validationResult.status === 'partial') && (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!validationResult && dockerAvailable && dockerRunning && (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!dockerAvailable && !isInstallingDocker && (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Pular Instalação Docker
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Configuração</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure a URL e porta onde SearXNG será acessível.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="searxng-url">URL do SearXNG</Label>
|
||||||
|
<Input
|
||||||
|
id="searxng-url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
URL completa onde o SearXNG estará acessível
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="searxng-port">Porta</Label>
|
||||||
|
<Input
|
||||||
|
id="searxng-port"
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
placeholder="8080"
|
||||||
|
min="1024"
|
||||||
|
max="65535"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Porta que será usada pelo container (padrão: 8080)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 p-4 rounded-lg border bg-muted/30">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-start"
|
||||||
|
checked={autoStart}
|
||||||
|
onCheckedChange={(checked) => setAutoStart(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-start" className="cursor-pointer">
|
||||||
|
Iniciar automaticamente com o OllaHub
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleNext} className="ml-auto">
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Instalação do SearXNG</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Instalando e configurando SearXNG. Isso pode levar alguns minutos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstalling ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{installProgress}</p>
|
||||||
|
<div className="mt-2 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary animate-pulse" style={{ width: '60%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : installError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium mb-2">Erro durante instalação</p>
|
||||||
|
<p className="text-sm">{installError}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="mt-3"
|
||||||
|
>
|
||||||
|
Tentar Novamente
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Instalação concluída!</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
SearXNG foi instalado com sucesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={handleBack} disabled={isInstalling}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
{!isInstalling && !installError && (
|
||||||
|
<Button onClick={handleNext} className="ml-auto">
|
||||||
|
Continuar
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isInstalling && (
|
||||||
|
<Button variant="outline" onClick={onCancel} className="ml-auto">
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Verificação</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Verificando se SearXNG está funcionando corretamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verificationStatus === null && (
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Clique em "Verificar" para testar a conexão com SearXNG.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleVerify} size="lg">
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Verificar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStatus === 'checking' && (
|
||||||
|
<div className="p-4 rounded-lg border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
|
<span>Verificando conexão...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStatus === 'success' && (
|
||||||
|
<div className="p-4 rounded-lg border bg-green-500/10 border-green-500/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">SearXNG está disponível!</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A conexão foi estabelecida com sucesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationStatus === 'error' && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="font-medium mb-2">Falha na verificação</p>
|
||||||
|
<p className="text-sm mb-3">{verificationError || 'SearXNG não está respondendo'}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleVerify}>
|
||||||
|
Tentar Novamente
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await invoke('start_searxng');
|
||||||
|
setTimeout(handleVerify, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error starting SearXNG:', error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
Iniciar Container
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" onClick={handleBack} disabled={verificationStatus === 'checking'}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
{verificationStatus === 'success' && (
|
||||||
|
<Button onClick={onComplete} className="ml-auto">
|
||||||
|
Concluir
|
||||||
|
<CheckCircle2 className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||||
|
<Card className="w-full max-w-2xl relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
|
||||||
|
aria-label="Fechar"
|
||||||
|
disabled={isInstalling || isInstallingDocker}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SearXNG - Instalação</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Passo {currentStep + 1} de 6
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{renderStep()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CheckCircle2, Shield, Infinity, Globe, Settings, ArrowRight, X, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SearxngOnboardingProps {
|
||||||
|
onInstall: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onLearnMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearxngOnboarding({ onInstall, onSkip, onLearnMore }: SearxngOnboardingProps) {
|
||||||
|
const benefits = [
|
||||||
|
{
|
||||||
|
icon: Infinity,
|
||||||
|
title: 'Sem limites de queries',
|
||||||
|
description: 'Enquanto Brave API oferece 2000 queries/mês, SearXNG é ilimitado',
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: 'Privacidade total',
|
||||||
|
description: 'Sem rastreamento, sem cookies, sem logs. Seus dados ficam privados',
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Globe,
|
||||||
|
title: 'Agrega múltiplos motores',
|
||||||
|
description: 'Combina resultados de Google, Bing, DuckDuckGo e outros em uma única busca',
|
||||||
|
color: 'text-purple-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Settings,
|
||||||
|
title: 'Controle total',
|
||||||
|
description: 'Configure motores, filtros e personalizações conforme sua necessidade',
|
||||||
|
color: 'text-orange-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||||
|
<Card className="w-full max-w-3xl max-h-[90vh] overflow-y-auto relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onSkip}
|
||||||
|
className="absolute top-2 right-2 sm:top-4 sm:right-4 z-10"
|
||||||
|
aria-label="Fechar"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<CardHeader className="text-center space-y-4 pb-6">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Globe className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl sm:text-3xl">SearXNG Self-Hosted</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Busca Ilimitada e Privada
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Por que SearXNG? */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
Por que SearXNG?
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{benefits.map((benefit, idx) => {
|
||||||
|
const Icon = benefit.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-4 rounded-lg border border-muted bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className={cn('w-5 h-5 mt-0.5 flex-shrink-0', benefit.color)} />
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<h4 className="font-medium text-sm">{benefit.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{benefit.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Como Funciona */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-blue-500" />
|
||||||
|
Como Funciona
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 rounded-lg border border-muted bg-muted/30">
|
||||||
|
<div className="flex items-center justify-center gap-2 sm:gap-4 flex-wrap">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-4 py-2 rounded-md bg-blue-500/10 border border-blue-500/20 text-sm font-medium">
|
||||||
|
Brave API
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Tier 1</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-4 py-2 rounded-md bg-green-500/10 border border-green-500/20 text-sm font-medium">
|
||||||
|
SearXNG
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Tier 2</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-5 h-5 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="px-4 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20 text-sm font-medium">
|
||||||
|
DuckDuckGo
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Tier 3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-4 text-center">
|
||||||
|
SearXNG é usado como fallback quando Brave API esgota sua quota mensal (2000 queries).
|
||||||
|
É completamente opcional e não afeta o uso normal do sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requisitos */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5 text-orange-500" />
|
||||||
|
Requisitos
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Docker instalado e rodando</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
O SearXNG roda em um container Docker. Se não tiver Docker, você pode instalá-lo durante o processo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">~200MB de RAM</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
O container SearXNG consome aproximadamente 200MB de memória RAM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-muted bg-muted/30">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Porta 8080 disponível</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Por padrão, SearXNG usa a porta 8080. Você pode configurar uma porta diferente se necessário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ações */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t">
|
||||||
|
<Button onClick={onInstall} className="flex-1" size="lg">
|
||||||
|
<ArrowRight className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Agora
|
||||||
|
</Button>
|
||||||
|
{onLearnMore && (
|
||||||
|
<Button onClick={onLearnMore} variant="outline" className="flex-1" size="lg">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Saiba Mais
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onSkip} variant="ghost" className="flex-1" size="lg">
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Pular por Enquanto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
line: string;
|
||||||
|
type: 'stdout' | 'stderr';
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstallationTerminalProps {
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstallationTerminal({ onComplete, onError }: InstallationTerminalProps) {
|
||||||
|
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Auto-scroll para última linha
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// Escutar eventos de log
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPromise = listen<LogLine>('install-log', (event) => {
|
||||||
|
const logLine = event.payload;
|
||||||
|
|
||||||
|
setLogs(prev => [...prev, logLine]);
|
||||||
|
|
||||||
|
// Atualizar progresso se disponível
|
||||||
|
if (logLine.progress !== undefined) {
|
||||||
|
setProgress(logLine.progress);
|
||||||
|
lastProgressRef.current = logLine.progress;
|
||||||
|
} else {
|
||||||
|
// Tentar extrair progresso da linha de texto
|
||||||
|
const extractedProgress = extractProgressFromText(logLine.line);
|
||||||
|
if (extractedProgress !== null) {
|
||||||
|
setProgress(extractedProgress);
|
||||||
|
lastProgressRef.current = extractedProgress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se instalação foi concluída
|
||||||
|
const lineLower = logLine.line.toLowerCase();
|
||||||
|
if (lineLower.includes('success') || lineLower.includes('completed') || lineLower.includes('finished')) {
|
||||||
|
setProgress(100);
|
||||||
|
if (onComplete) {
|
||||||
|
setTimeout(() => onComplete(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se houve erro
|
||||||
|
if (logLine.type === 'stderr' && (lineLower.includes('error') || lineLower.includes('failed'))) {
|
||||||
|
if (onError) {
|
||||||
|
onError(logLine.line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise.then(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, [onComplete, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 sm:space-y-3">
|
||||||
|
<div className="bg-zinc-950 rounded-lg border border-zinc-800 overflow-hidden max-h-[50vh] flex flex-col">
|
||||||
|
<div className="px-2 sm:px-3 py-1.5 sm:py-2 bg-zinc-900 border-b border-zinc-800 flex items-center justify-between gap-2 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
|
||||||
|
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-red-500"></div>
|
||||||
|
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-yellow-500"></div>
|
||||||
|
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full bg-green-500"></div>
|
||||||
|
<span className="text-[10px] sm:text-xs text-zinc-400 ml-1 sm:ml-2">Terminal</span>
|
||||||
|
</div>
|
||||||
|
{progress > 0 && (
|
||||||
|
<span className="text-[10px] sm:text-xs text-zinc-400 font-mono shrink-0">{progress}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="h-48 sm:h-64 md:h-80 flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 font-mono text-[10px] sm:text-xs"
|
||||||
|
style={{
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: '#3f3f46 #18181b',
|
||||||
|
scrollBehavior: 'smooth'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-zinc-500 text-[10px] sm:text-xs">Aguardando logs...</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`
|
||||||
|
${log.type === 'stderr' ? 'text-red-400' : 'text-green-400'}
|
||||||
|
whitespace-pre-wrap
|
||||||
|
break-words
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progress > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress value={progress} className="h-1.5 sm:h-2" />
|
||||||
|
<div className="text-[10px] sm:text-xs text-muted-foreground text-center">
|
||||||
|
{progress < 100 ? `Instalando... ${progress}%` : 'Instalação concluída'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrai progresso percentual de uma linha de texto
|
||||||
|
function extractProgressFromText(line: string): number | null {
|
||||||
|
// Procurar por padrão "Progress: n%" ou "n%"
|
||||||
|
const percentMatch = line.match(/(\d+)%/);
|
||||||
|
if (percentMatch) {
|
||||||
|
const num = parseInt(percentMatch[1], 10);
|
||||||
|
if (num >= 0 && num <= 100) {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapear keywords para progresso aproximado
|
||||||
|
const lineLower = line.toLowerCase();
|
||||||
|
if (lineLower.includes('downloading')) {
|
||||||
|
return 25;
|
||||||
|
} else if (lineLower.includes('installing')) {
|
||||||
|
return 50;
|
||||||
|
} else if (lineLower.includes('shim') || lineLower.includes('creating')) {
|
||||||
|
return 75;
|
||||||
|
} else if (lineLower.includes('finished') || lineLower.includes('completed') || lineLower.includes('success')) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback, startTransition } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useSetupWizard } from '@/hooks/use-setup-wizard';
|
||||||
|
import { useDownloadContext } from '@/contexts/download-context';
|
||||||
|
import { Search, Download, CheckCircle2, AlertCircle, Loader2, Sparkles, ArrowRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
minRam: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lista expandida de modelos populares
|
||||||
|
const POPULAR_MODELS: ModelInfo[] = [
|
||||||
|
{ id: 'llama3.2:1b', name: 'Llama 3.2 1B', size: '1.3GB', minRam: 4, description: 'Leve e rápido, ideal para iniciantes' },
|
||||||
|
{ id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2.0GB', minRam: 8, description: 'Equilíbrio perfeito entre velocidade e qualidade' },
|
||||||
|
{ id: 'llama3.1:8b', name: 'Llama 3.1 8B', size: '4.7GB', minRam: 16, description: 'Padrão da indústria, alta capacidade' },
|
||||||
|
{ id: 'mistral:latest', name: 'Mistral 7B', size: '4.1GB', minRam: 16, description: 'Excelente para raciocínio e código' },
|
||||||
|
{ id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5.4GB', minRam: 12, description: 'Modelo avançado do Google' },
|
||||||
|
{ id: 'qwen2.5:7b', name: 'Qwen 2.5 7B', size: '4.5GB', minRam: 16, description: 'Multilíngue e versátil' },
|
||||||
|
{ id: 'phi3:mini', name: 'Phi-3 Mini', size: '2.3GB', minRam: 8, description: 'Compacto e eficiente da Microsoft' },
|
||||||
|
{ id: 'deepseek-coder:6.7b', name: 'DeepSeek Coder 6.7B', size: '4.2GB', minRam: 16, description: 'Especializado em programação' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModelDownloadStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelDownloadStep({ onNext }: ModelDownloadStepProps) {
|
||||||
|
const { state, checkSetupState } = useSetupWizard();
|
||||||
|
const { state: downloadState, startDownload } = useDownloadContext();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [customModelInput, setCustomModelInput] = useState('');
|
||||||
|
const [activeModelTag, setActiveModelTag] = useState<string | null>(null);
|
||||||
|
const [installedModels, setInstalledModels] = useState<Set<string>>(new Set());
|
||||||
|
const [customModelInstalled, setCustomModelInstalled] = useState(false);
|
||||||
|
|
||||||
|
// Verificar modelos instalados ao montar
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAllModels = async () => {
|
||||||
|
const installed = new Set<string>();
|
||||||
|
|
||||||
|
// Verificar todos os modelos em paralelo
|
||||||
|
const checks = POPULAR_MODELS.map(async (model) => {
|
||||||
|
try {
|
||||||
|
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: model.id });
|
||||||
|
if (isInstalled) {
|
||||||
|
installed.add(model.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao verificar ${model.id}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(checks);
|
||||||
|
startTransition(() => {
|
||||||
|
setInstalledModels(installed);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAllModels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Verificar modelo customizado quando o usuário digita
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCustomModel = async () => {
|
||||||
|
const modelTag = customModelInput.trim();
|
||||||
|
if (!modelTag) {
|
||||||
|
setCustomModelInstalled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isInstalled = await invoke<boolean>('check_if_model_installed_command', { name: modelTag });
|
||||||
|
setCustomModelInstalled(isInstalled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erro ao verificar modelo customizado ${modelTag}:`, error);
|
||||||
|
setCustomModelInstalled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce: aguardar 500ms após o usuário parar de digitar
|
||||||
|
const timer = setTimeout(checkCustomModel, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [customModelInput]);
|
||||||
|
|
||||||
|
// Filtrar modelos baseado na pesquisa
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return POPULAR_MODELS;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return POPULAR_MODELS.filter(
|
||||||
|
model =>
|
||||||
|
model.name.toLowerCase().includes(query) ||
|
||||||
|
model.id.toLowerCase().includes(query) ||
|
||||||
|
model.description?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Progresso do download ativo
|
||||||
|
const activeProgress = useMemo(() => {
|
||||||
|
if (!downloadState.isDownloading || downloadState.currentModel !== activeModelTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
percent: downloadState.progress.percent,
|
||||||
|
downloaded: downloadState.progress.downloadedBytes,
|
||||||
|
total: downloadState.progress.totalBytes,
|
||||||
|
downloadedFormatted: downloadState.progress.downloadedFormatted,
|
||||||
|
totalFormatted: downloadState.progress.totalFormatted,
|
||||||
|
status: downloadState.progress.status,
|
||||||
|
speed: downloadState.progress.speed,
|
||||||
|
};
|
||||||
|
}, [downloadState, activeModelTag]);
|
||||||
|
|
||||||
|
// Atualizar lista de instalados e limpar activeModelTag quando download completar
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadState.isSuccess && activeModelTag) {
|
||||||
|
const modelTag = activeModelTag;
|
||||||
|
// Adicionar modelo à lista de instalados usando startTransition para evitar cascading renders
|
||||||
|
startTransition(() => {
|
||||||
|
setInstalledModels(prev => new Set([...prev, modelTag]));
|
||||||
|
// Verificar também o modelo customizado se for o caso
|
||||||
|
if (modelTag === customModelInput.trim()) {
|
||||||
|
setCustomModelInstalled(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Limpar activeModelTag após um pequeno delay para garantir que o estado foi atualizado
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setActiveModelTag(null);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [downloadState.isSuccess, activeModelTag, customModelInput]);
|
||||||
|
|
||||||
|
// Verificar estado do setup quando download completar
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadState.isSuccess || (!downloadState.isDownloading && downloadState.progress.percent === 100 && downloadState.progress.status === 'success')) {
|
||||||
|
checkSetupState().catch(console.error);
|
||||||
|
}
|
||||||
|
}, [downloadState.isSuccess, downloadState.isDownloading, downloadState.progress.percent, downloadState.progress.status, checkSetupState]);
|
||||||
|
|
||||||
|
const handleModelDownload = useCallback(async (modelTag: string) => {
|
||||||
|
if (downloadState.isDownloading) {
|
||||||
|
console.warn('Download já em andamento');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveModelTag(modelTag);
|
||||||
|
try {
|
||||||
|
await startDownload(modelTag);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao iniciar download:', error);
|
||||||
|
setActiveModelTag(null);
|
||||||
|
}
|
||||||
|
}, [downloadState.isDownloading, startDownload]);
|
||||||
|
|
||||||
|
const handleCustomDownload = useCallback(async () => {
|
||||||
|
const modelTag = customModelInput.trim();
|
||||||
|
if (!modelTag) return;
|
||||||
|
|
||||||
|
await handleModelDownload(modelTag);
|
||||||
|
setCustomModelInput('');
|
||||||
|
}, [customModelInput, handleModelDownload]);
|
||||||
|
|
||||||
|
if (state.modelDownloaded) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
||||||
|
<CardHeader className="p-6 sm:p-8">
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
|
||||||
|
<span>Modelo Instalado</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
Modelo {state.modelName || 'padrão'} pronto para uso.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
||||||
|
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-emerald-100">Download concluído com sucesso!</p>
|
||||||
|
<p className="text-xs text-emerald-300 mt-1">Avançando para o próximo passo...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-6xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl max-h-[calc(100vh-20rem)] flex flex-col">
|
||||||
|
<CardHeader className="p-6 sm:p-8 flex-shrink-0">
|
||||||
|
<CardTitle className="text-xl font-bold text-white flex items-center gap-2 mb-1">
|
||||||
|
<Download className="w-6 h-6 shrink-0" />
|
||||||
|
<span>Download do Modelo</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 text-xs">
|
||||||
|
Escolha um modelo e/ou informe uma tag custom para baixar.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-4 sm:pb-6 pt-0 space-y-4 flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 mb-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Pesquisar modelos..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 h-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 md:w-1/3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tag custom (ex: deepseek-coder:6.7b)"
|
||||||
|
value={customModelInput}
|
||||||
|
onChange={(e) => setCustomModelInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCustomDownload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={downloadState.isDownloading}
|
||||||
|
className="flex-1 h-10 text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCustomDownload}
|
||||||
|
disabled={!customModelInput.trim() || downloadState.isDownloading || customModelInstalled}
|
||||||
|
variant={customModelInstalled ? 'outline' : 'default'}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 h-10 px-4 text-sm",
|
||||||
|
customModelInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{downloadState.isDownloading && activeModelTag === customModelInput.trim() ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Baixando
|
||||||
|
</>
|
||||||
|
) : customModelInstalled ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Já Instalado
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Baixar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Modelos Recomendados */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mt-2 mb-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-primary" />
|
||||||
|
<h3 className="text-xs font-semibold">Modelos Recomendados</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 min-h-[300px] max-h-[450px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-zinc-800 custom-scrollbar">
|
||||||
|
{filteredModels.length === 0 ? (
|
||||||
|
<div className="col-span-1 sm:col-span-2 text-center py-12 text-sm text-muted-foreground">
|
||||||
|
Nenhum modelo encontrado para “{searchQuery}”
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredModels.map((model) => {
|
||||||
|
const isActive = activeModelTag === model.id;
|
||||||
|
const isDownloading = downloadState.isDownloading && isActive;
|
||||||
|
const progress = isActive ? activeProgress : null;
|
||||||
|
const isInstalled = installedModels.has(model.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={model.id}
|
||||||
|
className={cn(
|
||||||
|
'relative border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-900 transition-all',
|
||||||
|
isDownloading && 'border-emerald-500/50',
|
||||||
|
isInstalled && !isDownloading && 'border-emerald-500/30 bg-emerald-500/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-bold text-sm truncate">{model.name}</h4>
|
||||||
|
{isInstalled && (
|
||||||
|
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-[10px] px-1.5 py-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Instalado
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="hidden md:block text-[11px] text-muted-foreground mt-1 line-clamp-1">
|
||||||
|
{model.description || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[10px] sm:text-xs">
|
||||||
|
{model.size}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-[10px] sm:text-xs text-muted-foreground gap-2">
|
||||||
|
<span className="truncate">Mín. {model.minRam}GB RAM</span>
|
||||||
|
<code className="text-[9px] sm:text-[10px] bg-muted px-1 sm:px-1.5 py-0.5 rounded shrink-0 truncate max-w-[120px] sm:max-w-none">
|
||||||
|
{model.id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && progress && (
|
||||||
|
<div className="space-y-1 pt-2 border-t border-zinc-800/50">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{progress.status || 'Baixando...'}
|
||||||
|
</span>
|
||||||
|
{progress.speed && (
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
{progress.speed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={progress.total > 0 && progress.percent !== null ? progress.percent : undefined}
|
||||||
|
className="h-1"
|
||||||
|
/>
|
||||||
|
{progress.total > 0 && (
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground/70">
|
||||||
|
<span>
|
||||||
|
{progress.downloadedFormatted} / {progress.totalFormatted}
|
||||||
|
</span>
|
||||||
|
<span>{progress.percent ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleModelDownload(model.id)}
|
||||||
|
disabled={downloadState.isDownloading || isInstalled}
|
||||||
|
variant={isInstalled ? 'outline' : isDownloading ? 'outline' : 'default'}
|
||||||
|
className={cn(
|
||||||
|
"w-full gap-2 h-8 text-xs",
|
||||||
|
isInstalled && "border-emerald-500/30 text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
<span className="hidden sm:inline">Baixando...</span>
|
||||||
|
</>
|
||||||
|
) : isInstalled ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span className="hidden sm:inline">Já Instalado</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span className="hidden sm:inline">Baixar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mensagens de Erro */}
|
||||||
|
{state.lastError && (
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900/50">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||||
|
<p className="text-xs text-red-700 dark:text-red-300 mt-1 break-words">{state.lastError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SLIM FOOTER NAVIGATION */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 pt-4 mt-4 border-t border-zinc-800 bg-zinc-950/50 backdrop-blur-sm flex-shrink-0">
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
<span>Selecione um modelo ou pule para configurar depois.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onNext}
|
||||||
|
className="text-zinc-400 hover:text-white hover:bg-zinc-800 flex-1 sm:flex-initial"
|
||||||
|
disabled={downloadState.isDownloading}
|
||||||
|
>
|
||||||
|
Pular etapa
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="bg-white text-black hover:bg-zinc-200 px-8 flex-1 sm:flex-initial"
|
||||||
|
disabled={downloadState.isDownloading}
|
||||||
|
>
|
||||||
|
Continuar <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="p-6 sm:p-8 pt-0 border-t-0 flex-shrink-0 hidden">
|
||||||
|
{/* Footer vazio - mantido para compatibilidade, mas conteúdo movido para CardContent */}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -30,6 +31,7 @@ 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];
|
||||||
|
|
||||||
@@ -80,10 +82,11 @@ 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(() => {
|
setTimeout(async () => {
|
||||||
setIsInstalled(true);
|
setIsInstalled(true);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
checkInstallation(selectedModelId);
|
// Verificar instalação de forma assíncrona sem bloquear
|
||||||
|
checkInstallation(selectedModelId).catch(console.error);
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,20 +106,30 @@ 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 {
|
|
||||||
await invoke('pull_model', { name: selectedModelId });
|
// Chamar Tauri em background (não bloquear)
|
||||||
// O status será atualizado pelo listener quando o download completar
|
invoke('pull_model', { name: selectedModelId })
|
||||||
// Não precisamos setar aqui pois o listener já faz isso
|
.catch((error) => {
|
||||||
} 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 (
|
||||||
@@ -192,9 +205,22 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
{isInstalled ? (
|
{isInstalled ? (
|
||||||
<Button className="w-full h-12 text-lg" onClick={onComplete}>
|
<Button
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
|
import { useSetupWizard } from '@/hooks/use-setup-wizard';
|
||||||
|
import { useOllamaDownload } from '@/hooks/use-ollama-download';
|
||||||
|
import { useOllamaCheck } from '@/hooks/use-ollama-check';
|
||||||
|
import { useOperatingSystem } from '@/hooks/use-operating-system';
|
||||||
|
import { InstallationTerminal } from './installation-terminal';
|
||||||
|
import { Loader2, CheckCircle2, AlertCircle, Download, RefreshCw, Shield, Clock, WifiOff, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.round(seconds % 60);
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallErrorType = 'ERR_NO_PRIVILEGE' | 'ERR_DOWNLOAD_FAIL' | 'ERR_TIMEOUT' | 'ERR_INSTALL_FAILED';
|
||||||
|
|
||||||
|
function parseInstallError(error: string): InstallErrorType {
|
||||||
|
const errorLower = error.toLowerCase();
|
||||||
|
|
||||||
|
// Verificar privilégios/permissões
|
||||||
|
if (
|
||||||
|
errorLower.includes('privilege') ||
|
||||||
|
errorLower.includes('permission') ||
|
||||||
|
errorLower.includes('elevated') ||
|
||||||
|
errorLower.includes('uac') ||
|
||||||
|
errorLower.includes('administrator') ||
|
||||||
|
errorLower.includes('access denied') ||
|
||||||
|
errorLower.includes('acesso negado')
|
||||||
|
) {
|
||||||
|
return 'ERR_NO_PRIVILEGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar erros de download/rede
|
||||||
|
if (
|
||||||
|
errorLower.includes('download') ||
|
||||||
|
errorLower.includes('network') ||
|
||||||
|
errorLower.includes('connection') ||
|
||||||
|
errorLower.includes('failed to download') ||
|
||||||
|
errorLower.includes('falha ao baixar') ||
|
||||||
|
errorLower.includes('conexão') ||
|
||||||
|
errorLower.includes('timeout') && (errorLower.includes('download') || errorLower.includes('conexão'))
|
||||||
|
) {
|
||||||
|
return 'ERR_DOWNLOAD_FAIL';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar timeout
|
||||||
|
if (
|
||||||
|
errorLower.includes('timeout') ||
|
||||||
|
errorLower.includes('90 segundos') ||
|
||||||
|
errorLower.includes('timed out') ||
|
||||||
|
errorLower.includes('tempo esgotado')
|
||||||
|
) {
|
||||||
|
return 'ERR_TIMEOUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ERR_INSTALL_FAILED';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
errorType: InstallErrorType;
|
||||||
|
errorMessage: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
isRetrying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorDisplay({ errorType, errorMessage, onRetry, isRetrying }: ErrorDisplayProps) {
|
||||||
|
switch (errorType) {
|
||||||
|
case 'ERR_NO_PRIVILEGE':
|
||||||
|
return (
|
||||||
|
<Card className="border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/20">
|
||||||
|
<CardHeader className="pb-3 sm:pb-6">
|
||||||
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
|
<Shield className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm sm:text-base text-amber-900 dark:text-amber-100">
|
||||||
|
Permissões Administrativas Necessárias
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm text-amber-700 dark:text-amber-300 mt-1 sm:mt-2">
|
||||||
|
O Windows solicitou permissões de administrador para instalar o Ollama.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||||
|
<p className="text-xs sm:text-sm font-medium text-foreground mb-2">O que fazer:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-xs sm:text-sm text-muted-foreground">
|
||||||
|
<li>Verifique se apareceu um prompt do Windows pedindo permissões</li>
|
||||||
|
<li>Clique em "Sim" ou "Allow" no prompt de UAC (User Account Control)</li>
|
||||||
|
<li>Aguarde alguns segundos para a instalação continuar</li>
|
||||||
|
<li>Se o prompt não apareceu, tente executar o OllaHub como administrador</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Verificar Novamente
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'ERR_DOWNLOAD_FAIL':
|
||||||
|
return (
|
||||||
|
<Card className="border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20">
|
||||||
|
<CardHeader className="pb-3 sm:pb-6">
|
||||||
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
|
<WifiOff className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm sm:text-base text-red-900 dark:text-red-100">
|
||||||
|
Erro ao Baixar Instalador
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 sm:mt-2">
|
||||||
|
Não foi possível baixar o instalador do Ollama. Verifique sua conexão com a internet.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground break-words">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Tentando Novamente...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Tentar Novamente
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'ERR_TIMEOUT':
|
||||||
|
return (
|
||||||
|
<Card className="border-orange-200 dark:border-orange-900 bg-orange-50 dark:bg-orange-950/20">
|
||||||
|
<CardHeader className="pb-3 sm:pb-6">
|
||||||
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
|
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600 dark:text-orange-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm sm:text-base text-orange-900 dark:text-orange-100">
|
||||||
|
Instalação Demorando Mais do Que o Esperado
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs sm:text-sm text-orange-700 dark:text-orange-300 mt-1 sm:mt-2">
|
||||||
|
A instalação pode ainda estar em andamento em segundo plano.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 p-3 sm:p-4 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground mb-2 break-words">
|
||||||
|
O processo de instalação pode levar alguns minutos. Se o Ollama estiver sendo instalado,
|
||||||
|
você pode verificar o status manualmente.
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] sm:text-xs text-muted-foreground break-words">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Verificar Novamente
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 p-3 sm:p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2 sm:space-y-3 min-w-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm sm:text-base font-medium text-red-900 dark:text-red-100">Erro na instalação</p>
|
||||||
|
<p className="text-xs sm:text-sm text-red-700 dark:text-red-300 mt-1 break-words">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Verificar Novamente
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallStepState = 'IDLE' | 'INSTALLING' | 'VERIFYING' | 'SUCCESS';
|
||||||
|
|
||||||
|
interface OllamaInstallStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OllamaInstallStep({ onNext }: OllamaInstallStepProps) {
|
||||||
|
const { state, installOllama, checkChocolatey, installChocolatey, installOllamaViaChoco, checkSetupState } = useSetupWizard();
|
||||||
|
const { os: detectedOS } = useOperatingSystem();
|
||||||
|
const downloadState = useOllamaDownload();
|
||||||
|
const ollamaCheck = useOllamaCheck();
|
||||||
|
const [stepState, setStepState] = useState<InstallStepState>('IDLE');
|
||||||
|
const [useChocolatey, setUseChocolatey] = useState(false);
|
||||||
|
const [showTerminal, setShowTerminal] = useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
|
||||||
|
const installationInProgressRef = useRef(false);
|
||||||
|
const hasCheckedInitialRef = useRef(false);
|
||||||
|
|
||||||
|
// Função de polling ativo para aguardar serviço ficar disponível
|
||||||
|
const waitForService = async (maxAttempts = 15): Promise<boolean> => {
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
if (isIntact) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Tentativa ${attempt}/${maxAttempts} falhou:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Ollama não ficou disponível após 30 segundos');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificação inicial no mount: se Ollama já estiver instalado, ir direto para SUCCESS
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasCheckedInitialRef.current) return;
|
||||||
|
|
||||||
|
const checkInitial = async () => {
|
||||||
|
if (stepState === 'IDLE') {
|
||||||
|
try {
|
||||||
|
const isIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
if (isIntact) {
|
||||||
|
// Se a integridade está OK, atualizar estado do wizard e ir para SUCCESS
|
||||||
|
await checkSetupState();
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silenciosamente ignorar - usuário ainda não instalou
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasCheckedInitialRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
checkInitial();
|
||||||
|
}, [stepState, checkSetupState]);
|
||||||
|
|
||||||
|
// Sincronizar estado do polling com wizard state e atualizar FSM
|
||||||
|
useEffect(() => {
|
||||||
|
if (ollamaCheck.status === 'running' && !state.ollamaRunning) {
|
||||||
|
// Polling detectou que Ollama está rodando, atualizar wizard state
|
||||||
|
checkSetupState().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar FSM baseado no status
|
||||||
|
if (stepState === 'VERIFYING' && ollamaCheck.status === 'running' && state.ollamaInstalled && state.ollamaRunning) {
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
ollamaCheck.stopPolling();
|
||||||
|
}
|
||||||
|
}, [ollamaCheck.status, state.ollamaRunning, state.ollamaInstalled, checkSetupState, stepState, ollamaCheck]);
|
||||||
|
|
||||||
|
const handleManualRetry = async () => {
|
||||||
|
setIsRetrying(true);
|
||||||
|
try {
|
||||||
|
await checkSetupState();
|
||||||
|
|
||||||
|
// Se já estiver instalado, ir para SUCCESS
|
||||||
|
if (state.ollamaInstalled && state.ollamaRunning) {
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
} else {
|
||||||
|
// Iniciar verificação manual
|
||||||
|
setStepState('VERIFYING');
|
||||||
|
ollamaCheck.startPolling();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar novamente:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckExisting = async () => {
|
||||||
|
setStepState('VERIFYING');
|
||||||
|
ollamaCheck.startPolling();
|
||||||
|
await checkSetupState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (installationInProgressRef.current) {
|
||||||
|
return; // Evitar múltiplas instalações simultâneas
|
||||||
|
}
|
||||||
|
|
||||||
|
installationInProgressRef.current = true;
|
||||||
|
setStepState('INSTALLING');
|
||||||
|
setShowTerminal(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No Windows, verificar se Chocolatey está disponível
|
||||||
|
if (detectedOS === 'windows') {
|
||||||
|
const hasChoco = await checkChocolatey();
|
||||||
|
if (hasChoco) {
|
||||||
|
setUseChocolatey(true);
|
||||||
|
// Instalar Ollama via Chocolatey
|
||||||
|
await installOllamaViaChoco();
|
||||||
|
// Após instalação, fazer polling ativo
|
||||||
|
setStepState('VERIFYING');
|
||||||
|
await waitForService(15); // 15 tentativas = 30 segundos
|
||||||
|
// Se chegou aqui, Ollama está funcional
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
await checkSetupState(); // Atualizar estado do wizard
|
||||||
|
installationInProgressRef.current = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Tentar instalar Chocolatey primeiro
|
||||||
|
try {
|
||||||
|
await installChocolatey();
|
||||||
|
setUseChocolatey(true);
|
||||||
|
await installOllamaViaChoco();
|
||||||
|
// Após instalação, fazer polling ativo
|
||||||
|
setStepState('VERIFYING');
|
||||||
|
await waitForService(15); // 15 tentativas = 30 segundos
|
||||||
|
// Se chegou aqui, Ollama está funcional
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
await checkSetupState(); // Atualizar estado do wizard
|
||||||
|
installationInProgressRef.current = false;
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Falha ao usar Chocolatey, usando instalação direta:', error);
|
||||||
|
setUseChocolatey(false);
|
||||||
|
setShowTerminal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: instalação direta
|
||||||
|
if (!downloadState.filePath && detectedOS) {
|
||||||
|
await downloadState.handleDownload(detectedOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadState.filePath) {
|
||||||
|
await installOllama(downloadState.filePath);
|
||||||
|
} else {
|
||||||
|
await installOllama();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Após instalação, fazer polling ativo
|
||||||
|
setStepState('VERIFYING');
|
||||||
|
await waitForService(15); // 15 tentativas = 30 segundos
|
||||||
|
// Se chegou aqui, Ollama está funcional
|
||||||
|
setStepState('SUCCESS');
|
||||||
|
await checkSetupState(); // Atualizar estado do wizard
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao aguardar serviço:', error);
|
||||||
|
setStepState('IDLE');
|
||||||
|
} finally {
|
||||||
|
installationInProgressRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Estado SUCCESS: mostrar mensagem de sucesso e botão Continuar
|
||||||
|
if (stepState === 'SUCCESS' || (state.ollamaInstalled && state.ollamaRunning)) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
||||||
|
<CardHeader className="p-6 sm:p-8">
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
|
||||||
|
<span>Instalação Concluída</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
Ollama está instalado e rodando com sucesso.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
||||||
|
<div className="flex items-start sm:items-center gap-3 p-4 bg-emerald-950/30 rounded-lg border border-emerald-900/50">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-emerald-100">Ollama Service is Running</p>
|
||||||
|
<p className="text-xs text-emerald-300 mt-1">Pronto para continuar com o download de modelos.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
||||||
|
<Button onClick={onNext} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
|
||||||
|
Continuar <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estado IDLE: mostrar card informativo
|
||||||
|
if (stepState === 'IDLE') {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
||||||
|
<CardHeader className="p-6 sm:p-8">
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
<Download className="w-6 h-6 shrink-0" />
|
||||||
|
<span>Ollama Engine</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
O OllaHub requer o motor Ollama para executar IAs localmente.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
||||||
|
<p className="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
O Ollama é uma ferramenta open-source que permite executar modelos de linguagem grandes (LLMs) diretamente no seu computador, sem necessidade de conexão com a internet.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col sm:flex-row justify-end gap-4 p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
||||||
|
<Button onClick={handleCheckExisting} variant="outline" className="w-full sm:w-auto h-12 border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
||||||
|
Já tenho o Ollama instalado
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleInstall} className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Ollama
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorType = state.lastError ? parseInstallError(state.lastError) : null;
|
||||||
|
const showRetryButton = state.lastError || state.progressStatus === 'error';
|
||||||
|
const showManualRetry = showRetryButton && state.lastError;
|
||||||
|
const hasTerminal = showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl ${hasTerminal ? 'max-h-[calc(100vh-16rem)] flex flex-col' : ''}`}>
|
||||||
|
<CardHeader className={`p-6 sm:p-8 ${hasTerminal ? 'flex-shrink-0' : ''}`}>
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
{stepState === 'INSTALLING' && <Loader2 className="w-6 h-6 animate-spin" />}
|
||||||
|
{stepState === 'VERIFYING' && <Loader2 className="w-6 h-6 animate-spin" />}
|
||||||
|
{(state.lastError || state.progressStatus === 'error') && <AlertCircle className="w-6 h-6 text-red-500" />}
|
||||||
|
<span>
|
||||||
|
{stepState === 'INSTALLING' ? 'Instalando Ollama...' :
|
||||||
|
stepState === 'VERIFYING' ? 'Verificando Instalação...' :
|
||||||
|
'Instalação em Progresso'}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
Acompanhe o progresso da configuração do ambiente.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className={`px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6 ${hasTerminal ? 'flex-1 min-h-0 overflow-y-auto' : ''}`}>
|
||||||
|
{/* Terminal integrado para instalação via Chocolatey */}
|
||||||
|
{(showTerminal && (state.progressStatus === 'installing_chocolatey' || state.progressStatus === 'installing_ollama' || useChocolatey)) && (
|
||||||
|
<InstallationTerminal
|
||||||
|
onComplete={() => {
|
||||||
|
setShowTerminal(false);
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('Erro na instalação:', error);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progresso para instalação direta (fallback) */}
|
||||||
|
{state.progressMessage && !showTerminal && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{state.progressMessage}</span>
|
||||||
|
{ollamaCheck.isChecking && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Verificando... {formatTime(ollamaCheck.elapsedTime)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{state.progressStatus === 'installing_ollama' && (
|
||||||
|
<Progress value={undefined} className="h-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback do polling em background (estado VERIFYING) */}
|
||||||
|
{stepState === 'VERIFYING' && (
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||||
|
<span className="break-words">
|
||||||
|
Instalação concluída. Aguardando inicialização do serviço...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display de erro granular */}
|
||||||
|
{state.lastError && errorType && (
|
||||||
|
<ErrorDisplay
|
||||||
|
errorType={errorType}
|
||||||
|
errorMessage={state.lastError}
|
||||||
|
onRetry={handleManualRetry}
|
||||||
|
isRetrying={isRetrying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botão de retry manual quando não há erro específico */}
|
||||||
|
{showRetryButton && !showManualRetry && (
|
||||||
|
<Button
|
||||||
|
onClick={handleManualRetry}
|
||||||
|
disabled={isRetrying || stepState === 'INSTALLING'}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verificando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Verificar Novamente
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{downloadState.isDownloading && (
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{downloadState.status === 'verifying' ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
{downloadState.status === 'verifying'
|
||||||
|
? 'Verificando integridade...'
|
||||||
|
: 'Baixando...'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">{downloadState.speed || '0 MB/s'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={downloadState.downloadProgress}
|
||||||
|
className="h-2 w-full transition-all duration-300"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
|
||||||
|
<span>
|
||||||
|
{formatBytes(downloadState.downloadedBytes)} / {formatBytes(downloadState.totalBytes)}
|
||||||
|
</span>
|
||||||
|
<span>{downloadState.downloadProgress.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{downloadState.eta > 0 && downloadState.status === 'downloading' && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
Tempo restante: {formatTime(downloadState.eta)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Estado INSTALLING: mostrar progresso */}
|
||||||
|
{stepState === 'INSTALLING' && (
|
||||||
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||||
|
<span className="break-words">Instalando Ollama... Isso pode levar alguns minutos.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
{/* Footer opcional se necessário */}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { useSetupWizard } from '@/hooks/use-setup-wizard';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { CheckCircle2, ArrowRight, AlertCircle, Sparkles, Loader2 } from 'lucide-react';
|
||||||
|
import { startTransition } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export function SetupCompleteStep() {
|
||||||
|
const { state, goToStep } = useSetupWizard();
|
||||||
|
const { setSetupCompleted } = useSettingsStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const [integrityError, setIntegrityError] = useState<string | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const verifyAndComplete = async () => {
|
||||||
|
try {
|
||||||
|
setIsVerifying(true);
|
||||||
|
setIntegrityError(null);
|
||||||
|
|
||||||
|
// Verificar integridade ANTES de marcar como completo
|
||||||
|
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
|
||||||
|
if (!isOllamaIntact) {
|
||||||
|
// Redirecionar silenciosamente para o passo de instalação do Ollama
|
||||||
|
setIsVerifying(false);
|
||||||
|
goToStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Só definir flag e redirecionar se Ollama estiver íntegro
|
||||||
|
setSetupCompleted(true);
|
||||||
|
setIsVerifying(false);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/chat');
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} catch {
|
||||||
|
// Em caso de erro na verificação, redirecionar para instalação
|
||||||
|
setIsVerifying(false);
|
||||||
|
goToStep(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
verifyAndComplete();
|
||||||
|
}, [setSetupCompleted, router, goToStep]);
|
||||||
|
|
||||||
|
const handleGoToChat = async () => {
|
||||||
|
try {
|
||||||
|
// Verificar integridade antes de redirecionar manualmente também
|
||||||
|
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
|
||||||
|
if (!isOllamaIntact) {
|
||||||
|
// Redirecionar para o passo de instalação do Ollama
|
||||||
|
goToStep(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetupCompleted(true);
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace('/chat');
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Em caso de erro na verificação, redirecionar para instalação
|
||||||
|
goToStep(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoToInstallation = () => {
|
||||||
|
goToStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
||||||
|
<CardHeader className="p-6 sm:p-8">
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
{isVerifying ? (
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin shrink-0" />
|
||||||
|
) : integrityError ? (
|
||||||
|
<AlertCircle className="w-6 h-6 text-red-500 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-6 h-6 text-emerald-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isVerifying ? 'Verificando Integridade' :
|
||||||
|
integrityError ? 'Erro na Configuração' :
|
||||||
|
'Configuração Completa'}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
{isVerifying ? 'Validando instalação do Ollama...' :
|
||||||
|
integrityError ? 'Encontramos um problema.' :
|
||||||
|
'Tudo pronto para começar a usar o OllaHub.'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
||||||
|
{isVerifying ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-zinc-900/50 flex items-center justify-center border border-zinc-800">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-zinc-700 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||||
|
Estamos verificando se o serviço Ollama está respondendo corretamente antes de liberar o acesso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : integrityError ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 sm:py-8 space-y-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-950/20 flex items-center justify-center border border-red-900/50">
|
||||||
|
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2 max-w-md">
|
||||||
|
<h3 className="font-semibold text-red-400">Falha na Verificação</h3>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
{integrityError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-4 p-6 bg-emerald-950/10 rounded-xl border border-emerald-900/20">
|
||||||
|
<div className="p-3 bg-emerald-500/10 rounded-lg shrink-0">
|
||||||
|
<Sparkles className="w-6 h-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold text-emerald-400 text-lg">Ambiente Configurado</h3>
|
||||||
|
<p className="text-emerald-200/70 text-sm leading-relaxed">
|
||||||
|
Seu assistente de IA local está pronto. Você pode começar a conversar, gerar códigos ou analisar documentos agora mesmo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4 bg-zinc-900/50 rounded-xl border border-zinc-800/50">
|
||||||
|
<p className="text-sm font-medium text-zinc-300 flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
||||||
|
Resumo da Instalação:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-zinc-500 space-y-2 pl-6 list-disc marker:text-zinc-700">
|
||||||
|
<li>Hardware compatível detectado</li>
|
||||||
|
{state.ollamaInstalled && <li>Ollama Engine instalado e ativo</li>}
|
||||||
|
{state.modelDownloaded && (
|
||||||
|
<li>Modelo de IA <strong>{state.modelName || 'padrão'}</strong> baixado</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
||||||
|
<Button
|
||||||
|
onClick={integrityError ? handleGoToInstallation : handleGoToChat}
|
||||||
|
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
|
||||||
|
disabled={isVerifying}
|
||||||
|
>
|
||||||
|
{isVerifying ? 'Verificando...' : integrityError ? 'Instalar Ollama' : 'Ir para o Chat'}
|
||||||
|
{!isVerifying && !integrityError && <ArrowRight className="w-4 h-4 ml-2" />}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSetupWizard } from '@/hooks/use-setup-wizard';
|
||||||
|
import { SystemCheckStep } from './system-check-step';
|
||||||
|
import { OllamaInstallStep } from './ollama-install-step';
|
||||||
|
import { ModelDownloadStep } from './model-download-step';
|
||||||
|
import { SetupCompleteStep } from './setup-complete-step';
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, title: 'Verificação do Sistema', description: 'Analisando hardware' },
|
||||||
|
{ id: 2, title: 'Instalação do Ollama', description: 'Configurando engine' },
|
||||||
|
{ id: 3, title: 'Download do Modelo', description: 'Baixando modelo padrão' },
|
||||||
|
{ id: 4, title: 'Conclusão', description: 'Tudo pronto!' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SetupWizard() {
|
||||||
|
const { state, checkSetupState, goToStep, nextStep } = useSetupWizard();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSetupState();
|
||||||
|
}, [checkSetupState]);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
nextStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (state.currentStep) {
|
||||||
|
case 1:
|
||||||
|
return <SystemCheckStep onNext={handleNext} />;
|
||||||
|
case 2:
|
||||||
|
return <OllamaInstallStep onNext={handleNext} />;
|
||||||
|
case 3:
|
||||||
|
return <ModelDownloadStep onNext={handleNext} />;
|
||||||
|
case 4:
|
||||||
|
return <SetupCompleteStep />;
|
||||||
|
default:
|
||||||
|
return <SystemCheckStep onNext={handleNext} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const containerWidth = state.currentStep === 3 ? 'max-w-6xl' : 'max-w-2xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen max-h-screen w-full bg-background flex flex-col items-center overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
<div className={`w-full ${containerWidth} flex-shrink-0 transition-all duration-500 ease-in-out space-y-4 sm:space-y-6 lg:space-y-8 py-4`}>
|
||||||
|
<div className="text-center space-y-2 flex-shrink-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
|
Vamos preparar o ambiente ideal para você começar a usar o OllaHub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`w-full ${containerWidth} mx-auto mb-20 sm:mb-24 md:mb-28 px-2 sm:px-0 transition-all duration-500 ease-in-out flex-shrink-0 relative z-0`}>
|
||||||
|
<div className="flex items-center justify-between relative">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCompleted = state.completedSteps.includes(step.id);
|
||||||
|
const isCurrent = state.currentStep === step.id;
|
||||||
|
const isFuture = !isCompleted && !isCurrent;
|
||||||
|
|
||||||
|
const canNavigate = step.id <= state.currentStep;
|
||||||
|
const maxReachableStep = Math.max(state.currentStep, ...state.completedSteps);
|
||||||
|
const isClickable = canNavigate || step.id === maxReachableStep + 1;
|
||||||
|
const isLineActive = state.currentStep > index + 1 || state.completedSteps.includes(step.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.id} className={cn("contents")}>
|
||||||
|
{/* STEP CIRCLE & LABEL */}
|
||||||
|
<div className="flex flex-col items-center relative z-0 group pb-0 md:pb-0">
|
||||||
|
<div
|
||||||
|
onClick={() => isClickable && step.id !== state.currentStep && goToStep(step.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 sm:w-12 sm:h-12 rounded-full flex items-center justify-center border-2 transition-all duration-300 relative z-0",
|
||||||
|
// Estética dos Círculos
|
||||||
|
isCompleted && "bg-emerald-500 border-emerald-500 text-white",
|
||||||
|
isCurrent && "bg-zinc-900 border-white text-white shadow-[0_0_10px_rgba(255,255,255,0.5)]",
|
||||||
|
isFuture && "bg-zinc-900 border-zinc-800 text-zinc-500",
|
||||||
|
// Interatividade
|
||||||
|
isClickable ? "cursor-pointer" : "cursor-default"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm sm:text-base font-bold">{step.id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rótulos (Labels) - Desktop Only */}
|
||||||
|
<div className="hidden md:flex absolute top-14 flex-col items-center w-32 text-center z-10">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium transition-colors duration-300",
|
||||||
|
isCurrent ? "text-white" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LINHA CONECTORA (Entre passos) */}
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className={cn(
|
||||||
|
"flex-1 h-[2px] mx-2 sm:mx-4 rounded transition-colors duration-500 relative z-0",
|
||||||
|
isLineActive ? "bg-emerald-500" : "bg-zinc-800"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex-shrink-0 relative z-10">
|
||||||
|
{renderStep()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { CheckCircle2, Cpu, HardDrive, Zap, AlertCircle, ArrowRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useHardware } from '@/hooks/use-hardware';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Componente Skeleton simples para loading
|
||||||
|
function Skeleton({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('animate-pulse bg-zinc-900 rounded-xl', className)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemCheckStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemCheckStep({ onNext }: SystemCheckStepProps) {
|
||||||
|
const { specs, loading } = useHardware();
|
||||||
|
|
||||||
|
const status = loading ? 'checking' : (specs ? 'success' : 'error');
|
||||||
|
const ramGB = specs ? (specs.total_memory / (1024 * 1024 * 1024)).toFixed(1) : 0;
|
||||||
|
const isCompatible = specs ? specs.total_memory >= 4 * 1024 * 1024 * 1024 : false; // Mínimo 4GB
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto border-zinc-800 bg-zinc-950/50 backdrop-blur-sm shadow-2xl">
|
||||||
|
<CardHeader className="p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2 mb-2">
|
||||||
|
<Zap className="w-6 h-6 text-emerald-500 shrink-0" />
|
||||||
|
<span className="truncate">Verificação de Hardware</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400 mb-8 text-base">
|
||||||
|
Analisando compatibilidade para execução de IA local.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{status === 'success' && isCompatible && (
|
||||||
|
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 px-2 py-1 text-xs shrink-0">
|
||||||
|
Compatível
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="px-6 sm:px-8 pb-6 sm:pb-8 pt-0 space-y-6">
|
||||||
|
{/* Estado de Carregamento ou Grid de Specs */}
|
||||||
|
{status === 'checking' ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
) : specs ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
{/* Card RAM */}
|
||||||
|
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
|
||||||
|
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400 shrink-0">
|
||||||
|
<HardDrive className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-zinc-400">Memória RAM</p>
|
||||||
|
<h3 className="text-2xl font-bold text-white mt-1">
|
||||||
|
{ramGB} <span className="text-sm font-normal text-zinc-500">GB</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">Mínimo recomendado: 16GB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card CPU */}
|
||||||
|
<div className="p-4 sm:p-6 rounded-xl bg-zinc-900/50 border border-zinc-800 flex items-start gap-4 hover:border-zinc-700 transition-colors">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400 shrink-0">
|
||||||
|
<Cpu className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-zinc-400">Processador</p>
|
||||||
|
<h3 className="text-2xl font-bold text-white mt-1">
|
||||||
|
{specs.cpu_count} <span className="text-sm font-normal text-zinc-500">Núcleos</span>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">Alta capacidade de threads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Feedback Visual */}
|
||||||
|
<div className="pt-6">
|
||||||
|
{status === 'checking' && (
|
||||||
|
<p className="text-sm text-zinc-500 flex items-center gap-2 animate-pulse">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-zinc-500 block" />
|
||||||
|
Escaneando componentes...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex items-start sm:items-center gap-3 text-sm text-emerald-400 bg-emerald-950/30 p-4 rounded-lg border border-emerald-900/50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-5 h-5 shrink-0" />
|
||||||
|
<span>Hardware detectado com sucesso. Pronto para instalar o Ollama.</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="flex items-start sm:items-center gap-3 text-sm text-red-400 bg-red-950/30 p-4 rounded-lg border border-red-900/50">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0" />
|
||||||
|
<span>Não foi possível detectar o hardware. Verifique as permissões.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex justify-end p-6 sm:p-8 pt-6 mt-0 border-t border-zinc-800">
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={status !== 'success'}
|
||||||
|
className="w-full sm:w-auto bg-white text-black hover:bg-zinc-200 h-12 px-8 text-base font-medium"
|
||||||
|
>
|
||||||
|
{status === 'checking' ? 'Verificando...' : 'Continuar'}
|
||||||
|
{status === 'success' && <ArrowRight className="w-4 h-4 ml-2" />}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+36
-1
@@ -4,9 +4,12 @@ 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;
|
||||||
@@ -77,17 +80,49 @@ export function TitleBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-[9999] shrink-0 fixed top-0 left-0 right-0"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
WebkitAppRegion: 'drag'
|
||||||
|
} as React.CSSProperties & { WebkitAppRegion?: string }}
|
||||||
>
|
>
|
||||||
{/* Área de arrasto com logo/título */}
|
{/* Área de arrasto com logo/título */}
|
||||||
<div
|
<div
|
||||||
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 */}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant = "default", ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertDescription }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -38,9 +38,14 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed top-8 left-0 right-0 bottom-0 z-50 bg-black/50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
// Garantir que o overlay não bloqueie eventos na área do TitleBar (topo 32px)
|
||||||
|
clipPath: 'inset(0 0 0 0)',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -60,9 +65,10 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[calc(50%+16px)] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useOllamaHealth } from '@/contexts/ollama-health-context';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle, RefreshCw, Power } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export function ServiceInterruptionOverlay() {
|
||||||
|
const { isOllamaRunning, isChecking, checkNow } = useOllamaHealth();
|
||||||
|
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
|
const tryStartOllama = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
await invoke<boolean>('auto_start_ollama');
|
||||||
|
await checkNow();
|
||||||
|
} catch {}
|
||||||
|
setStarting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOllamaRunning || !isSetupCompleted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-6">
|
||||||
|
<div className="max-w-md w-full border rounded-lg bg-zinc-900 text-white p-6 shadow-xl">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-yellow-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Conexão com Ollama Perdida</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-300 mb-4">
|
||||||
|
O serviço Ollama não está respondendo. Verifique se ele está aberto.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
{isChecking ? (
|
||||||
|
<span className="text-xs text-zinc-400 flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" /> Tentando reconectar...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" onClick={checkNow}>
|
||||||
|
<RefreshCw className="w-4 h-4" /> Verificar novamente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={tryStartOllama} disabled={starting} className="flex-1">
|
||||||
|
{starting ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" /> Iniciando Ollama...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Power className="w-4 h-4" /> Tentar abrir Ollama
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={checkNow}>
|
||||||
|
Testar novamente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
// Helper para formatar bytes em formato legível (KB, MB, GB)
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para formatar velocidade em formato legível
|
||||||
|
function formatSpeed(bytesPerSec: number): string {
|
||||||
|
if (bytesPerSec === 0) return '0 B/s';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||||
|
const i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
|
||||||
|
return `${parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para parsear string formatada (ex: "552 MB") para bytes
|
||||||
|
function parseFormattedBytes(formatted: string): number {
|
||||||
|
if (!formatted || typeof formatted !== 'string') return 0;
|
||||||
|
|
||||||
|
const match = formatted.match(/([\d.]+)\s*(B|KB|MB|GB)/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2].toUpperCase();
|
||||||
|
const multiplier =
|
||||||
|
unit === 'GB' ? 1024 * 1024 * 1024 :
|
||||||
|
unit === 'MB' ? 1024 * 1024 :
|
||||||
|
unit === 'KB' ? 1024 : 1;
|
||||||
|
|
||||||
|
return value * multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadProgress {
|
||||||
|
percent: number | null; // null = indeterminado
|
||||||
|
downloadedBytes: number; // em bytes
|
||||||
|
totalBytes: number; // em bytes
|
||||||
|
downloadedFormatted: string; // "552 MB"
|
||||||
|
totalFormatted: string; // "1.2 GB"
|
||||||
|
speed: string; // "25 MB/s" (calculado no frontend)
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadState {
|
||||||
|
isDownloading: boolean;
|
||||||
|
isSuccess: boolean; // NOVO: indica se download foi concluído com sucesso
|
||||||
|
progress: DownloadProgress;
|
||||||
|
currentModel: string | null;
|
||||||
|
error: string | null;
|
||||||
|
isCancelling: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadContextType {
|
||||||
|
state: DownloadState;
|
||||||
|
startDownload: (modelName: string) => Promise<void>;
|
||||||
|
cancelDownload: () => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadContext = createContext<DownloadContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function DownloadProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, setState] = useState<DownloadState>({
|
||||||
|
isDownloading: false,
|
||||||
|
isSuccess: false,
|
||||||
|
progress: {
|
||||||
|
percent: null,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
downloadedFormatted: '0 B',
|
||||||
|
totalFormatted: '0 B',
|
||||||
|
speed: '0 B/s',
|
||||||
|
status: '',
|
||||||
|
},
|
||||||
|
currentModel: null,
|
||||||
|
error: null,
|
||||||
|
isCancelling: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const unlistenRef = useRef<(() => void) | null>(null);
|
||||||
|
const isCancellingRef = useRef(false);
|
||||||
|
const lastDownloadedBytesRef = useRef<number>(0);
|
||||||
|
const lastUpdateTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Sincronizar ref com estado
|
||||||
|
useEffect(() => {
|
||||||
|
isCancellingRef.current = state.isCancelling;
|
||||||
|
}, [state.isCancelling]);
|
||||||
|
|
||||||
|
// Escutar eventos de progresso do download
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isDownloading) {
|
||||||
|
// Limpar listener quando não estiver baixando
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
const unlisten = await listen<string>('download-progress', (event) => {
|
||||||
|
if (isCancellingRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.payload);
|
||||||
|
|
||||||
|
const status = data.status || '';
|
||||||
|
|
||||||
|
// Usar percent do backend diretamente (já calculado corretamente)
|
||||||
|
// Se não estiver disponível e tivermos completed/total, calcular
|
||||||
|
let percent: number | null = data.percent !== undefined ? data.percent : null;
|
||||||
|
|
||||||
|
// Parsear valores formatados do backend para bytes
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
if (data.downloaded) {
|
||||||
|
downloadedBytes = typeof data.downloaded === 'string'
|
||||||
|
? parseFormattedBytes(data.downloaded)
|
||||||
|
: data.downloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.total) {
|
||||||
|
totalBytes = typeof data.total === 'string'
|
||||||
|
? parseFormattedBytes(data.total)
|
||||||
|
: data.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se percent não veio do backend mas temos valores brutos, calcular
|
||||||
|
if (percent === null && totalBytes > 0 && downloadedBytes > 0) {
|
||||||
|
percent = Math.round((downloadedBytes / totalBytes) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se total for 0 ou indefinido, percent fica null (indeterminado)
|
||||||
|
if (totalBytes === 0) {
|
||||||
|
percent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular velocidade em tempo real usando refs
|
||||||
|
const now = Date.now();
|
||||||
|
let calculatedSpeed = '0 B/s';
|
||||||
|
|
||||||
|
if (downloadedBytes > 0) {
|
||||||
|
// Primeira atualização: inicializar timestamp
|
||||||
|
if (lastUpdateTimeRef.current === 0) {
|
||||||
|
lastUpdateTimeRef.current = now;
|
||||||
|
lastDownloadedBytesRef.current = downloadedBytes;
|
||||||
|
// Usar velocidade do backend se disponível, senão 0
|
||||||
|
calculatedSpeed = data.speed || '0 B/s';
|
||||||
|
} else {
|
||||||
|
const deltaTime = (now - lastUpdateTimeRef.current) / 1000; // em segundos
|
||||||
|
|
||||||
|
if (deltaTime > 0 && downloadedBytes > lastDownloadedBytesRef.current) {
|
||||||
|
const deltaBytes = downloadedBytes - lastDownloadedBytesRef.current;
|
||||||
|
const bytesPerSec = deltaBytes / deltaTime;
|
||||||
|
calculatedSpeed = formatSpeed(bytesPerSec);
|
||||||
|
} else {
|
||||||
|
// Sem mudança ou tempo inválido, manter velocidade anterior ou do backend
|
||||||
|
calculatedSpeed = data.speed || '0 B/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar refs para próximo cálculo
|
||||||
|
lastDownloadedBytesRef.current = downloadedBytes;
|
||||||
|
lastUpdateTimeRef.current = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatar valores para exibição
|
||||||
|
const downloadedFormatted = downloadedBytes > 0 ? formatBytes(downloadedBytes) : (data.downloaded || '0 B');
|
||||||
|
const totalFormatted = totalBytes > 0 ? formatBytes(totalBytes) : (data.total || '0 B');
|
||||||
|
|
||||||
|
// Determinar se é sucesso
|
||||||
|
const isSuccess = status === 'success' || percent === 100;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isSuccess: isSuccess,
|
||||||
|
isDownloading: !isSuccess,
|
||||||
|
progress: {
|
||||||
|
percent: percent !== null ? percent : prev.progress.percent,
|
||||||
|
downloadedBytes: downloadedBytes || prev.progress.downloadedBytes,
|
||||||
|
totalBytes: totalBytes || prev.progress.totalBytes,
|
||||||
|
downloadedFormatted: downloadedFormatted,
|
||||||
|
totalFormatted: totalFormatted,
|
||||||
|
speed: calculatedSpeed,
|
||||||
|
status: status || prev.progress.status,
|
||||||
|
},
|
||||||
|
isCancelling: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
// Não resetar progresso - manter em 100% para feedback visual
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
isSuccess: true,
|
||||||
|
progress: {
|
||||||
|
...prev.progress,
|
||||||
|
percent: 100,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao processar evento de progresso:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenRef.current = unlisten;
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state.isDownloading, state.isCancelling]);
|
||||||
|
|
||||||
|
const startDownload = useCallback(async (modelName: string) => {
|
||||||
|
if (state.isDownloading) {
|
||||||
|
console.warn('Download já em andamento');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resetar refs para cálculo de velocidade
|
||||||
|
lastDownloadedBytesRef.current = 0;
|
||||||
|
lastUpdateTimeRef.current = Date.now();
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isDownloading: true,
|
||||||
|
isSuccess: false,
|
||||||
|
progress: {
|
||||||
|
percent: null,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
downloadedFormatted: '0 B',
|
||||||
|
totalFormatted: '0 B',
|
||||||
|
speed: '0 B/s',
|
||||||
|
status: 'Iniciando download...',
|
||||||
|
},
|
||||||
|
currentModel: modelName,
|
||||||
|
error: null,
|
||||||
|
isCancelling: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('download_default_model_command', { modelName });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Erro desconhecido durante download';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: errorMsg,
|
||||||
|
isCancelling: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state.isDownloading]);
|
||||||
|
|
||||||
|
const cancelDownload = useCallback(async () => {
|
||||||
|
if (!state.isDownloading) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isCancelling: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('cancel_download');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Falha ao cancelar no backend:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isDownloading: false,
|
||||||
|
isCancelling: false,
|
||||||
|
progress: {
|
||||||
|
...prev.progress,
|
||||||
|
status: 'Cancelado',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar AbortController local
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [state.isDownloading]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DownloadContext.Provider
|
||||||
|
value={{
|
||||||
|
state,
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
clearError,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DownloadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadContext() {
|
||||||
|
const context = useContext(DownloadContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useDownloadContext deve ser usado dentro de DownloadProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
interface OllamaHealthContextType {
|
||||||
|
isOllamaRunning: boolean;
|
||||||
|
lastCheckedAt: number | null;
|
||||||
|
isChecking: boolean;
|
||||||
|
checkNow: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OllamaHealthContext = createContext<OllamaHealthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function OllamaHealthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isOllamaRunning, setIsOllamaRunning] = useState<boolean>(true);
|
||||||
|
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState<boolean>(false);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const mountedRef = useRef<boolean>(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isSetupCompleted = useSettingsStore((s) => s.isSetupCompleted);
|
||||||
|
|
||||||
|
const shouldPoll = useMemo(() => {
|
||||||
|
if (!isSetupCompleted) return false;
|
||||||
|
if (!pathname) return true;
|
||||||
|
return !pathname.startsWith('/setup');
|
||||||
|
}, [pathname, isSetupCompleted]);
|
||||||
|
|
||||||
|
const checkNow = async () => {
|
||||||
|
try {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setIsChecking(true);
|
||||||
|
const ok = await invoke<boolean>('check_ollama_heartbeat');
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setIsOllamaRunning(!!ok);
|
||||||
|
setLastCheckedAt(Date.now());
|
||||||
|
} catch {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setIsOllamaRunning(false);
|
||||||
|
setLastCheckedAt(Date.now());
|
||||||
|
} finally {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldPoll) {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// primeira verificação imediata após mount
|
||||||
|
setTimeout(() => { checkNow(); }, 0);
|
||||||
|
// polling
|
||||||
|
intervalRef.current = setInterval(() => { if (mountedRef.current) { checkNow(); } }, 3000);
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [shouldPoll]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OllamaHealthContext.Provider value={{ isOllamaRunning, lastCheckedAt, isChecking, checkNow }}>
|
||||||
|
{children}
|
||||||
|
</OllamaHealthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOllamaHealth() {
|
||||||
|
const ctx = useContext(OllamaHealthContext);
|
||||||
|
if (!ctx) throw new Error('useOllamaHealth deve ser usado dentro de OllamaHealthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "0.1.0",
|
||||||
|
"generatedAt": "2025-11-28T03:22:48.992Z",
|
||||||
|
"releases": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
searxng:
|
||||||
|
image: searxng/searxng:latest
|
||||||
|
container_name: ollaHub_searxng
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- searxng_data:/etc/searxng:rw
|
||||||
|
environment:
|
||||||
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- searxng_network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
container_name: ollaHub_searxng_redis
|
||||||
|
command: redis-server --save "" --appendonly "no"
|
||||||
|
networks:
|
||||||
|
- searxng_network
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/redis
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
searxng_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
searxng_network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Configuração do Sistema de Atualização Automática
|
||||||
|
|
||||||
|
Este documento explica como configurar o sistema de atualização automática do OllaHub.
|
||||||
|
|
||||||
|
## Pré-requisitos
|
||||||
|
|
||||||
|
1. Repositório GitHub configurado
|
||||||
|
2. Rust e Cargo instalados
|
||||||
|
3. Tauri CLI instalado
|
||||||
|
|
||||||
|
## Passo 1: Gerar Chaves de Assinatura
|
||||||
|
|
||||||
|
Execute o script para gerar as chaves:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/generate-updater-keys.sh
|
||||||
|
./scripts/generate-updater-keys.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou manualmente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.tauri
|
||||||
|
cargo tauri signer generate -w ~/.tauri/ollahub.key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passo 2: Configurar Chave Pública
|
||||||
|
|
||||||
|
1. Copie a chave pública gerada (está em `~/.tauri/ollahub.key.pub`)
|
||||||
|
2. Edite `src-tauri/tauri.conf.json`
|
||||||
|
3. Substitua `YOUR_PUBLIC_KEY_HERE` pela chave pública
|
||||||
|
4. Atualize o endpoint com seu repositório GitHub:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"endpoints": [
|
||||||
|
"https://api.github.com/repos/SEU_USUARIO/SEU_REPOSITORIO/releases/latest"
|
||||||
|
],
|
||||||
|
"dialog": true,
|
||||||
|
"pubkey": "SUA_CHAVE_PUBLICA_AQUI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Passo 3: Configurar GitHub Secrets
|
||||||
|
|
||||||
|
1. Vá em **Settings** > **Secrets and variables** > **Actions**
|
||||||
|
2. Clique em **New repository secret**
|
||||||
|
3. Nome: `TAURI_UPDATER_PRIVATE_KEY`
|
||||||
|
4. Valor: Cole o conteúdo completo do arquivo `~/.tauri/ollahub.key`
|
||||||
|
|
||||||
|
## Passo 4: Atualizar Workflow (se necessário)
|
||||||
|
|
||||||
|
O workflow `.github/workflows/release.yml` já está configurado para:
|
||||||
|
- Buildar para Windows, macOS e Linux
|
||||||
|
- Assinar binários com a chave privada
|
||||||
|
- Criar release automaticamente quando há push em `main`/`master`
|
||||||
|
|
||||||
|
## Como Funciona
|
||||||
|
|
||||||
|
1. **Build Automático**: Quando você faz push para `main`/`master`, o GitHub Actions:
|
||||||
|
- Builda o app para todas as plataformas
|
||||||
|
- Assina os binários
|
||||||
|
- Cria uma release no GitHub
|
||||||
|
|
||||||
|
2. **Verificação de Atualizações**: O app verifica automaticamente:
|
||||||
|
- 5 segundos após iniciar (primeira verificação)
|
||||||
|
- A cada 6 horas (verificação periódica)
|
||||||
|
- Manualmente via botão nas configurações
|
||||||
|
|
||||||
|
3. **Instalação**: Quando há atualização disponível:
|
||||||
|
- Usuário recebe notificação
|
||||||
|
- Pode escolher atualizar agora ou depois
|
||||||
|
- Download e instalação são automáticos
|
||||||
|
- App reinicia automaticamente após instalação
|
||||||
|
|
||||||
|
## Bump de Versão
|
||||||
|
|
||||||
|
Para criar uma nova versão:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch (0.1.0 -> 0.1.1)
|
||||||
|
pnpm run bump-version patch --commit
|
||||||
|
|
||||||
|
# Minor (0.1.0 -> 0.2.0)
|
||||||
|
pnpm run bump-version minor --commit
|
||||||
|
|
||||||
|
# Major (0.1.0 -> 1.0.0)
|
||||||
|
pnpm run bump-version major --commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Depois, faça push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
O GitHub Actions criará automaticamente a release.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erro: "Failed to check for updates"
|
||||||
|
- Verifique se o endpoint no `tauri.conf.json` está correto
|
||||||
|
- Verifique se há releases públicas no GitHub
|
||||||
|
- Verifique a conexão com a internet
|
||||||
|
|
||||||
|
### Erro: "Signature verification failed"
|
||||||
|
- Verifique se a chave pública no `tauri.conf.json` está correta
|
||||||
|
- Verifique se a chave privada no GitHub Secret está correta
|
||||||
|
- Regere as chaves se necessário
|
||||||
|
|
||||||
|
### Release não é criada automaticamente
|
||||||
|
- Verifique se o push foi feito para `main` ou `master`
|
||||||
|
- Verifique os logs do GitHub Actions
|
||||||
|
- Verifique se as permissões do workflow estão corretas
|
||||||
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { check as checkForUpdate } from '@tauri-apps/plugin-updater';
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
version: string;
|
||||||
|
date?: string;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUpdaterState {
|
||||||
|
currentVersion: string;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
updateInfo: UpdateInfo | null;
|
||||||
|
isChecking: boolean;
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadProgress: number;
|
||||||
|
error: string | null;
|
||||||
|
checkUpdate: () => Promise<void>;
|
||||||
|
installUpdate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUpdater(): AppUpdaterState {
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
|
||||||
|
|
||||||
|
// Obter versão atual
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const version = await getVersion();
|
||||||
|
setCurrentVersion(version);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get app version:', err);
|
||||||
|
setCurrentVersion('0.1.0');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Verificar atualizações
|
||||||
|
const checkUpdate = useCallback(async () => {
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Ignorar verificação de updates em ambiente de desenvolvimento
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
setIsChecking(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await checkForUpdate();
|
||||||
|
|
||||||
|
if (update?.available) {
|
||||||
|
setUpdateAvailable(true);
|
||||||
|
setUpdateInfo({
|
||||||
|
version: update.version,
|
||||||
|
date: update.date,
|
||||||
|
body: update.body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
setUpdateInfo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check for updates:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Erro ao verificar atualizações');
|
||||||
|
setUpdateAvailable(false);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Instalar atualização
|
||||||
|
const installUpdate = useCallback(async () => {
|
||||||
|
if (!updateAvailable) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const update = await checkForUpdate();
|
||||||
|
|
||||||
|
if (update?.available) {
|
||||||
|
// Baixar e instalar atualização
|
||||||
|
// O progresso pode ser monitorado via eventos do Tauri se necessário
|
||||||
|
await update.downloadAndInstall();
|
||||||
|
|
||||||
|
// Após downloadAndInstall(), o app será reiniciado automaticamente
|
||||||
|
// Não é necessário chamar installAndRestart() que não existe na API
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to install update:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Erro ao instalar atualização');
|
||||||
|
setIsDownloading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}
|
||||||
|
}, [updateAvailable]);
|
||||||
|
|
||||||
|
// Verificação automática periódica (a cada 6 horas)
|
||||||
|
useEffect(() => {
|
||||||
|
<<<<<<< HEAD
|
||||||
|
// Ignorar verificação automática em desenvolvimento
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
if (!autoCheckEnabled) return;
|
||||||
|
|
||||||
|
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
|
||||||
|
const initialTimer = setTimeout(() => {
|
||||||
|
checkUpdate();
|
||||||
|
}, 5000); // 5 segundos após o app carregar
|
||||||
|
|
||||||
|
// Verificar periodicamente
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
checkUpdate();
|
||||||
|
}, 6 * 60 * 60 * 1000); // 6 horas
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(initialTimer);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [autoCheckEnabled, checkUpdate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
isChecking,
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
error,
|
||||||
|
checkUpdate,
|
||||||
|
installUpdate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ export function useAutoLabelingModel() {
|
|||||||
|
|
||||||
const checkAndDownload = async () => {
|
const checkAndDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
|
// O comando Tauri é check_if_model_installed_command, mas o Tauri remove o sufixo _command
|
||||||
|
const installed = await invoke<boolean>('check_if_model_installed_command', { name: TARGET_MODEL });
|
||||||
if (installed) {
|
if (installed) {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
return;
|
return;
|
||||||
@@ -49,6 +50,25 @@ export function useAutoLabelingModel() {
|
|||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Detectar erros de conexão/rede e tratá-los silenciosamente
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const isConnectionError =
|
||||||
|
errorMessage.toLowerCase().includes('connection') ||
|
||||||
|
errorMessage.toLowerCase().includes('network') ||
|
||||||
|
errorMessage.toLowerCase().includes('econnrefused') ||
|
||||||
|
errorMessage.toLowerCase().includes('timeout') ||
|
||||||
|
errorMessage.toLowerCase().includes('failed to connect') ||
|
||||||
|
errorMessage.toLowerCase().includes('connection refused');
|
||||||
|
|
||||||
|
if (isConnectionError) {
|
||||||
|
// Erro de conexão: Ollama está offline - tratar silenciosamente
|
||||||
|
// Não logar console.error para não poluir o console
|
||||||
|
setIsDownloading(false);
|
||||||
|
setIsReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outros erros: manter log atual para debug
|
||||||
console.error("Failed to setup auto-labeling model:", error);
|
console.error("Failed to setup auto-labeling model:", error);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,24 +37,17 @@ export function useBatchVerify() {
|
|||||||
|
|
||||||
const callLLM = async (model: string, prompt: string, json = false) => {
|
const callLLM = async (model: string, prompt: string, json = false) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const response = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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;
|
||||||
|
|||||||
+165
-42
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Message } from './use-chat';
|
import { Message } from './use-chat';
|
||||||
import { removeMetadataNoise } from '@/lib/metadata';
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
@@ -10,8 +10,31 @@ export interface SessionSummary {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
match_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resultado de carregamento paginado do backend */
|
||||||
|
interface PaginatedHistoryResult {
|
||||||
|
messages: Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>;
|
||||||
|
total_count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estado de paginação para uma sessão */
|
||||||
|
export interface PaginationState {
|
||||||
|
totalCount: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
offset: number;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Número de mensagens a carregar por página */
|
||||||
|
const MESSAGES_PER_PAGE = 30;
|
||||||
|
|
||||||
const TITLE_MODEL = "qwen2.5:0.5b";
|
const TITLE_MODEL = "qwen2.5:0.5b";
|
||||||
|
|
||||||
// Helper to generate fallback title from user's first message
|
// Helper to generate fallback title from user's first message
|
||||||
@@ -41,6 +64,17 @@ export function useChatStorage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
// Estado de paginação por sessão
|
||||||
|
const [paginationState, setPaginationState] = useState<PaginationState>({
|
||||||
|
totalCount: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ref para evitar múltiplos loads simultâneos
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
||||||
@@ -73,23 +107,138 @@ export function useChatStorage() {
|
|||||||
}
|
}
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
/**
|
||||||
|
* Carrega histórico de uma sessão com paginação (lazy loading)
|
||||||
|
*
|
||||||
|
* Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens.
|
||||||
|
* Retorna as mensagens e atualiza o estado de paginação.
|
||||||
|
*/
|
||||||
|
const loadSessionHistory = useCallback(async (
|
||||||
|
id: string,
|
||||||
|
options?: { loadAll?: boolean }
|
||||||
|
): Promise<Message[]> => {
|
||||||
try {
|
try {
|
||||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
// Reset pagination state
|
||||||
// Convert role from String to type-safe union and ensure proper types
|
setPaginationState({
|
||||||
|
totalCount: 0,
|
||||||
|
hasMore: false,
|
||||||
|
offset: 0,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se loadAll = true, usa o comando antigo que carrega tudo
|
||||||
|
if (options?.loadAll) {
|
||||||
|
const rawMessages = await invoke<Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>>('load_chat_history', { id });
|
||||||
|
|
||||||
return rawMessages.map(msg => ({
|
return rawMessages.map(msg => ({
|
||||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
? msg.role as 'user' | 'assistant' | 'system'
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
: 'user' as const, // fallback
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar paginação: carregar últimas N mensagens
|
||||||
|
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||||
|
id,
|
||||||
|
limit: MESSAGES_PER_PAGE,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar estado de paginação
|
||||||
|
setPaginationState({
|
||||||
|
totalCount: result.total_count,
|
||||||
|
hasMore: result.has_more,
|
||||||
|
offset: result.messages.length,
|
||||||
|
isLoadingMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Converter para tipo Message
|
||||||
|
return result.messages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
content: msg.content || '',
|
content: msg.content || '',
|
||||||
metadata: msg.metadata || undefined
|
metadata: msg.metadata || undefined
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load history:", error);
|
console.error("Failed to load history:", error);
|
||||||
|
|
||||||
|
// Fallback: tentar carregar tudo com o comando antigo
|
||||||
|
try {
|
||||||
|
const rawMessages = await invoke<Array<{
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
metadata?: unknown;
|
||||||
|
}>>('load_chat_history', { id });
|
||||||
|
|
||||||
|
return rawMessages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega mais mensagens antigas (scroll infinito reverso)
|
||||||
|
*
|
||||||
|
* Chamado quando o usuário faz scroll para o topo do chat.
|
||||||
|
* Retorna as mensagens anteriores que devem ser prepend ao estado.
|
||||||
|
*/
|
||||||
|
const loadMoreMessages = useCallback(async (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<Message[]> => {
|
||||||
|
// Evitar múltiplos loads simultâneos
|
||||||
|
if (loadingMoreRef.current || !paginationState.hasMore) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMoreRef.current = true;
|
||||||
|
setPaginationState(prev => ({ ...prev, isLoadingMore: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
|
||||||
|
id: sessionId,
|
||||||
|
limit: MESSAGES_PER_PAGE,
|
||||||
|
offset: paginationState.offset
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar estado de paginação
|
||||||
|
setPaginationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
hasMore: result.has_more,
|
||||||
|
offset: prev.offset + result.messages.length,
|
||||||
|
isLoadingMore: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Converter para tipo Message
|
||||||
|
return result.messages.map(msg => ({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load more messages:", error);
|
||||||
|
setPaginationState(prev => ({ ...prev, isLoadingMore: false }));
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
loadingMoreRef.current = false;
|
||||||
|
}
|
||||||
|
}, [paginationState.hasMore, paginationState.offset]);
|
||||||
|
|
||||||
const deleteSession = useCallback(async (id: string) => {
|
const deleteSession = useCallback(async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await invoke('delete_chat_session', { id });
|
await invoke('delete_chat_session', { id });
|
||||||
@@ -105,30 +254,16 @@ export function useChatStorage() {
|
|||||||
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
||||||
// Generate title immediately from user's first message
|
// Generate title immediately from user's first message
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
let title = await invoke<string>('generate_chat_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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 }
|
||||||
],
|
],
|
||||||
stream: false,
|
options: undefined,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// If model not available, use fallback
|
|
||||||
if (response.status === 404 || response.status >= 500) {
|
|
||||||
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
|
||||||
}
|
|
||||||
throw new Error(`Title generation failed with status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
let title = data.message?.content?.trim() || "";
|
|
||||||
|
|
||||||
// If empty from start, use fallback
|
// If 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");
|
||||||
@@ -171,30 +306,17 @@ export function useChatStorage() {
|
|||||||
content: removeMetadataNoise(m.content),
|
content: removeMetadataNoise(m.content),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawTitle = await invoke<string>('generate_chat_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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
|
||||||
],
|
],
|
||||||
stream: false,
|
options: undefined,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
let title = removeMetadataNoise(rawTitle?.trim() || "");
|
||||||
// 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) {
|
||||||
@@ -243,7 +365,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 => typeof k === 'string'));
|
memoryContext.push(...keywords.filter((k: unknown) => typeof k === 'string'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
||||||
@@ -308,7 +430,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 => typeof k === 'string'));
|
memoryContext.push(...keywords.filter((k: unknown) => typeof k === 'string'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
@@ -396,6 +518,8 @@ export function useChatStorage() {
|
|||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
loadSessionHistory,
|
loadSessionHistory,
|
||||||
|
loadMoreMessages,
|
||||||
|
paginationState,
|
||||||
saveSession,
|
saveSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle,
|
isGeneratingTitle,
|
||||||
@@ -407,4 +531,3 @@ export function useChatStorage() {
|
|||||||
isSearching
|
isSearching
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+56
-29
@@ -26,6 +26,8 @@ export interface ThinkingMessageMetadata {
|
|||||||
error?: string;
|
error?: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
activeQueries?: Array<{ query: string; source: string; round?: number; startedAt: number }>;
|
||||||
|
activeUrls?: Array<{ url: string; title?: string; status: string; source?: string; startedAt: number; duration?: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -240,6 +242,29 @@ export function useChat() {
|
|||||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||||
const currentSessionIdRef = useRef<string | null>(null);
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
||||||
|
const renderScheduledRef = useRef<boolean>(false);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Função para atualizar o estado visual com o conteúdo acumulado
|
||||||
|
const flushToState = () => {
|
||||||
|
renderScheduledRef.current = false;
|
||||||
|
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: displayContent }];
|
||||||
|
}
|
||||||
|
return [...prev, { role: 'assistant', content: displayContent }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agendar flush com requestAnimationFrame (limita a ~60fps)
|
||||||
|
const scheduleFlush = () => {
|
||||||
|
if (!renderScheduledRef.current) {
|
||||||
|
renderScheduledRef.current = true;
|
||||||
|
rafIdRef.current = requestAnimationFrame(flushToState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Listener para eventos do Rust
|
// Listener para eventos do Rust
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,51 +287,38 @@ export function useChat() {
|
|||||||
const { content, done } = event.payload;
|
const { content, done } = event.payload;
|
||||||
|
|
||||||
if (content && content.length > 0) {
|
if (content && content.length > 0) {
|
||||||
// Verificar se este é o mesmo token do último processado (evitar duplicação)
|
// Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
|
||||||
// Isso pode acontecer se o evento for emitido múltiplas vezes
|
|
||||||
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
||||||
// Token duplicado, ignorar
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualizar referência do último token
|
|
||||||
lastTokenRef.current = content;
|
lastTokenRef.current = content;
|
||||||
|
|
||||||
// Accumulate raw content (including metadata) in ref
|
|
||||||
rawContentRef.current += content;
|
rawContentRef.current += content;
|
||||||
|
|
||||||
setMessages(prev => {
|
// MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
|
||||||
const last = prev[prev.length - 1];
|
scheduleFlush();
|
||||||
if (last && last.role === 'assistant') {
|
|
||||||
// For display: strip incomplete metadata tag if present
|
|
||||||
const displayContent = stripMetadataTag(rawContentRef.current);
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: displayContent }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// Se não existe mensagem do assistente, criar uma
|
|
||||||
return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
// Stream terminou, processar metadata final
|
// Cancelar qualquer flush pendente
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
renderScheduledRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush IMEDIATO com processamento de metadata
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
const rawContent = rawContentRef.current;
|
const rawContent = rawContentRef.current;
|
||||||
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
||||||
rawContentRef.current = '';
|
rawContentRef.current = '';
|
||||||
lastTokenRef.current = ''; // Reset last token tracker
|
lastTokenRef.current = '';
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: finalContent || last.content, metadata }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
@@ -331,6 +343,10 @@ export function useChat() {
|
|||||||
unlistenCreated?.();
|
unlistenCreated?.();
|
||||||
unlistenToken?.();
|
unlistenToken?.();
|
||||||
unlistenError?.();
|
unlistenError?.();
|
||||||
|
// Cancelar RAF pendente
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -514,10 +530,21 @@ export function useChat() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
const stop = async () => {
|
||||||
if (abortControllerRef.current) {
|
try {
|
||||||
abortControllerRef.current.abort();
|
// Cancelar processamento de scraping (prioridade alta)
|
||||||
|
try {
|
||||||
|
await invoke('cancel_scraping');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao cancelar scraping:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSessionIdRef.current) {
|
||||||
|
await invoke('stop_processing', { sessionId: currentSessionIdRef.current });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
abortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+69
-44
@@ -23,7 +23,7 @@ export interface DeepResearchLog {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
input: string;
|
input: string;
|
||||||
rawOutput?: string;
|
rawOutput?: string;
|
||||||
parsedOutput?: any;
|
parsedOutput?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ function logStep(message: string) {
|
|||||||
log.info(message);
|
log.info(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logData(label: string, data: any) {
|
function logData(label: string, data: unknown) {
|
||||||
if (typeof data === 'string' && data.length > 500) {
|
if (typeof data === 'string' && data.length > 500) {
|
||||||
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
||||||
} else if (typeof data === 'object') {
|
} else if (typeof data === 'object') {
|
||||||
@@ -57,7 +57,7 @@ function logData(label: string, data: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logError(message: string, error?: any) {
|
function logError(message: string, error?: unknown) {
|
||||||
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
|
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +75,17 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
logData('Raw response', raw);
|
logData('Raw response', raw);
|
||||||
|
|
||||||
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
||||||
let cleaned = raw
|
const cleaned = raw
|
||||||
.replace(/```json\s*/gi, '')
|
.replace(/```json\s*/gi, '')
|
||||||
.replace(/```\s*/g, '')
|
.replace(/```\s*/g, '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// 2. Try to parse as JSON
|
// 2. Try to parse as JSON
|
||||||
let parsed: any;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(cleaned);
|
parsed = JSON.parse(cleaned);
|
||||||
logStep('Direct JSON parse successful');
|
logStep('Direct JSON parse successful');
|
||||||
} catch (e) {
|
} catch {
|
||||||
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
||||||
// If JSON parse fails, try to find JSON-like content in the string
|
// If JSON parse fails, try to find JSON-like content in the string
|
||||||
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
||||||
@@ -93,7 +93,7 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(jsonMatch[0]);
|
parsed = JSON.parse(jsonMatch[0]);
|
||||||
logStep('Extracted JSON from string successfully');
|
logStep('Extracted JSON from string successfully');
|
||||||
} catch (e2) {
|
} catch {
|
||||||
logError('Could not extract JSON from response');
|
logError('Could not extract JSON from response');
|
||||||
throw new Error('Could not extract JSON from response');
|
throw new Error('Could not extract JSON from response');
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
|
|
||||||
// 3. Handle different formats
|
// 3. Handle different formats
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0);
|
const result = (parsed as unknown[]).filter(item => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Parsed as plain array: ${result.length} queries`);
|
logSuccess(`Parsed as plain array: ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -113,17 +113,19 @@ function extractQueriesFromResponse(raw: string): string[] {
|
|||||||
// 4. Check common object keys used by different models
|
// 4. Check common object keys used by different models
|
||||||
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
||||||
for (const key of commonKeys) {
|
for (const key of commonKeys) {
|
||||||
if (parsed[key] && Array.isArray(parsed[key])) {
|
const obj = parsed as Record<string, unknown>;
|
||||||
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
const val = obj[key];
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
|
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Fallback: find first array value in the object
|
// 5. Fallback: find first array value in the object
|
||||||
for (const val of Object.values(parsed)) {
|
for (const val of Object.values(parsed as Record<string, unknown>)) {
|
||||||
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
|
if (Array.isArray(val) && val.length > 0 && typeof (val as unknown[])[0] === 'string') {
|
||||||
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
const result = (val as unknown[]).filter((item: unknown) => typeof item === 'string' && (item as string).trim().length > 0) as string[];
|
||||||
logSuccess(`Found queries in object value: ${result.length} queries`);
|
logSuccess(`Found queries in object value: ${result.length} queries`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -151,29 +153,19 @@ export function useDeepResearch() {
|
|||||||
logData('Prompt length', `${prompt.length} chars`);
|
logData('Prompt length', `${prompt.length} chars`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const response = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
logSuccess(`LLM response received (${response?.length || 0} chars)`);
|
||||||
logError(`LLM API returned status ${response.status}`);
|
return response;
|
||||||
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);
|
||||||
@@ -195,7 +187,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);
|
log.warn(`Contextual analysis failed, continuing without context: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -219,7 +211,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);
|
log.warn(`Query enrichment failed, continuing without enrichment: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -255,16 +247,17 @@ 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);
|
||||||
@@ -278,8 +271,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(),
|
||||||
@@ -288,7 +281,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;
|
||||||
@@ -303,32 +296,64 @@ export function useDeepResearch() {
|
|||||||
|
|
||||||
// --- ADD TO KNOWLEDGE BASE ---
|
// --- ADD TO KNOWLEDGE BASE ---
|
||||||
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
||||||
logStep(`--- Adding to Knowledge Base ---`);
|
logSection('ADDING TO KNOWLEDGE BASE');
|
||||||
logData('Query', query);
|
logData('Query', query);
|
||||||
logData('Sources count', sources.length);
|
logData('Sources count', sources.length);
|
||||||
|
logData('Timestamp', new Date().toISOString());
|
||||||
|
|
||||||
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
||||||
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
||||||
query,
|
query,
|
||||||
sourceUrl: src.url,
|
sourceUrl: src.url,
|
||||||
title: src.title,
|
title: src.title,
|
||||||
content: src.markdown,
|
content: src.markdown || src.content || '',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Log detalhado de cada fonte
|
||||||
sources.forEach((src, idx) => {
|
sources.forEach((src, idx) => {
|
||||||
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
|
logStep(`\n--- Source ${idx + 1}/${sources.length} ---`);
|
||||||
const contentLength = src.markdown?.length || 0;
|
logData('Title', src.title);
|
||||||
logData(` Content length`, `${contentLength} chars`);
|
logData('URL', src.url);
|
||||||
|
|
||||||
|
const contentLength = src.markdown?.length || src.content?.length || 0;
|
||||||
|
const snippetLength = src.snippet?.length || 0;
|
||||||
|
|
||||||
|
logData('Content length (markdown)', `${contentLength} chars`);
|
||||||
|
logData('Snippet length', `${snippetLength} chars`);
|
||||||
|
logData('Cached', src.cached ? 'Yes' : 'No');
|
||||||
|
|
||||||
if (src.markdown && contentLength > 0) {
|
if (src.markdown && contentLength > 0) {
|
||||||
const preview = src.markdown.substring(0, 300);
|
// Log preview mais detalhado
|
||||||
logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
|
const preview = src.markdown.substring(0, 500);
|
||||||
|
logData('Content preview', preview + (contentLength > 500 ? '...' : ''));
|
||||||
|
|
||||||
|
// Estatísticas do conteúdo
|
||||||
|
const wordCount = src.markdown.split(/\s+/).length;
|
||||||
|
const paragraphCount = src.markdown.split(/\n\n/).length;
|
||||||
|
logData('Word count', wordCount);
|
||||||
|
logData('Paragraph count', paragraphCount);
|
||||||
|
} else if (src.content && src.content.length > 0) {
|
||||||
|
const preview = src.content.substring(0, 500);
|
||||||
|
logData('Content preview (raw)', preview + (src.content.length > 500 ? '...' : ''));
|
||||||
} else {
|
} else {
|
||||||
logStep(` ⚠️ No content extracted`);
|
logError('No content extracted from this source');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log snippet se disponível
|
||||||
|
if (src.snippet) {
|
||||||
|
logData('Snippet', src.snippet.substring(0, 200) + (snippetLength > 200 ? '...' : ''));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Estatísticas agregadas
|
||||||
|
const totalContentLength = entries.reduce((sum, e) => sum + e.content.length, 0);
|
||||||
|
const avgContentLength = entries.length > 0 ? totalContentLength / entries.length : 0;
|
||||||
|
logStep('\n--- Aggregated Statistics ---');
|
||||||
|
logData('Total entries', entries.length);
|
||||||
|
logData('Total content length', `${totalContentLength} chars`);
|
||||||
|
logData('Average content length', `${Math.round(avgContentLength)} chars`);
|
||||||
|
|
||||||
setState(s => ({
|
setState(s => ({
|
||||||
...s,
|
...s,
|
||||||
knowledgeBase: [...s.knowledgeBase, ...entries]
|
knowledgeBase: [...s.knowledgeBase, ...entries]
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ 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);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useLocalModels, LocalModel } from '@/hooks/use-local-models';
|
||||||
|
import { MODEL_REGISTRY, ModelDefinition } from '@/lib/model-registry';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'last_used_model';
|
||||||
|
|
||||||
|
export interface UseModelSelectionResult {
|
||||||
|
myModels: ModelDefinition[];
|
||||||
|
availableModels: ModelDefinition[];
|
||||||
|
selectedModel: string | null;
|
||||||
|
setSelectedModel: (model: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
installedVariantsByFamily: Record<string, string[]>;
|
||||||
|
refreshModels: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelSelection(): UseModelSelectionResult {
|
||||||
|
const { models: installedModels, loading: isLoadingModels, refresh: refreshLocalModels } = useLocalModels();
|
||||||
|
const [selectedModel, setSelectedModelState] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { myModels, availableModels } = useMemo(() => {
|
||||||
|
const installedNames = new Set(installedModels.map(m => m.name));
|
||||||
|
const my: ModelDefinition[] = [];
|
||||||
|
const available: ModelDefinition[] = [];
|
||||||
|
const registryByModel = new Map<string, ModelDefinition>();
|
||||||
|
for (const def of MODEL_REGISTRY) {
|
||||||
|
registryByModel.set(def.model, def);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const def of MODEL_REGISTRY) {
|
||||||
|
const isInstalled = def.variants.some(v => installedNames.has(v.name));
|
||||||
|
if (isInstalled) {
|
||||||
|
my.push(def);
|
||||||
|
} else {
|
||||||
|
available.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedByBase = new Map<string, LocalModel[]>();
|
||||||
|
for (const m of installedModels) {
|
||||||
|
const base = m.name.split(':')[0];
|
||||||
|
const list = installedByBase.get(base) || [];
|
||||||
|
list.push(m);
|
||||||
|
installedByBase.set(base, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [base, models] of installedByBase.entries()) {
|
||||||
|
if (!registryByModel.has(base)) {
|
||||||
|
const variants = models.map((m) => ({
|
||||||
|
name: m.name,
|
||||||
|
size: m.size,
|
||||||
|
context: 'Unknown',
|
||||||
|
inputType: 'Text',
|
||||||
|
}));
|
||||||
|
my.push({
|
||||||
|
model: base,
|
||||||
|
url: '',
|
||||||
|
description: 'Modelo instalado localmente',
|
||||||
|
variantsCount: variants.length,
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { myModels: my, availableModels: available };
|
||||||
|
}, [installedModels]);
|
||||||
|
|
||||||
|
const installedVariantsByFamily = useMemo(() => {
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
for (const m of installedModels) {
|
||||||
|
const base = m.name.split(':')[0];
|
||||||
|
if (!map[base]) map[base] = [];
|
||||||
|
map[base].push(m.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [installedModels]);
|
||||||
|
|
||||||
|
const defaultSelectedModel = useMemo(() => {
|
||||||
|
if (isLoadingModels) return null;
|
||||||
|
const lastUsed = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
|
||||||
|
const installedNames = installedModels.map(m => m.name);
|
||||||
|
if (lastUsed && installedNames.includes(lastUsed)) return lastUsed;
|
||||||
|
if (installedModels.length > 0) return installedModels[0].name;
|
||||||
|
return null;
|
||||||
|
}, [installedModels, isLoadingModels]);
|
||||||
|
|
||||||
|
// Persistence wrapper
|
||||||
|
const setSelectedModel = (model: string) => {
|
||||||
|
setSelectedModelState(model);
|
||||||
|
localStorage.setItem(STORAGE_KEY, model);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
myModels,
|
||||||
|
availableModels,
|
||||||
|
selectedModel: selectedModel ?? defaultSelectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
isLoading: isLoadingModels,
|
||||||
|
installedVariantsByFamily,
|
||||||
|
refreshModels: refreshLocalModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
+189
-15
@@ -1,50 +1,224 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
|
export type OllamaStatus = 'checking' | 'not_installed' | 'installed_stopped' | 'running';
|
||||||
|
|
||||||
export function useOllamaCheck() {
|
interface UseOllamaCheckReturn {
|
||||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
status: OllamaStatus;
|
||||||
|
isChecking: boolean;
|
||||||
|
check: () => Promise<void>;
|
||||||
|
startPolling: () => void;
|
||||||
|
stopPolling: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
elapsedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
const check = async () => {
|
const INITIAL_INTERVAL = 2000; // 2 segundos
|
||||||
setStatus('checking');
|
const MAX_INTERVAL = 30000; // 30 segundos
|
||||||
|
const BACKOFF_MULTIPLIER = 1.5;
|
||||||
|
const TOTAL_TIMEOUT = 300000; // 5 minutos
|
||||||
|
|
||||||
|
export function useOllamaCheck(): UseOllamaCheckReturn {
|
||||||
|
const [status, setStatus] = useState<OllamaStatus>('not_installed');
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [elapsedTime, setElapsedTime] = useState(0);
|
||||||
|
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
const currentIntervalRef = useRef<number>(INITIAL_INTERVAL);
|
||||||
|
const attemptCountRef = useRef<number>(0);
|
||||||
|
const isPollingActiveRef = useRef<boolean>(false);
|
||||||
|
const elapsedTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const performCheck = useCallback(async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
// Usar verificação completa que checa instalação E execução
|
|
||||||
const result = await invoke<{
|
const result = await invoke<{
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}>('check_ollama_full');
|
}>('check_ollama_full');
|
||||||
|
|
||||||
setStatus(result.status as OllamaStatus);
|
const newStatus = result.status as OllamaStatus;
|
||||||
|
setStatus(newStatus);
|
||||||
|
|
||||||
|
// Se Ollama está rodando, polling deve parar
|
||||||
|
if (newStatus === 'running' && result.installed && result.running) {
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // Continua polling
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check ollama:', error);
|
console.error('Failed to check ollama:', error);
|
||||||
// Se falhar, tentar verificação individual como fallback
|
// Se falhar, tentar verificação robusta como fallback
|
||||||
try {
|
try {
|
||||||
|
const isInstalledAndRunning = await invoke<boolean>('verify_ollama_integrity_command');
|
||||||
|
if (isInstalledAndRunning) {
|
||||||
|
setStatus('running');
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se verify_ollama_integrity retornou false, pode ser instalado mas não rodando
|
||||||
|
// ou não instalado. Verificar separadamente para determinar status exato
|
||||||
const installed = await invoke<boolean>('check_ollama_installed');
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
setStatus('not_installed');
|
setStatus('not_installed');
|
||||||
return;
|
return false; // Continua polling (não instalado ainda)
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = await invoke<boolean>('check_ollama_running');
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
if (!running) {
|
if (!running) {
|
||||||
setStatus('installed_stopped');
|
setStatus('installed_stopped');
|
||||||
return;
|
return false; // Continua polling (instalado mas não rodando)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('running');
|
setStatus('running');
|
||||||
|
return true; // Sucesso, parar polling
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('Fallback check also failed:', fallbackError);
|
console.error('Fallback check also failed:', fallbackError);
|
||||||
setStatus('not_installed');
|
setStatus('not_installed');
|
||||||
|
return false; // Continua polling
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
check();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { status, check };
|
const startPolling = useCallback(() => {
|
||||||
|
if (isPollingActiveRef.current) {
|
||||||
|
return; // Já está rodando
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPollingActiveRef.current = true;
|
||||||
|
setIsChecking(true);
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
attemptCountRef.current = 0;
|
||||||
|
currentIntervalRef.current = INITIAL_INTERVAL;
|
||||||
|
|
||||||
|
// Atualizar tempo decorrido a cada segundo
|
||||||
|
elapsedTimerRef.current = setInterval(() => {
|
||||||
|
if (startTimeRef.current) {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||||
|
setElapsedTime(elapsed);
|
||||||
|
|
||||||
|
// Verificar timeout total
|
||||||
|
if (elapsed * 1000 >= TOTAL_TIMEOUT) {
|
||||||
|
setIsChecking(false);
|
||||||
|
isPollingActiveRef.current = false;
|
||||||
|
if (elapsedTimerRef.current) {
|
||||||
|
clearInterval(elapsedTimerRef.current);
|
||||||
|
elapsedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Primeira verificação imediata
|
||||||
|
performCheck().then((shouldStop) => {
|
||||||
|
if (shouldStop) {
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar polling com exponential backoff
|
||||||
|
const scheduleNext = () => {
|
||||||
|
if (!isPollingActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptCountRef.current += 1;
|
||||||
|
|
||||||
|
// Calcular próximo intervalo com exponential backoff
|
||||||
|
const nextInterval = Math.min(
|
||||||
|
INITIAL_INTERVAL * Math.pow(BACKOFF_MULTIPLIER, attemptCountRef.current - 1),
|
||||||
|
MAX_INTERVAL
|
||||||
|
);
|
||||||
|
currentIntervalRef.current = nextInterval;
|
||||||
|
|
||||||
|
intervalRef.current = setTimeout(async () => {
|
||||||
|
if (!isPollingActiveRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldStop = await performCheck();
|
||||||
|
if (shouldStop) {
|
||||||
|
stopPolling();
|
||||||
|
} else {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}, nextInterval);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout total
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (isPollingActiveRef.current) {
|
||||||
|
console.warn('Ollama check polling timeout após 5 minutos');
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, TOTAL_TIMEOUT);
|
||||||
|
}, [performCheck]);
|
||||||
|
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
isPollingActiveRef.current = false;
|
||||||
|
setIsChecking(false);
|
||||||
|
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearTimeout(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elapsedTimerRef.current) {
|
||||||
|
clearInterval(elapsedTimerRef.current);
|
||||||
|
elapsedTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
// Forçar verificação manual
|
||||||
|
await performCheck();
|
||||||
|
}, [performCheck]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
stopPolling();
|
||||||
|
currentIntervalRef.current = INITIAL_INTERVAL;
|
||||||
|
attemptCountRef.current = 0;
|
||||||
|
setElapsedTime(0);
|
||||||
|
startTimeRef.current = null;
|
||||||
|
startPolling();
|
||||||
|
}, [stopPolling, startPolling]);
|
||||||
|
|
||||||
|
// Limpar polling ao desmontar
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopPolling();
|
||||||
|
};
|
||||||
|
}, [stopPolling]);
|
||||||
|
|
||||||
|
// Parar polling automaticamente quando status for 'running'
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'running' && isPollingActiveRef.current) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
}, [status, stopPolling]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
isChecking,
|
||||||
|
check,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
reset,
|
||||||
|
elapsedTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
+170
-12
@@ -12,6 +12,13 @@ interface DownloadState {
|
|||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
isChecking: boolean;
|
isChecking: boolean;
|
||||||
checkStatus: string;
|
checkStatus: string;
|
||||||
|
installStatus: string | null;
|
||||||
|
isInstalling: boolean;
|
||||||
|
speed: string;
|
||||||
|
downloadedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
eta: number;
|
||||||
|
status: 'downloading' | 'verifying' | 'completed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOllamaDownload() {
|
export function useOllamaDownload() {
|
||||||
@@ -24,6 +31,13 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -31,17 +45,71 @@ export function useOllamaDownload() {
|
|||||||
|
|
||||||
// Escutar eventos de progresso do download
|
// Escutar eventos de progresso do download
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlistenPromise = listen('installer-download-progress', (event: any) => {
|
const unlistenDownloadPromise = listen('installer-download-progress', (event: any) => {
|
||||||
const data = event.payload;
|
const data = event.payload;
|
||||||
|
const statusText = data.status || '';
|
||||||
|
const isVerifying = statusText.includes('Verificando');
|
||||||
|
const isCompleted = data.progress === 100 && !isVerifying;
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloadProgress: data.progress || 0,
|
downloadProgress: data.progress || 0,
|
||||||
downloadStatus: data.status || '',
|
downloadStatus: statusText,
|
||||||
|
speed: data.speed_formatted || '0 MB/s',
|
||||||
|
downloadedBytes: data.downloaded || 0,
|
||||||
|
totalBytes: data.total || 0,
|
||||||
|
eta: data.eta_seconds || 0,
|
||||||
|
status: isVerifying ? 'verifying' : isCompleted ? 'completed' : 'downloading',
|
||||||
|
isDownloading: !isCompleted,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Escutar eventos de progresso da instalação
|
||||||
|
const unlistenInstallPromise = listen('installer-progress', (event: any) => {
|
||||||
|
const data = event.payload;
|
||||||
|
const status = data.status || 'unknown';
|
||||||
|
const message = data.message || 'Processando...';
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalling: status !== 'success' && status !== 'error' && status !== 'partial',
|
||||||
|
installStatus: message,
|
||||||
|
isInstalled: status === 'success',
|
||||||
|
downloadError: status === 'error' ? message : prev.downloadError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Se a instalação foi bem-sucedida, iniciar verificação automática
|
||||||
|
if (status === 'success') {
|
||||||
|
// Usar setTimeout para evitar dependência circular
|
||||||
|
setTimeout(() => {
|
||||||
|
// startAutoCheck será chamado via closure do componente
|
||||||
|
// Por enquanto, apenas atualizar o estado
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: true,
|
||||||
|
checkStatus: 'Verificando instalação...',
|
||||||
|
}));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escutar evento de Ollama pronto
|
||||||
|
const unlistenReadyPromise = listen('ollama-ready', () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalling: false,
|
||||||
|
installStatus: 'Ollama instalado e rodando!',
|
||||||
|
isInstalled: true,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: 'Ollama encontrado e rodando!',
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent('ollama-installed'));
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlistenPromise.then(unlisten => unlisten());
|
unlistenDownloadPromise.then(unlisten => unlisten());
|
||||||
|
unlistenInstallPromise.then(unlisten => unlisten());
|
||||||
|
unlistenReadyPromise.then(unlisten => unlisten());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -53,6 +121,18 @@ 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 {
|
||||||
@@ -66,6 +146,18 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -79,6 +171,18 @@ 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({
|
||||||
@@ -88,6 +192,18 @@ 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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,36 +217,70 @@ export function useOllamaDownload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
<<<<<<< HEAD
|
||||||
await runInstaller(state.filePath);
|
// Atualizar estado para mostrar que a instalação está começando
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
isInstalled: true,
|
isInstalling: true,
|
||||||
|
installStatus: 'Iniciando instalação silenciosa...',
|
||||||
|
downloadError: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Iniciar verificação automática em segundo plano
|
// Chamar Tauri em background (não bloquear)
|
||||||
|
runInstaller(state.filePath)
|
||||||
|
.then(() => {
|
||||||
|
// Após iniciar a instalação, começar a verificação automática após um delay
|
||||||
|
setTimeout(() => {
|
||||||
startAutoCheck();
|
startAutoCheck();
|
||||||
} catch (error) {
|
}, 2000);
|
||||||
|
})
|
||||||
|
=======
|
||||||
|
// Optimistic UI: atualizar estado imediatamente antes da chamada Tauri
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalled: true, // Marcar como instalado imediatamente
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Iniciar verificação automática em segundo plano imediatamente
|
||||||
|
startAutoCheck();
|
||||||
|
|
||||||
|
// Chamar Tauri em background (não bloquear)
|
||||||
|
runInstaller(state.filePath)
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
|
.catch((error) => {
|
||||||
setState(prev => ({
|
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 (error) {
|
} catch (fallbackError) {
|
||||||
console.error('Erro ao verificar Ollama:', error);
|
console.error('Fallback check also failed:', fallbackError);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startAutoCheck = () => {
|
const startAutoCheck = () => {
|
||||||
@@ -226,6 +376,7 @@ export function useOllamaDownload() {
|
|||||||
filePath: existingPath,
|
filePath: existingPath,
|
||||||
downloadProgress: 100,
|
downloadProgress: 100,
|
||||||
downloadStatus: 'Download já concluído',
|
downloadStatus: 'Download já concluído',
|
||||||
|
status: 'completed',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -241,6 +392,13 @@ export function useOllamaDownload() {
|
|||||||
isInstalled: false,
|
isInstalled: false,
|
||||||
isChecking: false,
|
isChecking: false,
|
||||||
checkStatus: '',
|
checkStatus: '',
|
||||||
|
installStatus: null,
|
||||||
|
isInstalling: false,
|
||||||
|
speed: '0 MB/s',
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'downloading',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ export function useOperatingSystem() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// Hardcoded for now to avoid FS complexity in v1, but ideally read from file
|
// Hardcoded for now to avoid FS complexity in v1, but ideally read from file
|
||||||
const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade.
|
const PROMPT_GEN_SYSTEM = `Você é um Especialista em Engenharia de Prompts com raciocínio sequencial e acesso a fontes técnicas de primeira qualidade.
|
||||||
@@ -40,23 +41,16 @@ export function usePromptGenerator() {
|
|||||||
const generatePrompt = async (userGoal: string, model: string): Promise<string> => {
|
const generatePrompt = async (userGoal: string, model: string): Promise<string> => {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
const response = await invoke<string>('generate_chat_completion', {
|
||||||
method: 'POST',
|
|
||||||
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 }
|
||||||
],
|
],
|
||||||
stream: false, // We want the full prompt at once for this utility
|
options: undefined,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to generate prompt');
|
return response || "";
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data.message?.content || "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Prompt generation failed:", error);
|
console.error("Prompt generation failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook para gerar query de busca otimizada usando a LLM
|
* Hook para gerar query de busca otimizada usando a LLM
|
||||||
@@ -33,27 +34,15 @@ Pergunta do usuário: "${userInput}"
|
|||||||
|
|
||||||
Query de busca (ou NO_SEARCH):`;
|
Query de busca (ou NO_SEARCH):`;
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const generatedText = await invoke<string>('generate_completion', {
|
||||||
method: 'POST',
|
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface SetupWizardState {
|
||||||
|
currentStep: number;
|
||||||
|
completedSteps: number[];
|
||||||
|
ollamaInstalled: boolean;
|
||||||
|
ollamaRunning: boolean;
|
||||||
|
modelDownloaded: boolean;
|
||||||
|
modelName: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
progressMessage: string | null;
|
||||||
|
progressStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHECKPOINT_KEY = 'ollahub_setup_wizard_checkpoint';
|
||||||
|
const DEFAULT_MODEL = 'llama3.2:1b';
|
||||||
|
const MAX_STEPS = 4;
|
||||||
|
const MIN_STEP = 1;
|
||||||
|
|
||||||
|
function getDefaultState(): SetupWizardState {
|
||||||
|
return {
|
||||||
|
currentStep: 1,
|
||||||
|
completedSteps: [],
|
||||||
|
ollamaInstalled: false,
|
||||||
|
ollamaRunning: false,
|
||||||
|
modelDownloaded: false,
|
||||||
|
modelName: null,
|
||||||
|
lastError: null,
|
||||||
|
progressMessage: null,
|
||||||
|
progressStatus: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWizardState(state: unknown): SetupWizardState | null {
|
||||||
|
// Verificar se é um objeto
|
||||||
|
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateObj = state as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validar currentStep
|
||||||
|
const currentStep = typeof stateObj.currentStep === 'number'
|
||||||
|
? Math.max(MIN_STEP, Math.min(MAX_STEPS, Math.round(stateObj.currentStep)))
|
||||||
|
: MIN_STEP;
|
||||||
|
|
||||||
|
// Validar completedSteps
|
||||||
|
const completedSteps = Array.isArray(stateObj.completedSteps)
|
||||||
|
? stateObj.completedSteps
|
||||||
|
.filter((step: unknown) => typeof step === 'number' && step >= MIN_STEP && step <= MAX_STEPS)
|
||||||
|
.map((step: unknown) => Math.round(step as number))
|
||||||
|
.filter((step: number, index: number, arr: number[]) => arr.indexOf(step) === index) // Remover duplicatas
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Validar tipos dos campos booleanos
|
||||||
|
const ollamaInstalled = typeof stateObj.ollamaInstalled === 'boolean' ? stateObj.ollamaInstalled : false;
|
||||||
|
const ollamaRunning = typeof stateObj.ollamaRunning === 'boolean' ? stateObj.ollamaRunning : false;
|
||||||
|
const modelDownloaded = typeof stateObj.modelDownloaded === 'boolean' ? stateObj.modelDownloaded : false;
|
||||||
|
|
||||||
|
// Validar modelName (pode ser null ou string)
|
||||||
|
const modelName = stateObj.modelName === null || typeof stateObj.modelName === 'string'
|
||||||
|
? stateObj.modelName as string | null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Validar lastError (pode ser null ou string)
|
||||||
|
const lastError = stateObj.lastError === null || typeof stateObj.lastError === 'string'
|
||||||
|
? stateObj.lastError as string | null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Validar progressMessage (pode ser null ou string)
|
||||||
|
const progressMessage = stateObj.progressMessage === null || typeof stateObj.progressMessage === 'string'
|
||||||
|
? stateObj.progressMessage as string | null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Validar progressStatus (pode ser null ou string)
|
||||||
|
const progressStatus = stateObj.progressStatus === null || typeof stateObj.progressStatus === 'string'
|
||||||
|
? stateObj.progressStatus as string | null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
completedSteps,
|
||||||
|
ollamaInstalled,
|
||||||
|
ollamaRunning,
|
||||||
|
modelDownloaded,
|
||||||
|
modelName,
|
||||||
|
lastError,
|
||||||
|
progressMessage,
|
||||||
|
progressStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetupWizard() {
|
||||||
|
const [state, setState] = useState<SetupWizardState>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem(CHECKPOINT_KEY);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
const validated = validateWizardState(parsed);
|
||||||
|
|
||||||
|
if (validated) {
|
||||||
|
return validated;
|
||||||
|
} else {
|
||||||
|
// Estado inválido, remover do localStorage e usar estado inicial
|
||||||
|
console.warn('Estado do wizard inválido detectado, resetando para estado inicial');
|
||||||
|
localStorage.removeItem(CHECKPOINT_KEY);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Se falhar ao fazer parse, remover item corrompido
|
||||||
|
console.error('Erro ao fazer parse do estado do wizard:', error);
|
||||||
|
localStorage.removeItem(CHECKPOINT_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultState();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(CHECKPOINT_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPromise = listen<{ status: string; message: string }>('setup-progress', (event) => {
|
||||||
|
const { status, message } = event.payload;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
progressStatus: status,
|
||||||
|
progressMessage: message,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise.then(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkSetupState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const setupState = await invoke<{
|
||||||
|
ollama_installed: boolean;
|
||||||
|
ollama_running: boolean;
|
||||||
|
default_model_downloaded: boolean;
|
||||||
|
default_model_name: string | null;
|
||||||
|
}>('get_setup_state_command');
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
ollamaInstalled: setupState.ollama_installed,
|
||||||
|
ollamaRunning: setupState.ollama_running,
|
||||||
|
modelDownloaded: setupState.default_model_downloaded,
|
||||||
|
modelName: setupState.default_model_name || DEFAULT_MODEL,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let initialStep = 1;
|
||||||
|
if (setupState.ollama_installed && setupState.ollama_running) {
|
||||||
|
initialStep = 2;
|
||||||
|
if (setupState.default_model_downloaded) {
|
||||||
|
initialStep = 4;
|
||||||
|
} else {
|
||||||
|
initialStep = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: Math.max(prev.currentStep, initialStep),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return setupState;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: errorMsg,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
setState(prev => {
|
||||||
|
const next = prev.currentStep + 1;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
currentStep: next,
|
||||||
|
completedSteps: [...prev.completedSteps, prev.currentStep],
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previousStep = useCallback(() => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: Math.max(1, prev.currentStep - 1),
|
||||||
|
lastError: null,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToStep = useCallback((step: number) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: Math.max(1, Math.min(4, step)),
|
||||||
|
lastError: null,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkChocolatey = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
return await invoke<boolean>('check_chocolatey_installed_command');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar Chocolatey:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installChocolatey = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
progressMessage: 'Instalando Chocolatey...',
|
||||||
|
progressStatus: 'installing_chocolatey',
|
||||||
|
lastError: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invoke('install_chocolatey_command');
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
progressMessage: 'Chocolatey instalado com sucesso',
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Erro ao instalar Chocolatey';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: errorMsg,
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installOllamaViaChoco = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
progressMessage: 'Instalando Ollama via Chocolatey...',
|
||||||
|
progressStatus: 'installing_ollama',
|
||||||
|
lastError: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invoke('install_ollama_choco_command');
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
progressMessage: 'Ollama instalado com sucesso',
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Erro ao instalar Ollama via Chocolatey';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: errorMsg,
|
||||||
|
progressStatus: null,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const installOllama = useCallback(async (installerPath?: string) => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: null,
|
||||||
|
progressMessage: 'Iniciando instalação do Ollama...',
|
||||||
|
progressStatus: 'installing_ollama',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invoke('install_ollama_silently_command', {
|
||||||
|
installerPath: installerPath || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkSetupState();
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
ollamaInstalled: true,
|
||||||
|
ollamaRunning: true,
|
||||||
|
progressMessage: 'Ollama instalado e rodando!',
|
||||||
|
progressStatus: 'ollama_ready',
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: errorMsg,
|
||||||
|
progressMessage: `Erro: ${errorMsg}`,
|
||||||
|
progressStatus: 'error',
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [checkSetupState]);
|
||||||
|
|
||||||
|
const downloadModel = useCallback(async (modelName: string = DEFAULT_MODEL) => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: null,
|
||||||
|
progressMessage: `Baixando modelo ${modelName}...`,
|
||||||
|
progressStatus: 'downloading_model',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await invoke('download_default_model_command', { modelName });
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
modelDownloaded: true,
|
||||||
|
modelName,
|
||||||
|
progressMessage: `Modelo ${modelName} baixado com sucesso!`,
|
||||||
|
progressStatus: 'model_ready',
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
lastError: errorMsg,
|
||||||
|
progressMessage: `Erro ao baixar modelo: ${errorMsg}`,
|
||||||
|
progressStatus: 'error',
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetWizard = useCallback(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(CHECKPOINT_KEY);
|
||||||
|
}
|
||||||
|
setState(getDefaultState());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isStepComplete = useCallback((step: number) => {
|
||||||
|
return state.completedSteps.includes(step);
|
||||||
|
}, [state.completedSteps]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
checkSetupState,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
goToStep,
|
||||||
|
checkChocolatey,
|
||||||
|
installChocolatey,
|
||||||
|
installOllamaViaChoco,
|
||||||
|
installOllama,
|
||||||
|
downloadModel,
|
||||||
|
resetWizard,
|
||||||
|
isStepComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface ActiveQuery {
|
||||||
|
query: string;
|
||||||
|
source: string;
|
||||||
|
round?: number;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveUrl {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
status: 'started' | 'completed' | 'failed' | 'cached';
|
||||||
|
source?: string;
|
||||||
|
startedAt: number;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchQueryEvent {
|
||||||
|
query: string;
|
||||||
|
source: string;
|
||||||
|
round?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchQueryCompletedEvent {
|
||||||
|
query: string;
|
||||||
|
source: string;
|
||||||
|
results_count: number;
|
||||||
|
success: boolean;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSourceSwitchEvent {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
reason: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrapingUrlEvent {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
status: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
source?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSearchEvents() {
|
||||||
|
const [activeQueries, setActiveQueries] = useState<ActiveQuery[]>([]);
|
||||||
|
const [activeUrls, setActiveUrls] = useState<ActiveUrl[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenQueries: (() => void) | null = null;
|
||||||
|
let unlistenCompleted: (() => void) | null = null;
|
||||||
|
let unlistenSwitched: (() => void) | null = null;
|
||||||
|
let unlistenScraping: (() => void) | null = null;
|
||||||
|
|
||||||
|
const setupListeners = async () => {
|
||||||
|
// Listener para query iniciada
|
||||||
|
unlistenQueries = await listen<SearchQueryEvent>('search-query-started', (event) => {
|
||||||
|
const { query, source, round, timestamp } = event.payload;
|
||||||
|
setActiveQueries((prev) => {
|
||||||
|
// Remover query anterior se existir (mesma query)
|
||||||
|
const filtered = prev.filter((q) => q.query !== query);
|
||||||
|
return [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
source,
|
||||||
|
round,
|
||||||
|
startedAt: timestamp,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para query completada
|
||||||
|
unlistenCompleted = await listen<SearchQueryCompletedEvent>('search-query-completed', (event) => {
|
||||||
|
const { query, source } = event.payload;
|
||||||
|
setActiveQueries((prev) => prev.filter((q) => !(q.query === query && q.source === source)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para mudança de fonte
|
||||||
|
unlistenSwitched = await listen<SearchSourceSwitchEvent>('search-source-switched', (event) => {
|
||||||
|
const { from, to, reason } = event.payload;
|
||||||
|
console.log(`[WebSearchEvents] Source switched: ${from} → ${to} (${reason})`);
|
||||||
|
// Atualizar query ativa para nova fonte
|
||||||
|
setActiveQueries((prev) =>
|
||||||
|
prev.map((q) => (q.source === from ? { ...q, source: to } : q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para eventos de scraping
|
||||||
|
unlistenScraping = await listen<ScrapingUrlEvent>('scraping-url-event', (event) => {
|
||||||
|
const { url, title, status, duration_ms, source, timestamp } = event.payload;
|
||||||
|
|
||||||
|
setActiveUrls((prev) => {
|
||||||
|
const existingIndex = prev.findIndex((u) => u.url === url);
|
||||||
|
const newUrl: ActiveUrl = {
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
status: status as ActiveUrl['status'],
|
||||||
|
source,
|
||||||
|
startedAt: timestamp,
|
||||||
|
duration: duration_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Atualizar URL existente
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existingIndex] = newUrl;
|
||||||
|
return updated;
|
||||||
|
} else {
|
||||||
|
// Adicionar nova URL
|
||||||
|
return [...prev, newUrl];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remover URLs completadas/falhadas após 5 segundos
|
||||||
|
if (status === 'completed' || status === 'failed' || status === 'cached') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveUrls((prev) => prev.filter((u) => u.url !== url));
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListeners();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenQueries) unlistenQueries();
|
||||||
|
if (unlistenCompleted) unlistenCompleted();
|
||||||
|
if (unlistenSwitched) unlistenSwitched();
|
||||||
|
if (unlistenScraping) unlistenScraping();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearActiveQueries = useCallback(() => {
|
||||||
|
setActiveQueries([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearActiveUrls = useCallback(() => {
|
||||||
|
setActiveUrls([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setActiveQueries([]);
|
||||||
|
setActiveUrls([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeQueries,
|
||||||
|
activeUrls,
|
||||||
|
clearActiveQueries,
|
||||||
|
clearActiveUrls,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
+42
-3
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
|
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
|
||||||
import { useSettingsStore } from '@/store/settings-store';
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
import { useWebSearchEvents, type ActiveQuery, type ActiveUrl } from './use-web-search-events';
|
||||||
|
|
||||||
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
|
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
export interface WebSearchState {
|
export interface WebSearchState {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@@ -10,6 +12,8 @@ export interface WebSearchState {
|
|||||||
currentQuery: string;
|
currentQuery: string;
|
||||||
scrapedSources: ScrapedContent[];
|
scrapedSources: ScrapedContent[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
activeQueries: ActiveQuery[];
|
||||||
|
activeUrls: ActiveUrl[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATE: WebSearchState = {
|
const DEFAULT_STATE: WebSearchState = {
|
||||||
@@ -18,12 +22,16 @@ const DEFAULT_STATE: WebSearchState = {
|
|||||||
currentQuery: '',
|
currentQuery: '',
|
||||||
scrapedSources: [],
|
scrapedSources: [],
|
||||||
error: null,
|
error: null,
|
||||||
|
activeQueries: [],
|
||||||
|
activeUrls: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook para gerenciar estado do Web Search
|
* Hook para gerenciar estado do Web Search
|
||||||
*/
|
*/
|
||||||
export function useWebSearch() {
|
export function useWebSearch() {
|
||||||
|
const events = useWebSearchEvents();
|
||||||
|
|
||||||
const [state, setState] = useState<WebSearchState>(() => {
|
const [state, setState] = useState<WebSearchState>(() => {
|
||||||
// Carregar preferência do localStorage
|
// Carregar preferência do localStorage
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -36,6 +44,15 @@ export function useWebSearch() {
|
|||||||
return DEFAULT_STATE;
|
return DEFAULT_STATE;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sincronizar eventos com estado usando useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
activeQueries: events.activeQueries,
|
||||||
|
activeUrls: events.activeUrls,
|
||||||
|
}));
|
||||||
|
}, [events.activeQueries, events.activeUrls]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ativa/desativa Web Search
|
* Ativa/desativa Web Search
|
||||||
*/
|
*/
|
||||||
@@ -175,7 +192,7 @@ export function useWebSearch() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
||||||
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
|
console.warn('Erro em smartSearchRag (retornando vazio para usar fallback de modelo local):', errorMessage);
|
||||||
|
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -185,6 +202,7 @@ export function useWebSearch() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Retornar vazio em vez de throw para não quebrar o fluxo
|
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||||
|
// O sistema continuará usando o modelo local como fallback
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -218,6 +236,22 @@ export function useWebSearch() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancela a busca atual
|
||||||
|
*/
|
||||||
|
const cancel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await invoke('cancel_scraping');
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'cancelled',
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao cancelar busca:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reseta o estado
|
* Reseta o estado
|
||||||
*/
|
*/
|
||||||
@@ -235,14 +269,19 @@ export function useWebSearch() {
|
|||||||
webSearchService.clearCache();
|
webSearchService.clearCache();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
activeQueries: events.activeQueries,
|
||||||
|
activeUrls: events.activeUrls,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
search,
|
search,
|
||||||
smartSearchRag,
|
smartSearchRag,
|
||||||
extractUrl,
|
extractUrl,
|
||||||
|
cancel,
|
||||||
reset,
|
reset,
|
||||||
clearCache,
|
clearCache,
|
||||||
|
setState, // Expor setState para permitir atualização externa em caso de erro
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
+5
-15
@@ -108,27 +108,16 @@ export async function extractKeyFacts(
|
|||||||
.replace('{{url}}', entry.sourceUrl)
|
.replace('{{url}}', entry.sourceUrl)
|
||||||
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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, '')
|
||||||
@@ -156,7 +145,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);
|
chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}: ${error instanceof Error ? error.message : String(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,
|
||||||
@@ -273,7 +262,7 @@ export async function unifyContext(
|
|||||||
|
|
||||||
// Criar texto unificado
|
// Criar texto unificado
|
||||||
const unifiedSections: string[] = [];
|
const unifiedSections: string[] = [];
|
||||||
const sourceMap = new Map<string, { title: string; url: string; factsCount: number }>();
|
const sourceMap = new Map<string, { id: string; title: string; url: string; factsCount: number }>();
|
||||||
|
|
||||||
// Agrupar por fonte
|
// Agrupar por fonte
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -281,6 +270,7 @@ 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,
|
||||||
|
|||||||
+50
-18
@@ -154,27 +154,17 @@ export async function analyzeQueryContext(
|
|||||||
try {
|
try {
|
||||||
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
stream: false,
|
|
||||||
options: {
|
options: {
|
||||||
temperature: 0.3, // Baixa temperatura para análise mais determinística
|
temperature: 0.3,
|
||||||
num_predict: 800, // Espaço suficiente para JSON completo
|
num_predict: 800,
|
||||||
|
format: 'json',
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const rawResponse = (data.response || '').trim();
|
|
||||||
|
|
||||||
// Limpar resposta (remover markdown code blocks se houver)
|
// Limpar resposta (remover markdown code blocks se houver)
|
||||||
let jsonText = rawResponse
|
let jsonText = rawResponse
|
||||||
.replace(/^```json\s*/i, '')
|
.replace(/^```json\s*/i, '')
|
||||||
@@ -188,7 +178,7 @@ export async function analyzeQueryContext(
|
|||||||
jsonText = jsonMatch[0];
|
jsonText = jsonMatch[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
|
const analysis = tryParseJsonWithRepair(jsonText) as Partial<QueryContext>;
|
||||||
|
|
||||||
// Validar e preencher campos obrigatórios
|
// Validar e preencher campos obrigatórios
|
||||||
const context: QueryContext = {
|
const context: QueryContext = {
|
||||||
@@ -206,11 +196,11 @@ export async function analyzeQueryContext(
|
|||||||
complexity: analysis.complexity || 'moderate',
|
complexity: analysis.complexity || 'moderate',
|
||||||
};
|
};
|
||||||
|
|
||||||
chatLog.success(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`);
|
chatLog.info(`[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);
|
chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
|
||||||
// Fallback: análise básica usando heurísticas
|
// Fallback: análise básica usando heurísticas
|
||||||
return createFallbackContext(query);
|
return createFallbackContext(query);
|
||||||
@@ -274,3 +264,45 @@ function createFallbackContext(query: string): QueryContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repairJson(text: string): string {
|
||||||
|
let s = text.trim();
|
||||||
|
s = s.replace(/,\s*([}\]])/g, '$1');
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
let braceCount = 0;
|
||||||
|
let bracketCount = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const c = s[i];
|
||||||
|
if (escape) { escape = false; continue; }
|
||||||
|
if (c === '\\') { escape = true; continue; }
|
||||||
|
if (c === '"') { inString = !inString; continue; }
|
||||||
|
if (!inString) {
|
||||||
|
if (c === '{') braceCount++;
|
||||||
|
else if (c === '}') braceCount--;
|
||||||
|
else if (c === '[') bracketCount++;
|
||||||
|
else if (c === ']') bracketCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (braceCount > 0) s += '}'.repeat(braceCount);
|
||||||
|
if (bracketCount > 0) s += ']'.repeat(bracketCount);
|
||||||
|
const lastClose = Math.max(s.lastIndexOf('}'), s.lastIndexOf(']'));
|
||||||
|
if (lastClose > 0) {
|
||||||
|
const after = s.slice(lastClose + 1);
|
||||||
|
if (/\S/.test(after)) {
|
||||||
|
s = s.slice(0, lastClose + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJsonWithRepair(text: string): unknown {
|
||||||
|
try { return JSON.parse(text); } catch {}
|
||||||
|
const repaired = repairJson(text);
|
||||||
|
try { return JSON.parse(repaired); } catch {}
|
||||||
|
const lastClose = Math.max(repaired.lastIndexOf('}'), repaired.lastIndexOf(']'));
|
||||||
|
if (lastClose > 0) {
|
||||||
|
const truncated = repaired.slice(0, lastClose + 1);
|
||||||
|
return JSON.parse(truncated);
|
||||||
|
}
|
||||||
|
throw new Error('Invalid JSON');
|
||||||
|
}
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
export interface ProblemSolution {
|
||||||
|
id: string;
|
||||||
|
problem: string; // Texto exato ou padrão para matching
|
||||||
|
os: 'windows' | 'linux' | 'macos' | 'all';
|
||||||
|
links: Array<{
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
commands?: {
|
||||||
|
title: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
}[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROBLEM_SOLUTIONS: ProblemSolution[] = [
|
||||||
|
// ========== WINDOWS ==========
|
||||||
|
{
|
||||||
|
id: 'windows-build-old',
|
||||||
|
problem: 'Windows build',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'Versão do Windows muito antiga. É necessário Windows 10 20H1 (build 19041) ou superior, ou Windows 11.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Instalar Atualizações do Windows',
|
||||||
|
url: 'https://support.microsoft.com/windows/install-windows-updates-3c5ae7fc-9fb6-9af1-1984-b5e0412c556a'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Baixar Windows 10',
|
||||||
|
url: 'https://www.microsoft.com/software-download/windows10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Baixar Windows 11',
|
||||||
|
url: 'https://www.microsoft.com/software-download/windows11'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar build atual',
|
||||||
|
code: '[System.Environment]::OSVersion.Version',
|
||||||
|
description: 'Execute no PowerShell para verificar a versão atual do Windows'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-vm-platform',
|
||||||
|
problem: 'VirtualMachinePlatform não habilitado',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'A plataforma de máquina virtual precisa estar habilitada para suportar WSL 2.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Microsoft Learn - WSL Manual Install',
|
||||||
|
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Habilitar VirtualMachinePlatform (PowerShell como Administrador)',
|
||||||
|
code: 'dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart',
|
||||||
|
description: 'Execute no PowerShell como Administrador. Reinicie o sistema após a execução.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-wsl-not-enabled',
|
||||||
|
problem: 'WSL não habilitado',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'Windows Subsystem for Linux precisa estar habilitado para executar Docker no Windows.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Microsoft Learn - Instalar WSL',
|
||||||
|
url: 'https://learn.microsoft.com/windows/wsl/install'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Microsoft Learn - WSL Manual',
|
||||||
|
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Habilitar WSL (PowerShell como Administrador)',
|
||||||
|
code: 'dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart',
|
||||||
|
description: 'Execute no PowerShell como Administrador. Reinicie o sistema após a execução.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-wsl2-not-configured',
|
||||||
|
problem: 'WSL 2 não configurado',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'WSL 2 precisa ser configurado como versão padrão do WSL.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Microsoft Learn - Instalar WSL',
|
||||||
|
url: 'https://learn.microsoft.com/windows/wsl/install'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Microsoft Learn - WSL Manual',
|
||||||
|
url: 'https://learn.microsoft.com/windows/wsl/install-manual'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Configurar WSL 2 como padrão',
|
||||||
|
code: 'wsl --set-default-version 2',
|
||||||
|
description: 'Execute após habilitar WSL e VirtualMachinePlatform e reiniciar o sistema.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Comandos completos (PowerShell como Administrador)',
|
||||||
|
code: `# Habilitar WSL
|
||||||
|
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
|
||||||
|
|
||||||
|
# Habilitar Virtual Machine Platform
|
||||||
|
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
|
||||||
|
|
||||||
|
# Reiniciar e depois definir WSL 2 como padrão
|
||||||
|
wsl --set-default-version 2`,
|
||||||
|
description: 'Execute todos os comandos em sequência. Reinicie após os dois primeiros comandos.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-ram-insufficient',
|
||||||
|
problem: 'RAM insuficiente',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'Sistema requer no mínimo 4GB de RAM para executar Docker no Windows.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Adicionar mais memória ao computador',
|
||||||
|
url: 'https://support.microsoft.com/windows/add-more-memory-to-your-computer-0f7e2d8a-d2c9-c858-5dca-8d3b3759a0ff'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar processos consumindo mais memória',
|
||||||
|
code: 'Get-Process | Sort-Object -Property WS -Descending | Select-Object -First 10',
|
||||||
|
description: 'Lista os 10 processos que mais consomem memória. Feche aplicativos desnecessários.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-disk-insufficient',
|
||||||
|
problem: 'Espaço em disco insuficiente',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'É necessário pelo menos 10GB de espaço livre em disco para instalar e executar Docker.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Liberar espaço em disco no Windows',
|
||||||
|
url: 'https://support.microsoft.com/windows/free-up-drive-space-in-windows-85529ccb-c365-490d-b548-831022bc9b32'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Abrir Limpeza de Disco',
|
||||||
|
code: 'cleanmgr.exe',
|
||||||
|
description: 'Abre a ferramenta de limpeza de disco do Windows.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windows-cpu-cores',
|
||||||
|
problem: 'CPU cores insuficientes',
|
||||||
|
os: 'windows',
|
||||||
|
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar número de cores lógicos',
|
||||||
|
code: '(Get-WmiObject Win32_Processor).NumberOfLogicalProcessors',
|
||||||
|
description: 'Mostra o número de processadores lógicos disponíveis.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== LINUX ==========
|
||||||
|
{
|
||||||
|
id: 'linux-kernel-old',
|
||||||
|
problem: 'Kernel',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'Kernel Linux muito antigo. É necessário kernel 3.10 ou superior para executar Docker.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu/Debian - Gerenciamento de Pacotes',
|
||||||
|
url: 'https://ubuntu.com/server/docs/package-management'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch/Manjaro - Kernel',
|
||||||
|
url: 'https://wiki.archlinux.org/title/Kernel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fedora/RHEL - Kernel',
|
||||||
|
url: 'https://docs.fedoraproject.org/en-US/quick-docs/kernel/'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu/Debian - Atualizar sistema e kernel',
|
||||||
|
code: `sudo apt update && sudo apt upgrade -y
|
||||||
|
sudo apt install linux-generic`,
|
||||||
|
description: 'Atualiza todos os pacotes e instala o kernel genérico mais recente.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch/Manjaro - Atualizar kernel',
|
||||||
|
code: 'sudo pacman -Syu linux',
|
||||||
|
description: 'Atualiza o sistema e o kernel.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fedora - Atualizar kernel',
|
||||||
|
code: 'sudo dnf upgrade kernel',
|
||||||
|
description: 'Atualiza o kernel para a versão mais recente.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-curl-missing',
|
||||||
|
problem: 'Pacote curl não instalado',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'O pacote curl é necessário para baixar arquivos e chaves GPG durante a instalação do Docker.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'curl.se - Download oficial',
|
||||||
|
url: 'https://curl.se/download.html'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ubuntu packages - curl',
|
||||||
|
url: 'https://packages.ubuntu.com/search?keywords=curl'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu/Debian',
|
||||||
|
code: 'sudo apt update && sudo apt install curl',
|
||||||
|
description: 'Instala curl no Ubuntu ou Debian.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch/Manjaro',
|
||||||
|
code: 'sudo pacman -S curl',
|
||||||
|
description: 'Instala curl no Arch Linux ou Manjaro.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fedora/RHEL',
|
||||||
|
code: 'sudo dnf install curl',
|
||||||
|
description: 'Instala curl no Fedora ou RHEL.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'openSUSE',
|
||||||
|
code: 'sudo zypper install curl',
|
||||||
|
description: 'Instala curl no openSUSE.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-ca-certificates-missing',
|
||||||
|
problem: 'Pacote ca-certificates não instalado',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'Certificados CA são necessários para verificar conexões HTTPS seguras.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu packages - ca-certificates',
|
||||||
|
url: 'https://packages.ubuntu.com/search?keywords=ca-certificates'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Debian wiki - ca-certificates',
|
||||||
|
url: 'https://wiki.debian.org/ca-certificates'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu/Debian',
|
||||||
|
code: 'sudo apt update && sudo apt install ca-certificates',
|
||||||
|
description: 'Instala certificados CA no Ubuntu ou Debian.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch/Manjaro',
|
||||||
|
code: 'sudo pacman -S ca-certificates',
|
||||||
|
description: 'Instala certificados CA no Arch Linux ou Manjaro.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fedora/RHEL',
|
||||||
|
code: 'sudo dnf install ca-certificates',
|
||||||
|
description: 'Instala certificados CA no Fedora ou RHEL.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'openSUSE',
|
||||||
|
code: 'sudo zypper install ca-certificates',
|
||||||
|
description: 'Instala certificados CA no openSUSE.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-gnupg-missing',
|
||||||
|
problem: 'Pacote gnupg não instalado',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'GnuPG é necessário para verificar chaves GPG durante a instalação do Docker.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'GnuPG oficial - Download',
|
||||||
|
url: 'https://gnupg.org/download/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ubuntu packages - gnupg',
|
||||||
|
url: 'https://packages.ubuntu.com/search?keywords=gnupg'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu/Debian',
|
||||||
|
code: 'sudo apt update && sudo apt install gnupg',
|
||||||
|
description: 'Instala GnuPG no Ubuntu ou Debian.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch/Manjaro',
|
||||||
|
code: 'sudo pacman -S gnupg',
|
||||||
|
description: 'Instala GnuPG no Arch Linux ou Manjaro.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fedora/RHEL',
|
||||||
|
code: 'sudo dnf install gnupg',
|
||||||
|
description: 'Instala GnuPG no Fedora ou RHEL.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'openSUSE',
|
||||||
|
code: 'sudo zypper install gpg2',
|
||||||
|
description: 'Instala GnuPG no openSUSE.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-sudo-missing',
|
||||||
|
problem: 'Privilégios sudo necessários',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'Privilégios de administrador são necessários para instalar pacotes e configurar o sistema.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Ubuntu - RootSudo',
|
||||||
|
url: 'https://help.ubuntu.com/community/RootSudo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Arch Linux - Sudo',
|
||||||
|
url: 'https://wiki.archlinux.org/title/Sudo'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Adicionar usuário ao grupo sudo (Ubuntu/Debian)',
|
||||||
|
code: 'sudo usermod -aG sudo $USER',
|
||||||
|
description: 'Adiciona o usuário atual ao grupo sudo. Faça logout e login novamente para aplicar.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Adicionar ao grupo wheel (Arch/Fedora)',
|
||||||
|
code: 'sudo usermod -aG wheel $USER',
|
||||||
|
description: 'Adiciona o usuário atual ao grupo wheel. Faça logout e login novamente para aplicar.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-ram-insufficient',
|
||||||
|
problem: 'RAM insuficiente',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'Sistema requer no mínimo 2GB de RAM para executar Docker.',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar processos consumindo mais memória',
|
||||||
|
code: 'top',
|
||||||
|
description: 'Mostra processos em tempo real. Pressione "q" para sair.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Verificar processos (htop - se instalado)',
|
||||||
|
code: 'htop',
|
||||||
|
description: 'Interface mais amigável. Instale com: sudo apt install htop (Ubuntu/Debian)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Verificar uso de memória',
|
||||||
|
code: 'free -h',
|
||||||
|
description: 'Mostra uso de memória em formato legível.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-disk-insufficient',
|
||||||
|
problem: 'Espaço em disco insuficiente',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'É necessário pelo menos 10GB de espaço livre em disco.',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar espaço em disco',
|
||||||
|
code: 'df -h',
|
||||||
|
description: 'Mostra espaço usado e disponível em todos os sistemas de arquivos.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Verificar diretórios ocupando mais espaço',
|
||||||
|
code: 'du -h --max-depth=1 / | sort -hr | head -10',
|
||||||
|
description: 'Lista os 10 diretórios que mais ocupam espaço (execute com sudo para resultados completos).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Limpar cache de pacotes (Ubuntu/Debian)',
|
||||||
|
code: 'sudo apt clean && sudo apt autoremove',
|
||||||
|
description: 'Remove pacotes antigos e cache para liberar espaço.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'linux-cpu-cores',
|
||||||
|
problem: 'CPU cores insuficientes',
|
||||||
|
os: 'linux',
|
||||||
|
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar número de cores',
|
||||||
|
code: 'nproc',
|
||||||
|
description: 'Mostra o número de processadores disponíveis.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Informações detalhadas da CPU',
|
||||||
|
code: 'lscpu | grep "^CPU(s):"',
|
||||||
|
description: 'Mostra informações detalhadas sobre a CPU.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== macOS ==========
|
||||||
|
{
|
||||||
|
id: 'macos-version-old',
|
||||||
|
problem: 'macOS',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'Versão do macOS muito antiga. Recomenda-se macOS 11 (Big Sur) ou superior.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Apple - Atualizar macOS',
|
||||||
|
url: 'https://support.apple.com/HT201541'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Apple - Como atualizar',
|
||||||
|
url: 'https://support.apple.com/guide/mac-help/update-macos-mchlpx1065'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos-homebrew-missing',
|
||||||
|
problem: 'Homebrew não instalado',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'Homebrew é recomendado para facilitar a instalação do Docker no macOS.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Homebrew oficial',
|
||||||
|
url: 'https://brew.sh/'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Instalar Homebrew (Intel e Apple Silicon)',
|
||||||
|
code: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
||||||
|
description: 'Instala Homebrew. Siga as instruções na tela.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Configurar PATH para Apple Silicon (após instalação)',
|
||||||
|
code: `echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
|
||||||
|
eval "$(/opt/homebrew/bin/brew shellenv)"`,
|
||||||
|
description: 'Adiciona Homebrew ao PATH em Macs com Apple Silicon (M1/M2/M3).'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos-rosetta-missing',
|
||||||
|
problem: 'Rosetta 2 não instalado',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'Rosetta 2 é necessário para executar aplicativos x86_64 em Macs com Apple Silicon.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Apple - Rosetta 2',
|
||||||
|
url: 'https://support.apple.com/HT211861'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Apple Developer - Rosetta',
|
||||||
|
url: 'https://developer.apple.com/documentation/apple-silicon/about-the-rosetta-translation-environment'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Instalar Rosetta 2',
|
||||||
|
code: 'softwareupdate --install-rosetta --agree-to-license',
|
||||||
|
description: 'Instala Rosetta 2. Pode solicitar confirmação.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Verificar se Rosetta está instalado',
|
||||||
|
code: 'ls /Library/Apple/usr/share/rosetta',
|
||||||
|
description: 'Verifica se Rosetta 2 está instalado. Se o diretório existir, está instalado.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Testar execução x86_64',
|
||||||
|
code: 'arch -x86_64 uname -m',
|
||||||
|
description: 'Deve retornar "x86_64" se Rosetta estiver funcionando.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos-ram-insufficient',
|
||||||
|
problem: 'RAM insuficiente',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'Sistema requer no mínimo 4GB de RAM para executar Docker no macOS.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Apple - Memória Mac',
|
||||||
|
url: 'https://support.apple.com/mac/memory'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar processos consumindo mais memória',
|
||||||
|
code: 'top -o MEM',
|
||||||
|
description: 'Mostra processos ordenados por uso de memória. Pressione "q" para sair.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Abrir Monitor de Atividade',
|
||||||
|
code: 'open -a "Activity Monitor"',
|
||||||
|
description: 'Abre o Monitor de Atividade do macOS.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos-disk-insufficient',
|
||||||
|
problem: 'Espaço em disco insuficiente',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'É necessário pelo menos 4GB de espaço livre em disco.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Apple - Liberar espaço de armazenamento',
|
||||||
|
url: 'https://support.apple.com/guide/mac-help/free-up-storage-space-mh31934'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Abrir Gerenciamento de Armazenamento',
|
||||||
|
code: 'open "x-apple.systempreferences:com.apple.preference.storage"',
|
||||||
|
description: 'Abre as preferências de armazenamento do macOS.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'macos-cpu-cores',
|
||||||
|
problem: 'CPU cores insuficientes',
|
||||||
|
os: 'macos',
|
||||||
|
description: 'Recomenda-se pelo menos 2 cores de CPU para melhor desempenho.',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
title: 'Verificar número de cores',
|
||||||
|
code: 'sysctl -n hw.ncpu',
|
||||||
|
description: 'Mostra o número de cores de CPU disponíveis.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== GERAL ==========
|
||||||
|
{
|
||||||
|
id: 'os-not-supported',
|
||||||
|
problem: 'Sistema operacional não suportado',
|
||||||
|
os: 'all',
|
||||||
|
description: 'Este sistema operacional não é suportado. Docker requer Windows, Linux ou macOS.',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'Docker - Requisitos do Sistema',
|
||||||
|
url: 'https://docs.docker.com/desktop/supported-platforms/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encontra a solução correspondente para um problema
|
||||||
|
* @param problemText Texto do problema retornado pela verificação
|
||||||
|
* @param os Sistema operacional atual
|
||||||
|
* @returns Solução correspondente ou null se não encontrada
|
||||||
|
*/
|
||||||
|
export function findProblemSolution(problemText: string, os: string): ProblemSolution | null {
|
||||||
|
const normalizedProblem = problemText.toLowerCase();
|
||||||
|
const normalizedOs = os.toLowerCase();
|
||||||
|
|
||||||
|
// Buscar por matching exato primeiro
|
||||||
|
let solution = PROBLEM_SOLUTIONS.find(s => {
|
||||||
|
const matchesOs = s.os === 'all' || s.os === normalizedOs ||
|
||||||
|
(normalizedOs === 'mac' && s.os === 'macos') ||
|
||||||
|
(normalizedOs === 'windows' && s.os === 'windows') ||
|
||||||
|
(normalizedOs === 'linux' && s.os === 'linux');
|
||||||
|
|
||||||
|
if (!matchesOs) return false;
|
||||||
|
|
||||||
|
const problemLower = s.problem.toLowerCase();
|
||||||
|
return normalizedProblem.includes(problemLower) || problemLower.includes(normalizedProblem);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se não encontrou, tentar matching parcial por palavras-chave
|
||||||
|
if (!solution) {
|
||||||
|
const keywords: { [key: string]: string } = {
|
||||||
|
'windows build': 'windows-build-old',
|
||||||
|
'virtualmachineplatform': 'windows-vm-platform',
|
||||||
|
'wsl não habilitado': 'windows-wsl-not-enabled',
|
||||||
|
'wsl 2 não configurado': 'windows-wsl2-not-configured',
|
||||||
|
'ram insuficiente': normalizedOs === 'windows' ? 'windows-ram-insufficient' :
|
||||||
|
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-ram-insufficient' :
|
||||||
|
'linux-ram-insufficient',
|
||||||
|
'espaço em disco': normalizedOs === 'windows' ? 'windows-disk-insufficient' :
|
||||||
|
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-disk-insufficient' :
|
||||||
|
'linux-disk-insufficient',
|
||||||
|
'cpu cores': normalizedOs === 'windows' ? 'windows-cpu-cores' :
|
||||||
|
normalizedOs === 'mac' || normalizedOs === 'macos' ? 'macos-cpu-cores' :
|
||||||
|
'linux-cpu-cores',
|
||||||
|
'kernel': 'linux-kernel-old',
|
||||||
|
'pacote curl': 'linux-curl-missing',
|
||||||
|
'pacote ca-certificates': 'linux-ca-certificates-missing',
|
||||||
|
'pacote gnupg': 'linux-gnupg-missing',
|
||||||
|
'privilégios sudo': 'linux-sudo-missing',
|
||||||
|
'homebrew': 'macos-homebrew-missing',
|
||||||
|
'rosetta': 'macos-rosetta-missing',
|
||||||
|
'sistema operacional não suportado': 'os-not-supported'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [keyword, id] of Object.entries(keywords)) {
|
||||||
|
if (normalizedProblem.includes(keyword)) {
|
||||||
|
solution = PROBLEM_SOLUTIONS.find(s => s.id === id);
|
||||||
|
if (solution) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return solution || null;
|
||||||
|
}
|
||||||
@@ -53,14 +53,15 @@ export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
|
|||||||
* Faz download do instalador (oficial ou fallback)
|
* Faz download do instalador (oficial ou fallback)
|
||||||
* Retorna o caminho do arquivo baixado
|
* Retorna o caminho do arquivo baixado
|
||||||
*/
|
*/
|
||||||
export async function downloadInstaller(os: OS): Promise<string> {
|
export async function downloadInstaller(os: OS, expectedSha256?: string): Promise<string> {
|
||||||
const url = OLLAMA_DOWNLOAD_URLS[os];
|
const url = OLLAMA_DOWNLOAD_URLS[os];
|
||||||
const filename = LOCAL_INSTALLER_FILES[os];
|
const filename = LOCAL_INSTALLER_FILES[os];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filePath = await invoke<string>('download_installer', {
|
const filePath = await invoke<string>('download_installer', {
|
||||||
url,
|
url,
|
||||||
filename
|
filename,
|
||||||
|
expected_sha256: expectedSha256 || null
|
||||||
});
|
});
|
||||||
return filePath;
|
return filePath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,9 +88,12 @@ export async function getDownloadedInstallerPath(os: OS): Promise<string | null>
|
|||||||
/**
|
/**
|
||||||
* Executa o instalador baixado
|
* Executa o instalador baixado
|
||||||
*/
|
*/
|
||||||
export async function runInstaller(filePath: string): Promise<void> {
|
export async function runInstaller(filePath: string, expectedSha256?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await invoke('run_installer', { filePath });
|
await invoke('run_installer', {
|
||||||
|
file_path: filePath,
|
||||||
|
expected_sha256: expectedSha256 || null
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Falha ao executar instalador: ${error}`);
|
throw new Error(`Falha ao executar instalador: ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { QueryContext } from './contextual-analyzer';
|
|||||||
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||||
import { scoreAndSelectChunks } from './relevance-scorer';
|
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||||
import { summarizeMarkdown } from './content-condenser';
|
import { summarizeMarkdown } from './content-condenser';
|
||||||
|
import { chatLog } from './terminal-logger';
|
||||||
|
|
||||||
export interface CondensationResult {
|
export interface CondensationResult {
|
||||||
context: string;
|
context: string;
|
||||||
@@ -24,12 +25,13 @@ export interface CondensationOptions {
|
|||||||
context?: QueryContext; // Contexto analisado para ranqueamento semântico
|
context?: QueryContext; // Contexto analisado para ranqueamento semântico
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
|
const DEFAULT_OPTIONS: Omit<Required<CondensationOptions>, 'context'> & { context?: QueryContext } = {
|
||||||
maxTokens: 12000,
|
maxTokens: 12000,
|
||||||
minRelevanceScore: 0.1,
|
minRelevanceScore: 0.1,
|
||||||
autoSummarize: true,
|
autoSummarize: true,
|
||||||
summarizeThreshold: 5000,
|
summarizeThreshold: 5000,
|
||||||
fallbackToSummarization: true,
|
fallbackToSummarization: true,
|
||||||
|
context: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,9 +80,21 @@ export function condenseKnowledgeBase(
|
|||||||
query: string,
|
query: string,
|
||||||
options: CondensationOptions = {}
|
options: CondensationOptions = {}
|
||||||
): CondensationResult {
|
): CondensationResult {
|
||||||
|
const condensationStart = Date.now();
|
||||||
|
|
||||||
|
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION START ==========`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Query: "${query}"`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Entries: ${entries.length}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Options:`, {
|
||||||
|
maxTokens: options.maxTokens || DEFAULT_OPTIONS.maxTokens,
|
||||||
|
minRelevanceScore: options.minRelevanceScore || DEFAULT_OPTIONS.minRelevanceScore,
|
||||||
|
autoSummarize: options.autoSummarize ?? DEFAULT_OPTIONS.autoSummarize,
|
||||||
|
});
|
||||||
|
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
chatLog.warn(`[KnowledgeBase] ⚠️ No entries to condense`);
|
||||||
return {
|
return {
|
||||||
context: '',
|
context: '',
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
@@ -97,6 +111,9 @@ 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<{
|
||||||
@@ -105,10 +122,17 @@ 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
|
||||||
@@ -119,6 +143,19 @@ export function condenseKnowledgeBase(
|
|||||||
sourceTitle: entry.title,
|
sourceTitle: entry.title,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const entryDuration = Date.now() - entryStart;
|
||||||
|
chatLog.info(`[KnowledgeBase] Generated ${chunksWithSource.length} chunks in ${entryDuration}ms`);
|
||||||
|
|
||||||
|
// Log detalhes dos chunks
|
||||||
|
if (chunksWithSource.length > 0) {
|
||||||
|
chunksWithSource.slice(0, 3).forEach((chunk, chunkIdx) => {
|
||||||
|
chatLog.info(` Chunk ${chunkIdx + 1}: ${chunk.content.length} chars, relevance: ${chunk.relevanceScore?.toFixed(3) || 'N/A'}`);
|
||||||
|
});
|
||||||
|
if (chunksWithSource.length > 3) {
|
||||||
|
chatLog.info(` ... and ${chunksWithSource.length - 3} more chunks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processedEntries.push({
|
processedEntries.push({
|
||||||
entry,
|
entry,
|
||||||
processed,
|
processed,
|
||||||
@@ -128,10 +165,17 @@ 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,
|
||||||
@@ -140,6 +184,19 @@ 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 = '';
|
||||||
@@ -196,6 +253,26 @@ export function condenseKnowledgeBase(
|
|||||||
|
|
||||||
const totalTokens = estimateTokens(context);
|
const totalTokens = estimateTokens(context);
|
||||||
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
|
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
|
||||||
|
const condensationDuration = Date.now() - condensationStart;
|
||||||
|
|
||||||
|
// Log resultado final
|
||||||
|
chatLog.info(`\n[KnowledgeBase] ========== CONDENSATION RESULTS ==========`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Method: ${method}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Duration: ${condensationDuration}ms`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Original tokens: ${originalTokens}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Final tokens: ${totalTokens}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Compression ratio: ${(compressionRatio * 100).toFixed(1)}%`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Chunks used: ${selectedChunks.length} / ${allChunks.length}`);
|
||||||
|
chatLog.info(`[KnowledgeBase] Final context length: ${context.length} chars`);
|
||||||
|
|
||||||
|
// Log estatísticas por fonte
|
||||||
|
chatLog.info(`[KnowledgeBase] Chunks per source:`);
|
||||||
|
entries.forEach((entry, idx) => {
|
||||||
|
const chunksUsed = selectedChunks.filter(c => c.sourceIndex === idx).length;
|
||||||
|
chatLog.info(` ${entry.title}: ${chunksUsed} chunks`);
|
||||||
|
});
|
||||||
|
|
||||||
|
chatLog.info(`[KnowledgeBase] ============================================\n`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
context: context.trim(),
|
context: context.trim(),
|
||||||
|
|||||||
@@ -29,16 +29,12 @@ export async function detectModelContextWindow(modelName: string): Promise<numbe
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Tentar obter informações do modelo via Ollama API
|
// Tentar obter informações do modelo via Ollama API
|
||||||
const response = await fetch('http://localhost:11434/api/show', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const data = await invoke<any>('get_ollama_model_info', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
modelName,
|
||||||
body: JSON.stringify({
|
|
||||||
name: modelName,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (data) {
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
||||||
// Mapear tamanhos conhecidos de modelos
|
// Mapear tamanhos conhecidos de modelos
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { LocalModel } from '@/hooks/use-local-models';
|
||||||
|
|
||||||
|
// Interfaces based on the JSON structure
|
||||||
|
export interface ModelVariant {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
context: string;
|
||||||
|
inputType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelDefinition {
|
||||||
|
model: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
variantsCount: number;
|
||||||
|
variants: ModelVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import model definitions
|
||||||
|
// We use a static list as dynamic imports of JSONs are not trivial in client-side bundles without API routes
|
||||||
|
import aya from '@/data/models/aya.json';
|
||||||
|
import codellama from '@/data/models/codellama.json';
|
||||||
|
import commandR from '@/data/models/command-r.json';
|
||||||
|
import commandRPlus from '@/data/models/command-r-plus.json';
|
||||||
|
import deepseekCoder from '@/data/models/deepseek-coder.json';
|
||||||
|
import deepseekLLM from '@/data/models/deepseek-llm.json';
|
||||||
|
import deepseekV2 from '@/data/models/deepseek-v2.json';
|
||||||
|
import deepseekV25 from '@/data/models/deepseek-v2.5.json';
|
||||||
|
import deepseekV3 from '@/data/models/deepseek-v3.json';
|
||||||
|
import falcon from '@/data/models/falcon.json';
|
||||||
|
import gemma from '@/data/models/gemma.json';
|
||||||
|
import gemma2 from '@/data/models/gemma2.json';
|
||||||
|
import llama2 from '@/data/models/llama2.json';
|
||||||
|
import llama2Uncensored from '@/data/models/llama2-uncensored.json';
|
||||||
|
import llama3 from '@/data/models/llama3.json';
|
||||||
|
import llama31 from '@/data/models/llama3.1.json';
|
||||||
|
import llama32 from '@/data/models/llama3.2.json';
|
||||||
|
import llama32Vision from '@/data/models/llama3.2-vision.json';
|
||||||
|
import llama33 from '@/data/models/llama3.3.json';
|
||||||
|
import llava from '@/data/models/llava.json';
|
||||||
|
import mistral from '@/data/models/mistral.json';
|
||||||
|
import mistralNemo from '@/data/models/mistral-nemo.json';
|
||||||
|
import mixtral from '@/data/models/mixtral.json';
|
||||||
|
import moondream from '@/data/models/moondream.json';
|
||||||
|
import neuralChat from '@/data/models/neural-chat.json';
|
||||||
|
import openhermes from '@/data/models/openhermes.json';
|
||||||
|
import orcaMini from '@/data/models/orca-mini.json';
|
||||||
|
import phi from '@/data/models/phi.json';
|
||||||
|
import phi3 from '@/data/models/phi3.json';
|
||||||
|
import phi35 from '@/data/models/phi3.5.json';
|
||||||
|
import phi4 from '@/data/models/phi4.json';
|
||||||
|
import qwen from '@/data/models/qwen.json';
|
||||||
|
import qwen2 from '@/data/models/qwen2.json';
|
||||||
|
import qwen25 from '@/data/models/qwen2.5.json';
|
||||||
|
import qwen25Coder from '@/data/models/qwen2.5-coder.json';
|
||||||
|
import starlingLM from '@/data/models/starling-lm.json';
|
||||||
|
import tinyllama from '@/data/models/tinyllama.json';
|
||||||
|
import vicuna from '@/data/models/vicuna.json';
|
||||||
|
import wizardVicunaUncensored from '@/data/models/wizard-vicuna-uncensored.json';
|
||||||
|
import yi from '@/data/models/yi.json';
|
||||||
|
import zephyr from '@/data/models/zephyr.json';
|
||||||
|
|
||||||
|
// Aggregate into a registry
|
||||||
|
export const MODEL_REGISTRY: ModelDefinition[] = [
|
||||||
|
aya,
|
||||||
|
codellama,
|
||||||
|
commandR,
|
||||||
|
commandRPlus,
|
||||||
|
deepseekCoder,
|
||||||
|
deepseekLLM,
|
||||||
|
deepseekV2,
|
||||||
|
deepseekV25,
|
||||||
|
deepseekV3,
|
||||||
|
falcon,
|
||||||
|
gemma,
|
||||||
|
gemma2,
|
||||||
|
llama2,
|
||||||
|
llama2Uncensored,
|
||||||
|
llama3,
|
||||||
|
llama31,
|
||||||
|
llama32,
|
||||||
|
llama32Vision,
|
||||||
|
llama33,
|
||||||
|
llava,
|
||||||
|
mistral,
|
||||||
|
mistralNemo,
|
||||||
|
mixtral,
|
||||||
|
moondream,
|
||||||
|
neuralChat,
|
||||||
|
openhermes,
|
||||||
|
orcaMini,
|
||||||
|
phi,
|
||||||
|
phi3,
|
||||||
|
phi35,
|
||||||
|
phi4,
|
||||||
|
qwen,
|
||||||
|
qwen2,
|
||||||
|
qwen25,
|
||||||
|
qwen25Coder,
|
||||||
|
starlingLM,
|
||||||
|
tinyllama,
|
||||||
|
vicuna,
|
||||||
|
wizardVicunaUncensored,
|
||||||
|
yi,
|
||||||
|
zephyr,
|
||||||
|
] as ModelDefinition[];
|
||||||
|
|
||||||
|
// Helper to find a model definition by one of its variant names
|
||||||
|
export function findModelDefinition(variantName: string): ModelDefinition | undefined {
|
||||||
|
// Remove tag to match base model name roughly?
|
||||||
|
// Or search all variants
|
||||||
|
const cleanName = variantName.split(':')[0];
|
||||||
|
|
||||||
|
// First try to find by exact match in variants
|
||||||
|
const exactMatch = MODEL_REGISTRY.find(def =>
|
||||||
|
def.variants.some(v => v.name === variantName)
|
||||||
|
);
|
||||||
|
if (exactMatch) return exactMatch;
|
||||||
|
|
||||||
|
// Then try to match by model ID/name
|
||||||
|
return MODEL_REGISTRY.find(def => def.model === cleanName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstalledModels(
|
||||||
|
registry: ModelDefinition[],
|
||||||
|
installed: LocalModel[]
|
||||||
|
): ModelDefinition[] {
|
||||||
|
const installedNames = new Set(installed.map(m => m.name));
|
||||||
|
return registry.filter(def =>
|
||||||
|
def.variants.some(v => installedNames.has(v.name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableModels(
|
||||||
|
registry: ModelDefinition[],
|
||||||
|
installed: LocalModel[]
|
||||||
|
): ModelDefinition[] {
|
||||||
|
const installedNames = new Set(installed.map(m => m.name));
|
||||||
|
return registry.filter(def =>
|
||||||
|
!def.variants.some(v => installedNames.has(v.name))
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-15
@@ -118,27 +118,16 @@ export async function enrichQueries(
|
|||||||
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
|
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
|
||||||
.replace('{{domain}}', context.domain || 'geral');
|
.replace('{{domain}}', context.domain || 'geral');
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const rawResponse = await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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, '')
|
||||||
@@ -176,7 +165,7 @@ export async function enrichQueries(
|
|||||||
result.expanded.length +
|
result.expanded.length +
|
||||||
result.contextual.length;
|
result.contextual.length;
|
||||||
|
|
||||||
chatLog.success(
|
chatLog.info(
|
||||||
`[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, ` +
|
||||||
@@ -185,7 +174,7 @@ export async function enrichQueries(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.error(`[QueryEnricher] Erro ao enriquecer queries:`, error);
|
chatLog.error(`[QueryEnricher] Erro ao enriquecer queries: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
|
||||||
// Fallback: usar apenas query original
|
// Fallback: usar apenas query original
|
||||||
return createFallbackEnrichment(originalQuery, context);
|
return createFallbackEnrichment(originalQuery, context);
|
||||||
|
|||||||
@@ -211,3 +211,11 @@ export function formatSearchLog(log: SearchLog): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -38,3 +38,11 @@ export const deepResearchLog = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +13,34 @@ export interface SourcesConfig {
|
|||||||
last_updated: string;
|
last_updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearxngConfig {
|
||||||
|
searxng_url: string;
|
||||||
|
api_format: string;
|
||||||
|
default_engines: string[];
|
||||||
|
default_category: string;
|
||||||
|
timeout_seconds: number;
|
||||||
|
verify_ssl: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
log_search_content: boolean;
|
||||||
|
anonymize_identifiers: boolean;
|
||||||
|
encrypt_logs: boolean;
|
||||||
|
retention_days: number;
|
||||||
|
use_public_instances: boolean;
|
||||||
|
search_via_scraping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchLog {
|
||||||
|
id: string;
|
||||||
|
chat_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
timestamp: string;
|
||||||
|
searxng_instance?: string;
|
||||||
|
query: string;
|
||||||
|
engines?: string[];
|
||||||
|
filters?: string;
|
||||||
|
response_time_ms?: number;
|
||||||
|
total_results?: number;
|
||||||
|
results_per_engine?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
+34
-32
@@ -63,7 +63,7 @@ const DEFAULT_CONFIG: FallbackConfig = {
|
|||||||
maxRounds: 4,
|
maxRounds: 4,
|
||||||
maxResultsPerRound: 10,
|
maxResultsPerRound: 10,
|
||||||
maxTotalResults: 40,
|
maxTotalResults: 40,
|
||||||
minRelevanceScore: 0.3,
|
minRelevanceScore: 0.1,
|
||||||
enableQueryExpansion: true,
|
enableQueryExpansion: true,
|
||||||
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
||||||
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
||||||
@@ -100,39 +100,35 @@ Instruções:
|
|||||||
|
|
||||||
Query expandida:`;
|
Query expandida:`;
|
||||||
|
|
||||||
const response = await fetch('http://localhost:11434/api/generate', {
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
method: 'POST',
|
const expanded = (await invoke<string>('generate_completion', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
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();
|
||||||
|
|
||||||
if (expanded.length < 3) {
|
const sanitized = expanded
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||||
|
.replace(/^\s*```[a-zA-Z]*\s*/g, '')
|
||||||
|
.replace(/\s*```\s*$/g, '')
|
||||||
|
.replace(/^(here\s+is\s+the\s+query|sure|okay|final\s+query)\s*[:,-]*\s*/i, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\r\n]+/g, ' ');
|
||||||
|
|
||||||
|
if (sanitized.length < 3 || sanitized.length > 150 || /[<>]/.test(sanitized)) {
|
||||||
return originalQuery;
|
return originalQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
chatLog.info(`Query expanded: "${originalQuery}" → "${expanded}"`);
|
chatLog.info(`Query expanded: "${originalQuery}" → "${sanitized}"`);
|
||||||
return expanded;
|
return sanitized;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn('Error expanding query, using original:', error);
|
chatLog.warn(`Error expanding query, using original: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
return originalQuery;
|
return originalQuery;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +151,7 @@ export function calculateRelevanceScoreForFallback(
|
|||||||
|
|
||||||
// Contar matches de palavras-chave
|
// Contar matches de palavras-chave
|
||||||
let matches = 0;
|
let matches = 0;
|
||||||
let totalWords = queryWords.length;
|
const totalWords = queryWords.length;
|
||||||
|
|
||||||
for (const word of queryWords) {
|
for (const word of queryWords) {
|
||||||
if (combinedText.includes(word)) {
|
if (combinedText.includes(word)) {
|
||||||
@@ -283,7 +279,7 @@ export async function executeProgressiveSearch(
|
|||||||
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
||||||
const attempts: FallbackAttempt[] = [];
|
const attempts: FallbackAttempt[] = [];
|
||||||
let totalResultsAnalyzed = 0;
|
let totalResultsAnalyzed = 0;
|
||||||
let allResults: ScrapedContent[] = [];
|
const allResults: ScrapedContent[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
const queryStartTime = Date.now();
|
const queryStartTime = Date.now();
|
||||||
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
||||||
@@ -337,8 +333,8 @@ export async function executeProgressiveSearch(
|
|||||||
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
||||||
|
|
||||||
const roundStartTime = Date.now();
|
const roundStartTime = Date.now();
|
||||||
let roundResults: ScrapedContent[] = [];
|
const roundResults: ScrapedContent[] = [];
|
||||||
let roundQueries: string[] = [];
|
const roundQueries: string[] = [];
|
||||||
|
|
||||||
// Executar todas as queries do round em paralelo com timeout por round
|
// Executar todas as queries do round em paralelo com timeout por round
|
||||||
const queryPromises = roundConfig.queries.map(async (query) => {
|
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||||
@@ -351,8 +347,13 @@ export async function executeProgressiveSearch(
|
|||||||
// Passar round para searchFn para timeout adaptativo
|
// Passar round para searchFn para timeout adaptativo
|
||||||
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
||||||
return { query: finalQuery, results };
|
return { query: finalQuery, results };
|
||||||
|
<<<<<<< HEAD
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
=======
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error);
|
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
|
||||||
return { query: finalQuery, results: [] };
|
return { query: finalQuery, results: [] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -367,7 +368,7 @@ export async function executeProgressiveSearch(
|
|||||||
let queryResults: QueryResult[];
|
let queryResults: QueryResult[];
|
||||||
try {
|
try {
|
||||||
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
||||||
} catch (error) {
|
} catch (_err) {
|
||||||
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
||||||
// Tentar obter resultados parciais das promises que já completaram
|
// Tentar obter resultados parciais das promises que já completaram
|
||||||
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
||||||
@@ -406,13 +407,15 @@ export async function executeProgressiveSearch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcular relevância usando contexto se disponível
|
// Calcular relevância usando contexto se disponível
|
||||||
|
let semanticSimilarity: ((ctx: QueryContext, text: string) => number) | null = null;
|
||||||
|
if (finalConfig.context) {
|
||||||
|
const mod = await import('./relevance-scorer');
|
||||||
|
semanticSimilarity = mod.calculateSemanticSimilarity;
|
||||||
|
}
|
||||||
const relevanceScore = roundResults.length > 0
|
const relevanceScore = roundResults.length > 0
|
||||||
? roundResults.reduce((sum, r) => {
|
? roundResults.reduce((sum, r) => {
|
||||||
// Usar contexto para cálculo de relevância se disponível
|
if (semanticSimilarity && finalConfig.context) {
|
||||||
if (finalConfig.context) {
|
return sum + semanticSimilarity(finalConfig.context, r.markdown || r.title);
|
||||||
// Importar função dinamicamente para evitar circular dependency
|
|
||||||
const { calculateSemanticSimilarity } = require('./relevance-scorer');
|
|
||||||
return sum + calculateSemanticSimilarity(finalConfig.context, r.markdown || r.title);
|
|
||||||
}
|
}
|
||||||
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
return sum + calculateRelevanceScoreForFallback(r, originalQuery);
|
||||||
}, 0) / roundResults.length
|
}, 0) / roundResults.length
|
||||||
@@ -479,4 +482,3 @@ export async function executeProgressiveSearch(
|
|||||||
usedFallback: true,
|
usedFallback: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+45
-1
@@ -5,16 +5,60 @@ 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: 'raw-loader',
|
use: {
|
||||||
|
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;
|
||||||
+12
-4
@@ -4,9 +4,11 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate-changelog": "node scripts/generate-changelog.js",
|
||||||
|
"bump-version": "node scripts/bump-version.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -23,13 +25,14 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-regex": "^10.6.0",
|
"emoji-regex": "^10.6.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"next": "16.0.3",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -48,10 +51,15 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.1.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"mdast-util-to-hast": ">=13.2.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+69
-55
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
mdast-util-to-hast: '>=13.2.1'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -50,6 +53,9 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
'@tauri-apps/plugin-updater':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.9.0
|
||||||
'@types/react-syntax-highlighter':
|
'@types/react-syntax-highlighter':
|
||||||
specifier: ^15.5.13
|
specifier: ^15.5.13
|
||||||
version: 15.5.13
|
version: 15.5.13
|
||||||
@@ -69,8 +75,8 @@ importers:
|
|||||||
specifier: ^0.554.0
|
specifier: ^0.554.0
|
||||||
version: 0.554.0([email protected])
|
version: 0.554.0([email protected])
|
||||||
next:
|
next:
|
||||||
specifier: 16.0.3
|
specifier: 16.1.1
|
||||||
version: 16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected])
|
version: 16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected])
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6([email protected]([email protected]))([email protected])
|
version: 0.4.6([email protected]([email protected]))([email protected])
|
||||||
@@ -121,8 +127,8 @@ importers:
|
|||||||
specifier: ^9
|
specifier: ^9
|
||||||
version: 9.39.1([email protected])
|
version: 9.39.1([email protected])
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 16.0.3
|
specifier: 16.1.1
|
||||||
version: 16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
version: 16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected])
|
||||||
raw-loader:
|
raw-loader:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2([email protected])
|
version: 4.0.2([email protected])
|
||||||
@@ -450,56 +456,56 @@ packages:
|
|||||||
'@napi-rs/[email protected]':
|
'@napi-rs/[email protected]':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
'@next/env@16.0.3':
|
'@next/env@16.1.1':
|
||||||
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
|
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.3':
|
'@next/eslint-plugin-next@16.1.1':
|
||||||
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
|
resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.3':
|
'@next/swc-darwin-arm64@16.1.1':
|
||||||
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
|
resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.3':
|
'@next/swc-darwin-x64@16.1.1':
|
||||||
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
|
resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||||
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
|
resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.3':
|
'@next/swc-linux-arm64-musl@16.1.1':
|
||||||
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
|
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.3':
|
'@next/swc-linux-x64-gnu@16.1.1':
|
||||||
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
|
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.3':
|
'@next/swc-linux-x64-musl@16.1.1':
|
||||||
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
|
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||||
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
|
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.3':
|
'@next/swc-win32-x64-msvc@16.1.1':
|
||||||
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
|
resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -1153,6 +1159,9 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/[email protected]':
|
||||||
|
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1764,8 +1773,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
eslint-config-next@16.0.3:
|
eslint-config-next@16.1.1:
|
||||||
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
|
resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=9.0.0'
|
eslint: '>=9.0.0'
|
||||||
typescript: '>=3.3.1'
|
typescript: '>=3.3.1'
|
||||||
@@ -2461,8 +2470,8 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||||
|
|
||||||
[email protected].0:
|
[email protected].1:
|
||||||
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
|
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
|
||||||
@@ -2614,8 +2623,8 @@ packages:
|
|||||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
next@16.0.3:
|
next@16.1.1:
|
||||||
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
|
resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3593,34 +3602,34 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.0.3': {}
|
'@next/env@16.1.1': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@16.0.3':
|
'@next/eslint-plugin-next@16.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.0.3':
|
'@next/swc-darwin-arm64@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.0.3':
|
'@next/swc-darwin-x64@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.0.3':
|
'@next/swc-linux-arm64-gnu@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.0.3':
|
'@next/swc-linux-arm64-musl@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.0.3':
|
'@next/swc-linux-x64-gnu@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.0.3':
|
'@next/swc-linux-x64-musl@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.0.3':
|
'@next/swc-win32-arm64-msvc@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.0.3':
|
'@next/swc-win32-x64-msvc@16.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/[email protected]':
|
'@nodelib/[email protected]':
|
||||||
@@ -4228,6 +4237,10 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.9.4
|
'@tauri-apps/cli-win32-ia32-msvc': 2.9.4
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.9.4
|
'@tauri-apps/cli-win32-x64-msvc': 2.9.4
|
||||||
|
|
||||||
|
'@tauri-apps/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.9.0
|
||||||
|
|
||||||
'@tybys/[email protected]':
|
'@tybys/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -4938,9 +4951,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
eslint-config-next@16.0.3(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
|
eslint-config-next@16.1.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected]))([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/eslint-plugin-next': 16.0.3
|
'@next/eslint-plugin-next': 16.1.1
|
||||||
eslint: 9.39.1([email protected])
|
eslint: 9.39.1([email protected])
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
|
eslint-import-resolver-typescript: 3.10.1([email protected])([email protected]([email protected]))
|
||||||
@@ -5793,7 +5806,7 @@ snapshots:
|
|||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
unist-util-is: 6.0.1
|
unist-util-is: 6.0.1
|
||||||
|
|
||||||
[email protected].0:
|
[email protected].1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
@@ -6058,24 +6071,25 @@ snapshots:
|
|||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0([email protected])
|
react-dom: 19.2.0([email protected])
|
||||||
|
|
||||||
next@16.0.3(@babel/[email protected])([email protected]([email protected]))([email protected]):
|
next@16.1.1(@babel/[email protected])([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.0.3
|
'@next/env': 16.1.1
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
|
baseline-browser-mapping: 2.8.29
|
||||||
caniuse-lite: 1.0.30001755
|
caniuse-lite: 1.0.30001755
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
react-dom: 19.2.0([email protected])
|
react-dom: 19.2.0([email protected])
|
||||||
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 16.0.3
|
'@next/swc-darwin-arm64': 16.1.1
|
||||||
'@next/swc-darwin-x64': 16.0.3
|
'@next/swc-darwin-x64': 16.1.1
|
||||||
'@next/swc-linux-arm64-gnu': 16.0.3
|
'@next/swc-linux-arm64-gnu': 16.1.1
|
||||||
'@next/swc-linux-arm64-musl': 16.0.3
|
'@next/swc-linux-arm64-musl': 16.1.1
|
||||||
'@next/swc-linux-x64-gnu': 16.0.3
|
'@next/swc-linux-x64-gnu': 16.1.1
|
||||||
'@next/swc-linux-x64-musl': 16.0.3
|
'@next/swc-linux-x64-musl': 16.1.1
|
||||||
'@next/swc-win32-arm64-msvc': 16.0.3
|
'@next/swc-win32-arm64-msvc': 16.1.1
|
||||||
'@next/swc-win32-x64-msvc': 16.0.3
|
'@next/swc-win32-x64-msvc': 16.1.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -6229,7 +6243,7 @@ snapshots:
|
|||||||
devlop: 1.1.0
|
devlop: 1.1.0
|
||||||
hast-util-to-jsx-runtime: 2.3.6
|
hast-util-to-jsx-runtime: 2.3.6
|
||||||
html-url-attributes: 3.0.1
|
html-url-attributes: 3.0.1
|
||||||
mdast-util-to-hast: 13.2.0
|
mdast-util-to-hast: 13.2.1
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
remark-parse: 11.0.0
|
remark-parse: 11.0.0
|
||||||
remark-rehype: 11.1.2
|
remark-rehype: 11.1.2
|
||||||
@@ -6334,7 +6348,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
mdast-util-to-hast: 13.2.0
|
mdast-util-to-hast: 13.2.1
|
||||||
unified: 11.0.5
|
unified: 11.0.5
|
||||||
vfile: 6.0.3
|
vfile: 6.0.3
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const versionType = process.argv[2] || 'patch'; // patch, minor, major
|
||||||
|
const shouldCommit = process.argv.includes('--commit');
|
||||||
|
|
||||||
|
// Validar tipo de versão
|
||||||
|
if (!['patch', 'minor', 'major'].includes(versionType)) {
|
||||||
|
console.error('❌ Tipo de versão inválido. Use: patch, minor ou major');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ler package.json
|
||||||
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Bump version
|
||||||
|
const [major, minor, patch] = packageJson.version.split('.').map(Number);
|
||||||
|
let newVersion;
|
||||||
|
|
||||||
|
switch (versionType) {
|
||||||
|
case 'major':
|
||||||
|
newVersion = `${major + 1}.0.0`;
|
||||||
|
break;
|
||||||
|
case 'minor':
|
||||||
|
newVersion = `${major}.${minor + 1}.0`;
|
||||||
|
break;
|
||||||
|
case 'patch':
|
||||||
|
default:
|
||||||
|
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 Atualizando versão de ${packageJson.version} para ${newVersion}...`);
|
||||||
|
|
||||||
|
// Atualizar package.json
|
||||||
|
packageJson.version = newVersion;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||||
|
console.log('✅ package.json atualizado');
|
||||||
|
|
||||||
|
// Atualizar tauri.conf.json
|
||||||
|
const tauriConfPath = path.join(__dirname, '..', 'src-tauri', 'tauri.conf.json');
|
||||||
|
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
|
||||||
|
tauriConf.version = newVersion;
|
||||||
|
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
|
||||||
|
console.log('✅ tauri.conf.json atualizado');
|
||||||
|
|
||||||
|
// Atualizar Cargo.toml
|
||||||
|
const cargoTomlPath = path.join(__dirname, '..', 'src-tauri', 'Cargo.toml');
|
||||||
|
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf-8');
|
||||||
|
cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${newVersion}"`);
|
||||||
|
fs.writeFileSync(cargoTomlPath, cargoToml);
|
||||||
|
console.log('✅ Cargo.toml atualizado');
|
||||||
|
|
||||||
|
// Criar commit e tag (opcional)
|
||||||
|
if (shouldCommit) {
|
||||||
|
try {
|
||||||
|
execSync(`git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml`, { stdio: 'inherit' });
|
||||||
|
execSync(`git commit -m "chore: bump version to ${newVersion}"`, { stdio: 'inherit' });
|
||||||
|
execSync(`git tag v${newVersion}`, { stdio: 'inherit' });
|
||||||
|
console.log(`✅ Commit e tag v${newVersion} criados`);
|
||||||
|
console.log(`\n📝 Próximo passo: git push && git push --tags`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erro ao criar commit/tag:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Versão atualizada para ${newVersion}`);
|
||||||
|
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Ler versão do package.json
|
||||||
|
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
// Verificar se estamos em um repositório Git
|
||||||
|
let isGitRepo = false;
|
||||||
|
try {
|
||||||
|
execSync('git rev-parse --git-dir', { stdio: 'ignore', cwd: path.join(__dirname, '..') });
|
||||||
|
isGitRepo = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Não é um repositório Git. Gerando changelog vazio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para sanitizar mensagens de commit
|
||||||
|
function sanitizeMessage(message) {
|
||||||
|
return message
|
||||||
|
.replace(/\n/g, ' ')
|
||||||
|
.replace(/\r/g, '')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para extrair tipo e mensagem do commit
|
||||||
|
function parseCommitMessage(fullMessage) {
|
||||||
|
const featMatch = fullMessage.match(/^feat(\(.+?\))?:\s*(.+)$/i);
|
||||||
|
const fixMatch = fullMessage.match(/^fix(\(.+?\))?:\s*(.+)$/i);
|
||||||
|
|
||||||
|
if (featMatch) {
|
||||||
|
return { type: 'feat', message: featMatch[2] || featMatch[0] };
|
||||||
|
}
|
||||||
|
if (fixMatch) {
|
||||||
|
return { type: 'fix', message: fixMatch[2] || fixMatch[0] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter tags de versão
|
||||||
|
function getVersionTags() {
|
||||||
|
try {
|
||||||
|
const tags = execSync('git tag --sort=-version:refname', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(tag => /^v?\d+\.\d+\.\d+/.test(tag));
|
||||||
|
return tags;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter commits entre duas tags ou desde o início
|
||||||
|
function getCommitsBetweenTags(fromTag, toTag) {
|
||||||
|
try {
|
||||||
|
let range = '';
|
||||||
|
if (fromTag && toTag) {
|
||||||
|
range = `${fromTag}..${toTag}`;
|
||||||
|
} else if (toTag) {
|
||||||
|
range = toTag;
|
||||||
|
} else {
|
||||||
|
range = 'HEAD';
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFormat = '%H|%an|%ad|%s';
|
||||||
|
const dateFormat = '--date=iso-strict';
|
||||||
|
|
||||||
|
const output = execSync(
|
||||||
|
`git log ${range} --pretty=format:"${logFormat}" --date=${dateFormat}`,
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.join(__dirname, '..'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return output
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => {
|
||||||
|
const [hash, author, date, message] = line.split('|');
|
||||||
|
const parsed = parseCommitMessage(message);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: hash.substring(0, 7),
|
||||||
|
type: parsed.type,
|
||||||
|
message: sanitizeMessage(parsed.message),
|
||||||
|
author: author.trim(),
|
||||||
|
date: date.trim(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função principal para gerar changelog
|
||||||
|
function generateChangelog() {
|
||||||
|
const changelogPath = path.join(__dirname, '..', 'data', 'changelog.json');
|
||||||
|
const dataDir = path.dirname(changelogPath);
|
||||||
|
|
||||||
|
// Criar diretório data se não existir
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGitRepo) {
|
||||||
|
// Gerar changelog vazio se não for repositório Git
|
||||||
|
const emptyChangelog = {
|
||||||
|
version: currentVersion,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
releases: [],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(changelogPath, JSON.stringify(emptyChangelog, null, 2), 'utf-8');
|
||||||
|
console.log('✅ Changelog vazio gerado (não é repositório Git)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = [];
|
||||||
|
const versionTags = getVersionTags();
|
||||||
|
|
||||||
|
if (versionTags.length > 0) {
|
||||||
|
// Agrupar por tags de versão
|
||||||
|
for (let i = 0; i < versionTags.length; i++) {
|
||||||
|
const currentTag = versionTags[i];
|
||||||
|
const nextTag = i < versionTags.length - 1 ? versionTags[i + 1] : null;
|
||||||
|
|
||||||
|
const commits = getCommitsBetweenTags(nextTag, currentTag);
|
||||||
|
|
||||||
|
if (commits.length > 0) {
|
||||||
|
// Extrair versão da tag (remover 'v' prefix se existir)
|
||||||
|
const version = currentTag.replace(/^v/, '');
|
||||||
|
const releaseDate = commits[0]?.date || new Date().toISOString();
|
||||||
|
|
||||||
|
releases.push({
|
||||||
|
version,
|
||||||
|
date: releaseDate.split('T')[0],
|
||||||
|
commits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar commits não versionados (após a última tag)
|
||||||
|
const unreleasedCommits = getCommitsBetweenTags(versionTags[0], 'HEAD');
|
||||||
|
if (unreleasedCommits.length > 0) {
|
||||||
|
releases.unshift({
|
||||||
|
version: 'unreleased',
|
||||||
|
date: unreleasedCommits[0]?.date.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||||
|
commits: unreleasedCommits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sem tags, agrupar por data (últimos 30 dias ou todos se menos)
|
||||||
|
const allCommits = getCommitsBetweenTags(null, 'HEAD');
|
||||||
|
|
||||||
|
if (allCommits.length > 0) {
|
||||||
|
// Agrupar por data
|
||||||
|
const commitsByDate = {};
|
||||||
|
allCommits.forEach(commit => {
|
||||||
|
const date = commit.date.split('T')[0];
|
||||||
|
if (!commitsByDate[date]) {
|
||||||
|
commitsByDate[date] = [];
|
||||||
|
}
|
||||||
|
commitsByDate[date].push(commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar releases por data (ordenado por data decrescente)
|
||||||
|
Object.keys(commitsByDate)
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
.forEach(date => {
|
||||||
|
releases.push({
|
||||||
|
version: 'unreleased',
|
||||||
|
date,
|
||||||
|
commits: commitsByDate[date],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelog = {
|
||||||
|
version: currentVersion,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
releases,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf-8');
|
||||||
|
console.log(`✅ Changelog gerado: ${releases.length} release(s) encontrada(s)`);
|
||||||
|
console.log(` Total de commits: ${releases.reduce((sum, r) => sum + r.commits.length, 0)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executar
|
||||||
|
try {
|
||||||
|
generateChangelog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erro ao gerar changelog:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# OllaHub Recommended SearXNG Configuration (2025)
|
||||||
|
# Optimized for resilience against 403 Forbidden and Rate Limiting
|
||||||
|
|
||||||
|
# General Settings
|
||||||
|
use_default_settings: true
|
||||||
|
general:
|
||||||
|
debug: false
|
||||||
|
instance_name: "OllaHub Search"
|
||||||
|
|
||||||
|
# Search Engine Settings
|
||||||
|
search:
|
||||||
|
safe_search: 0
|
||||||
|
autocomplete: ""
|
||||||
|
# CRITICAL: Adjust ban times to avoid long lockouts
|
||||||
|
ban_time_on_fail: 5
|
||||||
|
max_ban_time_on_fail: 120
|
||||||
|
suspended_times:
|
||||||
|
SearxEngineAccessDenied: 0 # Disable suspension on 403 (Access Denied)
|
||||||
|
SearxEngineCaptcha: 0 # Disable suspension on Captcha
|
||||||
|
SearxEngineTooManyRequests: 3600 # Keep suspension for 429
|
||||||
|
|
||||||
|
# Rate Limiter (Disable for self-hosted single user)
|
||||||
|
limiter:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
bind_address: "0.0.0.0"
|
||||||
|
secret_key: "ollahub-secret-key-change-me" # Change this!
|
||||||
|
# Proxy configuration (Crucial for proper IP forwarding)
|
||||||
|
method: "GET"
|
||||||
|
image_proxy: true
|
||||||
|
|
||||||
|
# Engine Configuration
|
||||||
|
engines:
|
||||||
|
- name: google
|
||||||
|
engine: google
|
||||||
|
shortcut: g
|
||||||
|
disabled: false
|
||||||
|
- name: duckduckgo
|
||||||
|
engine: duckduckgo
|
||||||
|
shortcut: ddg
|
||||||
|
disabled: false
|
||||||
|
- name: bing
|
||||||
|
engine: bing
|
||||||
|
shortcut: b
|
||||||
|
disabled: false
|
||||||
|
- name: wikipedia
|
||||||
|
engine: wikipedia
|
||||||
|
shortcut: w
|
||||||
|
disabled: false
|
||||||
|
|
||||||
|
# Outgoing Requests
|
||||||
|
outgoing:
|
||||||
|
# Request timeout
|
||||||
|
request_timeout: 10.0
|
||||||
|
# Max request timeout
|
||||||
|
max_request_timeout: 20.0
|
||||||
|
# User Agent (SearXNG default rotates, but we can enforce generic if needed)
|
||||||
|
# useragent_suffix: ""
|
||||||
|
|
||||||
|
# Proxy Support (Optional - Uncomment if using proxies)
|
||||||
|
# proxies:
|
||||||
|
# all:
|
||||||
|
# - http://user:pass@proxy_ip:port
|
||||||
|
# - http://user:pass@proxy_ip2:port
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* Circuit Breaker para motores de busca
|
|
||||||
* Desabilita motores que estão falhando consistentemente
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface EngineHealth {
|
|
||||||
engine: string;
|
|
||||||
successCount: number;
|
|
||||||
failureCount: number;
|
|
||||||
totalAttempts: number;
|
|
||||||
avgLatency: number; // ms
|
|
||||||
lastFailure: number | null; // timestamp
|
|
||||||
lastSuccess: number | null; // timestamp
|
|
||||||
isOpen: boolean; // circuit breaker aberto?
|
|
||||||
openedAt: number | null; // quando foi aberto
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CircuitBreakerConfig {
|
|
||||||
failureThreshold: number; // Taxa de falha para abrir (0-1, padrão: 0.5 = 50%)
|
|
||||||
cooldownPeriod: number; // Tempo em ms antes de tentar novamente (padrão: 5 minutos)
|
|
||||||
trackingWindow: number; // Número de tentativas para rastrear (padrão: 10)
|
|
||||||
minAttempts: number; // Mínimo de tentativas antes de considerar (padrão: 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: CircuitBreakerConfig = {
|
|
||||||
failureThreshold: 0.5, // 50% de falha
|
|
||||||
cooldownPeriod: 5 * 60 * 1000, // 5 minutos
|
|
||||||
trackingWindow: 10,
|
|
||||||
minAttempts: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export class EngineCircuitBreaker {
|
|
||||||
private health: Map<string, EngineHealth> = new Map();
|
|
||||||
private config: CircuitBreakerConfig;
|
|
||||||
|
|
||||||
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
|
||||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registra uma tentativa de busca
|
|
||||||
*/
|
|
||||||
recordAttempt(engine: string, success: boolean, latency: number): void {
|
|
||||||
const health = this.getOrCreateHealth(engine);
|
|
||||||
|
|
||||||
health.totalAttempts++;
|
|
||||||
health.avgLatency = (health.avgLatency * (health.totalAttempts - 1) + latency) / health.totalAttempts;
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
health.successCount++;
|
|
||||||
health.lastSuccess = Date.now();
|
|
||||||
|
|
||||||
// Se teve sucesso e circuit estava aberto há tempo suficiente, tentar fechar
|
|
||||||
if (health.isOpen && health.openedAt) {
|
|
||||||
const timeSinceOpen = Date.now() - health.openedAt;
|
|
||||||
if (timeSinceOpen >= this.config.cooldownPeriod) {
|
|
||||||
health.isOpen = false;
|
|
||||||
health.openedAt = null;
|
|
||||||
console.log(`[CircuitBreaker] ${engine}: Circuit fechado após sucesso (${timeSinceOpen}ms desde abertura)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
health.failureCount++;
|
|
||||||
health.lastFailure = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limitar histórico às últimas N tentativas
|
|
||||||
if (health.totalAttempts > this.config.trackingWindow) {
|
|
||||||
// Remover tentativas mais antigas (simplificado: resetar contadores)
|
|
||||||
const successRate = health.successCount / health.totalAttempts;
|
|
||||||
health.successCount = Math.floor(successRate * this.config.trackingWindow);
|
|
||||||
health.failureCount = this.config.trackingWindow - health.successCount;
|
|
||||||
health.totalAttempts = this.config.trackingWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se deve abrir circuit breaker
|
|
||||||
if (!health.isOpen && health.totalAttempts >= this.config.minAttempts) {
|
|
||||||
const failureRate = health.failureCount / health.totalAttempts;
|
|
||||||
if (failureRate >= this.config.failureThreshold) {
|
|
||||||
health.isOpen = true;
|
|
||||||
health.openedAt = Date.now();
|
|
||||||
console.warn(`[CircuitBreaker] ${engine}: Circuit aberto (taxa de falha: ${(failureRate * 100).toFixed(1)}%)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.health.set(engine, health);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica se um motor está disponível (circuit não está aberto)
|
|
||||||
*/
|
|
||||||
isAvailable(engine: string): boolean {
|
|
||||||
const health = this.getOrCreateHealth(engine);
|
|
||||||
|
|
||||||
if (!health.isOpen) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se está aberto, verificar se passou tempo suficiente para tentar novamente
|
|
||||||
if (health.openedAt) {
|
|
||||||
const timeSinceOpen = Date.now() - health.openedAt;
|
|
||||||
if (timeSinceOpen >= this.config.cooldownPeriod) {
|
|
||||||
// Tentar fechar (half-open state)
|
|
||||||
health.isOpen = false;
|
|
||||||
this.health.set(engine, health);
|
|
||||||
console.log(`[CircuitBreaker] ${engine}: Tentando fechar circuit (half-open state)`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prioriza motores baseado em saúde
|
|
||||||
*/
|
|
||||||
prioritizeEngines(engines: string[]): string[] {
|
|
||||||
return [...engines].sort((a, b) => {
|
|
||||||
const healthA = this.getOrCreateHealth(a);
|
|
||||||
const healthB = this.getOrCreateHealth(b);
|
|
||||||
|
|
||||||
// Motores não disponíveis vão para o final
|
|
||||||
if (healthA.isOpen && !healthB.isOpen) return 1;
|
|
||||||
if (!healthA.isOpen && healthB.isOpen) return -1;
|
|
||||||
|
|
||||||
// Comparar taxa de sucesso
|
|
||||||
const successRateA = healthA.totalAttempts > 0
|
|
||||||
? healthA.successCount / healthA.totalAttempts
|
|
||||||
: 0.5;
|
|
||||||
const successRateB = healthB.totalAttempts > 0
|
|
||||||
? healthB.successCount / healthB.totalAttempts
|
|
||||||
: 0.5;
|
|
||||||
|
|
||||||
if (Math.abs(successRateA - successRateB) > 0.1) {
|
|
||||||
return successRateB - successRateA; // Maior taxa de sucesso primeiro
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se taxa similar, comparar latência
|
|
||||||
return healthA.avgLatency - healthB.avgLatency; // Menor latência primeiro
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtém estatísticas de um motor
|
|
||||||
*/
|
|
||||||
getHealth(engine: string): EngineHealth | null {
|
|
||||||
return this.health.get(engine) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtém ou cria health para um motor
|
|
||||||
*/
|
|
||||||
private getOrCreateHealth(engine: string): EngineHealth {
|
|
||||||
if (!this.health.has(engine)) {
|
|
||||||
this.health.set(engine, {
|
|
||||||
engine,
|
|
||||||
successCount: 0,
|
|
||||||
failureCount: 0,
|
|
||||||
totalAttempts: 0,
|
|
||||||
avgLatency: 0,
|
|
||||||
lastFailure: null,
|
|
||||||
lastSuccess: null,
|
|
||||||
isOpen: false,
|
|
||||||
openedAt: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.health.get(engine)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reseta estatísticas de um motor
|
|
||||||
*/
|
|
||||||
reset(engine: string): void {
|
|
||||||
this.health.delete(engine);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reseta todas as estatísticas
|
|
||||||
*/
|
|
||||||
resetAll(): void {
|
|
||||||
this.health.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtém todos os motores disponíveis
|
|
||||||
*/
|
|
||||||
getAvailableEngines(engines: string[]): string[] {
|
|
||||||
return engines.filter(engine => this.isAvailable(engine));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+90
-50
@@ -4,11 +4,9 @@ import {
|
|||||||
retryWithBackoff,
|
retryWithBackoff,
|
||||||
withTimeout,
|
withTimeout,
|
||||||
isRetryableError,
|
isRetryableError,
|
||||||
type RetryConfig,
|
|
||||||
calculateEngineTimeout,
|
calculateEngineTimeout,
|
||||||
TIMEOUT_CONFIG
|
TIMEOUT_CONFIG
|
||||||
} from '@/lib/retry-utils';
|
} from '@/lib/retry-utils';
|
||||||
import { EngineCircuitBreaker } from './engine-circuit-breaker';
|
|
||||||
import { FailureCache } from './failure-cache';
|
import { FailureCache } from './failure-cache';
|
||||||
|
|
||||||
export interface ScrapedContent {
|
export interface ScrapedContent {
|
||||||
@@ -16,7 +14,7 @@ export interface ScrapedContent {
|
|||||||
url: string;
|
url: string;
|
||||||
content: string;
|
content: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
cached?: boolean;
|
cached: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchConfig {
|
export interface SearchConfig {
|
||||||
@@ -58,7 +56,6 @@ class WebSearchService {
|
|||||||
private lastSearchTime = 0;
|
private lastSearchTime = 0;
|
||||||
private pendingRequest: PendingRequest | null = null;
|
private pendingRequest: PendingRequest | null = null;
|
||||||
private rateLimitQueue: PendingRequest[] = [];
|
private rateLimitQueue: PendingRequest[] = [];
|
||||||
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
|
|
||||||
private failureCache: FailureCache = new FailureCache();
|
private failureCache: FailureCache = new FailureCache();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -200,8 +197,9 @@ class WebSearchService {
|
|||||||
): Promise<ScrapedContent[]> {
|
): Promise<ScrapedContent[]> {
|
||||||
try {
|
try {
|
||||||
// Se SearchConfig foi fornecido, converter para formato Rust
|
// Se SearchConfig foi fornecido, converter para formato Rust
|
||||||
let rustConfig: any = undefined;
|
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||||
if (searchConfig) {
|
if (searchConfig) {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
rustConfig = {
|
rustConfig = {
|
||||||
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
|
||||||
total_sources_limit: searchConfig.totalSourcesLimit,
|
total_sources_limit: searchConfig.totalSourcesLimit,
|
||||||
@@ -213,6 +211,10 @@ class WebSearchService {
|
|||||||
})),
|
})),
|
||||||
user_custom_sites: searchConfig.userCustomSites,
|
user_custom_sites: searchConfig.userCustomSites,
|
||||||
excluded_domains: searchConfig.excludedDomains,
|
excluded_domains: searchConfig.excludedDomains,
|
||||||
|
searxng: {
|
||||||
|
enabled: settings.webSearch.searxng.enabled,
|
||||||
|
url: settings.webSearch.searxng.url,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ class WebSearchService {
|
|||||||
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
|
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
|
||||||
search_config: rustConfig,
|
search_config: rustConfig,
|
||||||
});
|
});
|
||||||
|
console.log('Raw scraped results from backend:', results?.length ?? 0);
|
||||||
return results || [];
|
return results || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao buscar conteúdo:', error);
|
console.error('Erro ao buscar conteúdo:', error);
|
||||||
@@ -255,30 +257,10 @@ class WebSearchService {
|
|||||||
// Se não há resultados parciais, retornar vazio imediatamente (sem retry)
|
// Se não há resultados parciais, retornar vazio imediatamente (sem retry)
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Obter configurações de motores do store
|
|
||||||
const settings = useSettingsStore.getState();
|
|
||||||
const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
|
||||||
let engineOrder = settings.webSearch.engineOrder || defaultEngines;
|
|
||||||
|
|
||||||
// Priorizar motores usando circuit breaker
|
let rustConfig: Record<string, unknown> | undefined = undefined;
|
||||||
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
|
|
||||||
|
|
||||||
// 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) {
|
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,
|
||||||
@@ -290,16 +272,29 @@ 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 da tentativa
|
// Log detalhado da tentativa
|
||||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
const searchStartTime = Date.now();
|
||||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
console.log(`\n[WebSearch] ========== METADATA SEARCH START ==========`);
|
||||||
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`);
|
console.log(`[WebSearch] Query: "${query}"`);
|
||||||
|
console.log(`[WebSearch] Limit: ${limit}`);
|
||||||
|
console.log(`[WebSearch] Timeout: ${adaptiveTimeout}ms`);
|
||||||
|
console.log(`[WebSearch] Attempt: ${attempt}`);
|
||||||
|
console.log(`[WebSearch] Config:`, searchConfig ? {
|
||||||
|
maxConcurrentTabs: searchConfig.maxConcurrentTabs,
|
||||||
|
totalSourcesLimit: searchConfig.totalSourcesLimit,
|
||||||
|
categories: searchConfig.categories.map(c => c.name),
|
||||||
|
excludedDomains: searchConfig.excludedDomains.length,
|
||||||
|
} : 'default');
|
||||||
|
|
||||||
// Executar com retry e timeout adaptativo
|
// Executar com retry e timeout adaptativo
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
@@ -315,7 +310,7 @@ class WebSearchService {
|
|||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
limit,
|
limit,
|
||||||
search_config: rustConfig,
|
search_config: rustConfig,
|
||||||
engineOrder: engineOrder, // Passar ordem de motores
|
engine_order: undefined, // Não usado mais, mantido para compatibilidade
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
@@ -323,16 +318,25 @@ class WebSearchService {
|
|||||||
retryTimeout,
|
retryTimeout,
|
||||||
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
|
||||||
|
|
||||||
// Registrar sucesso no circuit breaker para cada motor usado
|
// Log detalhado dos resultados
|
||||||
// (assumindo que o primeiro motor disponível foi usado)
|
console.log(`\n[WebSearch] ========== METADATA SEARCH RESULTS ==========`);
|
||||||
if (engineOrder.length > 0) {
|
console.log(`[WebSearch] Duration: ${duration}ms`);
|
||||||
const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem
|
console.log(`[WebSearch] Results count: ${result?.length ?? 0}`);
|
||||||
this.circuitBreaker.recordAttempt(usedEngine, true, duration);
|
|
||||||
|
if (result && result.length > 0) {
|
||||||
|
console.log(`[WebSearch] Top ${Math.min(5, result.length)} results:`);
|
||||||
|
result.slice(0, 5).forEach((meta, idx) => {
|
||||||
|
console.log(` [${idx + 1}] ${meta.title}`);
|
||||||
|
console.log(` URL: ${meta.url}`);
|
||||||
|
console.log(` Snippet: ${meta.snippet?.substring(0, 100) || 'N/A'}...`);
|
||||||
|
console.log(` Engine: ${meta.engine || 'unknown'}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No metadata results returned`);
|
||||||
}
|
}
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -340,12 +344,6 @@ class WebSearchService {
|
|||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
|
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
|
||||||
|
|
||||||
// Registrar falha no circuit breaker para cada motor tentado
|
|
||||||
// (assumindo que tentou todos os motores na ordem)
|
|
||||||
for (const engine of engineOrder) {
|
|
||||||
this.circuitBreaker.recordAttempt(engine, false, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -360,7 +358,7 @@ class WebSearchService {
|
|||||||
const results = retryResult.result || [];
|
const results = retryResult.result || [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
|
console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`);
|
||||||
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`);
|
console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}`);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -384,9 +382,8 @@ class WebSearchService {
|
|||||||
errorMsg
|
errorMsg
|
||||||
);
|
);
|
||||||
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
|
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
|
||||||
// Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores
|
// Retornar vazio mas não quebrar o fluxo
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
||||||
@@ -395,7 +392,6 @@ class WebSearchService {
|
|||||||
errorMsg
|
errorMsg
|
||||||
);
|
);
|
||||||
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,6 +497,13 @@ class WebSearchService {
|
|||||||
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
||||||
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
|
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
|
||||||
|
|
||||||
|
const smartSearchStart = Date.now();
|
||||||
|
console.log(`\n[WebSearch] ========== SMART SEARCH RAG START ==========`);
|
||||||
|
console.log(`[WebSearch] Query: "${query}"`);
|
||||||
|
console.log(`[WebSearch] Limit: ${limit}`);
|
||||||
|
console.log(`[WebSearch] Round: ${round}`);
|
||||||
|
console.log(`[WebSearch] Timeout: ${roundTimeout}ms`);
|
||||||
|
|
||||||
// Buscar metadados com retry e timeout adaptativo
|
// Buscar metadados com retry e timeout adaptativo
|
||||||
const metas = await this.executeMetadataSearch(
|
const metas = await this.executeMetadataSearch(
|
||||||
query,
|
query,
|
||||||
@@ -513,7 +516,14 @@ class WebSearchService {
|
|||||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||||
const topUrls = metas.map(m => m.url).slice(0, limit);
|
const topUrls = metas.map(m => m.url).slice(0, limit);
|
||||||
|
|
||||||
|
console.log(`[WebSearch] Selected ${topUrls.length} URLs for scraping:`);
|
||||||
|
topUrls.forEach((url, idx) => {
|
||||||
|
console.log(` [${idx + 1}] ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
if (topUrls.length === 0) {
|
if (topUrls.length === 0) {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No URLs to scrape, returning metadata only`);
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
// Retornar resultados parciais (metadados vazios mas sem erro)
|
// Retornar resultados parciais (metadados vazios mas sem erro)
|
||||||
return { metadata: metas, contents: [] };
|
return { metadata: metas, contents: [] };
|
||||||
}
|
}
|
||||||
@@ -521,6 +531,8 @@ class WebSearchService {
|
|||||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||||
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
||||||
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
||||||
|
console.log(`[WebSearch] Starting scraping with timeout: ${scrapingTimeout}ms`);
|
||||||
|
const scrapingStart = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contents = await withTimeout(
|
const contents = await withTimeout(
|
||||||
@@ -528,6 +540,34 @@ class WebSearchService {
|
|||||||
scrapingTimeout,
|
scrapingTimeout,
|
||||||
`Timeout ao fazer scraping para "${query}"`
|
`Timeout ao fazer scraping para "${query}"`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrapingDuration = Date.now() - scrapingStart;
|
||||||
|
const totalDuration = Date.now() - smartSearchStart;
|
||||||
|
|
||||||
|
// Log detalhado do scraping
|
||||||
|
console.log(`\n[WebSearch] ========== SCRAPING RESULTS ==========`);
|
||||||
|
console.log(`[WebSearch] Scraping duration: ${scrapingDuration}ms`);
|
||||||
|
console.log(`[WebSearch] Total duration: ${totalDuration}ms`);
|
||||||
|
console.log(`[WebSearch] Contents extracted: ${contents?.length ?? 0}`);
|
||||||
|
|
||||||
|
if (contents && contents.length > 0) {
|
||||||
|
contents.forEach((content, idx) => {
|
||||||
|
console.log(`\n [${idx + 1}] ${content.title}`);
|
||||||
|
console.log(` URL: ${content.url}`);
|
||||||
|
const contentLength = content.markdown?.length || 0;
|
||||||
|
const snippetLength = content.snippet?.length || 0;
|
||||||
|
console.log(` Markdown length: ${contentLength} chars`);
|
||||||
|
console.log(` Snippet length: ${snippetLength} chars`);
|
||||||
|
if (content.markdown) {
|
||||||
|
const preview = content.markdown.substring(0, 200);
|
||||||
|
console.log(` Preview: ${preview}${contentLength > 200 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[WebSearch] ⚠️ No content extracted from URLs`);
|
||||||
|
}
|
||||||
|
console.log(`[WebSearch] ============================================\n`);
|
||||||
|
|
||||||
return { metadata: metas, contents: contents || [] };
|
return { metadata: metas, contents: contents || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
||||||
|
|||||||
Generated
+1169
-332
File diff suppressed because it is too large.
Load diff
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user