feat: enhance UI and functionality across chat and settings

- Added custom scrollbar styles for improved aesthetics and usability across the interface.
- Implemented responsive adjustments for chat sidebar and input components to enhance mobile experience.
- Integrated tooltip components in select elements for better user guidance and information display.
- Updated chat input and message components for improved styling and functionality.
- Enhanced settings page with semantic query expansion options and improved model selection UI.
- Refactored sidebar list to include better date formatting and tooltip support for session titles.
- Introduced GPU stats monitoring in the system panel for better hardware insights.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 19:04:46 -03:00
1 parent 84ea2dec19
commit e5f062040c
33 files changed
+4670 -465

No files matched your search

+249 -12
View File
@@ -114,6 +114,7 @@ dependencies = [
"readability",
"regex",
"reqwest 0.12.24",
"rfd",
"scraper",
"serde",
"serde_json",
@@ -139,6 +140,24 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "ashpd"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093"
dependencies = [
"async-fs",
"async-net",
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.8.5",
"serde",
"serde_repr",
"url",
"zbus 4.4.0",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -177,6 +196,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -206,6 +236,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-net"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
dependencies = [
"async-io",
"blocking",
"futures-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -375,6 +416,12 @@ dependencies = [
"wyz",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -2579,6 +2626,15 @@ dependencies = [
"time",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.11.0"
@@ -2758,6 +2814,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nix"
version = "0.30.1"
@@ -2798,7 +2867,7 @@ dependencies = [
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
"zbus 5.12.0",
]
[[package]]
@@ -2867,6 +2936,26 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -3141,6 +3230,15 @@ dependencies = [
"objc2-security",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -3531,6 +3629,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3990,6 +4094,29 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251"
dependencies = [
"ashpd",
"block",
"dispatch",
"js-sys",
"log",
"objc",
"objc-foundation",
"objc_id",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -6728,6 +6855,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "xml5ever"
version = "0.17.0"
@@ -6762,6 +6899,44 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix 0.29.0",
"ordered-stream",
"rand 0.8.5",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.12.0"
@@ -6782,7 +6957,7 @@ dependencies = [
"futures-core",
"futures-lite",
"hex",
"nix",
"nix 0.30.1",
"ordered-stream",
"serde",
"serde_repr",
@@ -6791,9 +6966,22 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.13",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.12.0",
"zbus_names 4.2.0",
"zvariant 5.8.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.110",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -6806,9 +6994,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.2.0",
"zvariant 5.8.0",
"zvariant_utils 3.2.1",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -6820,7 +7019,7 @@ dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant",
"zvariant 5.8.0",
]
[[package]]
@@ -6952,6 +7151,20 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"url",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.8.0"
@@ -6962,8 +7175,21 @@ dependencies = [
"enumflags2",
"serde",
"winnow 0.7.13",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.8.0",
"zvariant_utils 3.2.1",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.110",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -6976,7 +7202,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
"zvariant_utils",
"zvariant_utils 3.2.1",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
+1
View File
@@ -45,3 +45,4 @@ regex = "1.10"
walkdir = "2.5"
tauri-plugin-autostart = "2"
dirs = "5.0"
rfd = "0.14"
+270 -16
View File
@@ -30,13 +30,15 @@ use web_scraper::{
scrape_url,
SearchConfig,
search_duckduckgo_metadata,
search_multi_engine_metadata,
SearchEngine,
smart_search,
scrape_urls_bulk,
};
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};
use system_monitor::{SystemStats, SystemMonitorState, GpuInfo, GpuStats};
// CommandExt é importado localmente onde necessário
@@ -591,6 +593,168 @@ fn check_if_model_installed(name: String) -> bool {
}
}
/// Instala um modelo GGUF a partir de um arquivo local
#[command]
async fn install_gguf_model(
app_handle: AppHandle,
file_path: String,
model_name: Option<String>,
) -> Result<String, String> {
use std::path::Path;
let source_path = Path::new(&file_path);
// Validar que o arquivo existe
if !source_path.exists() {
return Err("Arquivo não encontrado".to_string());
}
// Validar extensão (mas aceitar arquivos sem extensão também)
let is_gguf = if let Some(ext) = source_path.extension() {
ext.to_string_lossy().to_lowercase() == "gguf"
} else {
// Arquivo sem extensão - verificar pelo tamanho (modelos GGUF são grandes)
let metadata = fs::metadata(source_path)
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
metadata.len() >= 50 * 1024 * 1024 // Pelo menos 50MB
};
if !is_gguf {
// Verificar se é um arquivo grande sem extensão (pode ser GGUF)
let metadata = fs::metadata(source_path)
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
if metadata.len() < 50 * 1024 * 1024 {
return Err("Arquivo muito pequeno ou não é um modelo GGUF válido".to_string());
}
// Se for grande o suficiente, aceitar mesmo sem extensão
}
// Validar tamanho mínimo (100MB)
let metadata = fs::metadata(source_path)
.map_err(|e| format!("Erro ao ler metadados do arquivo: {}", e))?;
let min_size = 100 * 1024 * 1024; // 100MB
if metadata.len() < min_size {
return Err("Arquivo muito pequeno. Modelos GGUF geralmente têm pelo menos 100MB".to_string());
}
// Determinar nome do modelo
let final_model_name = if let Some(name) = model_name {
name.trim().to_string()
} else {
// Extrair nome do arquivo sem extensão
source_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("model")
.to_string()
};
if final_model_name.is_empty() {
return Err("Nome do modelo não pode estar vazio".to_string());
}
// Obter diretório de modelos do Ollama
// Ollama armazena modelos em ~/.ollama/models (Linux/Mac) ou %USERPROFILE%\.ollama\models (Windows)
let models_dir = dirs::home_dir()
.ok_or_else(|| "Não foi possível determinar diretório home".to_string())?
.join(".ollama")
.join("models");
// Criar diretório se não existir
fs::create_dir_all(&models_dir)
.map_err(|e| format!("Erro ao criar diretório de modelos: {}", e))?;
// Criar diretório para o modelo específico
let model_dir = models_dir.join(&final_model_name);
fs::create_dir_all(&model_dir)
.map_err(|e| format!("Erro ao criar diretório do modelo: {}", e))?;
// Nome do arquivo de destino (usar nome do modelo + .gguf)
let dest_file = model_dir.join(format!("{}.gguf", final_model_name));
// Copiar arquivo
log::info!("Copiando arquivo GGUF de {} para {}", file_path, dest_file.display());
fs::copy(source_path, &dest_file)
.map_err(|e| format!("Erro ao copiar arquivo: {}", e))?;
log::info!("Arquivo copiado com sucesso. Tentando registrar no Ollama...");
// Tentar criar Modelfile e importar modelo no Ollama
// Ollama pode importar modelos GGUF usando: ollama create <name> -f <modelfile>
// Mas para GGUF direto, podemos usar: ollama create <name> --file <path>
// Ou simplesmente copiar para o diretório e o Ollama detecta automaticamente
// Tentar criar Modelfile e registrar modelo no Ollama
// Ollama requer um Modelfile para criar modelos GGUF
let modelfile_path = model_dir.join("Modelfile");
let modelfile_content = format!("FROM {}\n", dest_file.display());
// Escrever Modelfile
if let Err(e) = fs::write(&modelfile_path, &modelfile_content) {
log::warn!("Erro ao criar Modelfile: {}. Tentando método alternativo...", e);
}
// Tentar usar ollama create com Modelfile
let create_output = Command::new("ollama")
.arg("create")
.arg(&final_model_name)
.arg("-f")
.arg(&modelfile_path)
.output();
match create_output {
Ok(output) => {
if output.status.success() {
log::info!("Modelo {} registrado com sucesso no Ollama", final_model_name);
Ok(final_model_name)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// Se o modelo já existe, ainda consideramos sucesso
if stderr.contains("already exists") || stderr.contains("model already exists") {
log::info!("Modelo {} já existe no Ollama", final_model_name);
Ok(final_model_name)
} else {
// Tentar método alternativo: usar FROM diretamente
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")
.arg(&final_model_name)
.arg("--file")
.arg(&dest_file)
.output();
match alt_output {
Ok(alt_out) => {
if alt_out.status.success() {
log::info!("Modelo {} registrado com sucesso (método alternativo)", final_model_name);
Ok(final_model_name)
} else {
let alt_stderr = String::from_utf8_lossy(&alt_out.stderr);
// Se falhar, ainda retornamos sucesso pois o arquivo foi copiado
log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", alt_stderr, dest_file.display(), final_model_name, modelfile_path.display());
Ok(final_model_name)
}
}
Err(_) => {
// Se ambos falharem, ainda retornamos sucesso pois o arquivo foi copiado
log::warn!("Não foi possível registrar modelo automaticamente. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", dest_file.display(), final_model_name, modelfile_path.display());
Ok(final_model_name)
}
}
}
}
}
Err(e) => {
// Se ollama create falhar, ainda retornamos sucesso pois o arquivo foi copiado
// O usuário pode registrar manualmente depois
log::warn!("Não foi possível registrar modelo automaticamente: {}. Arquivo copiado para: {}. Você pode registrar manualmente usando: ollama create {} -f {}", e, dest_file.display(), final_model_name, modelfile_path.display());
Ok(final_model_name)
}
}
}
// Função auxiliar para ler linha até encontrar \r ou \n (mantida para fallback)
#[allow(dead_code)]
fn read_line_until_delimiter<R: Read>(reader: &mut BufReader<R>, buffer: &mut Vec<u8>) -> Result<usize, std::io::Error> {
@@ -1599,6 +1763,7 @@ async fn search_web_metadata(
query: String,
limit: Option<usize>,
search_config: Option<SearchConfig>,
engine_order: Option<Vec<String>>,
) -> Result<Vec<SearchResultMetadata>, String> {
if query.trim().is_empty() {
return Err("Query não pode estar vazia".to_string());
@@ -1606,23 +1771,63 @@ async fn search_web_metadata(
let lim = limit.unwrap_or(5);
if let Some(config) = search_config {
// Usar smart_search para obter URLs e convertê-las em metadados simples
match smart_search(&query, &config).await {
Ok(mut urls) => {
urls.truncate(lim);
let metas = urls
.into_iter()
.map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() })
.collect::<Vec<_>>();
Ok(metas)
}
Err(e) => Err(format!("Erro ao executar smart_search: {}", e)),
}
// Converter engine_order (strings) para Vec<SearchEngine>
let engines: Vec<SearchEngine> = if let Some(order) = engine_order {
order.iter()
.filter_map(|s| SearchEngine::from_str(s))
.collect()
} else {
search_duckduckgo_metadata(&query, lim)
// Ordem padrão: Google primeiro, depois outros
vec![
SearchEngine::Google,
SearchEngine::Bing,
SearchEngine::Yahoo,
SearchEngine::DuckDuckGo,
SearchEngine::Startpage,
]
};
// Se não há engines configuradas, usar DuckDuckGo como fallback
if engines.is_empty() {
log::warn!("No valid engines in order, using DuckDuckGo as fallback");
return search_duckduckgo_metadata(&query, lim)
.await
.map_err(|e| format!("Erro ao buscar metadados: {}", e))
.map_err(|e| format!("Erro ao buscar metadados: {}", e));
}
// Usar multi-engine search
let min_results = 1; // Mínimo de 1 resultado para considerar sucesso
match search_multi_engine_metadata(&query, lim, &engines, min_results).await {
Ok(results) => {
if results.is_empty() && search_config.is_some() {
// Fallback para smart_search se multi-engine retornou vazio
log::info!("Multi-engine returned empty, trying smart_search fallback");
if let Some(config) = search_config {
match smart_search(&query, &config).await {
Ok(mut urls) => {
urls.truncate(lim);
let metas = urls
.into_iter()
.map(|u| SearchResultMetadata { title: u.clone(), url: u, snippet: String::new() })
.collect::<Vec<_>>();
Ok(metas)
}
Err(e) => Err(format!("Erro ao executar smart_search: {}", e)),
}
} else {
Ok(results)
}
} else {
Ok(results)
}
}
Err(e) => {
// Se multi-engine falhou completamente, tentar DuckDuckGo como último recurso
log::warn!("Multi-engine search failed: {}, trying DuckDuckGo fallback", e);
search_duckduckgo_metadata(&query, lim)
.await
.map_err(|e| format!("Erro ao buscar metadados: {}", e))
}
}
}
@@ -1849,6 +2054,45 @@ fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
Ok(format!("{}", app_data_dir.display()))
}
/// Salva um arquivo temporário e retorna o caminho
#[command]
fn save_temp_file(app_handle: AppHandle, data: Vec<u8>, extension: String) -> Result<String, String> {
use std::time::{SystemTime, UNIX_EPOCH};
// Obter diretório temporário
let temp_dir = std::env::temp_dir();
// Criar nome de arquivo único
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let filename = format!("ollama_model_{}.{}", timestamp, extension);
let temp_path = temp_dir.join(&filename);
// Escrever arquivo
fs::write(&temp_path, data)
.map_err(|e| format!("Erro ao salvar arquivo temporário: {}", e))?;
Ok(temp_path.to_string_lossy().to_string())
}
/// Abre um dialog de seleção de arquivo GGUF usando dialog nativo do sistema
#[command]
async fn open_gguf_file_dialog() -> Result<Option<String>, String> {
use rfd::FileDialog;
// No rfd, o filtro "*" não funciona corretamente no Windows.
// Para garantir que todos os arquivos sejam mostrados, vamos criar
// um dialog sem filtro algum. O dialog nativo do Windows mostrará
// todos os arquivos por padrão quando não há filtro.
let file = FileDialog::new()
.set_title("Selecionar modelo GGUF")
.pick_file();
Ok(file.map(|p| p.to_string_lossy().to_string()))
}
// ========== Sources Config Commands ==========
/// Carrega a configuração de fontes de busca
@@ -2264,6 +2508,12 @@ fn get_system_stats(
Ok(monitor.get_stats())
}
/// Obtém estatísticas detalhadas de uma GPU específica
#[command]
fn get_gpu_stats(gpu_id: Option<String>) -> Result<Option<GpuStats>, String> {
Ok(system_monitor::get_gpu_stats(gpu_id.as_deref()))
}
// ========== Task Scheduler Commands ==========
#[command]
@@ -2434,8 +2684,12 @@ pub fn run() {
get_operating_system,
check_if_model_installed,
pull_model,
install_gguf_model,
save_temp_file,
open_gguf_file_dialog,
start_ollama_server,
start_system_monitor,
get_gpu_stats,
list_local_models,
delete_model,
save_chat_session,
+185
View File
@@ -10,6 +10,36 @@ pub struct GpuInfo {
pub memory_mb: Option<u64>,
}
/// Estatísticas detalhadas de uma GPU
#[derive(Serialize, Clone, Debug, Default)]
pub struct GpuStats {
pub id: String,
pub name: String,
pub vendor: Option<String>,
// Memória VRAM
pub vram_used_mb: Option<u64>,
pub vram_total_mb: Option<u64>,
pub vram_percent: Option<f32>,
// Uso de processamento
pub compute_usage_percent: Option<f32>,
pub graphics_usage_percent: Option<f32>,
pub overall_usage_percent: Option<f32>,
// Temperatura
pub temperature_celsius: Option<f32>,
pub temperature_max_celsius: Option<f32>,
// Energia
pub power_watts: Option<f32>,
pub power_max_watts: Option<f32>,
// Ventilador
pub fan_speed_rpm: Option<u32>,
pub fan_speed_percent: Option<f32>,
// Processos
pub processes_count: Option<usize>,
// Driver/API
pub driver_version: Option<String>,
pub api: Option<String>, // CUDA, Vulkan, OpenCL, etc.
}
/// Estatísticas do sistema em tempo real
#[derive(Serialize, Clone, Debug)]
pub struct SystemStats {
@@ -615,3 +645,158 @@ fn parse_memory_string(s: &str) -> Option<u64> {
None
}
/// Obtém estatísticas detalhadas de uma GPU específica
pub fn get_gpu_stats(gpu_id: Option<&str>) -> Option<GpuStats> {
let gpus = detect_all_gpus();
// Se gpu_id fornecido, buscar GPU específica, senão usar primeira GPU
let target_gpu = if let Some(id) = gpu_id {
gpus.iter().find(|g| g.id == id)
} else {
gpus.first()
}?;
// Tentar obter stats detalhados baseado no vendor
if let Some(vendor) = &target_gpu.vendor {
match vendor.as_str() {
"NVIDIA" => get_nvidia_gpu_stats(target_gpu),
"AMD" => get_amd_gpu_stats(target_gpu),
"Intel" => get_intel_gpu_stats(target_gpu),
_ => get_generic_gpu_stats(target_gpu),
}
} else {
get_generic_gpu_stats(target_gpu)
}
}
/// Obtém estatísticas detalhadas de GPU NVIDIA via nvidia-smi
fn get_nvidia_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
use std::process::Command;
log::info!("Coletando stats detalhados da GPU NVIDIA: {}", gpu.name);
// Query nvidia-smi para obter todas as métricas
let query = "name,memory.used,memory.total,utilization.gpu,utilization.memory,temperature.gpu,temperature.max,power.draw,power.limit,fan.speed,driver_version";
let output = Command::new("nvidia-smi")
.args(&[
"--query-gpu", query,
"--format=csv,noheader,nounits"
])
.output()
.ok()?;
if !output.status.success() {
log::warn!("nvidia-smi falhou ao coletar stats");
return get_generic_gpu_stats(gpu);
}
let stdout = String::from_utf8(output.stdout).ok()?;
let line = stdout.lines().next()?;
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 11 {
log::warn!("nvidia-smi retornou formato inesperado");
return get_generic_gpu_stats(gpu);
}
// Parse dos valores
let vram_used_mb = parts[1].parse::<u64>().ok();
let vram_total_mb = parts[2].parse::<u64>().ok();
let vram_percent = if let (Some(used), Some(total)) = (vram_used_mb, vram_total_mb) {
if total > 0 {
Some((used as f32 / total as f32) * 100.0)
} else {
None
}
} else {
None
};
let compute_usage_percent = parts[3].parse::<f32>().ok();
let memory_usage_percent = parts[4].parse::<f32>().ok();
let overall_usage_percent = compute_usage_percent;
let temperature_celsius = parts[5].parse::<f32>().ok();
let temperature_max_celsius = parts[6].parse::<f32>().ok();
let power_watts = parts[7].parse::<f32>().ok();
let power_max_watts = parts[8].parse::<f32>().ok();
let fan_speed_percent = parts[9].parse::<f32>().ok();
let fan_speed_rpm = None; // nvidia-smi não retorna RPM diretamente
let driver_version = Some(parts[10].to_string());
// Contar processos usando GPU
let processes_count = count_nvidia_gpu_processes().unwrap_or(0);
Some(GpuStats {
id: gpu.id.clone(),
name: gpu.name.clone(),
vendor: gpu.vendor.clone(),
vram_used_mb,
vram_total_mb,
vram_percent,
compute_usage_percent,
graphics_usage_percent: compute_usage_percent, // NVIDIA não diferencia
overall_usage_percent,
temperature_celsius,
temperature_max_celsius,
power_watts,
power_max_watts,
fan_speed_rpm,
fan_speed_percent,
processes_count: Some(processes_count),
driver_version,
api: Some("CUDA".to_string()),
})
}
/// Conta processos usando GPU NVIDIA
fn count_nvidia_gpu_processes() -> Result<usize, String> {
use std::process::Command;
let output = Command::new("nvidia-smi")
.args(&["--query-compute-apps=pid", "--format=csv,noheader"])
.output()
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
if !output.status.success() {
return Ok(0);
}
let stdout = String::from_utf8(output.stdout)
.map_err(|e| format!("Erro ao parsear output: {}", e))?;
// Contar linhas não vazias
let count = stdout.lines().filter(|l| !l.trim().is_empty()).count();
Ok(count)
}
/// Obtém estatísticas de GPU AMD (implementação básica)
fn get_amd_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
log::info!("Coletando stats da GPU AMD: {} (suporte limitado)", gpu.name);
// AMD requer rocm-smi ou outras ferramentas específicas
// Por enquanto, retornar stats genéricos
get_generic_gpu_stats(gpu)
}
/// Obtém estatísticas de GPU Intel (implementação básica)
fn get_intel_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
log::info!("Coletando stats da GPU Intel: {} (suporte limitado)", gpu.name);
// Intel requer intel_gpu_top ou outras ferramentas específicas
get_generic_gpu_stats(gpu)
}
/// Retorna stats genéricos quando não há suporte específico
fn get_generic_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
Some(GpuStats {
id: gpu.id.clone(),
name: gpu.name.clone(),
vendor: gpu.vendor.clone(),
vram_total_mb: gpu.memory_mb,
..Default::default()
})
}
+766
View File
@@ -8,6 +8,7 @@ use url::Url;
use rand::Rng;
use tokio::sync::Semaphore;
use regex::Regex;
use std::time::Instant;
/// Resultado da extração de conteúdo de uma URL
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
@@ -57,6 +58,178 @@ fn default_total_sources() -> usize {
100
}
/// Enum para identificar diferentes motores de busca
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngine {
Google,
Bing,
Yahoo,
DuckDuckGo,
Startpage,
}
impl SearchEngine {
/// Converte string para SearchEngine
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"google" => Some(SearchEngine::Google),
"bing" => Some(SearchEngine::Bing),
"yahoo" => Some(SearchEngine::Yahoo),
"duckduckgo" | "duck_duck_go" => Some(SearchEngine::DuckDuckGo),
"startpage" => Some(SearchEngine::Startpage),
_ => None,
}
}
/// Retorna nome do motor como string
pub fn as_str(&self) -> &'static str {
match self {
SearchEngine::Google => "Google",
SearchEngine::Bing => "Bing",
SearchEngine::Yahoo => "Yahoo",
SearchEngine::DuckDuckGo => "DuckDuckGo",
SearchEngine::Startpage => "Startpage",
}
}
/// Retorna URL base de busca
fn base_url(&self) -> &'static str {
match self {
SearchEngine::Google => "https://www.google.com/search",
SearchEngine::Bing => "https://www.bing.com/search",
SearchEngine::Yahoo => "https://search.yahoo.com/search",
SearchEngine::DuckDuckGo => "https://html.duckduckgo.com/html",
SearchEngine::Startpage => "https://www.startpage.com/sp/search",
}
}
/// Retorna selectors CSS específicos para cada motor
fn selectors(&self) -> SearchSelectors {
match self {
SearchEngine::Google => SearchSelectors {
container: vec![
"div.g",
"div[data-ved]",
".tF2Cxc",
],
title: vec![
"h3",
".LC20lb",
".DKV0Md",
],
url: vec![
"a[href]",
"cite",
],
snippet: vec![
".VwiC3b",
".s",
".st",
],
},
SearchEngine::Bing => SearchSelectors {
container: vec![
".b_algo",
"li.b_algo",
],
title: vec![
"h2 a",
".b_title a",
],
url: vec![
"h2 a[href]",
".b_title a[href]",
],
snippet: vec![
".b_caption p",
".b_caption",
],
},
SearchEngine::Yahoo => SearchSelectors {
container: vec![
".dd.algo",
".Sr",
],
title: vec![
"h3 a",
".ac-algo h3 a",
],
url: vec![
"h3 a[href]",
".ac-algo h3 a[href]",
],
snippet: vec![
".ac-algo .ac-text",
".compText",
],
},
SearchEngine::DuckDuckGo => SearchSelectors {
container: vec![
".result",
".web-result",
".result__body",
],
title: vec![
".result__a",
".web-result__link",
"a.result__a",
],
url: vec![
".result__a[href]",
".web-result__link[href]",
],
snippet: vec![
".result__snippet",
".result__snippet.js-result-snippet",
".web-result__snippet",
],
},
SearchEngine::Startpage => SearchSelectors {
container: vec![
".w-gl__result",
".result",
],
title: vec![
".w-gl__result-title a",
"h3 a",
],
url: vec![
".w-gl__result-title a[href]",
"h3 a[href]",
],
snippet: vec![
".w-gl__result-snippet",
".snippet",
],
},
}
}
/// Normaliza query para o motor específico
fn normalize_query(&self, query: &str) -> String {
// Todos os motores usam encoding padrão, mas alguns podem ter requisitos específicos
query.trim().to_string()
}
}
/// Estrutura para selectors CSS de cada motor
struct SearchSelectors {
container: Vec<&'static str>,
title: Vec<&'static str>,
url: Vec<&'static str>,
snippet: Vec<&'static str>,
}
/// Log de tentativa de busca em um motor
struct SearchAttemptLog {
engine: SearchEngine,
query: String,
success: bool,
results_count: usize,
duration_ms: u64,
error: Option<String>,
}
/// Pool de User-Agents para rotação (evita bloqueios 429)
const USER_AGENTS: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
@@ -128,6 +301,389 @@ pub async fn search_duckduckgo(query: &str, limit: usize) -> Result<Vec<String>>
Ok(links)
}
/// Busca no Google retornando apenas metadados (título, URL, snippet)
pub async fn search_google_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("{}?q={}&num={}",
SearchEngine::Google.base_url(),
urlencoding::encode(query),
limit.min(100)
);
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::info!("[SearchEngine:Google] Query: '{}', Attempting...", query);
let res = match client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await
{
Ok(r) => r.text().await?,
Err(e) => {
let duration = start_time.elapsed().as_millis() as u64;
log::warn!("[SearchEngine:Google] Failed: {} ({}ms)", e, duration);
return Err(anyhow::anyhow!("Google search failed: {}", e));
}
};
let mut results: Vec<SearchResultMetadata> = Vec::new();
let selectors = SearchEngine::Google.selectors();
let document = Html::parse_document(&res);
for cont_sel in &selectors.container {
if results.len() >= limit { break; }
if let Ok(container) = Selector::parse(cont_sel) {
for node in document.select(&container) {
if results.len() >= limit { break; }
let mut found_url: Option<String> = None;
let mut found_title: Option<String> = None;
// Buscar título
for tsel in &selectors.title {
if let Ok(ts) = Selector::parse(tsel) {
if let Some(a) = node.select(&ts).next() {
// Extrair URL
if let Some(href) = a.value().attr("href") {
let cleaned = clean_url(href);
if cleaned.is_some() {
found_url = cleaned;
}
}
// Extrair título
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
if !text.is_empty() { found_title = Some(text); }
}
if found_url.is_some() && found_title.is_some() { break; }
}
}
if found_url.is_none() { continue; }
// Buscar snippet
let mut snippet_text = String::new();
for ssel in &selectors.snippet {
if let Ok(ss) = Selector::parse(ssel) {
if let Some(s) = node.select(&ss).next() {
let t = s.text().collect::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
if !norm.is_empty() { snippet_text = norm; break; }
}
}
}
let url_final = found_url.unwrap();
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
results.push(SearchResultMetadata {
title: found_title.unwrap_or_else(|| url_final.clone()),
url: url_final,
snippet: snippet_text,
});
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if results.is_empty() {
log::warn!("[SearchEngine:Google] No results found ({}ms)", duration);
} else {
log::info!("[SearchEngine:Google] Found {} results ({}ms)", results.len(), duration);
}
Ok(results)
}
/// Busca no Bing retornando apenas metadados (título, URL, snippet)
pub async fn search_bing_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("{}?q={}&count={}",
SearchEngine::Bing.base_url(),
urlencoding::encode(query),
limit.min(50)
);
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::info!("[SearchEngine:Bing] Query: '{}', Attempting...", query);
let res = match client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await
{
Ok(r) => r.text().await?,
Err(e) => {
let duration = start_time.elapsed().as_millis() as u64;
log::warn!("[SearchEngine:Bing] Failed: {} ({}ms)", e, duration);
return Err(anyhow::anyhow!("Bing search failed: {}", e));
}
};
let mut results: Vec<SearchResultMetadata> = Vec::new();
let selectors = SearchEngine::Bing.selectors();
let document = Html::parse_document(&res);
for cont_sel in &selectors.container {
if results.len() >= limit { break; }
if let Ok(container) = Selector::parse(cont_sel) {
for node in document.select(&container) {
if results.len() >= limit { break; }
let mut found_url: Option<String> = None;
let mut found_title: Option<String> = None;
for tsel in &selectors.title {
if let Ok(ts) = Selector::parse(tsel) {
if let Some(a) = node.select(&ts).next() {
if let Some(href) = a.value().attr("href") {
let cleaned = clean_url(href);
if cleaned.is_some() {
found_url = cleaned;
}
}
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
if !text.is_empty() { found_title = Some(text); }
}
if found_url.is_some() && found_title.is_some() { break; }
}
}
if found_url.is_none() { continue; }
let mut snippet_text = String::new();
for ssel in &selectors.snippet {
if let Ok(ss) = Selector::parse(ssel) {
if let Some(s) = node.select(&ss).next() {
let t = s.text().collect::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
if !norm.is_empty() { snippet_text = norm; break; }
}
}
}
let url_final = found_url.unwrap();
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
results.push(SearchResultMetadata {
title: found_title.unwrap_or_else(|| url_final.clone()),
url: url_final,
snippet: snippet_text,
});
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if results.is_empty() {
log::warn!("[SearchEngine:Bing] No results found ({}ms)", duration);
} else {
log::info!("[SearchEngine:Bing] Found {} results ({}ms)", results.len(), duration);
}
Ok(results)
}
/// Busca no Yahoo retornando apenas metadados (título, URL, snippet)
pub async fn search_yahoo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("{}?p={}&n={}",
SearchEngine::Yahoo.base_url(),
urlencoding::encode(query),
limit.min(40)
);
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::info!("[SearchEngine:Yahoo] Query: '{}', Attempting...", query);
let res = match client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await
{
Ok(r) => r.text().await?,
Err(e) => {
let duration = start_time.elapsed().as_millis() as u64;
log::warn!("[SearchEngine:Yahoo] Failed: {} ({}ms)", e, duration);
return Err(anyhow::anyhow!("Yahoo search failed: {}", e));
}
};
let mut results: Vec<SearchResultMetadata> = Vec::new();
let selectors = SearchEngine::Yahoo.selectors();
let document = Html::parse_document(&res);
for cont_sel in &selectors.container {
if results.len() >= limit { break; }
if let Ok(container) = Selector::parse(cont_sel) {
for node in document.select(&container) {
if results.len() >= limit { break; }
let mut found_url: Option<String> = None;
let mut found_title: Option<String> = None;
for tsel in &selectors.title {
if let Ok(ts) = Selector::parse(tsel) {
if let Some(a) = node.select(&ts).next() {
if let Some(href) = a.value().attr("href") {
let cleaned = clean_url(href);
if cleaned.is_some() {
found_url = cleaned;
}
}
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
if !text.is_empty() { found_title = Some(text); }
}
if found_url.is_some() && found_title.is_some() { break; }
}
}
if found_url.is_none() { continue; }
let mut snippet_text = String::new();
for ssel in &selectors.snippet {
if let Ok(ss) = Selector::parse(ssel) {
if let Some(s) = node.select(&ss).next() {
let t = s.text().collect::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
if !norm.is_empty() { snippet_text = norm; break; }
}
}
}
let url_final = found_url.unwrap();
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
results.push(SearchResultMetadata {
title: found_title.unwrap_or_else(|| url_final.clone()),
url: url_final,
snippet: snippet_text,
});
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if results.is_empty() {
log::warn!("[SearchEngine:Yahoo] No results found ({}ms)", duration);
} else {
log::info!("[SearchEngine:Yahoo] Found {} results ({}ms)", results.len(), duration);
}
Ok(results)
}
/// Busca no Startpage retornando apenas metadados (título, URL, snippet)
pub async fn search_startpage_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("{}?query={}&page=1",
SearchEngine::Startpage.base_url(),
urlencoding::encode(query)
);
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::info!("[SearchEngine:Startpage] Query: '{}', Attempting...", query);
let res = match client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await
{
Ok(r) => r.text().await?,
Err(e) => {
let duration = start_time.elapsed().as_millis() as u64;
log::warn!("[SearchEngine:Startpage] Failed: {} ({}ms)", e, duration);
return Err(anyhow::anyhow!("Startpage search failed: {}", e));
}
};
let mut results: Vec<SearchResultMetadata> = Vec::new();
let selectors = SearchEngine::Startpage.selectors();
let document = Html::parse_document(&res);
for cont_sel in &selectors.container {
if results.len() >= limit { break; }
if let Ok(container) = Selector::parse(cont_sel) {
for node in document.select(&container) {
if results.len() >= limit { break; }
let mut found_url: Option<String> = None;
let mut found_title: Option<String> = None;
for tsel in &selectors.title {
if let Ok(ts) = Selector::parse(tsel) {
if let Some(a) = node.select(&ts).next() {
if let Some(href) = a.value().attr("href") {
let cleaned = clean_url(href);
if cleaned.is_some() {
found_url = cleaned;
}
}
let text = a.text().collect::<Vec<_>>().join(" ").trim().to_string();
if !text.is_empty() { found_title = Some(text); }
}
if found_url.is_some() && found_title.is_some() { break; }
}
}
if found_url.is_none() { continue; }
let mut snippet_text = String::new();
for ssel in &selectors.snippet {
if let Ok(ss) = Selector::parse(ssel) {
if let Some(s) = node.select(&ss).next() {
let t = s.text().collect::<Vec<_>>().join(" ");
let norm = t.split_whitespace().collect::<Vec<_>>().join(" ");
if !norm.is_empty() { snippet_text = norm; break; }
}
}
}
let url_final = found_url.unwrap();
if is_ad_or_tracker_url(&url_final) || url_final.is_empty() { continue; }
results.push(SearchResultMetadata {
title: found_title.unwrap_or_else(|| url_final.clone()),
url: url_final,
snippet: snippet_text,
});
}
}
}
let duration = start_time.elapsed().as_millis() as u64;
if results.is_empty() {
log::warn!("[SearchEngine:Startpage] No results found ({}ms)", duration);
} else {
log::info!("[SearchEngine:Startpage] Found {} results ({}ms)", results.len(), duration);
}
Ok(results)
}
/// Busca no DuckDuckGo retornando apenas metadados (título, URL, snippet)
pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec<SearchResultMetadata>> {
let client = reqwest::Client::builder()
@@ -237,6 +793,216 @@ pub async fn search_duckduckgo_metadata(query: &str, limit: usize) -> Result<Vec
Ok(results)
}
/// Calcula score de relevância baseado em matches de palavras-chave
fn calculate_relevance_score(result: &SearchResultMetadata, query: &str) -> f32 {
let query_lower = query.to_lowercase();
let query_words: Vec<&str> = query_lower.split_whitespace()
.filter(|w| w.len() > 2)
.collect();
if query_words.is_empty() {
return 0.5; // Score neutro se não há palavras-chave
}
let title_lower = result.title.to_lowercase();
let snippet_lower = result.snippet.to_lowercase();
let combined = format!("{} {}", title_lower, snippet_lower);
let mut matches = 0;
for word in &query_words {
if combined.contains(word) {
matches += 1;
}
}
let base_score = matches as f32 / query_words.len() as f32;
// Bônus se palavra está no título
let title_matches = query_words.iter()
.filter(|w| title_lower.contains(*w))
.count();
let title_bonus = (title_matches as f32 / query_words.len() as f32) * 0.3;
// Bônus se snippet não está vazio
let snippet_bonus = if !result.snippet.is_empty() { 0.1 } else { 0.0 };
(base_score + title_bonus + snippet_bonus).min(1.0)
}
/// Busca multi-engine com fallback automático
pub async fn search_multi_engine_metadata(
query: &str,
limit: usize,
engine_order: &[SearchEngine],
min_results: usize,
) -> Result<Vec<SearchResultMetadata>> {
let mut all_results: Vec<SearchResultMetadata> = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
let mut attempt_logs: Vec<SearchAttemptLog> = Vec::new();
log::info!("[MultiEngine] Starting search for: '{}'", query);
log::info!("[MultiEngine] Engine order: {:?}", engine_order.iter().map(|e| e.as_str()).collect::<Vec<_>>());
log::info!("[MultiEngine] Min results required: {}", min_results);
for engine in engine_order {
let start_time = Instant::now();
let mut attempt_log = SearchAttemptLog {
engine: *engine,
query: query.to_string(),
success: false,
results_count: 0,
duration_ms: 0,
error: None,
};
let result = match *engine {
SearchEngine::Google => search_google_metadata(query, limit).await,
SearchEngine::Bing => search_bing_metadata(query, limit).await,
SearchEngine::Yahoo => search_yahoo_metadata(query, limit).await,
SearchEngine::DuckDuckGo => search_duckduckgo_metadata(query, limit).await,
SearchEngine::Startpage => search_startpage_metadata(query, limit).await,
};
attempt_log.duration_ms = start_time.elapsed().as_millis() as u64;
match result {
Ok(mut engine_results) => {
// Filtrar duplicatas
engine_results.retain(|r| {
if seen_urls.contains(&r.url) {
false
} else {
seen_urls.insert(r.url.clone());
true
}
});
attempt_log.results_count = engine_results.len();
attempt_log.success = true;
if !engine_results.is_empty() {
log::info!("[MultiEngine:{}] Found {} unique results ({}ms)",
engine.as_str(), engine_results.len(), attempt_log.duration_ms);
all_results.extend(engine_results);
// Se atingiu mínimo necessário, pode parar
if all_results.len() >= min_results {
log::info!("[MultiEngine] Minimum results ({}) reached, stopping early", min_results);
break;
}
} else {
log::warn!("[MultiEngine:{}] No results found ({}ms), trying next engine...",
engine.as_str(), attempt_log.duration_ms);
}
}
Err(e) => {
let error_msg = format!("{}", e);
attempt_log.error = Some(error_msg.clone());
log::warn!("[MultiEngine:{}] Failed: {} ({}ms), trying next engine...",
engine.as_str(), error_msg, attempt_log.duration_ms);
}
}
attempt_logs.push(attempt_log);
}
// Ranquear resultados por relevância
let mut scored_results: Vec<(SearchResultMetadata, f32)> = all_results
.into_iter()
.map(|r| {
let score = calculate_relevance_score(&r, query);
(r, score)
})
.collect();
// Ordenar por score (maior primeiro)
scored_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Retornar top limit resultados
let final_results: Vec<SearchResultMetadata> = scored_results
.into_iter()
.take(limit)
.map(|(r, _)| r)
.collect();
// Log resumo
log::info!("[MultiEngine] Final results: {} (from {} engines)",
final_results.len(), attempt_logs.len());
for log_entry in &attempt_logs {
if log_entry.success {
log::info!(" ✓ {}: {} results ({}ms)",
log_entry.engine.as_str(), log_entry.results_count, log_entry.duration_ms);
} else {
log::warn!(" ✗ {}: Failed - {} ({}ms)",
log_entry.engine.as_str(),
log_entry.error.as_ref().unwrap_or(&"Unknown error".to_string()),
log_entry.duration_ms);
}
}
Ok(final_results)
}
/// Expande query semanticamente (adiciona sinônimos, remove stopwords)
pub fn expand_query_semantic(query: &str, language: &str) -> Vec<String> {
let mut variants = Vec::new();
// Query original sempre incluída
variants.push(query.trim().to_string());
// Stopwords por idioma
let stopwords: Vec<&str> = match language {
"pt-BR" | "pt" => vec!["o", "a", "os", "as", "de", "do", "da", "dos", "das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "que", "qual", "quais"],
"en" => vec!["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"],
"es" => vec!["el", "la", "los", "las", "de", "del", "en", "un", "una", "para", "por", "con", "sin"],
_ => vec![],
};
// Remover stopwords
let words: Vec<&str> = query.split_whitespace()
.filter(|w| !stopwords.contains(&w.to_lowercase().as_str()))
.collect();
if words.len() > 1 {
let without_stopwords = words.join(" ");
if without_stopwords != query.trim() {
variants.push(without_stopwords);
}
}
// Sinônimos comuns (básico - pode ser expandido)
let synonyms: Vec<(&str, &str)> = match language {
"pt-BR" | "pt" => vec![
("pesquisa", "estudo investigação"),
("resultado", "achado descoberta"),
("acadêmico", "científico universitário"),
],
"en" => vec![
("research", "study investigation"),
("result", "finding discovery"),
("academic", "scientific scholarly"),
],
_ => vec![],
};
// Adicionar variantes com sinônimos
for (original, replacements) in synonyms {
if query.to_lowercase().contains(original) {
for replacement in replacements.split_whitespace() {
let variant = query.to_lowercase().replace(original, replacement);
if variant != query.to_lowercase() {
variants.push(variant);
}
}
}
}
// Remover duplicatas e retornar
variants.sort();
variants.dedup();
variants
}
/// Extrai a URL real do redirecionamento do DuckDuckGo
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>