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:
1 parent
84ea2dec19
commit
e5f062040c
33 files changed
+4670
-465
No files matched your search
Generated
+249
-12
@@ -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]]
|
||||
|
||||
@@ -45,3 +45,4 @@ regex = "1.10"
|
||||
walkdir = "2.5"
|
||||
tauri-plugin-autostart = "2"
|
||||
dirs = "5.0"
|
||||
rfd = "0.14"
|
||||
+270
-16
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user