diff --git a/app/page.tsx b/app/page.tsx index 915840d..863531a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -23,12 +23,10 @@ export default function Home() { localStorage.setItem("ollahub_setup_complete", "true"); } - // Redirecionar para chat após um breve delay para mostrar feedback - const timer = setTimeout(() => { - startTransition(() => { - router.push("/chat"); - }); - }, 500); + // Redirecionar para chat imediatamente (startTransition já garante não-bloqueio) + startTransition(() => { + router.push("/chat"); + }); return () => clearTimeout(timer); } diff --git a/components/setup/model-selector.tsx b/components/setup/model-selector.tsx index 791988e..498266c 100644 --- a/components/setup/model-selector.tsx +++ b/components/setup/model-selector.tsx @@ -115,19 +115,21 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps }; const handleDownload = async () => { + // Optimistic UI: atualizar estado imediatamente antes da chamada Tauri setIsDownloading(true); setDownloadProgress(0); setDownloadStatus("Iniciando download..."); - try { - await invoke('pull_model', { name: selectedModelId }); - // O status será atualizado pelo listener quando o download completar - // Não precisamos setar aqui pois o listener já faz isso - } catch (error) { - console.error("Download failed", error); - setDownloadStatus("Falha no download. Tente novamente."); - setDownloadProgress(0); - setIsDownloading(false); - } + + // Chamar Tauri em background (não bloquear) + invoke('pull_model', { name: selectedModelId }) + .catch((error) => { + console.error("Download failed", error); + setDownloadStatus("Falha no download. Tente novamente."); + setDownloadProgress(0); + setIsDownloading(false); + }); + // O status será atualizado pelo listener quando o download completar + // Não precisamos aguardar aqui pois o listener já faz isso }; return ( diff --git a/components/titlebar.tsx b/components/titlebar.tsx index e091f7f..575c018 100644 --- a/components/titlebar.tsx +++ b/components/titlebar.tsx @@ -4,9 +4,12 @@ import { useEffect, useState } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { Minus, Square, X } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useSystemHealth } from '@/hooks/use-system-health'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; export function TitleBar() { const [isMaximized, setIsMaximized] = useState(false); + const health = useSystemHealth(); useEffect(() => { let isMounted = true; @@ -94,6 +97,28 @@ export function TitleBar() { OllaHub + + {/* Indicador de saúde do sistema */} + + + +
+ + +

{health.message}

+

+ RAM: {health.ram_percent.toFixed(1)}% | CPU: {health.cpu_percent.toFixed(1)}% +

+
+ +
{/* Controles de janela */} diff --git a/hooks/use-ollama-download.ts b/hooks/use-ollama-download.ts index 84f3176..ce3c708 100644 --- a/hooks/use-ollama-download.ts +++ b/hooks/use-ollama-download.ts @@ -109,21 +109,24 @@ export function useOllamaDownload() { return; } - try { - await runInstaller(state.filePath); - setState(prev => ({ - ...prev, - isInstalled: true, - })); - - // Iniciar verificação automática em segundo plano - startAutoCheck(); - } catch (error) { - setState(prev => ({ - ...prev, - downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador', - })); - } + // 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) + .catch((error) => { + setState(prev => ({ + ...prev, + downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador', + isInstalled: false, // Reverter se falhar + })); + }); }; const checkOllama = async (): Promise => { diff --git a/hooks/use-system-health.ts b/hooks/use-system-health.ts new file mode 100644 index 0000000..bfaf03b --- /dev/null +++ b/hooks/use-system-health.ts @@ -0,0 +1,53 @@ +'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({ + status: 'healthy', + ram_percent: 0, + cpu_percent: 0, + message: 'Sistema saudável', + }); + const [hasShownCriticalToast, setHasShownCriticalToast] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + const unlistenPromise = listen('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', + duration: 5000, + }); + 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; +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94bec03..086b188 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,7 +40,7 @@ use web_scraper::{ use headless_chrome::Browser; use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction}; use sources_config::{SourcesConfig, load_sources_config, save_sources_config}; -use system_monitor::{SystemStats, SystemMonitorState, GpuInfo, GpuStats}; +use system_monitor::{SystemStats, SystemMonitorState, GpuInfo, GpuStats, SystemHealth, HealthStatus}; // CommandExt é importado localmente onde necessário @@ -181,6 +181,9 @@ type BrowserState = Arc>>>; // File Lock Manager - previne corrupção de dados em escritas concorrentes type FileLockMap = Arc>>>>; +// Ollama Process State - rastreia processo Ollama spawnado pelo app +type OllamaProcessState = Arc>>; + // Helper to send JSON-RPC request to MCP server fn send_jsonrpc_request( child: &mut Child, @@ -1062,8 +1065,11 @@ fn get_operating_system() -> String { #[command] fn start_system_monitor(window: Window) { + let window_clone = window.clone(); std::thread::spawn(move || { let mut sys = System::new_all(); + let mut last_health_status: Option = None; + loop { sys.refresh_cpu_all(); sys.refresh_memory(); @@ -1078,20 +1084,75 @@ fn start_system_monitor(window: Window) { memory_total, }; - if window.emit("system-stats", stats).is_err() { + if window_clone.emit("system-stats", stats).is_err() { break; // Stop if window is closed } + + // Verificar saúde do sistema e emitir apenas quando mudar + if let Ok(mut monitor) = monitor_state.lock() { + let health = monitor.get_health(); + if last_health_status.as_ref() != Some(&health.status) { + // Status mudou, emitir evento + if window_clone.emit("system-state-change", health.clone()).is_err() { + break; + } + last_health_status = Some(health.status); + } + } std::thread::sleep(Duration::from_secs(2)); } }); } +/// Inicia monitoramento contínuo da saúde do sistema +#[command] +fn start_health_monitor(app_handle: AppHandle) -> Result<(), String> { + let monitor_state = app_handle.state::>>(); + let window = app_handle.get_webview_window("main") + .ok_or_else(|| "Main window not found".to_string())?; + + // Iniciar monitoramento em background + tauri::async_runtime::spawn(async move { + let mut last_status: Option = None; + + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + let health = { + let mut monitor = match monitor_state.lock() { + Ok(m) => m, + Err(_) => break, + }; + monitor.get_health() + }; + + // Emitir evento apenas quando status mudar (histerese já aplicada) + if last_status.as_ref() != Some(&health.status) { + if window.emit("system-state-change", health.clone()).is_err() { + break; + } + last_status = Some(health.status); + } + } + }); + + Ok(()) +} + #[command] fn list_local_models() -> Vec { - let output = Command::new("ollama") - .arg("list") - .output(); + let mut cmd = Command::new("ollama"); + cmd.arg("list"); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let output = cmd.output(); match output { Ok(output) => { @@ -1127,10 +1188,17 @@ fn list_local_models() -> Vec { #[command] async fn delete_model(name: String) -> Result<(), String> { - let output = Command::new("ollama") - .arg("rm") - .arg(&name) - .output() + let mut cmd = Command::new("ollama"); + cmd.arg("rm").arg(&name); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let output = cmd.output() .map_err(|e| e.to_string())?; if output.status.success() { @@ -1142,9 +1210,17 @@ async fn delete_model(name: String) -> Result<(), String> { #[command] fn check_if_model_installed(name: String) -> bool { - let output = Command::new("ollama") - .arg("list") - .output(); + let mut cmd = Command::new("ollama"); + cmd.arg("list"); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let output = cmd.output(); match output { Ok(output) => { @@ -1257,12 +1333,20 @@ async fn install_gguf_model( } // Tentar usar ollama create com Modelfile - let create_output = Command::new("ollama") - .arg("create") + let mut cmd = Command::new("ollama"); + cmd.arg("create") .arg(&final_model_name) .arg("-f") - .arg(&modelfile_path) - .output(); + .arg(&modelfile_path); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let create_output = cmd.output(); match create_output { Ok(output) => { @@ -1280,8 +1364,17 @@ async fn install_gguf_model( log::warn!("Primeira tentativa falhou: {}. Tentando método alternativo...", stderr); // Método alternativo: criar modelo usando FROM diretamente - let alt_output = Command::new("ollama") - .arg("create") + let mut alt_cmd = Command::new("ollama"); + alt_cmd.arg("create"); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + alt_cmd.creation_flags(CREATE_NO_WINDOW); + } + + let alt_output = alt_cmd .arg(&final_model_name) .arg("--file") .arg(&dest_file) @@ -1564,7 +1657,17 @@ fn format_speed(bytes_per_sec: f64) -> String { #[command] fn check_ollama_installed() -> bool { - match Command::new("ollama").arg("--version").output() { + let mut cmd = Command::new("ollama"); + cmd.arg("--version"); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + match cmd.output() { Ok(output) => output.status.success(), Err(_) => false, } @@ -1586,9 +1689,38 @@ struct OllamaCheckResult { status: String, // "not_installed" | "installed_stopped" | "running" } +/// Faz polling do serviço Ollama até estar disponível +/// Retorna Ok(()) quando o serviço responder 200 OK, ou erro após timeout +async fn poll_ollama_ready(timeout_secs: u64) -> Result<(), String> { + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + + loop { + match reqwest::get("http://localhost:11434").await { + Ok(resp) => { + if resp.status().is_success() { + log::info!("Ollama está pronto e respondendo"); + return Ok(()); + } + } + Err(_) => { + // Serviço ainda não está disponível, continuar polling + } + } + + // Verificar timeout + if start.elapsed() >= timeout { + return Err(format!("Timeout: Ollama não ficou pronto em {} segundos", timeout_secs)); + } + + // Aguardar 2 segundos antes da próxima tentativa + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + /// Inicia o Ollama automaticamente se estiver instalado mas não estiver rodando #[command] -async fn auto_start_ollama() -> Result { +async fn auto_start_ollama(ollama_process: State<'_, OllamaProcessState>) -> Result { // Verificar se está instalado let installed = check_ollama_installed(); if !installed { @@ -1605,7 +1737,7 @@ async fn auto_start_ollama() -> Result { // Tentar iniciar log::info!("Iniciando Ollama automaticamente..."); - match start_ollama_server() { + match start_ollama_server(ollama_process) { Ok(_) => { // Aguardar um pouco para o servidor iniciar tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; @@ -1657,7 +1789,16 @@ async fn check_ollama_full() -> Result { } #[command] -fn start_ollama_server() -> Result<(), String> { +fn start_ollama_server(ollama_process: State<'_, OllamaProcessState>) -> Result<(), String> { + // Verificar se já há processo rastreado + { + let process_state = ollama_process.lock() + .map_err(|e| format!("Failed to lock Ollama process state: {}", e))?; + if process_state.is_some() { + return Err("Ollama já está rodando (processo rastreado)".to_string()); + } + } + let mut cmd = Command::new("ollama"); cmd.arg("serve"); @@ -1668,9 +1809,16 @@ fn start_ollama_server() -> Result<(), String> { cmd.creation_flags(CREATE_NO_WINDOW); } - // Spawn detached - cmd.spawn() + // Spawn e armazenar handle + let child = cmd.spawn() .map_err(|e| format!("Failed to start ollama: {}", e))?; + + // Armazenar Child handle no state + { + let mut process_state = ollama_process.lock() + .map_err(|e| format!("Failed to lock Ollama process state: {}", e))?; + *process_state = Some(child); + } Ok(()) } @@ -2244,8 +2392,27 @@ fn check_mcp_server_available( // ========== Web Scraper Commands ========== +/// Verifica se pode criar browser baseado no estado do sistema +fn can_create_browser(monitor_state: &State<'_, Arc>>) -> Result { + let monitor = monitor_state.lock() + .map_err(|e| format!("Failed to lock monitor state: {}", e))?; + let health = monitor.get_health(); + + match health.status { + HealthStatus::Critical => { + log::warn!("Sistema em estado crítico (RAM: {:.1}%, CPU: {:.1}%), bloqueando criação de browser", health.ram_percent, health.cpu_percent); + Ok(false) + } + _ => Ok(true), + } +} + /// Obtém ou cria uma instância do Browser (singleton) -pub fn get_or_create_browser(state: State) -> Result, String> { +/// Verifica estado do sistema antes de criar novo browser +pub fn get_or_create_browser( + state: State, + monitor_state: Option>>>, +) -> Result, String> { let mut browser_opt = state.lock().map_err(|e| format!("Erro ao acessar estado do browser: {}", e))?; if let Some(ref browser) = *browser_opt { @@ -2257,6 +2424,13 @@ pub fn get_or_create_browser(state: State) -> Result, } } + // Verificar estado do sistema antes de criar novo browser + if let Some(monitor) = monitor_state { + if !can_create_browser(&monitor)? { + return Err("Sistema sobrecarregado: não é possível criar browser no momento. Use reqwest como alternativa.".to_string()); + } + } + // Criar nova instância let browser = Arc::new( create_browser() @@ -2275,26 +2449,50 @@ async fn search_and_extract_content( excluded_domains: Option>, search_config: Option, state: State<'_, BrowserState>, + monitor_state: State<'_, Arc>>, ) -> Result, String> { if query.trim().is_empty() { return Err("Query não pode estar vazia".to_string()); } - let browser = get_or_create_browser(state)?; + // Verificar estado do sistema e ajustar configuração + let health = { + let mut monitor = monitor_state.lock() + .map_err(|e| format!("Failed to lock monitor state: {}", e))?; + monitor.get_health() + }; - // Se SearchConfig foi fornecido, usar a nova função - if let Some(config) = search_config { - search_and_scrape_with_config(&query, &config, browser) - .await - .map_err(|e| format!("Erro ao buscar e extrair conteúdo: {}", e)) - } else { - // Backward compatibility: usar configuração padrão - let limit = limit.unwrap_or(3); - let excluded_domains = excluded_domains.unwrap_or_default(); - search_and_scrape(&query, limit, browser, excluded_domains) - .await - .map_err(|e| format!("Erro ao buscar e extrair conteúdo: {}", e)) + // Ajustar configuração baseado no estado do sistema + let mut config = search_config.unwrap_or_else(|| SearchConfig { + max_concurrent_tabs: 5, + total_sources_limit: limit.unwrap_or(3), + categories: Vec::new(), + user_custom_sites: Vec::new(), + excluded_domains: excluded_domains.unwrap_or_default(), + }); + + // Aplicar throttling baseado no estado do sistema + match health.status { + HealthStatus::Critical => { + // Bloquear browser completamente, usar apenas reqwest + return Err("Sistema sobrecarregado: recursos limitados. Tente novamente em alguns instantes.".to_string()); + } + HealthStatus::Warning => { + // Limitar concorrência drasticamente + config.max_concurrent_tabs = 1; + log::info!("Sistema em warning, limitando concorrência do scraper para {}", config.max_concurrent_tabs); + } + HealthStatus::Healthy => { + // Configuração normal + } } + + let browser = get_or_create_browser(state, Some(monitor_state))?; + + // Usar a função com configuração ajustada + search_and_scrape_with_config(&query, &config, browser) + .await + .map_err(|e| format!("Erro ao buscar e extrair conteúdo: {}", e)) } /// Extrai conteúdo de uma URL específica @@ -2302,6 +2500,7 @@ async fn search_and_extract_content( async fn extract_url_content( url: String, state: State<'_, BrowserState>, + monitor_state: State<'_, Arc>>, ) -> Result { if url.trim().is_empty() { return Err("URL não pode estar vazia".to_string()); @@ -2312,7 +2511,18 @@ async fn extract_url_content( return Err("URL deve começar com http:// ou https://".to_string()); } - let browser = get_or_create_browser(state)?; + // Verificar estado do sistema + let health = { + let mut monitor = monitor_state.lock() + .map_err(|e| format!("Failed to lock monitor state: {}", e))?; + monitor.get_health() + }; + + if health.status == HealthStatus::Critical { + return Err("Sistema sobrecarregado: recursos limitados. Tente novamente em alguns instantes.".to_string()); + } + + let browser = get_or_create_browser(state, Some(monitor_state))?; scrape_url(&url, browser) .await @@ -2403,7 +2613,7 @@ async fn scrape_urls( return Ok(Vec::new()); } - let browser = get_or_create_browser(state)?; + let browser = get_or_create_browser(state, None)?; scrape_urls_bulk(urls, browser) .await @@ -2420,6 +2630,61 @@ fn reset_browser(state: State<'_, BrowserState>) -> Result<(), String> { Ok(()) } +/// Limpa todos os processos filhos (MCP, Chrome headless, Ollama) +/// Chamado durante o shutdown do app para evitar processos zumbis +fn cleanup_all_child_processes( + app_handle: &AppHandle, +) -> Result<(), String> { + log::info!("Iniciando cleanup de processos filhos..."); + + // 1. Limpar processos MCP + if let Some(mcp_processes) = app_handle.try_state::() { + let mut processes_map = mcp_processes.lock() + .map_err(|e| format!("Failed to lock MCP processes map: {}", e))?; + let mut killed_count = 0; + for (_name, mut handle) in processes_map.drain() { + if let Err(e) = handle.child.kill() { + log::warn!("Erro ao matar processo MCP: {}", e); + } else { + let _ = handle.child.wait(); + killed_count += 1; + } + } + if killed_count > 0 { + log::info!("{} processos MCP encerrados", killed_count); + } + } + + // 2. Limpar processo Ollama + if let Some(ollama_state) = app_handle.try_state::() { + let mut process_state = ollama_state.lock() + .map_err(|e| format!("Failed to lock Ollama process state: {}", e))?; + if let Some(mut child) = process_state.take() { + if let Err(e) = child.kill() { + log::warn!("Erro ao matar processo Ollama: {}", e); + } else { + let _ = child.wait(); + log::info!("Processo Ollama encerrado"); + } + } + } + + // 3. Limpar processos Chrome headless + match force_kill_browser() { + Ok(count) => { + if count > 0 { + log::info!("{} processos Chrome headless encerrados", count); + } + } + Err(e) => { + log::warn!("Erro ao encerrar processos Chrome headless: {}", e); + } + } + + log::info!("Cleanup de processos filhos concluído"); + Ok(()) +} + /// Força o encerramento apenas de processos Chrome/Chromium headless criados pelo app /// Seguro: não mata o navegador pessoal do usuário #[command] @@ -2892,9 +3157,9 @@ async fn download_installer( Ok(dest_path.to_string_lossy().to_string()) } -/// Executa o instalador baixado +/// Executa o instalador baixado e faz polling até Ollama estar pronto #[command] -fn run_installer(file_path: String) -> Result<(), String> { +async fn run_installer(app_handle: AppHandle, file_path: String) -> Result<(), String> { let path = PathBuf::from(&file_path); if !path.exists() { @@ -2903,15 +3168,20 @@ fn run_installer(file_path: String) -> Result<(), String> { #[cfg(target_os = "windows")] { - // No Windows, executar o .exe diretamente - Command::new(&path) - .spawn() + // No Windows, executar o .exe diretamente com flags silenciosas + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = Command::new(&path); + cmd.creation_flags(CREATE_NO_WINDOW); + // Adicionar flag /S para instalação silenciosa (se suportado pelo instalador) + cmd.arg("/S"); + cmd.spawn() .map_err(|e| format!("Failed to run installer: {}", e))?; } #[cfg(target_os = "linux")] { - // No Linux, dar permissão de execução e executar + // No Linux, dar permissão de execução e executar com flags silenciosas use std::os::unix::fs::PermissionsExt; let mut perms = fs::metadata(&path) .map_err(|e| format!("Failed to get file metadata: {}", e))? @@ -2920,22 +3190,68 @@ fn run_installer(file_path: String) -> Result<(), String> { fs::set_permissions(&path, perms) .map_err(|e| format!("Failed to set executable permissions: {}", e))?; - Command::new(&path) - .spawn() - .map_err(|e| format!("Failed to run installer: {}", e))?; + // Tentar executar com --quiet se for um script .sh + let path_str = path.to_string_lossy(); + if path_str.ends_with(".sh") { + Command::new("sh") + .arg(&path) + .arg("--quiet") + .spawn() + .map_err(|e| format!("Failed to run installer: {}", e))?; + } else { + // Para outros formatos, executar diretamente + Command::new(&path) + .spawn() + .map_err(|e| format!("Failed to run installer: {}", e))?; + } } #[cfg(target_os = "macos")] { - // No macOS, executar o .zip (precisa ser extraído primeiro) - // Por enquanto, apenas abrir o arquivo - Command::new("open") - .arg(&path) - .spawn() - .map_err(|e| format!("Failed to open installer: {}", e))?; + // No macOS, usar installer com flags silenciosas + // Se for .pkg, usar installer com --quiet + let path_str = path.to_string_lossy(); + if path_str.ends_with(".pkg") { + Command::new("installer") + .arg("-pkg") + .arg(&path) + .arg("-target") + .arg("/") + .arg("-verboseR") + .spawn() + .map_err(|e| format!("Failed to run installer: {}", e))?; + } else { + // Para outros formatos, abrir normalmente + Command::new("open") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to open installer: {}", e))?; + } } log::info!("Instalador executado: {:?}", path); + + // Iniciar polling em background para detectar quando Ollama estiver pronto + let window = app_handle.get_webview_window("main"); + tauri::async_runtime::spawn(async move { + // Aguardar um pouco para a instalação começar + tokio::time::sleep(Duration::from_secs(3)).await; + + // Fazer polling por até 60 segundos + match poll_ollama_ready(60).await { + Ok(_) => { + log::info!("Ollama está pronto após instalação"); + // Emitir evento para o frontend + if let Some(w) = window { + let _ = w.emit("ollama-ready", ()); + } + } + Err(e) => { + log::warn!("Polling timeout: {}", e); + } + } + }); + Ok(()) } @@ -3233,12 +3549,24 @@ async fn chat_stream( model: String, system_prompt: Option, enable_rag: Option, + monitor_state: State<'_, Arc>>, ) -> Result { use uuid::Uuid; use ollama_client::OllamaClient; use futures_util::StreamExt; use db::{Database, ChatSession, ChatMessage}; + // Verificar estado do sistema antes de processar + let health = { + let mut monitor = monitor_state.lock() + .map_err(|e| format!("Failed to lock monitor state: {}", e))?; + monitor.get_health() + }; + + if health.status == HealthStatus::Critical { + return Err("Sistema sobrecarregado: aguarde alguns instantes antes de fazer novas requisições.".to_string()); + } + // Gerar ou usar session_id existente let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let enable_rag = enable_rag.unwrap_or(false); @@ -3617,6 +3945,52 @@ fn prune_context( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Verificação de instância única usando lock file + let lock_file_path = dirs::data_local_dir() + .map(|mut path| { + path.push("OllaHub"); + std::fs::create_dir_all(&path).ok(); + path.push("ollahub.lock"); + path + }); + + if let Some(lock_path) = lock_file_path { + // Tentar criar lock file exclusivo + // No Windows, isso falhará se o arquivo já existir e estiver em uso + // No Unix, podemos usar flock ou similar + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + if let Ok(file) = OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + // Lock file criado com sucesso - manter aberto durante a execução + std::mem::forget(file); // Manter arquivo aberto + } else { + eprintln!("OllaHub já está em execução. Encerrando..."); + std::process::exit(1); + } + } + + #[cfg(windows)] + { + use std::fs::OpenOptions; + if let Ok(_file) = OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + // Lock file criado com sucesso + } else { + eprintln!("OllaHub já está em execução. Encerrando..."); + std::process::exit(1); + } + } + } + tauri::Builder::default() .setup(|app| { if cfg!(debug_assertions) { @@ -3636,8 +4010,13 @@ pub fn run() { // Modificar comportamento de fechar janela (ocultar ao invés de fechar) if let Some(window) = app.get_webview_window("main") { let window_clone = window.clone(); + let app_handle_cleanup = app.handle().clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // Limpar processos filhos antes de esconder + if let Err(e) = cleanup_all_child_processes(&app_handle_cleanup) { + log::warn!("Erro durante cleanup de processos: {}", e); + } // Ocultar ao invés de fechar let _ = window_clone.hide(); api.prevent_close(); @@ -3661,14 +4040,23 @@ pub fn run() { let app_handle = app.handle().clone(); let scheduler_clone = scheduler_state.clone(); + // Inicializar Ollama Process State + let ollama_process_state: OllamaProcessState = Arc::new(Mutex::new(None)); + app.manage(ollama_process_state.clone()); + // Inicializar Ollama automaticamente se estiver instalado + let app_handle_ollama = app.handle().clone(); tauri::async_runtime::spawn(async move { // Aguardar um pouco para o app inicializar completamente tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // Tentar iniciar Ollama automaticamente - if let Err(e) = auto_start_ollama().await { - log::warn!("Falha ao iniciar Ollama automaticamente: {}", e); + if let Some(ollama_state) = app_handle_ollama.try_state::() { + if let Err(e) = auto_start_ollama(ollama_state).await { + log::warn!("Falha ao iniciar Ollama automaticamente: {}", e); + } + } else { + log::warn!("OllamaProcessState não encontrado no app state"); } }); @@ -3690,7 +4078,12 @@ pub fn run() { // Inicializar System Monitor State let monitor_state: Arc> = Arc::new(Mutex::new(SystemMonitorState::new())); - app.manage(monitor_state); + app.manage(monitor_state.clone()); + + // Iniciar monitoramento de saúde do sistema + if let Err(e) = start_health_monitor(app.handle().clone()) { + log::warn!("Falha ao iniciar monitor de saúde: {}", e); + } Ok(()) }) @@ -3709,6 +4102,7 @@ pub fn run() { open_gguf_file_dialog, start_ollama_server, start_system_monitor, + start_health_monitor, get_gpu_stats, list_local_models, delete_model, diff --git a/src-tauri/src/system_monitor.rs b/src-tauri/src/system_monitor.rs index fb9d388..26906b8 100644 --- a/src-tauri/src/system_monitor.rs +++ b/src-tauri/src/system_monitor.rs @@ -1,5 +1,33 @@ use serde::Serialize; use sysinfo::System; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::Duration; + +/// Status de saúde do sistema +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum HealthStatus { + Healthy, + Warning, + Critical, +} + +/// Estado de saúde do sistema com histerese +#[derive(Serialize, Clone, Debug)] +pub struct SystemHealth { + pub status: HealthStatus, + pub ram_percent: f32, + pub cpu_percent: f32, + pub message: String, +} + +/// Estado interno para monitoramento com histerese +struct HealthMonitorState { + current_status: AtomicU8, // 0=Healthy, 1=Warning, 2=Critical + consecutive_warnings: AtomicU8, + consecutive_critical: AtomicU8, +} /// Informações sobre uma GPU #[derive(Serialize, Clone, Debug)] @@ -58,6 +86,7 @@ pub struct SystemMonitorState { system: System, #[allow(dead_code)] last_cpu_check: std::time::Instant, + health_state: Arc, } impl SystemMonitorState { @@ -68,9 +97,111 @@ impl SystemMonitorState { Self { system, last_cpu_check: std::time::Instant::now(), + health_state: Arc::new(HealthMonitorState { + current_status: AtomicU8::new(0), // Healthy + consecutive_warnings: AtomicU8::new(0), + consecutive_critical: AtomicU8::new(0), + }), } } + /// Obtém o estado de saúde do sistema com histerese + pub fn get_health(&mut self) -> SystemHealth { + self.system.refresh_all(); + self.system.refresh_cpu_all(); + + // Pequeno delay para cálculo preciso de CPU + std::thread::sleep(Duration::from_millis(100)); + self.system.refresh_cpu_all(); + + let cpu_percent = self.system.global_cpu_usage(); + let ram_total = self.system.total_memory(); + let ram_used = self.system.used_memory(); + let ram_percent = if ram_total > 0 { + (ram_used as f32 / ram_total as f32) * 100.0 + } else { + 0.0 + }; + + // Determinar status baseado em thresholds com histerese + // Thresholds: Warning: RAM > 80% ou CPU > 85%, Critical: RAM > 90% ou CPU > 95% + // Histerese: precisa de 3 leituras consecutivas para mudar de status (evita alternância rápida) + + let should_be_warning = ram_percent > 80.0 || cpu_percent > 85.0; + let should_be_critical = ram_percent > 90.0 || cpu_percent > 95.0; + + let current_status = self.health_state.current_status.load(Ordering::Relaxed); + let mut new_status = current_status; + + if should_be_critical { + let count = self.health_state.consecutive_critical.fetch_add(1, Ordering::Relaxed) + 1; + if count >= 3 { + // 3 leituras consecutivas críticas + new_status = 2; // Critical + self.health_state.current_status.store(2, Ordering::Relaxed); + self.health_state.consecutive_warnings.store(0, Ordering::Relaxed); + } else if current_status == 2 { + // Já está em Critical, manter contador + self.health_state.consecutive_critical.store(2, Ordering::Relaxed); + } + } else if should_be_warning { + // Reset contador crítico se não está mais crítico + self.health_state.consecutive_critical.store(0, Ordering::Relaxed); + + let count = self.health_state.consecutive_warnings.fetch_add(1, Ordering::Relaxed) + 1; + if count >= 3 { + if current_status == 0 { + // 3 leituras consecutivas de warning para entrar em Warning + new_status = 1; // Warning + self.health_state.current_status.store(1, Ordering::Relaxed); + } else if current_status == 1 { + // Já está em Warning, manter contador + self.health_state.consecutive_warnings.store(2, Ordering::Relaxed); + } else if current_status == 2 { + // Estava em Critical, pode voltar para Warning após 3 leituras + new_status = 1; // Warning + self.health_state.current_status.store(1, Ordering::Relaxed); + } + } + } else { + // Sistema saudável - reset contadores e voltar para Healthy após 3 leituras + self.health_state.consecutive_critical.store(0, Ordering::Relaxed); + let count = self.health_state.consecutive_warnings.fetch_sub(1, Ordering::Relaxed); + if count <= 1 { + // Voltou para saudável após 3 leituras consecutivas + self.health_state.consecutive_warnings.store(0, Ordering::Relaxed); + if current_status != 0 { + new_status = 0; // Healthy + self.health_state.current_status.store(0, Ordering::Relaxed); + } + } + } + + let status = match new_status { + 2 => HealthStatus::Critical, + 1 => HealthStatus::Warning, + _ => HealthStatus::Healthy, + }; + + let message = match status { + HealthStatus::Critical => format!("Sistema sobrecarregado (RAM: {:.1}%, CPU: {:.1}%)", ram_percent, cpu_percent), + HealthStatus::Warning => format!("Recursos elevados (RAM: {:.1}%, CPU: {:.1}%)", ram_percent, cpu_percent), + HealthStatus::Healthy => format!("Sistema saudável (RAM: {:.1}%, CPU: {:.1}%)", ram_percent, cpu_percent), + }; + + SystemHealth { + status, + ram_percent, + cpu_percent, + message, + } + } + + /// Obtém uma referência ao estado de saúde para monitoramento contínuo + pub fn health_state(&self) -> Arc { + self.health_state.clone() + } + pub fn get_stats(&mut self) -> SystemStats { // Refresh system info self.system.refresh_all();