Files
OllaHub/src-tauri/src/lib.rs
T
Evandro Fonseca Junior c7da6cb8a8 feat: enhance OllaHub with new features and UI improvements
- Introduced a comprehensive README.md detailing the application, its architecture, and key features.
- Implemented a local-first architecture with deep research capabilities, including query decomposition and parallel web scraping.
- Added a reactive UI built with React and Next.js, featuring Tailwind CSS for styling and Zustand for state management.
- Integrated a task scheduler for recurring tasks and system monitoring for real-time resource management.
- Enhanced settings page with debug mode options and improved hardware dashboard loading states.
- Updated chat interface to include debug data visualization and improved user experience during message streaming.
- Removed unused favicon and updated application icons for better branding.
- Improved navigation handling with non-blocking transitions and state management for setup completion.
2025-11-28 15:15:38 -03:00

3775 lines
133 KiB
Rust

use std::process::{Command, Stdio, Child};
use std::io::{BufRead, BufReader, Write, Read};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use std::fs;
use std::path::PathBuf;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::{command, Window, Emitter, Manager, AppHandle, State, WebviewWindow};
use sysinfo::System;
use chrono::{DateTime, Utc};
use zip::write::{FileOptions, ZipWriter};
use zip::CompressionMethod;
mod web_scraper;
mod scheduler;
mod ollama_client;
mod task_executor;
mod scheduler_loop;
mod sources_config;
mod system_monitor;
mod intent_classifier;
mod db;
mod embeddings;
use web_scraper::{
ScrapedContent,
SearchResultMetadata,
create_browser,
search_and_scrape,
search_and_scrape_with_config,
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, GpuStats};
// CommandExt é importado localmente onde necessário
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct Message {
role: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
}
// Eventos para comunicação Frontend <-> Rust
#[derive(serde::Serialize, Clone)]
struct ChatCreatedEvent {
session_id: String,
title: String,
emoji: String,
}
#[derive(serde::Serialize, Clone)]
struct ChatTokenEvent {
session_id: String,
content: String,
done: bool,
}
#[derive(serde::Serialize, Clone)]
struct ChatErrorEvent {
session_id: String,
error: String,
}
#[derive(serde::Serialize)]
struct DownloadProgress {
status: String, // "pulling", "verifying", "success"
percent: Option<u8>, // 0-100
downloaded: Option<String>, // "552 MB"
total: Option<String>, // "1.2 GB"
speed: Option<String>, // "25 MB/s"
raw: String, // linha original para fallback
}
#[derive(serde::Deserialize)]
struct PullProgress {
status: String,
#[serde(default)]
digest: String, // Mantido para compatibilidade com API, mas não usado atualmente
#[serde(default)]
total: u64,
#[serde(default)]
completed: u64,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct ChatSession {
id: String,
title: String,
messages: Vec<Message>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
#[serde(default)]
platform: String,
#[serde(default)]
memory_context: Vec<String>,
}
#[derive(serde::Serialize, Clone)]
struct SessionSummary {
id: String,
title: String,
emoji: String,
updated_at: DateTime<Utc>,
preview: String,
platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
match_count: Option<usize>,
}
#[derive(serde::Serialize)]
struct SystemSpecs {
total_memory: u64,
cpu_count: usize,
os_name: String,
gpus: Vec<GpuInfo>,
}
// SystemStats movido para system_monitor.rs
// Mantendo apenas para compatibilidade com start_system_monitor
#[derive(serde::Serialize, Clone)]
struct LegacySystemStats {
cpu_usage: f32,
memory_used: u64,
memory_total: u64,
}
#[derive(serde::Serialize)]
struct LocalModel {
name: String,
size: String,
id: String,
modified_at: String,
}
// MCP Configuration Structures
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
struct McpServerConfig {
command: String,
args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<HashMap<String, String>>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
struct McpConfig {
#[serde(rename = "mcpServers")]
mcp_servers: HashMap<String, McpServerConfig>,
}
#[derive(serde::Serialize, Clone)]
struct McpServerStatus {
name: String,
status: String, // "running" | "stopped" | "error"
pid: Option<u32>,
}
// MCP Process Manager - wraps Child with request ID counter
struct McpProcessHandle {
child: Child,
request_id: Arc<Mutex<u64>>,
}
// MCP Process Manager State
type McpProcessMap = Arc<Mutex<HashMap<String, McpProcessHandle>>>;
// Web Scraper Browser State (singleton para reutilização)
type BrowserState = Arc<Mutex<Option<Arc<Browser>>>>;
// File Lock Manager - previne corrupção de dados em escritas concorrentes
type FileLockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
// Helper to send JSON-RPC request to MCP server
fn send_jsonrpc_request(
child: &mut Child,
method: &str,
params: Option<serde_json::Value>,
request_id: u64,
) -> Result<(), String> {
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: request_id,
method: method.to_string(),
params,
};
let request_json = serde_json::to_string(&request)
.map_err(|e| format!("Failed to serialize JSON-RPC request: {}", e))?;
let stdin = child.stdin.as_mut()
.ok_or_else(|| "Failed to get stdin handle".to_string())?;
writeln!(stdin, "{}", request_json)
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
// Helper to read JSON-RPC response from MCP server
// Reads from stdout line by line until we get a matching response
fn read_jsonrpc_response(
child: &mut Child,
expected_id: u64,
timeout_secs: u64,
) -> Result<JsonRpcResponse, String> {
let stdout = child.stdout.as_mut()
.ok_or_else(|| "Failed to get stdout handle".to_string())?;
let mut reader = BufReader::new(stdout);
let start = std::time::Instant::now();
// Read line by line until we get a valid JSON-RPC response
loop {
if start.elapsed().as_secs() > timeout_secs {
return Err("Timeout waiting for MCP server response".to_string());
}
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
// EOF, wait a bit and try again
std::thread::sleep(Duration::from_millis(100));
continue;
}
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<JsonRpcResponse>(trimmed) {
Ok(response) => {
if response.id == expected_id {
return Ok(response);
}
// Continue reading if ID doesn't match (might be previous response)
}
Err(_) => {
// Not a valid JSON-RPC response, continue
continue;
}
}
}
Err(e) => {
return Err(format!("Failed to read line: {}", e));
}
}
}
}
// MCP Tool structures
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
struct McpTool {
name: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
input_schema: Option<serde_json::Value>,
}
#[derive(serde::Serialize, Clone, Debug)]
struct McpToolInfo {
server_name: String,
tool: McpTool,
}
// JSON-RPC structures
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct JsonRpcRequest {
jsonrpc: String,
id: u64,
method: String,
#[serde(skip_serializing_if = "Option::is_none")]
params: Option<serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct JsonRpcResponse {
jsonrpc: String,
id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
struct JsonRpcError {
code: i32,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
}
// Helper to get chats directory
pub fn get_chats_dir(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let chats_dir = app_data_dir.join("chats");
if !chats_dir.exists() {
fs::create_dir_all(&chats_dir)
.map_err(|e| format!("Failed to create chats dir: {}", e))?;
}
Ok(chats_dir)
}
// Helper to get MCP config file path
fn get_mcp_config_path(app_handle: &AppHandle) -> Result<PathBuf, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
Ok(app_data_dir.join("mcp_config.json"))
}
#[command]
fn toggle_devtools(window: WebviewWindow) -> Result<(), String> {
#[cfg(debug_assertions)]
{
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
#[cfg(not(debug_assertions))]
{
// Em release, não fazer nada
}
Ok(())
}
#[command]
fn save_chat_session(
app_handle: AppHandle,
file_locks: State<'_, FileLockMap>,
id: String,
title: String,
messages: Vec<Message>,
platform: Option<String>,
memory_context: Option<Vec<String>>
) -> Result<(), String> {
// Obter ou criar lock para este arquivo específico
let lock = {
let mut locks_map = file_locks.lock()
.map_err(|e| format!("Failed to lock file locks map: {}", e))?;
// Usar o ID da sessão como chave única para o lock
locks_map.entry(id.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
// Adquirir o lock antes de qualquer operação de I/O
let _guard = lock.lock()
.map_err(|e| format!("Failed to acquire file lock for session {}: {}", id, e))?;
let chats_dir = get_chats_dir(&app_handle)?;
let file_path = chats_dir.join(format!("{}.json", id));
let now = Utc::now();
// Try to load existing to keep created_at, or use now
let created_at = if file_path.exists() {
if let Ok(content) = fs::read_to_string(&file_path) {
if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
session.created_at
} else {
now
}
} else {
now
}
} else {
now
};
let platform = platform.unwrap_or_else(|| System::name().unwrap_or("Unknown".to_string()));
let memory_context = memory_context.unwrap_or_default();
let session = ChatSession {
id: id.clone(),
title,
messages,
created_at,
updated_at: now,
platform,
memory_context,
};
let json = serde_json::to_string_pretty(&session)
.map_err(|e| format!("Failed to serialize session: {}", e))?;
// Escrever em arquivo temporário primeiro, depois renomear (atomic write)
let temp_path = file_path.with_extension("json.tmp");
fs::write(&temp_path, json)
.map_err(|e| format!("Failed to write temp session file: {}", e))?;
// Renomear atomicamente (operação atômica na maioria dos sistemas)
fs::rename(&temp_path, &file_path)
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
// Também salvar no SQLite (sistema novo) para melhor performance e paginação
// Se falhar, apenas logar erro mas não falhar a operação (compatibilidade)
use db::Database;
match Database::new(&app_handle) {
Ok(db) => {
// Criar/atualizar sessão no SQLite
let db_session = db::ChatSession {
id: session.id.clone(),
title: session.title.clone(),
emoji: "💬".to_string(), // Emoji padrão
created_at: session.created_at,
updated_at: session.updated_at,
};
if let Err(e) = db.save_session(&db_session) {
log::warn!("Failed to save session to SQLite (continuing with JSON only): {}", e);
} else {
// Converter Message para ChatMessage e salvar no SQLite
// Preservar ordem usando timestamps incrementais baseados no índice
let chat_messages: Vec<db::ChatMessage> = session.messages.iter().enumerate().map(|(idx, msg)| {
let metadata_str = msg.metadata.as_ref()
.and_then(|m| serde_json::to_string(m).ok());
// Criar timestamp incremental para preservar ordem das mensagens
// Usar created_at da sessão como base e adicionar segundos baseados no índice
// Isso garante que a ordem seja mantida quando ordenado por created_at ASC
let base_time = session.created_at;
let msg_created_at = base_time + chrono::Duration::seconds(idx as i64);
db::ChatMessage {
id: None,
session_id: session.id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
metadata: metadata_str,
created_at: msg_created_at,
}
}).collect();
if let Err(e) = db.save_messages_batch(&session.id, &chat_messages) {
log::warn!("Failed to save messages to SQLite (continuing with JSON only): {}", e);
} else {
log::debug!("Successfully saved {} messages to SQLite for session {}", chat_messages.len(), session.id);
}
}
}
Err(e) => {
log::debug!("Failed to open database for saving (JSON saved successfully): {}", e);
}
}
// Lock é liberado automaticamente quando _guard sai de escopo
Ok(())
}
#[command]
fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usize>) -> Result<Vec<SessionSummary>, String> {
use db::Database;
let db = Database::new(&app_handle)
.map_err(|e| format!("Failed to open database: {}", e))?;
let search_limit = limit.unwrap_or(50);
let search_results = db.search_sessions(&query, search_limit)
.map_err(|e| format!("Search failed: {}", e))?;
// Validar existência de cada sessão antes de retornar
let chats_dir = get_chats_dir(&app_handle)?;
let mut summaries = Vec::new();
let mut orphan_count = 0;
for search_result in search_results {
let session = search_result.session;
let match_count = search_result.match_count;
// Verificar se sessão existe no SQLite (já temos)
let exists_in_sqlite = db.get_session(&session.id)
.ok()
.flatten()
.is_some();
// Verificar se existe no JSON (sistema legado) para compatibilidade
let json_path = chats_dir.join(format!("{}.json", session.id));
let exists_in_json = json_path.exists();
// Sessão deve existir em pelo menos um sistema
if !exists_in_sqlite && !exists_in_json {
orphan_count += 1;
log::warn!("Found orphan session in search results: {} (title: {})", session.id, session.title);
continue; // Pular sessões órfãs
}
// Buscar primeira mensagem para preview
let preview = db.get_messages(&session.id)
.ok()
.and_then(|msgs| {
msgs.iter()
.find(|m| m.role == "user" || m.role == "assistant")
.map(|m| {
m.content.chars().take(50).collect::<String>()
})
})
.or_else(|| {
// Fallback: tentar ler do JSON se não encontrou no SQLite
if exists_in_json {
if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(session_data) = serde_json::from_str::<ChatSession>(&content) {
return session_data.messages.iter()
.find(|m| m.role == "user" || m.role == "assistant")
.map(|m| m.content.chars().take(50).collect::<String>());
}
}
}
None
})
.unwrap_or_default();
summaries.push(SessionSummary {
id: session.id,
title: session.title,
emoji: session.emoji,
updated_at: session.updated_at, // Já é DateTime<Utc>
preview,
platform: String::new(), // Platform não está no SQLite ainda
match_count: Some(match_count as usize),
});
}
if orphan_count > 0 {
log::info!("Filtered out {} orphan sessions from search results", orphan_count);
}
Ok(summaries)
}
#[command]
fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, String> {
let chats_dir = get_chats_dir(&app_handle)?;
let mut summaries = Vec::new();
let entries = fs::read_dir(chats_dir)
.map_err(|e| format!("Failed to read chats dir: {}", e))?;
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
let last_msg = session.messages.last()
.map(|m| m.content.chars().take(50).collect::<String>())
.unwrap_or_default();
// Extract emoji from metadata (first assistant message with emoji)
let emoji = session.messages.iter()
.find_map(|m| {
if let Some(meta) = &m.metadata {
if let Some(emoji_val) = meta.get("emoji") {
if let Some(emoji_str) = emoji_val.as_str() {
return Some(emoji_str.to_string());
}
}
}
None
})
.unwrap_or_else(|| "💬".to_string());
summaries.push(SessionSummary {
id: session.id,
title: session.title,
emoji,
updated_at: session.updated_at,
preview: last_msg,
platform: session.platform,
match_count: None,
});
}
}
}
}
}
// Sort by updated_at desc
summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(summaries)
}
#[command]
fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>, String> {
use db::Database;
// 1. Tentar carregar do SQLite primeiro (sistema novo)
match Database::new(&app_handle) {
Ok(db) => {
match db.get_messages(&id) {
Ok(messages) if !messages.is_empty() => {
// Converter ChatMessage para Message
let result: Vec<Message> = messages.into_iter().map(|msg| {
let role = if msg.role == "user" {
"user"
} else if msg.role == "assistant" {
"assistant"
} else {
"system"
};
let metadata = msg.metadata.and_then(|m| {
serde_json::from_str::<serde_json::Value>(&m).ok()
});
let metadata_value = metadata
.and_then(|m| {
if m.is_object() && !m.as_object().unwrap().is_empty() {
Some(m)
} else {
None
}
});
Message {
role: role.to_string(),
content: msg.content,
metadata: metadata_value,
}
}).collect();
log::info!("Loaded {} messages from SQLite for session {}", result.len(), id);
return Ok(result);
}
Ok(_) => {
// Sessão existe mas não tem mensagens, continuar para fallback
}
Err(e) => {
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
}
}
}
Err(e) => {
log::debug!("Failed to open database: {}, trying JSON fallback", e);
}
}
// 2. Fallback: tentar carregar do sistema legado (arquivos JSON)
let chats_dir = get_chats_dir(&app_handle)?;
let file_path = chats_dir.join(format!("{}.json", id));
if !file_path.exists() {
return Err("Session not found".to_string());
}
let content = fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read session file: {}", e))?;
let session: ChatSession = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse session: {}", e))?;
log::info!("Loaded {} messages from JSON for session {}", session.messages.len(), id);
Ok(session.messages)
}
/// Resultado de carregamento paginado de histórico
#[derive(serde::Serialize)]
struct PaginatedHistory {
messages: Vec<Message>,
total_count: usize,
has_more: bool,
}
/// Carrega histórico de chat com paginação (lazy loading)
///
/// Parâmetros:
/// - id: ID da sessão
/// - limit: número máximo de mensagens a retornar (default: 20)
/// - offset: número de mensagens a pular do final (default: 0)
#[command]
fn load_chat_history_paginated(
app_handle: AppHandle,
id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<PaginatedHistory, String> {
use db::Database;
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
match Database::new(&app_handle) {
Ok(db) => {
match db.get_messages_paginated(&id, limit, offset) {
Ok((messages, total_count, has_more)) => {
// Se SQLite retornou 0 mensagens, tentar fallback para JSON (sistema legado)
if total_count == 0 {
log::debug!("No messages in SQLite for session {}, trying JSON fallback", id);
// Tentar carregar do JSON
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e) => {
log::debug!("Failed to get chats dir for JSON fallback: {}", e);
// Retornar vazio se não conseguir acessar diretório
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Aplicar paginação em memória (simular comportamento do SQLite)
// offset=0 significa últimas N mensagens
// offset>0 significa mensagens antes das últimas N
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e) => {
log::debug!("Failed to parse JSON session: {}", e);
}
}
}
Err(e) => {
log::debug!("Failed to read JSON file: {}", e);
}
}
}
// Se chegou aqui, não encontrou mensagens nem no SQLite nem no JSON
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Converter ChatMessage para Message
let result: Vec<Message> = messages.into_iter().map(|msg| {
let role = if msg.role == "user" {
"user"
} else if msg.role == "assistant" {
"assistant"
} else {
"system"
};
let metadata = msg.metadata.and_then(|m| {
serde_json::from_str::<serde_json::Value>(&m).ok()
});
let metadata_value = metadata
.and_then(|m| {
if m.is_object() && !m.as_object().unwrap().is_empty() {
Some(m)
} else {
None
}
});
Message {
role: role.to_string(),
content: msg.content,
metadata: metadata_value,
}
}).collect();
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from SQLite for session {}",
result.len(), offset, total_count, has_more, id
);
Ok(PaginatedHistory {
messages: result,
total_count,
has_more,
})
}
Err(e) => {
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
// Fallback para JSON em caso de erro também
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(_) => {
return Err(format!("Failed to load paginated history: {}", e));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (error case) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to load paginated history: {}", e))
}
}
}
Err(e) => {
log::debug!("Failed to open database: {}, trying JSON fallback", e);
// Fallback para JSON se não conseguir abrir banco
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e2) => {
return Err(format!("Failed to open database: {} (chats dir error: {})", e, e2));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (db open error) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to open database: {}", e))
}
}
}
#[command]
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> {
use db::Database;
let mut errors = Vec::new();
// 1. Deletar do sistema legado (arquivos JSON)
let chats_dir = get_chats_dir(&app_handle)?;
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
errors.push(format!("Failed to delete JSON file: {}", e));
} else {
log::info!("Deleted session JSON file: {}", id);
}
}
// 2. Deletar do SQLite (sistema novo)
match Database::new(&app_handle) {
Ok(db) => {
if let Err(e) = db.delete_session(&id) {
errors.push(format!("Failed to delete from SQLite: {}", e));
} else {
log::info!("Deleted session from SQLite: {}", id);
}
}
Err(e) => {
errors.push(format!("Failed to open database: {}", e));
}
}
// Se ambos falharam, retornar erro
if !errors.is_empty() && !file_path.exists() {
// Se arquivo JSON não existe, verificar se pelo menos deletou do SQLite
match Database::new(&app_handle) {
Ok(db) => {
if db.get_session(&id).ok().flatten().is_none() {
// Sessão não existe em nenhum lugar, considerar sucesso
return Ok(());
}
}
_ => {}
}
}
// Se houve erros mas pelo menos um sistema foi atualizado, logar mas não falhar
if !errors.is_empty() {
log::warn!("Some errors during deletion of session {}: {:?}", id, errors);
}
Ok(())
}
#[command]
fn get_system_specs() -> SystemSpecs {
let mut sys = System::new_all();
sys.refresh_all();
// Detectar todas as GPUs
let gpus = system_monitor::detect_all_gpus();
SystemSpecs {
total_memory: sys.total_memory(),
cpu_count: sys.cpus().len(),
os_name: System::name().unwrap_or("Unknown".to_string()),
gpus,
}
}
/// Retorna o sistema operacional atual: 'windows', 'mac', ou 'linux'
#[command]
fn get_operating_system() -> String {
#[cfg(target_os = "windows")]
{
return "windows".to_string();
}
#[cfg(target_os = "macos")]
{
return "mac".to_string();
}
#[cfg(target_os = "linux")]
{
return "linux".to_string();
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
return "unknown".to_string();
}
#[command]
fn start_system_monitor(window: Window) {
std::thread::spawn(move || {
let mut sys = System::new_all();
loop {
sys.refresh_cpu_all();
sys.refresh_memory();
let cpu_usage = sys.global_cpu_usage();
let memory_used = sys.used_memory();
let memory_total = sys.total_memory();
let stats = LegacySystemStats {
cpu_usage,
memory_used,
memory_total,
};
if window.emit("system-stats", stats).is_err() {
break; // Stop if window is closed
}
std::thread::sleep(Duration::from_secs(2));
}
});
}
#[command]
fn list_local_models() -> Vec<LocalModel> {
let output = Command::new("ollama")
.arg("list")
.output();
match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut models = Vec::new();
// Skip header line
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
// NAME ID SIZE MODIFIED
// Note: Modified can be "2 days ago" (multiple parts)
// We'll take the first part as name, second as ID, third as size
// and the rest as modified
let name = parts[0].to_string();
let id = parts[1].to_string();
let size = parts[2].to_string();
let modified_at = parts[3..].join(" ");
models.push(LocalModel {
name,
id,
size,
modified_at,
});
}
}
models
}
Err(_) => Vec::new(),
}
}
#[command]
async fn delete_model(name: String) -> Result<(), String> {
let output = Command::new("ollama")
.arg("rm")
.arg(&name)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[command]
fn check_if_model_installed(name: String) -> bool {
let output = Command::new("ollama")
.arg("list")
.output();
match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.contains(&name)
}
Err(_) => false,
}
}
/// 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> {
buffer.clear();
let mut byte = [0u8; 1];
let mut count = 0;
loop {
match reader.read(&mut byte)? {
0 => break, // EOF
_ => {
if byte[0] == b'\r' {
// Se for \r, verificar se o próximo é \n e pular ambos
let mut peek = [0u8; 1];
if reader.read(&mut peek).unwrap_or(0) > 0 && peek[0] == b'\n' {
// É \r\n, já consumimos ambos
} else {
// É apenas \r, já consumimos
}
break;
} else if byte[0] == b'\n' {
break;
}
buffer.push(byte[0]);
count += 1;
}
}
}
Ok(count)
}
// Função auxiliar para formatar bytes em formato legível
fn format_bytes(bytes: u64) -> Option<String> {
if bytes == 0 {
return None;
}
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
Some(if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
})
}
// Função para parsear linha do Ollama e extrair informações (mantida para fallback)
#[allow(dead_code)]
fn parse_ollama_progress(line: &str) -> DownloadProgress {
let line_lower = line.to_lowercase();
let mut status = "downloading".to_string();
let mut percent: Option<u8> = None;
let mut downloaded: Option<String> = None;
let mut total: Option<String> = None;
let mut speed: Option<String> = None;
// Determinar status
if line_lower.contains("pulling manifest") || line_lower.contains("pulling") {
status = "pulling".to_string();
} else if line_lower.contains("verifying") {
status = "verifying".to_string();
} else if line_lower.contains("writing manifest") {
status = "writing".to_string();
} else if line_lower.contains("success") || line_lower.contains("complete") || line_lower.contains("pulled") {
status = "success".to_string();
}
// Extrair porcentagem: "45%" ou "45 %"
if let Some(caps) = regex::Regex::new(r"(\d+)\s*%").unwrap().captures(line) {
if let Ok(p) = caps[1].parse::<u8>() {
percent = Some(p);
}
}
// Extrair tamanho baixado/total: "552 MB/1.2 GB" ou "552MB / 1.2GB"
if let Some(caps) = regex::Regex::new(r"(\d+(?:\.\d+)?)\s*([KMGT]?B)\s*/\s*(\d+(?:\.\d+)?)\s*([KMGT]?B)").unwrap().captures(line) {
downloaded = Some(format!("{} {}", &caps[1], &caps[2]));
total = Some(format!("{} {}", &caps[3], &caps[4]));
}
// Extrair velocidade: "25 MB/s" ou "25MB/s"
if let Some(caps) = regex::Regex::new(r"(\d+(?:\.\d+)?)\s*([KMGT]?B/s)").unwrap().captures(line) {
speed = Some(format!("{} {}", &caps[1], &caps[2]));
}
DownloadProgress {
status,
percent,
downloaded,
total,
speed,
raw: line.to_string(),
}
}
#[command]
async fn pull_model(window: Window, name: String) -> Result<(), String> {
let client = reqwest::Client::new();
// Fazer requisição POST para API do Ollama com streaming
let response = client
.post("http://localhost:11434/api/pull")
.json(&serde_json::json!({ "name": name, "stream": true }))
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama API: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama API returned error: {}", response.status()));
}
let mut stream = response.bytes_stream();
let mut buffer = String::new();
let mut last_completed: u64 = 0;
let mut last_time = Instant::now();
// Processar stream NDJSON (Newline Delimited JSON)
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk);
buffer.push_str(&chunk_str);
// Processar linhas completas (separadas por \n)
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
continue;
}
// Tentar deserializar como PullProgress
match serde_json::from_str::<PullProgress>(&line) {
Ok(json_progress) => {
// Calcular porcentagem se tiver total/completed
let percent = if json_progress.total > 0 {
Some(((json_progress.completed as f64 / json_progress.total as f64) * 100.0) as u8)
} else {
None
};
// Calcular velocidade
let now = Instant::now();
let delta_time = now.duration_since(last_time).as_secs_f64();
let speed = if delta_time > 0.0 && json_progress.completed > last_completed {
let delta_bytes = json_progress.completed - last_completed;
let bytes_per_sec = delta_bytes as f64 / delta_time;
Some(format_speed(bytes_per_sec))
} else {
None
};
last_completed = json_progress.completed;
last_time = now;
// Criar DownloadProgress estruturado
let progress = DownloadProgress {
status: json_progress.status.clone(),
percent,
downloaded: format_bytes(json_progress.completed),
total: format_bytes(json_progress.total),
speed,
raw: line.clone(),
};
// Emitir evento para frontend
if let Ok(json) = serde_json::to_string(&progress) {
window.emit("download-progress", json).unwrap_or(());
}
// Se status for "success", finalizar
if json_progress.status == "success" {
let success_progress = DownloadProgress {
status: "success".to_string(),
percent: Some(100),
downloaded: format_bytes(json_progress.completed),
total: format_bytes(json_progress.total),
speed: None,
raw: "success".to_string(),
};
if let Ok(json) = serde_json::to_string(&success_progress) {
window.emit("download-progress", json).unwrap_or(());
}
return Ok(());
}
}
Err(_) => {
// Se não conseguir parsear como JSON, tratar como linha raw (fallback)
let progress = DownloadProgress {
status: "downloading".to_string(),
percent: None,
downloaded: None,
total: None,
speed: None,
raw: line,
};
if let Ok(json) = serde_json::to_string(&progress) {
window.emit("download-progress", json).unwrap_or(());
}
}
}
}
}
// Se chegou aqui, o stream terminou sem "success" explícito
// Emitir sucesso final
let success_progress = DownloadProgress {
status: "success".to_string(),
percent: Some(100),
downloaded: format_bytes(last_completed),
total: None,
speed: None,
raw: "success".to_string(),
};
if let Ok(json) = serde_json::to_string(&success_progress) {
window.emit("download-progress", json).unwrap_or(());
}
Ok(())
}
// Função auxiliar para formatar velocidade
fn format_speed(bytes_per_sec: f64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
if bytes_per_sec >= GB {
format!("{:.1} GB/s", bytes_per_sec / GB)
} else if bytes_per_sec >= MB {
format!("{:.1} MB/s", bytes_per_sec / MB)
} else if bytes_per_sec >= KB {
format!("{:.1} KB/s", bytes_per_sec / KB)
} else {
format!("{:.0} B/s", bytes_per_sec)
}
}
#[command]
fn check_ollama_installed() -> bool {
match Command::new("ollama").arg("--version").output() {
Ok(output) => output.status.success(),
Err(_) => false,
}
}
#[command]
async fn check_ollama_running() -> bool {
match reqwest::get("http://localhost:11434").await {
Ok(resp) => resp.status().is_success(),
Err(_) => false,
}
}
/// Verificação completa do Ollama: instalação e execução
#[derive(serde::Serialize)]
struct OllamaCheckResult {
installed: bool,
running: bool,
status: String, // "not_installed" | "installed_stopped" | "running"
}
/// Inicia o Ollama automaticamente se estiver instalado mas não estiver rodando
#[command]
async fn auto_start_ollama() -> Result<bool, String> {
// Verificar se está instalado
let installed = check_ollama_installed();
if !installed {
log::info!("Ollama não está instalado, pulando inicialização automática");
return Ok(false);
}
// Verificar se já está rodando
let running = check_ollama_running().await;
if running {
log::info!("Ollama já está rodando");
return Ok(true);
}
// Tentar iniciar
log::info!("Iniciando Ollama automaticamente...");
match start_ollama_server() {
Ok(_) => {
// Aguardar um pouco para o servidor iniciar
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Verificar se iniciou com sucesso
let is_running = check_ollama_running().await;
if is_running {
log::info!("Ollama iniciado com sucesso");
Ok(true)
} else {
log::warn!("Ollama foi iniciado mas ainda não está respondendo");
Ok(false)
}
}
Err(e) => {
log::error!("Falha ao iniciar Ollama automaticamente: {}", e);
Err(e)
}
}
}
#[command]
async fn check_ollama_full() -> Result<OllamaCheckResult, String> {
let installed = check_ollama_installed();
if !installed {
return Ok(OllamaCheckResult {
installed: false,
running: false,
status: "not_installed".to_string(),
});
}
let running = check_ollama_running().await;
if !running {
return Ok(OllamaCheckResult {
installed: true,
running: false,
status: "installed_stopped".to_string(),
});
}
Ok(OllamaCheckResult {
installed: true,
running: true,
status: "running".to_string(),
})
}
#[command]
fn start_ollama_server() -> Result<(), String> {
let mut cmd = Command::new("ollama");
cmd.arg("serve");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
// Spawn detached
cmd.spawn()
.map_err(|e| format!("Failed to start ollama: {}", e))?;
Ok(())
}
// MCP Configuration Commands
#[command]
fn load_mcp_config(app_handle: AppHandle) -> Result<McpConfig, String> {
let config_path = get_mcp_config_path(&app_handle)?;
// If file doesn't exist, return empty config
if !config_path.exists() {
return Ok(McpConfig {
mcp_servers: HashMap::new(),
});
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read MCP config: {}", e))?;
let config: McpConfig = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse MCP config: {}", e))?;
Ok(config)
}
#[command]
fn save_mcp_config(app_handle: AppHandle, config: McpConfig) -> Result<(), String> {
let config_path = get_mcp_config_path(&app_handle)?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
}
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config: {}", e))?;
fs::write(&config_path, json)
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
Ok(())
}
#[command]
fn get_mcp_config_path_command(app_handle: AppHandle) -> Result<String, String> {
let path = get_mcp_config_path(&app_handle)?;
Ok(path.to_string_lossy().to_string())
}
// MCP Process Management Commands
#[command]
fn start_mcp_server(
processes: State<'_, McpProcessMap>,
name: String,
config: McpServerConfig,
) -> Result<u32, String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
// Kill existing process if running
if let Some(mut handle) = processes_map.remove(&name) {
let _ = handle.child.kill();
let _ = handle.child.wait();
}
// Check if command exists before attempting to spawn
// On Windows, we need to check both with and without .exe extension
let mut command_exists = {
let check = Command::new(&config.command)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.output();
#[cfg(target_os = "windows")]
{
if check.is_err() {
// Try with .exe extension on Windows
Command::new(format!("{}.exe", config.command))
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.output()
.is_ok()
} else {
true
}
}
#[cfg(not(target_os = "windows"))]
{
check.is_ok()
}
};
// Build command - try to use full path if found, otherwise use command as-is
// On Windows, we may need to check common Node.js installation paths
let mut command_path = config.command.clone();
#[cfg(target_os = "windows")]
{
// If command is npx and not found in PATH, try common Node.js locations
if config.command == "npx" && !command_exists {
let program_files = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string());
let program_files_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| "C:\\Program Files (x86)".to_string());
let common_paths: Vec<String> = vec![
format!("{}\\nodejs\\npx.cmd", program_files),
format!("{}\\nodejs\\npx.cmd", program_files_x86),
r"C:\Program Files\nodejs\npx.cmd".to_string(),
r"C:\Program Files (x86)\nodejs\npx.cmd".to_string(),
];
for path in common_paths {
if std::path::Path::new(&path).exists() {
command_path = path;
command_exists = true; // Mark as found
break;
}
}
}
}
if !command_exists {
// Command not found - provide helpful error message
return Err(format!(
"Comando '{}' não encontrado no PATH. Verifique se está instalado e acessível. {}",
config.command,
if config.command == "npx" {
"O Node.js e npm precisam estar instalados. Instale de https://nodejs.org/ e reinicie o aplicativo após a instalação."
} else if config.command == "uvx" {
"O uv (Python package manager) precisa estar instalado. Instale com: pip install uv"
} else {
"Certifique-se de que o comando está disponível no PATH do sistema."
}
));
}
let mut cmd = Command::new(&command_path);
cmd.args(&config.args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Set environment variables if provided
if let Some(env_vars) = &config.env {
for (key, value) in env_vars {
cmd.env(key, value);
}
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
// Spawn process
let child = cmd.spawn()
.map_err(|e| {
let error_msg = e.to_string();
// Provide more context for common errors
if error_msg.contains("program not found") ||
error_msg.contains("No such file") ||
error_msg.contains("The system cannot find the file") ||
error_msg.contains("not found") {
format!(
"Comando '{}' não encontrado. Verifique se está instalado e no PATH do sistema. {}",
config.command,
if config.command == "npx" {
"Instale Node.js de https://nodejs.org/ e reinicie o aplicativo após a instalação."
} else if config.command == "uvx" {
"Instale uv com: pip install uv"
} else {
"Certifique-se de que o comando está disponível no PATH."
}
)
} else {
format!("Erro ao iniciar servidor '{}': {}", name, error_msg)
}
})?;
let pid = child.id();
// Create process handle with request ID counter
let handle = McpProcessHandle {
child,
request_id: Arc::new(Mutex::new(0)),
};
// Store in map
processes_map.insert(name, handle);
Ok(pid)
}
#[command]
fn stop_mcp_server(
processes: State<'_, McpProcessMap>,
name: String,
) -> Result<(), String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
if let Some(mut handle) = processes_map.remove(&name) {
handle.child.kill()
.map_err(|e| format!("Failed to kill process '{}': {}", name, e))?;
let _ = handle.child.wait();
Ok(())
} else {
Err(format!("MCP server '{}' not found", name))
}
}
#[command]
fn restart_mcp_server(
processes: State<'_, McpProcessMap>,
app_handle: AppHandle,
name: String,
) -> Result<u32, String> {
// Load config
let config = load_mcp_config(app_handle)?;
// Find server config
let server_config = config.mcp_servers.get(&name)
.ok_or_else(|| format!("MCP server '{}' not found in config", name))?
.clone();
// Stop if running
{
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
if let Some(mut handle) = processes_map.remove(&name) {
let _ = handle.child.kill();
let _ = handle.child.wait();
}
}
// Start again
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
// Build command
let mut cmd = Command::new(&server_config.command);
cmd.args(&server_config.args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Set environment variables if provided
if let Some(env_vars) = &server_config.env {
for (key, value) in env_vars {
cmd.env(key, value);
}
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
// Spawn process
let child = cmd.spawn()
.map_err(|e| format!("Failed to spawn MCP server '{}': {}", name, e))?;
let pid = child.id();
// Create process handle with request ID counter
let handle = McpProcessHandle {
child,
request_id: Arc::new(Mutex::new(0)),
};
// Store in map
processes_map.insert(name, handle);
Ok(pid)
}
#[command]
fn list_mcp_server_status(
processes: State<'_, McpProcessMap>,
app_handle: AppHandle,
) -> Result<Vec<McpServerStatus>, String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
let config = load_mcp_config(app_handle)?;
let mut statuses = Vec::new();
for (name, _) in config.mcp_servers {
let status = if let Some(handle) = processes_map.get_mut(&name) {
// Check if process is still alive by trying to get its status
match handle.child.try_wait() {
Ok(Some(_)) => {
// Process finished, remove from map
processes_map.remove(&name);
McpServerStatus {
name: name.clone(),
status: "stopped".to_string(),
pid: None,
}
},
Ok(None) => McpServerStatus {
name: name.clone(),
status: "running".to_string(),
pid: Some(handle.child.id()),
},
Err(_) => McpServerStatus {
name: name.clone(),
status: "error".to_string(),
pid: None,
},
}
} else {
McpServerStatus {
name: name.clone(),
status: "stopped".to_string(),
pid: None,
}
};
statuses.push(status);
}
Ok(statuses)
}
#[command]
fn restart_all_mcp_servers(
processes: State<'_, McpProcessMap>,
app_handle: AppHandle,
) -> Result<Vec<String>, String> {
let config = load_mcp_config(app_handle)?;
let mut started = Vec::new();
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
// First, kill all existing processes
for (_name, mut handle) in processes_map.drain() {
let _ = handle.child.kill();
let _ = handle.child.wait();
}
// Now start all servers from config
for (name, server_config) in config.mcp_servers {
// Build command
let mut cmd = Command::new(&server_config.command);
cmd.args(&server_config.args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
// Set environment variables if provided
if let Some(env_vars) = &server_config.env {
for (key, value) in env_vars {
cmd.env(key, value);
}
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
// Spawn process
match cmd.spawn() {
Ok(child) => {
let handle = McpProcessHandle {
child,
request_id: Arc::new(Mutex::new(0)),
};
processes_map.insert(name.clone(), handle);
started.push(name);
}
Err(e) => {
eprintln!("Failed to start MCP server '{}': {}", name, e);
}
}
}
Ok(started)
}
// MCP JSON-RPC Communication Commands
#[command]
fn list_mcp_tools(
processes: State<'_, McpProcessMap>,
server_name: String,
) -> Result<Vec<McpTool>, String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
let handle = processes_map.get_mut(&server_name)
.ok_or_else(|| format!("MCP server '{}' not found or not running", server_name))?;
list_mcp_tools_internal(handle)
}
#[command]
fn call_mcp_tool(
processes: State<'_, McpProcessMap>,
server_name: String,
tool_name: String,
arguments: serde_json::Value,
) -> Result<serde_json::Value, String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
let handle = processes_map.get_mut(&server_name)
.ok_or_else(|| format!("MCP server '{}' not found or not running", server_name))?;
// Increment request ID
let request_id = {
let mut id = handle.request_id.lock()
.map_err(|e| format!("Failed to lock request ID: {}", e))?;
*id += 1;
*id
};
// Build params for tools/call
let params = serde_json::json!({
"name": tool_name,
"arguments": arguments
});
// Send tools/call request
send_jsonrpc_request(
&mut handle.child,
"tools/call",
Some(params),
request_id,
)?;
// Read response (wait a moment for server to process)
std::thread::sleep(Duration::from_millis(200));
let response = read_jsonrpc_response(&mut handle.child, request_id, 30)?;
// Parse result from response
if let Some(error) = response.error {
return Err(format!("MCP server error: {} ({})", error.message, error.code));
}
response.result
.ok_or_else(|| "No result in response".to_string())
}
// Helper function to list tools from a server (not a Tauri command, used internally)
fn list_mcp_tools_internal(
handle: &mut McpProcessHandle,
) -> Result<Vec<McpTool>, String> {
// Increment request ID
let request_id = {
let mut id = handle.request_id.lock()
.map_err(|e| format!("Failed to lock request ID: {}", e))?;
*id += 1;
*id
};
// Send tools/list request
send_jsonrpc_request(
&mut handle.child,
"tools/list",
None,
request_id,
)?;
// Read response (wait a moment for server to process)
std::thread::sleep(Duration::from_millis(200));
let response = read_jsonrpc_response(&mut handle.child, request_id, 10)?;
// Parse tools from response
if let Some(error) = response.error {
return Err(format!("MCP server error: {} ({})", error.message, error.code));
}
let result = response.result
.ok_or_else(|| "No result in response".to_string())?;
let tools_obj = result.get("tools")
.ok_or_else(|| "No 'tools' field in response".to_string())?
.as_array()
.ok_or_else(|| "Tools field is not an array".to_string())?;
let mut tools = Vec::new();
for tool_json in tools_obj {
let tool: McpTool = serde_json::from_value(tool_json.clone())
.map_err(|e| format!("Failed to parse tool: {}", e))?;
tools.push(tool);
}
Ok(tools)
}
#[command]
fn get_all_mcp_tools(
processes: State<'_, McpProcessMap>,
app_handle: AppHandle,
) -> Result<Vec<McpToolInfo>, String> {
let mut processes_map = processes.lock()
.map_err(|e| format!("Failed to lock processes map: {}", e))?;
let config = load_mcp_config(app_handle)?;
let mut all_tools = Vec::new();
// Get tools from each running server
for (server_name, _) in config.mcp_servers {
if let Some(handle) = processes_map.get_mut(&server_name) {
match list_mcp_tools_internal(handle) {
Ok(tools) => {
for tool in tools {
all_tools.push(McpToolInfo {
server_name: server_name.clone(),
tool,
});
}
}
Err(e) => {
eprintln!("Failed to list tools from '{}': {}", server_name, e);
}
}
}
}
Ok(all_tools)
}
#[command]
fn ensure_mcp_server_installed(
_name: String,
config: McpServerConfig,
) -> Result<bool, String> {
// Check if command exists
let command_exists = Command::new(&config.command)
.arg("--version")
.output()
.is_ok();
if !command_exists {
return Err(format!("Command '{}' not found in PATH", config.command));
}
// For npx commands with -y flag, check if package exists
// Note: This is a simplified check - in production, you might want to verify
// the package actually exists before trying to run it
if config.command == "npx" && config.args.contains(&"-y".to_string()) {
// npx -y will auto-install if needed, so we consider it available
return Ok(true);
}
// For other commands, assume they're installed if command exists
Ok(true)
}
#[command]
fn check_mcp_server_available(
name: String,
config: McpServerConfig,
) -> Result<bool, String> {
ensure_mcp_server_installed(name, config)
}
// ========== Web Scraper Commands ==========
/// Obtém ou cria uma instância do Browser (singleton)
pub fn get_or_create_browser(state: State<BrowserState>) -> Result<Arc<Browser>, String> {
let mut browser_opt = state.lock().map_err(|e| format!("Erro ao acessar estado do browser: {}", e))?;
if let Some(ref browser) = *browser_opt {
let alive = browser.new_tab().is_ok();
if alive {
return Ok(browser.clone());
} else {
*browser_opt = None;
}
}
// Criar nova instância
let browser = Arc::new(
create_browser()
.map_err(|e| format!("Falha ao criar browser: {}", e))?
);
*browser_opt = Some(browser.clone());
Ok(browser)
}
/// Busca no DuckDuckGo e extrai conteúdo das URLs encontradas
#[command]
async fn search_and_extract_content(
query: String,
limit: Option<usize>,
excluded_domains: Option<Vec<String>>,
search_config: Option<SearchConfig>,
state: State<'_, BrowserState>,
) -> Result<Vec<ScrapedContent>, String> {
if query.trim().is_empty() {
return Err("Query não pode estar vazia".to_string());
}
let browser = get_or_create_browser(state)?;
// Se SearchConfig foi fornecido, usar a nova função
if let Some(config) = search_config {
search_and_scrape_with_config(&query, &config, browser)
.await
.map_err(|e| format!("Erro ao buscar e extrair conteúdo: {}", e))
} else {
// Backward compatibility: usar configuração padrão
let limit = limit.unwrap_or(3);
let excluded_domains = excluded_domains.unwrap_or_default();
search_and_scrape(&query, limit, browser, excluded_domains)
.await
.map_err(|e| format!("Erro ao buscar e extrair conteúdo: {}", e))
}
}
/// Extrai conteúdo de uma URL específica
#[command]
async fn extract_url_content(
url: String,
state: State<'_, BrowserState>,
) -> Result<ScrapedContent, String> {
if url.trim().is_empty() {
return Err("URL não pode estar vazia".to_string());
}
// Validar formato de URL
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("URL deve começar com http:// ou https://".to_string());
}
let browser = get_or_create_browser(state)?;
scrape_url(&url, browser)
.await
.map_err(|e| format!("Erro ao extrair conteúdo da URL: {}", e))
}
/// Busca metadados leves (título/URL/snippet) sem abrir páginas
#[command]
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());
}
let lim = limit.unwrap_or(5);
// 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 {
// 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));
}
// 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))
}
}
}
/// Faz scraping em lote de URLs fornecidas
#[command]
async fn scrape_urls(
urls: Vec<String>,
state: State<'_, BrowserState>,
) -> Result<Vec<ScrapedContent>, String> {
if urls.is_empty() {
return Ok(Vec::new());
}
let browser = get_or_create_browser(state)?;
scrape_urls_bulk(urls, browser)
.await
.map_err(|e| format!("Erro ao extrair conteúdo das URLs: {}", e))
}
/// Reinicia o browser (útil se houver problemas)
#[command]
fn reset_browser(state: State<'_, BrowserState>) -> Result<(), String> {
let mut browser_opt = state.lock().map_err(|e| format!("Erro ao acessar estado do browser: {}", e))?;
// Limpar referência - o browser será dropado automaticamente
*browser_opt = None;
log::info!("Browser resetado - processo será encerrado quando não houver mais referências");
Ok(())
}
/// Força o encerramento apenas de processos Chrome/Chromium headless criados pelo app
/// Seguro: não mata o navegador pessoal do usuário
#[command]
fn force_kill_browser() -> Result<u32, String> {
let mut system = System::new_all();
system.refresh_all();
let mut killed_count = 0;
let process_names = vec!["chrome", "chromium", "chromedriver", "headless_shell"];
for (pid, process) in system.processes() {
let name = process.name().to_string_lossy().to_lowercase();
// Verifica se o nome do processo corresponde
if !process_names.iter().any(|&pn| name.contains(pn)) {
continue;
}
// SAFE KILL: Estratégia conservadora para identificar processos headless
// No Windows, tentamos usar wmic para obter a linha de comando completa
#[cfg(target_os = "windows")]
let is_headless = {
use std::process::Command;
// Tenta obter a linha de comando do processo via wmic
let cmd_output = Command::new("wmic")
.args(&["process", "where", &format!("ProcessId={}", pid), "get", "CommandLine", "/format:list"])
.output();
if let Ok(output) = cmd_output {
if let Ok(cmd_str) = String::from_utf8(output.stdout) {
let cmd_lower = cmd_str.to_lowercase();
// Só mata se tiver flags muito específicas de headless
cmd_lower.contains("--headless")
|| cmd_lower.contains("--remote-debugging-port")
|| (cmd_lower.contains("--disable-gpu") && cmd_lower.contains("--no-sandbox"))
} else {
false // Se não conseguir ler, não mata (seguro)
}
} else {
// Se wmic falhar, usa heurística conservadora: só mata se o nome for muito específico
name.contains("headless_shell") || name.contains("chromedriver")
}
};
#[cfg(not(target_os = "windows"))]
let is_headless = {
// No Linux/Mac, tenta ler /proc/PID/cmdline
use std::fs;
if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", pid)) {
let cmd_lower = cmdline.to_lowercase();
cmd_lower.contains("--headless")
|| cmd_lower.contains("--remote-debugging-port")
|| (cmd_lower.contains("--disable-gpu") && cmd_lower.contains("--no-sandbox"))
} else {
// Se não conseguir ler, usa heurística conservadora
name.contains("headless_shell") || name.contains("chromedriver")
}
};
if !is_headless {
log::debug!("Ignorando processo Chrome não-headless: PID {} ({})", pid, name);
continue;
}
// Processo identificado como headless - pode matar com segurança
#[cfg(target_os = "windows")]
{
use std::process::Command;
match Command::new("taskkill")
.args(&["/F", "/PID", &pid.to_string()])
.output()
{
Ok(output) => {
if output.status.success() {
killed_count += 1;
log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name);
}
}
Err(e) => {
log::warn!("Erro ao encerrar processo {}: {}", pid, e);
}
}
}
#[cfg(not(target_os = "windows"))]
{
use std::process::Command;
match Command::new("kill")
.args(&["-9", &pid.to_string()])
.output()
{
Ok(output) => {
if output.status.success() {
killed_count += 1;
log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name);
}
}
Err(e) => {
log::warn!("Erro ao encerrar processo {}: {}", pid, e);
}
}
}
}
if killed_count > 0 {
log::info!("Total de {} processos Chrome headless encerrados (seguro)", killed_count);
} else {
log::info!("Nenhum processo Chrome headless encontrado para encerrar");
}
Ok(killed_count)
}
// ========== Storage Management Commands ==========
/// Exporta todas as sessões de chat para um arquivo ZIP
#[command]
async fn export_chat_sessions(app_handle: AppHandle) -> Result<String, String> {
let chats_dir = get_chats_dir(&app_handle)?;
// Criar nome do arquivo com timestamp
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let export_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let zip_path = export_dir.join(format!("ollahub_export_{}.zip", timestamp));
// Criar arquivo ZIP
let file = fs::File::create(&zip_path)
.map_err(|e| format!("Failed to create ZIP file: {}", e))?;
let mut zip = ZipWriter::new(file);
let options = FileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o755);
// Ler todos os arquivos JSON do diretório chats
let entries = fs::read_dir(&chats_dir)
.map_err(|e| format!("Failed to read chats dir: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let file_name = path.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| "Invalid file name".to_string())?;
let file_content = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file {}: {}", file_name, e))?;
zip.start_file(format!("chats/{}", file_name), options)
.map_err(|e| format!("Failed to add file to ZIP: {}", e))?;
zip.write_all(file_content.as_bytes())
.map_err(|e| format!("Failed to write file to ZIP: {}", e))?;
}
}
zip.finish()
.map_err(|e| format!("Failed to finalize ZIP: {}", e))?;
Ok(format!("{}", zip_path.display()))
}
/// Apaga todo o histórico de conversas
#[command]
fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> {
use db::Database;
let chats_dir = get_chats_dir(&app_handle)?;
// 1. Deletar todos os arquivos JSON
let entries = fs::read_dir(&chats_dir)
.map_err(|e| format!("Failed to read chats dir: {}", e))?;
let mut deleted_count = 0;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(&path)
.map_err(|e| format!("Failed to delete file {:?}: {}", path, e))?;
deleted_count += 1;
}
}
// 2. Deletar todas as sessões do SQLite
match Database::new(&app_handle) {
Ok(db) => {
match db.list_sessions() {
Ok(sessions) => {
let mut sqlite_deleted = 0;
for session in sessions {
if let Err(e) = db.delete_session(&session.id) {
log::warn!("Failed to delete session {} from SQLite: {}", session.id, e);
} else {
sqlite_deleted += 1;
}
}
log::info!("Deleted {} sessions from SQLite", sqlite_deleted);
}
Err(e) => {
log::warn!("Failed to list sessions from SQLite: {}", e);
}
}
}
Err(e) => {
log::warn!("Failed to open database: {}", e);
}
}
log::info!("Deleted {} chat session files", deleted_count);
Ok(())
}
/// Limpa sessões órfãs do SQLite que não têm arquivo JSON correspondente
#[command]
fn cleanup_orphan_sessions(app_handle: AppHandle) -> Result<u32, String> {
use db::Database;
let db = Database::new(&app_handle)
.map_err(|e| format!("Failed to open database: {}", e))?;
let chats_dir = get_chats_dir(&app_handle)?;
let mut orphan_count = 0;
// Listar todas as sessões do SQLite
let sessions = db.list_sessions()
.map_err(|e| format!("Failed to list sessions: {}", e))?;
for session in sessions {
let json_path = chats_dir.join(format!("{}.json", session.id));
// Se não existe arquivo JSON correspondente, é uma sessão órfã
if !json_path.exists() {
log::info!("Found orphan session: {} (title: {}), removing from SQLite", session.id, session.title);
if let Err(e) = db.delete_session(&session.id) {
log::warn!("Failed to delete orphan session {}: {}", session.id, e);
} else {
orphan_count += 1;
}
}
}
log::info!("Cleaned up {} orphan sessions from SQLite", orphan_count);
Ok(orphan_count)
}
/// Retorna o caminho do diretório de dados do app
#[command]
fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
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
#[command]
fn load_sources_config_command(app_handle: AppHandle) -> Result<SourcesConfig, String> {
load_sources_config(&app_handle)
}
/// Salva a configuração de fontes de busca
#[command]
fn save_sources_config_command(app_handle: AppHandle, config: SourcesConfig) -> Result<(), String> {
save_sources_config(&app_handle, config)
}
// ========== Ollama Installer Download Commands ==========
/// Verifica se uma URL de download está disponível
#[command]
async fn check_download_url(url: String) -> Result<bool, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
match client.head(&url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
/// Obtém o caminho do instalador local se existir
#[command]
fn get_local_installer_path(filename: String, app_handle: AppHandle) -> Result<Option<String>, String> {
// Tentar no diretório do executável (dev e produção)
// Em desenvolvimento, os arquivos estão em public/ relativo ao projeto
// Em produção, tentamos encontrar o arquivo em vários locais possíveis
if let Ok(exe_dir) = app_handle.path().executable_dir() {
// Tentar vários caminhos possíveis
let possible_paths = vec![
// Caminho relativo ao executável (dev) - subir até a raiz do projeto
exe_dir.parent()
.and_then(|p| p.parent())
.map(|p| p.join("public").join("installers").join(&filename)),
// Caminho direto do executável (dev)
exe_dir.parent()
.map(|p| p.join("public").join("installers").join(&filename)),
// Caminho absoluto do workspace (dev) - tentar encontrar a raiz
exe_dir.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(|p| p.join("public").join("installers").join(&filename)),
// Em produção, arquivos podem estar no diretório do executável
Some(exe_dir.join("installers").join(&filename)),
// Ou no diretório pai do executável
exe_dir.parent()
.map(|p| p.join("installers").join(&filename)),
];
for path_opt in possible_paths {
if let Some(path) = path_opt {
if path.exists() {
return Ok(Some(path.to_string_lossy().to_string()));
}
}
}
}
Ok(None)
}
/// Faz download do instalador da URL oficial ou usa fallback local
#[command]
async fn download_installer(
url: String,
filename: String,
window: Window,
app_handle: AppHandle,
) -> Result<String, String> {
use std::io::Write;
use futures_util::StreamExt;
// Primeiro, tentar usar instalador local como fallback
if let Some(local_path) = get_local_installer_path(filename.clone(), app_handle.clone())? {
let local_path_buf = PathBuf::from(&local_path);
if local_path_buf.exists() {
// Copiar para app_data_dir/installers
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installers_dir = app_data_dir.join("installers");
if !installers_dir.exists() {
fs::create_dir_all(&installers_dir)
.map_err(|e| format!("Failed to create installers directory: {}", e))?;
}
let dest_path = installers_dir.join(&filename);
fs::copy(&local_path_buf, &dest_path)
.map_err(|e| format!("Failed to copy local installer: {}", e))?;
window.emit("installer-download-progress", serde_json::json!({
"progress": 100,
"status": "Concluído (versão local)"
})).ok();
return Ok(dest_path.to_string_lossy().to_string());
}
}
// Fazer download da URL
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minutos de timeout
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to download installer: {}", e))?;
if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()));
}
// Obter tamanho total do arquivo
let total_size = response.content_length().unwrap_or(0);
// Criar diretório de instaladores
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installers_dir = app_data_dir.join("installers");
if !installers_dir.exists() {
fs::create_dir_all(&installers_dir)
.map_err(|e| format!("Failed to create installers directory: {}", e))?;
}
let dest_path = installers_dir.join(&filename);
let mut file = fs::File::create(&dest_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(item) = stream.next().await {
let chunk = item.map_err(|e| format!("Failed to read chunk: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded += chunk.len() as u64;
// Emitir progresso
let progress = if total_size > 0 {
(downloaded * 100) / total_size
} else {
0
};
window.emit("installer-download-progress", serde_json::json!({
"progress": progress,
"downloaded": downloaded,
"total": total_size,
"status": format!("Baixando... {}%", progress)
})).ok();
}
window.emit("installer-download-progress", serde_json::json!({
"progress": 100,
"status": "Download concluído"
})).ok();
log::info!("Instalador baixado para: {:?}", dest_path);
Ok(dest_path.to_string_lossy().to_string())
}
/// Executa o instalador baixado
#[command]
fn run_installer(file_path: String) -> Result<(), String> {
let path = PathBuf::from(&file_path);
if !path.exists() {
return Err(format!("Instalador não encontrado: {}", file_path));
}
#[cfg(target_os = "windows")]
{
// No Windows, executar o .exe diretamente
Command::new(&path)
.spawn()
.map_err(|e| format!("Failed to run installer: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// No Linux, dar permissão de execução e executar
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms)
.map_err(|e| format!("Failed to set executable permissions: {}", e))?;
Command::new(&path)
.spawn()
.map_err(|e| format!("Failed to run installer: {}", e))?;
}
#[cfg(target_os = "macos")]
{
// No macOS, executar o .zip (precisa ser extraído primeiro)
// Por enquanto, apenas abrir o arquivo
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open installer: {}", e))?;
}
log::info!("Instalador executado: {:?}", path);
Ok(())
}
/// Verifica se o instalador já foi baixado
#[command]
fn get_downloaded_installer_path(filename: String, app_handle: AppHandle) -> Result<Option<String>, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let installer_path = app_data_dir.join("installers").join(&filename);
if installer_path.exists() {
Ok(Some(installer_path.to_string_lossy().to_string()))
} else {
Ok(None)
}
}
// ========== Export & Backup Commands ==========
/// Exporta todos os dados do app (chats, tasks, sources, settings) para um arquivo ZIP
#[command]
async fn export_all_data(app_handle: AppHandle) -> Result<String, String> {
use walkdir::WalkDir;
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Criar nome do arquivo com timestamp
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
let zip_path = app_data_dir.join(format!("ollahub_backup_{}.zip", timestamp));
// Criar arquivo ZIP
let file = fs::File::create(&zip_path)
.map_err(|e| format!("Failed to create ZIP file: {}", e))?;
let mut zip = ZipWriter::new(file);
let options = FileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o755);
// 1. Adicionar pasta chats/ inteira
let chats_dir = get_chats_dir(&app_handle)?;
if chats_dir.exists() {
for entry in WalkDir::new(&chats_dir) {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_file() {
// Obter caminho relativo a partir de chats_dir
let relative_path = path.strip_prefix(&chats_dir)
.map_err(|e| format!("Failed to get relative path: {}", e))?;
// Construir caminho no ZIP como "chats/nome_arquivo.json"
let zip_path = format!("chats/{}", relative_path.to_string_lossy().replace('\\', "/"));
let file_content = fs::read(path)
.map_err(|e| format!("Failed to read file {:?}: {}", path, e))?;
zip.start_file(zip_path, options)
.map_err(|e| format!("Failed to add file to ZIP: {}", e))?;
zip.write_all(&file_content)
.map_err(|e| format!("Failed to write file to ZIP: {}", e))?;
}
}
}
// 2. Adicionar tasks.json
let tasks_file = app_data_dir.join("tasks.json");
if tasks_file.exists() {
let tasks_content = fs::read_to_string(&tasks_file)
.map_err(|e| format!("Failed to read tasks.json: {}", e))?;
zip.start_file("tasks.json", options)
.map_err(|e| format!("Failed to add tasks.json to ZIP: {}", e))?;
zip.write_all(tasks_content.as_bytes())
.map_err(|e| format!("Failed to write tasks.json to ZIP: {}", e))?;
}
// 3. Adicionar sources.json
let sources_file = app_data_dir.join("sources.json");
if sources_file.exists() {
let sources_content = fs::read_to_string(&sources_file)
.map_err(|e| format!("Failed to read sources.json: {}", e))?;
zip.start_file("sources.json", options)
.map_err(|e| format!("Failed to add sources.json to ZIP: {}", e))?;
zip.write_all(sources_content.as_bytes())
.map_err(|e| format!("Failed to write sources.json to ZIP: {}", e))?;
} else {
// Se não existir, criar um sources.json padrão no ZIP
let default_config = SourcesConfig::default();
let default_json = serde_json::to_string_pretty(&default_config)
.map_err(|e| format!("Failed to serialize default sources config: {}", e))?;
zip.start_file("sources.json", options)
.map_err(|e| format!("Failed to add default sources.json to ZIP: {}", e))?;
zip.write_all(default_json.as_bytes())
.map_err(|e| format!("Failed to write default sources.json to ZIP: {}", e))?;
}
// 4. Adicionar settings.json (se existir)
let settings_file = app_data_dir.join("settings.json");
if settings_file.exists() {
let settings_content = fs::read_to_string(&settings_file)
.map_err(|e| format!("Failed to read settings.json: {}", e))?;
zip.start_file("settings.json", options)
.map_err(|e| format!("Failed to add settings.json to ZIP: {}", e))?;
zip.write_all(settings_content.as_bytes())
.map_err(|e| format!("Failed to write settings.json to ZIP: {}", e))?;
}
// Finalizar ZIP
zip.finish()
.map_err(|e| format!("Failed to finalize ZIP: {}", e))?;
log::info!("Backup completo exportado para: {}", zip_path.display());
Ok(format!("{}", zip_path.display()))
}
// ========== Logs Commands ==========
/// Obtém as últimas N linhas dos logs do sistema
#[command]
fn get_recent_logs(app_handle: AppHandle, lines: usize) -> Result<Vec<String>, String> {
// O tauri-plugin-log geralmente salva logs em app_data_dir/logs/
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let logs_dir = app_data_dir.join("logs");
// Se o diretório de logs não existir, retornar vazio
if !logs_dir.exists() {
return Ok(Vec::new());
}
// Procurar pelo arquivo de log mais recente
let mut log_files: Vec<_> = fs::read_dir(&logs_dir)
.map_err(|e| format!("Failed to read logs directory: {}", e))?
.filter_map(|entry| {
entry.ok().and_then(|e| {
let path = e.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("log") {
Some((path, e.metadata().ok()?.modified().ok()?))
} else {
None
}
})
})
.collect();
// Ordenar por data de modificação (mais recente primeiro)
log_files.sort_by(|a, b| b.1.cmp(&a.1));
// Ler o arquivo mais recente
if let Some((log_file_path, _)) = log_files.first() {
let content = fs::read_to_string(log_file_path)
.map_err(|e| format!("Failed to read log file: {}", e))?;
// Dividir em linhas e pegar as últimas N
let all_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let total_lines = all_lines.len();
let start = if total_lines > lines { total_lines - lines } else { 0 };
Ok(all_lines[start..].to_vec())
} else {
Ok(Vec::new())
}
}
/// Recebe logs do frontend e os imprime no terminal
#[command]
fn log_to_terminal(level: String, message: String) -> Result<(), String> {
match level.as_str() {
"info" => log::info!("{}", message),
"warn" => log::warn!("{}", message),
"error" => log::error!("{}", message),
"debug" => log::debug!("{}", message),
_ => log::info!("{}", message),
}
Ok(())
}
// ========== System Monitor Commands ==========
/// Obtém estatísticas do sistema em tempo real
#[command]
fn get_system_stats(
monitor_state: State<'_, Arc<Mutex<SystemMonitorState>>>,
) -> Result<SystemStats, String> {
let mut monitor = monitor_state.lock()
.map_err(|e| format!("Failed to lock monitor state: {}", e))?;
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]
async fn create_task(
scheduler: State<'_, SchedulerState>,
label: String,
cron_schedule: String,
action: TaskAction,
) -> Result<String, String> {
use uuid::Uuid;
let task = SentinelTask {
id: Uuid::new_v4().to_string(),
label,
cron_schedule,
action,
enabled: true,
last_run: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let mut sched = scheduler.lock().await;
sched.upsert_task(task.clone())?;
Ok(task.id)
}
#[command]
async fn list_tasks(
scheduler: State<'_, SchedulerState>,
) -> Result<Vec<SentinelTask>, String> {
let sched = scheduler.lock().await;
Ok(sched.list_tasks())
}
#[command]
async fn update_task(
scheduler: State<'_, SchedulerState>,
task: SentinelTask,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
let mut updated = task;
updated.updated_at = Utc::now();
sched.upsert_task(updated)
}
#[command]
async fn delete_task(
scheduler: State<'_, SchedulerState>,
id: String,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
sched.remove_task(&id)
}
#[command]
async fn toggle_task(
scheduler: State<'_, SchedulerState>,
id: String,
enabled: bool,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
if let Some(mut task) = sched.get_task(&id).cloned() {
task.enabled = enabled;
task.updated_at = Utc::now();
sched.upsert_task(task)
} else {
Err("Task not found".to_string())
}
}
#[command]
fn classify_intent(query: String) -> String {
use intent_classifier::{IntentClassifier, QueryIntent};
let intent = IntentClassifier::classify(&query);
match intent {
QueryIntent::Factual => "factual".to_string(),
QueryIntent::Conversational => "conversational".to_string(),
QueryIntent::Technical => "technical".to_string(),
QueryIntent::Opinion => "opinion".to_string(),
QueryIntent::Calculation => "calculation".to_string(),
QueryIntent::Unknown => "unknown".to_string(),
}
}
/// Comando principal para streaming de chat via Rust
#[command]
async fn chat_stream(
window: Window,
app_handle: AppHandle,
session_id: Option<String>,
messages: Vec<Message>,
model: String,
system_prompt: Option<String>,
enable_rag: Option<bool>,
) -> Result<String, String> {
use uuid::Uuid;
use ollama_client::OllamaClient;
use futures_util::StreamExt;
use db::{Database, ChatSession, ChatMessage};
// Gerar ou usar session_id existente
let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
let enable_rag = enable_rag.unwrap_or(false);
// Verificar se é nova sessão (apenas 1 mensagem do usuário)
let is_new_session = messages.len() == 1 && messages[0].role == "user";
// Variáveis para título e emoji (usadas depois na persistência)
let (title, emoji) = if is_new_session {
let user_input = &messages[0].content;
let ollama_client = OllamaClient::new(None);
// Tentar gerar título (com timeout curto)
let generated_title = match tokio::time::timeout(
tokio::time::Duration::from_secs(5),
ollama_client.generate_title(&model, user_input)
).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
log::warn!("Erro ao gerar título: {}. Usando fallback.", e);
// Fallback: primeiras palavras da pergunta
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
},
Err(_) => {
log::warn!("Timeout ao gerar título. Usando fallback.");
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
}
};
let generated_emoji = OllamaClient::generate_emoji(&generated_title);
// Emitir evento de chat criado
let created_event = ChatCreatedEvent {
session_id: session_id.clone(),
title: generated_title.clone(),
emoji: generated_emoji.clone(),
};
if let Err(e) = window.emit("chat-created", &created_event) {
log::warn!("Erro ao emitir evento chat-created: {}", e);
}
(generated_title, generated_emoji)
} else {
(String::new(), "💬".to_string())
};
// 2. Preparar mensagens para Ollama
let mut ollama_messages = Vec::new();
// Adicionar system prompt se fornecido
if let Some(sys_prompt) = system_prompt {
ollama_messages.push(serde_json::json!({
"role": "system",
"content": sys_prompt
}));
}
// Converter mensagens para formato Ollama
for msg in &messages {
ollama_messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content
}));
}
// 3. TODO: Classificar intent e aplicar RAG se necessário
// if enable_rag {
// let intent = classify_intent(messages.last().unwrap().content.clone());
// // Buscar contexto via RAG
// // Injetar no system prompt
// }
// 4. Fazer requisição streaming para Ollama
let ollama_client = OllamaClient::new(None);
ollama_client.check_connection().await?;
let request = serde_json::json!({
"model": model,
"messages": ollama_messages,
"stream": true
});
// Usar reqwest diretamente para streaming
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = "http://localhost:11434/api/chat";
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
if !response.status().is_success() {
let error_msg = format!("Ollama returned status: {}", response.status());
let error_event = ChatErrorEvent {
session_id: session_id.clone(),
error: error_msg.clone(),
};
let _ = window.emit("chat-error", &error_event);
return Err(error_msg);
}
// 5. Processar stream e emitir tokens COM BUFFERING
// OTIMIZAÇÃO: Acumular tokens e emitir em batches para reduzir overhead da bridge
let mut stream = response.bytes_stream();
let mut buffer = String::new();
let mut full_content = String::new();
// Buffer de tokens para reduzir eventos na bridge
let mut token_buffer = String::new();
let mut last_emit = std::time::Instant::now();
const EMIT_INTERVAL_MS: u64 = 16; // ~60fps para sincronizar com RAF do frontend
const MAX_BUFFER_CHARS: usize = 50; // Emitir quando buffer tiver ~50 chars
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk);
buffer.push_str(&chunk_str);
// Processar linhas completas (separadas por \n)
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
continue;
}
// Tentar deserializar como JSON do Ollama
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(json) => {
// Verificar se stream terminou primeiro
let is_done = json.get("done").and_then(|d| d.as_bool()) == Some(true);
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
if !content.is_empty() {
full_content.push_str(content);
token_buffer.push_str(content);
// Emitir buffer quando: tempo >= 16ms OU buffer >= 50 chars
let elapsed = last_emit.elapsed().as_millis() as u64;
if elapsed >= EMIT_INTERVAL_MS || token_buffer.len() >= MAX_BUFFER_CHARS {
let token_event = ChatTokenEvent {
session_id: session_id.clone(),
content: std::mem::take(&mut token_buffer),
done: false,
};
if let Err(e) = window.emit("chat-token", &token_event) {
log::warn!("Erro ao emitir token: {}", e);
}
last_emit = std::time::Instant::now();
}
}
}
}
// Verificar se stream terminou
if is_done {
// Flush do buffer residual antes de finalizar
if !token_buffer.is_empty() {
let flush_event = ChatTokenEvent {
session_id: session_id.clone(),
content: std::mem::take(&mut token_buffer),
done: false,
};
let _ = window.emit("chat-token", &flush_event);
}
// Emitir evento final
let final_event = ChatTokenEvent {
session_id: session_id.clone(),
content: String::new(),
done: true,
};
let _ = window.emit("chat-token", &final_event);
break;
}
}
Err(e) => {
log::debug!("Failed to parse JSON chunk: {} - Line: {}", e, line);
// Continuar mesmo com erro de parse
}
}
}
}
// 6. Persistir sessão e mensagens no SQLite
match Database::new(&app_handle) {
Ok(db) => {
let now = Utc::now();
// Criar ou atualizar sessão
let session = if is_new_session && !title.is_empty() {
ChatSession {
id: session_id.clone(),
title,
emoji,
created_at: now,
updated_at: now,
}
} else {
// Buscar sessão existente ou criar nova
match db.get_session(&session_id) {
Ok(Some(mut existing)) => {
existing.updated_at = now;
existing
}
_ => ChatSession {
id: session_id.clone(),
title: "Nova Conversa".to_string(),
emoji: "💬".to_string(),
created_at: now,
updated_at: now,
}
}
};
if let Err(e) = db.create_session(&session) {
log::warn!("Erro ao salvar sessão: {}", e);
}
// Salvar mensagens do usuário
for msg in &messages {
let chat_msg = ChatMessage {
id: None,
session_id: session_id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
metadata: msg.metadata.as_ref().and_then(|m| serde_json::to_string(m).ok()),
created_at: now,
};
if let Err(e) = db.add_message(&chat_msg) {
log::warn!("Erro ao salvar mensagem: {}", e);
}
}
// Salvar mensagem final do assistente
if !full_content.is_empty() {
let assistant_msg = ChatMessage {
id: None,
session_id: session_id.clone(),
role: "assistant".to_string(),
content: full_content,
metadata: None,
created_at: Utc::now(),
};
if let Err(e) = db.add_message(&assistant_msg) {
log::warn!("Erro ao salvar mensagem do assistente: {}", e);
}
}
}
Err(e) => {
log::warn!("Erro ao inicializar banco de dados: {}", e);
}
}
Ok(session_id)
}
// ============== COMANDOS DE EMBEDDINGS ==============
/// Baixa o modelo de embeddings se não existir
#[command]
async fn download_embedding_model(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
match embeddings::ensure_model_files(&app_data_dir).await {
Ok(_) => {
log::info!("[Embeddings] Model files ready");
Ok(true)
}
Err(e) => {
log::error!("[Embeddings] Failed to ensure model files: {}", e);
Err(format!("Failed to download model: {}", e))
}
}
}
/// Verifica se o modelo de embeddings está disponível
#[command]
fn is_embedding_model_available(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
Ok(embeddings::is_model_available(&app_data_dir))
}
/// Calcula scores de relevância para textos em relação a uma query
#[command]
fn calculate_relevance_scores(
app_handle: AppHandle,
query: String,
texts: Vec<String>,
) -> Result<Vec<(usize, f32)>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
embeddings::rank_by_relevance(&mut model, &query, &text_refs)
.map_err(|e| format!("Failed to calculate relevance: {}", e))
}
/// Gera embedding para um texto
#[command]
fn generate_embedding(
app_handle: AppHandle,
text: String,
) -> Result<Vec<f32>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
model.embed(&text)
.map_err(|e| format!("Failed to generate embedding: {}", e))
}
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
#[command]
fn prune_context(
app_handle: AppHandle,
query: String,
context: String,
max_tokens: Option<usize>,
min_score: Option<f32>,
) -> Result<String, String> {
let max_tokens = max_tokens.unwrap_or(2000);
let min_score = min_score.unwrap_or(0.3);
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Tentar usar embeddings se modelo disponível
if embeddings::is_model_available(&app_data_dir) {
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
embeddings::prune_context(&mut model, &query, &context, max_tokens, min_score)
.map_err(|e| format!("Failed to prune context: {}", e))
} else {
// Fallback para BM25-like
log::info!("[PruneContext] Using BM25 fallback (embedding model not available)");
Ok(embeddings::prune_context_bm25(&query, &context, max_tokens))
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// Plugin de notificações
app.handle().plugin(tauri_plugin_notification::init())?;
// Plugin de atualização automática
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Modificar comportamento de fechar janela (ocultar ao invés de fechar)
if let Some(window) = app.get_webview_window("main") {
let window_clone = window.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Ocultar ao invés de fechar
let _ = window_clone.hide();
api.prevent_close();
}
});
}
// Inicializar scheduler
let scheduler_service = match SchedulerService::new(app.handle().clone()) {
Ok(service) => service,
Err(e) => {
log::error!("Erro ao criar scheduler service: {}", e);
return Err(e.into());
}
};
// Usar tokio::sync::Mutex para SchedulerState (async)
let scheduler_state: SchedulerState = Arc::new(tokio::sync::Mutex::new(scheduler_service));
// Iniciar loop do scheduler em background
let app_handle = app.handle().clone();
let scheduler_clone = scheduler_state.clone();
// Inicializar Ollama automaticamente se estiver instalado
tauri::async_runtime::spawn(async move {
// Aguardar um pouco para o app inicializar completamente
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
// Tentar iniciar Ollama automaticamente
if let Err(e) = auto_start_ollama().await {
log::warn!("Falha ao iniciar Ollama automaticamente: {}", e);
}
});
// BrowserState não é mais necessário - o scheduler criará o browser quando necessário
// Usar o runtime async do Tauri ao invés de tokio::spawn
tauri::async_runtime::spawn(async move {
if let Err(e) = scheduler_loop::start_scheduler_loop(
app_handle,
scheduler_clone,
None, // BrowserState não é mais necessário
None, // Ollama URL - pode vir do settings store
).await {
log::error!("Erro ao iniciar scheduler: {}", e);
}
});
// Adicionar scheduler ao manage
app.manage(scheduler_state.clone());
// Inicializar System Monitor State
let monitor_state: Arc<Mutex<SystemMonitorState>> = Arc::new(Mutex::new(SystemMonitorState::new()));
app.manage(monitor_state);
Ok(())
})
.manage(Arc::new(Mutex::new(None::<Arc<Browser>>)) as BrowserState)
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
.invoke_handler(tauri::generate_handler![
chat_stream,
check_ollama_installed,
check_ollama_running,
get_system_specs,
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,
load_chat_sessions,
toggle_devtools,
search_chat_sessions,
load_chat_history,
load_chat_history_paginated,
delete_chat_session,
cleanup_orphan_sessions,
load_mcp_config,
save_mcp_config,
get_mcp_config_path_command,
start_mcp_server,
stop_mcp_server,
restart_mcp_server,
list_mcp_server_status,
restart_all_mcp_servers,
list_mcp_tools,
call_mcp_tool,
get_all_mcp_tools,
ensure_mcp_server_installed,
check_mcp_server_available,
search_and_extract_content,
extract_url_content,
search_web_metadata,
scrape_urls,
reset_browser,
force_kill_browser,
export_chat_sessions,
export_all_data,
clear_chat_history,
get_app_data_dir,
load_sources_config_command,
save_sources_config_command,
get_recent_logs,
log_to_terminal,
get_system_stats,
create_task,
list_tasks,
update_task,
delete_task,
toggle_task,
check_download_url,
get_local_installer_path,
download_installer,
run_installer,
get_downloaded_installer_path,
check_ollama_full,
auto_start_ollama,
classify_intent,
// Embeddings commands
download_embedding_model,
is_embedding_model_available,
calculate_relevance_scores,
generate_embedding,
prune_context
])
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}