import { invoke } from '@tauri-apps/api/core'; import { SearchCategory, useSettingsStore } from '@/store/settings-store'; import { retryWithBackoff, withTimeout, isRetryableError, type RetryConfig } from '@/lib/retry-utils'; export interface ScrapedContent { title: string; url: string; content: string; markdown: string; cached?: boolean; } export interface SearchConfig { maxConcurrentTabs: number; totalSourcesLimit: number; categories: SearchCategory[]; userCustomSites: string[]; excludedDomains: string[]; } export interface SearchResultMetadata { title: string; url: string; snippet: string; } interface CachedResult { results: ScrapedContent[]; timestamp: number; query: string; } interface PendingRequest { query: string; limit: number; excludedDomains: string[]; searchConfig?: SearchConfig; resolve: (results: ScrapedContent[]) => void; reject: (error: Error) => void; } /** * Serviço de Web Search com cache e rate limiting */ class WebSearchService { private cache: Map = new Map(); private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hora private readonly RATE_LIMIT_DELAY = 5000; // 5 segundos private lastSearchTime = 0; private pendingRequest: PendingRequest | null = null; private rateLimitQueue: PendingRequest[] = []; constructor() { // Carregar cache do localStorage na inicialização this.loadCacheFromStorage(); } /** * Carrega cache do localStorage */ private loadCacheFromStorage(): void { // Verificar se estamos no ambiente do browser (não SSR) if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return; } try { const stored = localStorage.getItem('webSearchCache'); if (stored) { const parsed = JSON.parse(stored) as Array<[string, CachedResult]>; const now = Date.now(); // Filtrar entradas expiradas for (const [key, value] of parsed) { if (now - value.timestamp < this.CACHE_TTL) { this.cache.set(key, value); } } } } catch (error) { console.warn('Erro ao carregar cache do Web Search:', error); } } /** * Salva cache no localStorage */ private saveCacheToStorage(): void { // Verificar se estamos no ambiente do browser (não SSR) if (typeof window === 'undefined' || typeof localStorage === 'undefined') { return; } try { const entries = Array.from(this.cache.entries()); localStorage.setItem('webSearchCache', JSON.stringify(entries)); } catch (error) { console.warn('Erro ao salvar cache do Web Search:', error); } } /** * Gera chave de cache baseada na query */ private getCacheKey(query: string, limit?: number): string { const normalizedQuery = query.toLowerCase().trim(); return `search:${normalizedQuery}:${limit || 3}`; } /** * Verifica se há resultado em cache válido */ private getCachedResult(query: string, limit?: number): ScrapedContent[] | null { const key = this.getCacheKey(query, limit); const cached = this.cache.get(key); if (!cached) return null; const now = Date.now(); if (now - cached.timestamp > this.CACHE_TTL) { // Cache expirado, remover this.cache.delete(key); this.saveCacheToStorage(); return null; } return cached.results; } /** * Salva resultado no cache */ private setCachedResult(query: string, results: ScrapedContent[], limit?: number): void { const key = this.getCacheKey(query, limit); this.cache.set(key, { results, timestamp: Date.now(), query: query.toLowerCase().trim(), }); this.saveCacheToStorage(); } /** * Processa fila de rate limiting */ private async processRateLimitQueue(): Promise { if (this.rateLimitQueue.length === 0) return; const now = Date.now(); const timeSinceLastSearch = now - this.lastSearchTime; if (timeSinceLastSearch < this.RATE_LIMIT_DELAY) { // Aguardar antes de processar próximo const waitTime = this.RATE_LIMIT_DELAY - timeSinceLastSearch; await new Promise(resolve => setTimeout(resolve, waitTime)); } const nextRequest = this.rateLimitQueue.shift(); if (nextRequest) { this.lastSearchTime = Date.now(); this.pendingRequest = nextRequest; try { const results = await this.executeSearch( nextRequest.query, nextRequest.limit, nextRequest.excludedDomains, nextRequest.searchConfig ); nextRequest.resolve(results); } catch (error) { nextRequest.reject(error instanceof Error ? error : new Error('Erro desconhecido')); } finally { this.pendingRequest = null; // Processar próximo da fila this.processRateLimitQueue(); } } } /** * Executa busca no backend (sem cache/rate limit) */ private async executeSearch( query: string, limit: number = 3, excludedDomains: string[] = [], searchConfig?: SearchConfig ): Promise { try { // Se SearchConfig foi fornecido, converter para formato Rust let rustConfig: any = undefined; if (searchConfig) { rustConfig = { max_concurrent_tabs: searchConfig.maxConcurrentTabs, total_sources_limit: searchConfig.totalSourcesLimit, categories: searchConfig.categories.map(cat => ({ id: cat.id, name: cat.name, base_sites: cat.baseSites, enabled: cat.enabled, })), user_custom_sites: searchConfig.userCustomSites, excluded_domains: searchConfig.excludedDomains, }; } const results = await invoke('search_and_extract_content', { query: query.trim(), limit: searchConfig ? undefined : limit, // Só usar limit se não tiver config excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined), search_config: rustConfig, }); return results || []; } catch (error) { console.error('Erro ao buscar conteúdo:', error); throw error instanceof Error ? error : new Error('Falha ao buscar conteúdo na web'); } } /** * Executa busca de metadados (sem scraping) */ private async executeMetadataSearch( query: string, limit: number = 5, searchConfig?: SearchConfig, timeoutMs: number = 15000 ): Promise { // Obter configurações de motores do store const settings = useSettingsStore.getState(); const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage']; let rustConfig: any = undefined; if (searchConfig) { rustConfig = { max_concurrent_tabs: searchConfig.maxConcurrentTabs, total_sources_limit: searchConfig.totalSourcesLimit, categories: searchConfig.categories.map(cat => ({ id: cat.id, name: cat.name, base_sites: cat.baseSites, enabled: cat.enabled, })), user_custom_sites: searchConfig.userCustomSites, excluded_domains: searchConfig.excludedDomains, }; } // Log da tentativa console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`); console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`); console.log(`[WebSearch] Limit: ${limit}, Timeout: ${timeoutMs}ms`); // Executar com retry e timeout const retryResult = await retryWithBackoff( async () => { const startTime = Date.now(); try { const promise = invoke('search_web_metadata', { query: query.trim(), limit, search_config: rustConfig, engineOrder: engineOrder, // Passar ordem de motores }); const result = await withTimeout( promise, timeoutMs, `Timeout ao buscar metadados para "${query}"` ); const duration = Date.now() - startTime; console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`); return result; } catch (error) { const duration = Date.now() - startTime; const errorMsg = error instanceof Error ? error.message : String(error); console.error(`[WebSearch] Erro após ${duration}ms:`, errorMsg); throw error; } }, { maxAttempts: 3, initialDelay: 1000, maxDelay: 5000, } ); if (retryResult.success && retryResult.result) { const results = retryResult.result || []; if (results.length === 0) { console.warn(`[WebSearch] Nenhum resultado encontrado para: "${query}"`); console.warn(`[WebSearch] Tentativas: ${retryResult.attempts}, Motores testados: ${engineOrder.join(', ')}`); } return results; } // Se falhou mas é erro recuperável, logar detalhadamente if (retryResult.lastError && isRetryableError(retryResult.lastError)) { console.warn( `[WebSearch] Falha após ${retryResult.attempts} tentativas:`, retryResult.lastError.message ); console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`); console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); return []; } // Para erros não recuperáveis, logar detalhadamente if (retryResult.lastError) { console.error( `[WebSearch] Erro não recuperável:`, retryResult.lastError.message ); console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`); console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); } return []; } /** * Busca conteúdo na web com cache e rate limiting */ async search( query: string, limit: number = 3, excludedDomains: string[] = [], searchConfig?: SearchConfig ): Promise { if (!query || !query.trim()) { return []; } // Verificar cache primeiro const cached = this.getCachedResult(query, limit); if (cached) { return cached.map(r => ({ ...r, cached: true })); } // Se já há uma requisição pendente para a mesma query, aguardar if (this.pendingRequest && this.pendingRequest.query.toLowerCase() === query.toLowerCase()) { return new Promise((resolve, reject) => { const checkPending = () => { if (this.pendingRequest?.query.toLowerCase() === query.toLowerCase()) { setTimeout(checkPending, 100); } else { // Tentar cache novamente após requisição completar const cachedAfter = this.getCachedResult(query, limit); if (cachedAfter) { resolve(cachedAfter.map(r => ({ ...r, cached: true }))); } else { reject(new Error('Requisição pendente cancelada')); } } }; checkPending(); }); } // Verificar rate limiting const now = Date.now(); const timeSinceLastSearch = now - this.lastSearchTime; if (timeSinceLastSearch < this.RATE_LIMIT_DELAY) { // Adicionar à fila (armazenar excludedDomains na requisição) return new Promise((resolve, reject) => { this.rateLimitQueue.push({ query, limit, excludedDomains, searchConfig, resolve: (results) => { this.setCachedResult(query, results, limit); resolve(results); }, reject, }); this.processRateLimitQueue(); }); } // Executar imediatamente this.lastSearchTime = now; this.pendingRequest = { query, limit, excludedDomains, searchConfig, resolve: () => {}, reject: () => {} }; try { const results = await this.executeSearch(query, limit, excludedDomains, searchConfig); this.setCachedResult(query, results, limit); this.pendingRequest = null; return results; } catch (error) { this.pendingRequest = null; throw error; } finally { // Processar fila se houver this.processRateLimitQueue(); } } /** * Busca em duas etapas: metadados → scraping das melhores URLs */ async smartSearchRag( query: string, limit: number = 3, searchConfig?: SearchConfig, timeoutMs: number = 15000 ): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> { if (!query || !query.trim()) { return { metadata: [], contents: [] }; } // Buscar metadados com retry e timeout (já implementado em executeMetadataSearch) const metas = await this.executeMetadataSearch( query, Math.max(limit * 2, 5), searchConfig, timeoutMs ); // Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente) const topUrls = metas.map(m => m.url).slice(0, limit); if (topUrls.length === 0) { // Retornar resultados parciais (metadados vazios mas sem erro) return { metadata: metas, contents: [] }; } // Tentar fazer scraping mesmo se metadados foram parciais try { const contents = await withTimeout( invoke('scrape_urls', { urls: topUrls }), timeoutMs, `Timeout ao fazer scraping para "${query}"` ); return { metadata: metas, contents: contents || [] }; } catch (error) { // Logar erro mas retornar metadados disponíveis (resultado parcial) const errorMessage = error instanceof Error ? error.message : String(error); console.warn('Erro ao fazer scraping em lote (continuando com metadados):', errorMessage); // Retornar metadados mesmo se scraping falhou return { metadata: metas, contents: [] }; } } /** * Extrai conteúdo de uma URL específica */ async extractUrl(url: string): Promise { try { const result = await invoke('extract_url_content', { url }); return result; } catch (error) { console.error('Erro ao extrair URL:', error); throw error instanceof Error ? error : new Error('Falha ao extrair conteúdo da URL'); } } /** * Limpa o cache */ clearCache(): void { this.cache.clear(); // Verificar se estamos no ambiente do browser (não SSR) if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { localStorage.removeItem('webSearchCache'); } } /** * Reseta o rate limiter (útil para testes ou após erros) */ resetRateLimit(): void { this.lastSearchTime = 0; this.pendingRequest = null; this.rateLimitQueue = []; } } // Singleton export const webSearchService = new WebSearchService();