From 8c546f6474c2bfb3a2b24c3e9949d40518e18858 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 17:32:46 -0300 Subject: [PATCH] feat(chat): implement adaptive timeout and enhanced search functionality - Introduced adaptive timeout logic for web searches and chat streaming, improving responsiveness and efficiency. - Updated `smartSearchRag` method to support timeout adjustments based on search rounds. - Enhanced `executeProgressiveSearch` to utilize adaptive timeouts and improve search result handling. - Added new utility functions for calculating adaptive timeouts based on rounds and attempts. - Integrated event listeners for chat session management, improving user experience during chat interactions. - Refactored chat message handling to accommodate new streaming and timeout features. --- app/chat/page.tsx | 8 +- hooks/use-chat.ts | 375 ++++++++++++----------------- hooks/use-web-search.ts | 8 +- lib/retry-utils.ts | 38 +++ lib/web-search-fallback.ts | 66 ++++- services/engine-circuit-breaker.ts | 191 +++++++++++++++ services/failure-cache.ts | 160 ++++++++++++ services/webSearch.ts | 210 +++++++++++++--- src-tauri/Cargo.lock | 56 +++++ src-tauri/Cargo.toml | 1 + src-tauri/src/db.rs | 308 +++++++++++++++++++++++ src-tauri/src/lib.rs | 289 ++++++++++++++++++++++ src-tauri/src/ollama_client.rs | 108 ++++++++- 13 files changed, 1547 insertions(+), 271 deletions(-) create mode 100644 services/engine-circuit-breaker.ts create mode 100644 services/failure-cache.ts create mode 100644 src-tauri/src/db.rs diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 7c36bdd..486161d 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -397,8 +397,9 @@ export default function ChatPage() { // Executar busca progressiva com semantic search se disponível const fallbackResult = await executeProgressiveSearch( query, - async (q, limit) => { - return await webSearch.smartSearchRag(q, limit); + async (q, limit, round = 1) => { + // Passar round para timeout adaptativo + return await webSearch.smartSearchRag(q, limit, undefined, undefined, round); }, selectedModel, { @@ -410,6 +411,9 @@ export default function ChatPage() { useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas context: context || undefined, enrichedQueries: enrichedQueries || undefined, + initialTimeout: settings.webSearch.timeout || 10000, // Usar timeout das configurações + maxTimeoutPerQuery: 25000, // 25s máximo por query + maxTimeoutPerRound: 15000, // 15s máximo por round } ); diff --git a/hooks/use-chat.ts b/hooks/use-chat.ts index 73cbd45..491c031 100644 --- a/hooks/use-chat.ts +++ b/hooks/use-chat.ts @@ -1,5 +1,6 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import { removeMetadataNoise } from '@/lib/metadata'; import { useSettingsStore } from '@/store/settings-store'; @@ -214,11 +215,124 @@ export async function executeToolCall(toolCall: ToolCall): Promise { } } +// Tipos para eventos Tauri +interface ChatCreatedEvent { + session_id: string; + title: string; + emoji: string; +} + +interface ChatTokenEvent { + session_id: string; + content: string; + done: boolean; +} + +interface ChatErrorEvent { + session_id: string; + error: string; +} + export function useChat() { const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const abortControllerRef = useRef(null); const rawContentRef = useRef(''); // Track raw content including metadata during streaming + const currentSessionIdRef = useRef(null); + const lastTokenRef = useRef(''); // Track last token to detect duplicates + + // Listener para eventos do Rust + useEffect(() => { + let unlistenCreated: (() => void) | null = null; + let unlistenToken: (() => void) | null = null; + let unlistenError: (() => void) | null = null; + + // Listener para chat-created (título gerado) + listen('chat-created', (event) => { + const { session_id, title, emoji } = event.payload; + currentSessionIdRef.current = session_id; + // TODO: Atualizar sidebar com novo chat (será implementado no componente de sidebar) + console.log('Chat criado:', { session_id, title, emoji }); + }).then(unlisten => { + unlistenCreated = unlisten; + }); + + // Listener para chat-token (streaming de tokens) + listen('chat-token', (event) => { + const { content, done } = event.payload; + + if (content && content.length > 0) { + // Verificar se este é o mesmo token do último processado (evitar duplicação) + // Isso pode acontecer se o evento for emitido múltiplas vezes + if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) { + // Token duplicado, ignorar + if (process.env.NODE_ENV === 'development') { + console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30)); + } + return; + } + + // Atualizar referência do último token + lastTokenRef.current = content; + + // Accumulate raw content (including metadata) in ref + rawContentRef.current += content; + + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant') { + // For display: strip incomplete metadata tag if present + const displayContent = stripMetadataTag(rawContentRef.current); + return [ + ...prev.slice(0, -1), + { ...last, content: displayContent } + ]; + } + // Se não existe mensagem do assistente, criar uma + return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }]; + }); + } + + if (done) { + // Stream terminou, processar metadata final + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant') { + const rawContent = rawContentRef.current; + const { content: finalContent, metadata } = parseMetadata(rawContent); + rawContentRef.current = ''; + lastTokenRef.current = ''; // Reset last token tracker + + return [ + ...prev.slice(0, -1), + { ...last, content: finalContent || last.content, metadata } + ]; + } + return prev; + }); + setIsLoading(false); + } + }).then(unlisten => { + unlistenToken = unlisten; + }); + + // Listener para chat-error + listen('chat-error', (event) => { + const { error } = event.payload; + console.error('Erro no chat:', error); + setIsLoading(false); + // TODO: Mostrar erro ao usuário + }).then(unlisten => { + unlistenError = unlisten; + }); + + // Cleanup + return () => { + unlistenCreated?.(); + unlistenToken?.(); + unlistenError?.(); + }; + }, []); const sendMessage = async ( content: string, @@ -325,235 +439,47 @@ export function useChat() { console.groupEnd(); // [DEBUG INJECTION END] - abortControllerRef.current = new AbortController(); + // Reset raw content buffer for this message + rawContentRef.current = ''; + lastTokenRef.current = ''; // Reset last token tracker + + // Add empty assistant message to start streaming into + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant' && last.content === '') { + // Já existe mensagem vazia, não adicionar outra + return prev; + } + // Adicionar mensagem vazia do assistente + return [...prev, { role: 'assistant', content: '' }]; + }); try { - // Função auxiliar para tentar enviar mensagem (usada no retry) - const attemptSend = async (): Promise => { - // Verificar se Ollama está rodando antes de fazer requisição - try { - const isRunning = await invoke('check_ollama_running'); - if (!isRunning) { - throw new Error('OllamaNotRunning'); - } - } catch (checkError: any) { - if (checkError.message === 'OllamaNotRunning') { - throw checkError; - } - console.warn('Erro ao verificar status do Ollama:', checkError); - // Continuar mesmo se a verificação falhar + // Verificar se Ollama está rodando antes de fazer requisição + try { + const isRunning = await invoke('check_ollama_running'); + if (!isRunning) { + throw new Error('OllamaNotRunning'); } - - // Timeout configurável (padrão 5 minutos) - const timeoutMs = 5 * 60 * 1000; // 5 minutos - const timeoutId = setTimeout(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }, timeoutMs); - - let response: Response; - try { - response = await fetch('http://localhost:11434/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages: apiMessages, - stream: true, - }), - signal: abortControllerRef.current.signal, - }); - } catch (fetchError: any) { - clearTimeout(timeoutId); - if (fetchError.name === 'AbortError') { - throw new Error('RequestTimeout'); - } - if (fetchError.message?.includes('Failed to fetch') || fetchError.message?.includes('NetworkError')) { - throw new Error('ConnectionError'); - } - throw fetchError; - } - - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 404) { - throw new Error('ModelNotFound'); - } - if (response.status === 500) { - throw new Error('ServerError'); - } - throw new Error(`HTTPError: ${response.status}`); - } - if (!response.body) throw new Error('NoResponseBody'); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - // Reset raw content buffer for this message - rawContentRef.current = ''; - - // Add empty assistant message to start streaming into - // Verificar se já existe uma mensagem vazia do assistente (adicionada pelo handleSend) - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant' && last.content === '') { - // Já existe mensagem vazia, não adicionar outra - return prev; - } - // Adicionar mensagem vazia do assistente - return [...prev, { role: 'assistant', content: '' }]; - }); - - let streamFinished = false; - let metadataProcessed = false; - - while (true) { - const { done, value } = await reader.read(); - if (done) { - streamFinished = true; - break; - } - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n').filter(line => line.trim() !== ''); - - for (const line of lines) { - // Validação básica: verificar se a linha parece ser JSON válido - const trimmedLine = line.trim(); - if (!trimmedLine || trimmedLine.length === 0) { - continue; // Linha vazia, ignorar - } - - // Verificar se começa com { e termina com } (formato básico de JSON) - if (!trimmedLine.startsWith('{') || !trimmedLine.endsWith('}')) { - // Linha parcial ou inválida, ignorar silenciosamente - // Isso pode acontecer quando o streaming divide JSON em múltiplas chunks - continue; - } - - try { - const json = JSON.parse(trimmedLine); - if (json.message?.content) { - // Accumulate raw content (including metadata) in ref - rawContentRef.current += json.message.content; - - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last.role === 'assistant') { - // For display: strip incomplete metadata tag if present (opening tag but not closed) - // This prevents showing "..." in the UI while streaming - const displayContent = stripMetadataTag(rawContentRef.current); - return [ - ...prev.slice(0, -1), - { ...last, content: displayContent } - ]; - } - return prev; - }); - } - if (json.done) { - streamFinished = true; - metadataProcessed = true; - - // Process metadata FIRST before setting isLoading to false - // This prevents race condition with saveSession useEffect - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last.role === 'assistant') { - // Parse metadata from raw accumulated content - const rawContent = rawContentRef.current; - const { content, metadata } = parseMetadata(rawContent); - - // Fallback: if content is empty after parse but we had content before, - // keep the stripped version (without metadata tag) to preserve user-visible text - const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent)); - - // Clear ref for next message - rawContentRef.current = ''; - - return [ - ...prev.slice(0, -1), - { ...last, content: finalContent, metadata } - ]; - } - return prev; - }); - - // Set loading to false AFTER processing metadata - setIsLoading(false); - break; // Exit inner loop when done - } - } catch (parseError) { - // Log apenas em desenvolvimento para debug, não quebrar o fluxo - if (process.env.NODE_ENV === 'development') { - const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); - if (!errorMessage.includes('Unexpected end') && !errorMessage.includes('Unexpected token')) { - console.warn('Failed to parse JSON chunk:', errorMessage); - } - } - // Continuar processamento mesmo se uma linha falhar - continue; - } - } - } - - // If stream finished without explicit json.done, process metadata anyway - if (streamFinished && !metadataProcessed && rawContentRef.current) { - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant') { - const rawContent = rawContentRef.current; - const { content, metadata } = parseMetadata(rawContent); - const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent)); - rawContentRef.current = ''; - - return [ - ...prev.slice(0, -1), - { ...last, content: finalContent, metadata } - ]; - } - return prev; - }); - setIsLoading(false); - } else if (streamFinished && !metadataProcessed) { - // Stream finished but no content to process, just set loading to false - setIsLoading(false); - } - }; - - // Tentar enviar com retry logic - let lastError: any = null; - const maxRetries = 2; // Máximo de 2 retries (3 tentativas no total) - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - await attemptSend(); - return; // Sucesso, sair do loop - } catch (error: any) { - lastError = error; - - // Não fazer retry para certos erros - if (error.message === 'ModelNotFound' || - error.message === 'OllamaNotRunning' || - error.message === 'ConnectionError' || - error.name === 'AbortError' || - attempt === maxRetries) { // Última tentativa - break; - } - - // Aguardar antes de tentar novamente (backoff exponencial) - if (attempt < maxRetries) { - const delay = Math.min(1000 * Math.pow(2, attempt), 5000); - console.log(`Tentativa ${attempt + 1} falhou, tentando novamente em ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - } + } catch (checkError: any) { + if (checkError.message === 'OllamaNotRunning') { + throw checkError; } + console.warn('Erro ao verificar status do Ollama:', checkError); + // Continuar mesmo se a verificação falhar } + + // Chamar comando Rust para streaming + await invoke('chat_stream', { + sessionId: currentSessionIdRef.current || null, + messages: apiMessages, + model, + systemPrompt: systemPrompt || null, + enableRag: false, // TODO: Implementar RAG depois + }); - // Se chegou aqui, todas as tentativas falharam - throw lastError; + // O streaming será processado pelos listeners de eventos + // Não precisamos fazer mais nada aqui, os eventos cuidam do resto } catch (error: any) { console.error('Chat error:', error); @@ -598,6 +524,7 @@ export function useChat() { const clearChat = () => { setMessages([]); rawContentRef.current = ''; + lastTokenRef.current = ''; }; return { messages, setMessages, sendMessage, isLoading, stop, clearChat }; diff --git a/hooks/use-web-search.ts b/hooks/use-web-search.ts index 0fd6f0a..c09fec6 100644 --- a/hooks/use-web-search.ts +++ b/hooks/use-web-search.ts @@ -121,8 +121,9 @@ export function useWebSearch() { /** * Pipeline em duas etapas: metadados → scraping das top URLs + * Agora suporta timeout adaptativo por round */ - const smartSearchRag = useCallback(async (query: string, limit: number = 3): Promise => { + const smartSearchRag = useCallback(async (query: string, limit: number = 3, round?: number): Promise => { if (!query || !query.trim()) { return []; } @@ -147,12 +148,13 @@ export function useWebSearch() { // Etapa 1: metadados setState(prev => ({ ...prev, status: 'searching' })); - const timeoutMs = settings.webSearch.timeout || 15000; + const timeoutMs = settings.webSearch.timeout || 10000; // Reduzido de 15s para 10s const { metadata, contents } = await webSearchService.smartSearchRag( query, limit, searchConfig, - timeoutMs + timeoutMs, + round || 1 ); // Etapa 2: scraping diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts index 8370eff..f376521 100644 --- a/lib/retry-utils.ts +++ b/lib/retry-utils.ts @@ -127,5 +127,43 @@ export function withTimeout( ]); } +/** + * Calcula timeout adaptativo baseado no round + * Reduz 20% a cada round: 10s → 8s → 6s → 4.8s + */ +export function calculateAdaptiveTimeout( + round: number, + initialTimeout: number = 10000, + reductionRate: number = 0.2 +): number { + const timeout = initialTimeout * Math.pow(1 - reductionRate, round - 1); + return Math.max(timeout, 5000); // Mínimo de 5s +} + +/** + * Calcula timeout por motor baseado em tentativas + * Reduz após cada falha: 10s → 7s → 5s + */ +export function calculateEngineTimeout( + attempt: number, + initialTimeout: number = 10000, + reductionRate: number = 0.3 +): number { + const timeout = initialTimeout * Math.pow(1 - reductionRate, attempt - 1); + return Math.max(timeout, 3000); // Mínimo de 3s +} + +/** + * Configurações otimizadas de timeout + */ +export const TIMEOUT_CONFIG = { + initialTimeout: 10000, // 10s (reduzido de 15s) + timeoutReductionPerRound: 0.2, // 20% + maxTimeoutPerQuery: 25000, // 25s total por query + maxTimeoutPerRound: 15000, // 15s por round + minTimeout: 5000, // Mínimo absoluto + minEngineTimeout: 3000, // Mínimo por motor +} as const; + diff --git a/lib/web-search-fallback.ts b/lib/web-search-fallback.ts index f560dde..71d07ae 100644 --- a/lib/web-search-fallback.ts +++ b/lib/web-search-fallback.ts @@ -11,6 +11,7 @@ import type { ScrapedContent } from '@/services/webSearch'; import type { QueryContext } from './contextual-analyzer'; import type { EnrichedQueries } from './query-enricher'; import { chatLog } from '@/lib/terminal-logger'; +import { calculateAdaptiveTimeout, TIMEOUT_CONFIG } from '@/lib/retry-utils'; export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual'; @@ -29,6 +30,12 @@ export interface FallbackConfig { useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false) context?: QueryContext; // Contexto analisado (para semantic search) enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search) + initialTimeout?: number; // Timeout inicial em ms (padrão: 10000) + maxTimeoutPerQuery?: number; // Timeout máximo total por query (padrão: 25000) + maxTimeoutPerRound?: number; // Timeout máximo por round (padrão: 15000) + minResultsForEarlyExit?: number; // Mínimo de resultados para early exit (padrão: 5) + minRelevanceForEarlyExit?: number; // Relevância mínima para early exit (padrão: 0.7) + maxRoundsBeforePartial?: number; // Máximo de rounds antes de aceitar parciais (padrão: 2) } export interface FallbackAttempt { @@ -58,6 +65,12 @@ const DEFAULT_CONFIG: FallbackConfig = { maxTotalResults: 40, minRelevanceScore: 0.3, enableQueryExpansion: true, + initialTimeout: TIMEOUT_CONFIG.initialTimeout, + maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery, + maxTimeoutPerRound: TIMEOUT_CONFIG.maxTimeoutPerRound, + minResultsForEarlyExit: 5, + minRelevanceForEarlyExit: 0.7, + maxRoundsBeforePartial: 2, }; /** @@ -263,7 +276,7 @@ function createSemanticRounds( */ export async function executeProgressiveSearch( originalQuery: string, - searchFn: (query: string, limit: number) => Promise, + searchFn: (query: string, limit: number, round?: number) => Promise, model: string, config: Partial = {} ): Promise { @@ -272,10 +285,13 @@ export async function executeProgressiveSearch( let totalResultsAnalyzed = 0; let allResults: ScrapedContent[] = []; const seenUrls = new Set(); + const queryStartTime = Date.now(); + const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery; chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`); chatLog.info(`Original Query: "${originalQuery}"`); chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`); + chatLog.info(`Timeout config: initial=${finalConfig.initialTimeout}ms, max per query=${maxQueryTimeout}ms, max per round=${finalConfig.maxTimeoutPerRound}ms`); // Se usar semantic search, criar rounds baseados em estratégias let rounds: Array<{ round: number; config: RoundConfig }> = []; @@ -305,13 +321,26 @@ export async function executeProgressiveSearch( rounds = rounds.slice(0, finalConfig.maxRounds); for (const { round, config: roundConfig } of rounds) { + // Verificar timeout total da query + const elapsedTime = Date.now() - queryStartTime; + if (elapsedTime >= maxQueryTimeout) { + chatLog.warn(`[Round ${round}] Timeout total da query atingido (${elapsedTime}ms >= ${maxQueryTimeout}ms), parando busca`); + break; + } + + // Calcular timeout adaptativo para este round + const roundTimeout = calculateAdaptiveTimeout(round, finalConfig.initialTimeout || TIMEOUT_CONFIG.initialTimeout); + const maxRoundTimeout = finalConfig.maxTimeoutPerRound || TIMEOUT_CONFIG.maxTimeoutPerRound; + const actualRoundTimeout = Math.min(roundTimeout, maxRoundTimeout); + chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`); + chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`); const roundStartTime = Date.now(); let roundResults: ScrapedContent[] = []; let roundQueries: string[] = []; - // Executar todas as queries do round em paralelo + // Executar todas as queries do round em paralelo com timeout por round const queryPromises = roundConfig.queries.map(async (query) => { // Expandir query se necessário (modo tradicional) const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch @@ -319,7 +348,8 @@ export async function executeProgressiveSearch( : query; try { - const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound); + // Passar round para searchFn para timeout adaptativo + const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round); return { query: finalQuery, results }; } catch (error) { chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error); @@ -327,7 +357,23 @@ export async function executeProgressiveSearch( } }); - const queryResults = await Promise.all(queryPromises); + // Adicionar timeout total para o round + const roundPromise = Promise.all(queryPromises); + type QueryResult = { query: string; results: ScrapedContent[] }; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Round ${round} timeout após ${actualRoundTimeout}ms`)), actualRoundTimeout); + }); + + let queryResults: QueryResult[]; + try { + queryResults = await Promise.race([roundPromise, timeoutPromise]); + } catch (error) { + chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`); + // Tentar obter resultados parciais das promises que já completaram + queryResults = await Promise.allSettled(queryPromises).then(results => + results.map(r => r.status === 'fulfilled' ? r.value : { query: '', results: [] }) + ); + } // Agregar resultados de todas as queries do round for (const { query, results } of queryResults) { @@ -388,12 +434,14 @@ export async function executeProgressiveSearch( allResults.push(...roundResults); totalResultsAnalyzed += roundResults.length; - // Verificar se encontrou resultados relevantes - const queryForRelevance = roundQueries[0] || originalQuery; - if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) { - chatLog.info(`[Round ${round}] ✓ Relevant results found!`); + // Verificar early exit: resultados suficientes com alta relevância + const minResultsForEarlyExit = finalConfig.minResultsForEarlyExit || 5; + const minRelevanceForEarlyExit = finalConfig.minRelevanceForEarlyExit || 0.7; + + if (roundResults.length >= minResultsForEarlyExit && relevanceScore >= minRelevanceForEarlyExit) { + chatLog.info(`[Round ${round}] 🎯 Early exit: resultados suficientes encontrados!`); + chatLog.info(`[Round ${round}] Results: ${roundResults.length} (mínimo: ${minResultsForEarlyExit}), Relevance: ${relevanceScore.toFixed(2)} (mínimo: ${minRelevanceForEarlyExit})`); chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`); - chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`); return { success: true, diff --git a/services/engine-circuit-breaker.ts b/services/engine-circuit-breaker.ts new file mode 100644 index 0000000..050fd24 --- /dev/null +++ b/services/engine-circuit-breaker.ts @@ -0,0 +1,191 @@ +/** + * Circuit Breaker para motores de busca + * Desabilita motores que estão falhando consistentemente + */ + +export interface EngineHealth { + engine: string; + successCount: number; + failureCount: number; + totalAttempts: number; + avgLatency: number; // ms + lastFailure: number | null; // timestamp + lastSuccess: number | null; // timestamp + isOpen: boolean; // circuit breaker aberto? + openedAt: number | null; // quando foi aberto +} + +export interface CircuitBreakerConfig { + failureThreshold: number; // Taxa de falha para abrir (0-1, padrão: 0.5 = 50%) + cooldownPeriod: number; // Tempo em ms antes de tentar novamente (padrão: 5 minutos) + trackingWindow: number; // Número de tentativas para rastrear (padrão: 10) + minAttempts: number; // Mínimo de tentativas antes de considerar (padrão: 3) +} + +const DEFAULT_CONFIG: CircuitBreakerConfig = { + failureThreshold: 0.5, // 50% de falha + cooldownPeriod: 5 * 60 * 1000, // 5 minutos + trackingWindow: 10, + minAttempts: 3, +}; + +export class EngineCircuitBreaker { + private health: Map = new Map(); + private config: CircuitBreakerConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Registra uma tentativa de busca + */ + recordAttempt(engine: string, success: boolean, latency: number): void { + const health = this.getOrCreateHealth(engine); + + health.totalAttempts++; + health.avgLatency = (health.avgLatency * (health.totalAttempts - 1) + latency) / health.totalAttempts; + + if (success) { + health.successCount++; + health.lastSuccess = Date.now(); + + // Se teve sucesso e circuit estava aberto há tempo suficiente, tentar fechar + if (health.isOpen && health.openedAt) { + const timeSinceOpen = Date.now() - health.openedAt; + if (timeSinceOpen >= this.config.cooldownPeriod) { + health.isOpen = false; + health.openedAt = null; + console.log(`[CircuitBreaker] ${engine}: Circuit fechado após sucesso (${timeSinceOpen}ms desde abertura)`); + } + } + } else { + health.failureCount++; + health.lastFailure = Date.now(); + } + + // Limitar histórico às últimas N tentativas + if (health.totalAttempts > this.config.trackingWindow) { + // Remover tentativas mais antigas (simplificado: resetar contadores) + const successRate = health.successCount / health.totalAttempts; + health.successCount = Math.floor(successRate * this.config.trackingWindow); + health.failureCount = this.config.trackingWindow - health.successCount; + health.totalAttempts = this.config.trackingWindow; + } + + // Verificar se deve abrir circuit breaker + if (!health.isOpen && health.totalAttempts >= this.config.minAttempts) { + const failureRate = health.failureCount / health.totalAttempts; + if (failureRate >= this.config.failureThreshold) { + health.isOpen = true; + health.openedAt = Date.now(); + console.warn(`[CircuitBreaker] ${engine}: Circuit aberto (taxa de falha: ${(failureRate * 100).toFixed(1)}%)`); + } + } + + this.health.set(engine, health); + } + + /** + * Verifica se um motor está disponível (circuit não está aberto) + */ + isAvailable(engine: string): boolean { + const health = this.getOrCreateHealth(engine); + + if (!health.isOpen) { + return true; + } + + // Se está aberto, verificar se passou tempo suficiente para tentar novamente + if (health.openedAt) { + const timeSinceOpen = Date.now() - health.openedAt; + if (timeSinceOpen >= this.config.cooldownPeriod) { + // Tentar fechar (half-open state) + health.isOpen = false; + this.health.set(engine, health); + console.log(`[CircuitBreaker] ${engine}: Tentando fechar circuit (half-open state)`); + return true; + } + } + + return false; + } + + /** + * Prioriza motores baseado em saúde + */ + prioritizeEngines(engines: string[]): string[] { + return [...engines].sort((a, b) => { + const healthA = this.getOrCreateHealth(a); + const healthB = this.getOrCreateHealth(b); + + // Motores não disponíveis vão para o final + if (healthA.isOpen && !healthB.isOpen) return 1; + if (!healthA.isOpen && healthB.isOpen) return -1; + + // Comparar taxa de sucesso + const successRateA = healthA.totalAttempts > 0 + ? healthA.successCount / healthA.totalAttempts + : 0.5; + const successRateB = healthB.totalAttempts > 0 + ? healthB.successCount / healthB.totalAttempts + : 0.5; + + if (Math.abs(successRateA - successRateB) > 0.1) { + return successRateB - successRateA; // Maior taxa de sucesso primeiro + } + + // Se taxa similar, comparar latência + return healthA.avgLatency - healthB.avgLatency; // Menor latência primeiro + }); + } + + /** + * Obtém estatísticas de um motor + */ + getHealth(engine: string): EngineHealth | null { + return this.health.get(engine) || null; + } + + /** + * Obtém ou cria health para um motor + */ + private getOrCreateHealth(engine: string): EngineHealth { + if (!this.health.has(engine)) { + this.health.set(engine, { + engine, + successCount: 0, + failureCount: 0, + totalAttempts: 0, + avgLatency: 0, + lastFailure: null, + lastSuccess: null, + isOpen: false, + openedAt: null, + }); + } + return this.health.get(engine)!; + } + + /** + * Reseta estatísticas de um motor + */ + reset(engine: string): void { + this.health.delete(engine); + } + + /** + * Reseta todas as estatísticas + */ + resetAll(): void { + this.health.clear(); + } + + /** + * Obtém todos os motores disponíveis + */ + getAvailableEngines(engines: string[]): string[] { + return engines.filter(engine => this.isAvailable(engine)); + } +} + diff --git a/services/failure-cache.ts b/services/failure-cache.ts new file mode 100644 index 0000000..a8b5d2f --- /dev/null +++ b/services/failure-cache.ts @@ -0,0 +1,160 @@ +/** + * Cache de falhas para evitar retentar queries que falharam recentemente + */ + +export interface FailureCacheEntry { + query: string; + timestamp: number; + error: string; + partialResults?: any[]; // Resultados parciais se houver + attempts: number; // Número de tentativas antes de falhar +} + +export interface FailureCacheConfig { + ttl: number; // Time to live em ms (padrão: 5 minutos) + maxEntries: number; // Máximo de entradas no cache (padrão: 100) +} + +const DEFAULT_CONFIG: FailureCacheConfig = { + ttl: 5 * 60 * 1000, // 5 minutos + maxEntries: 100, +}; + +export class FailureCache { + private cache: Map = new Map(); + private config: FailureCacheConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + // Limpar entradas expiradas periodicamente + setInterval(() => this.cleanExpired(), 60000); // A cada 1 minuto + } + + /** + * Normaliza uma query para usar como chave + */ + private normalizeQuery(query: string): string { + return query.trim().toLowerCase(); + } + + /** + * Verifica se uma query falhou recentemente + */ + hasFailed(query: string): boolean { + const normalized = this.normalizeQuery(query); + const entry = this.cache.get(normalized); + + if (!entry) { + return false; + } + + // Verificar se expirou + const age = Date.now() - entry.timestamp; + if (age >= this.config.ttl) { + this.cache.delete(normalized); + return false; + } + + return true; + } + + /** + * Obtém entrada de falha (se existir e não expirada) + */ + get(query: string): FailureCacheEntry | null { + const normalized = this.normalizeQuery(query); + const entry = this.cache.get(normalized); + + if (!entry) { + return null; + } + + // Verificar se expirou + const age = Date.now() - entry.timestamp; + if (age >= this.config.ttl) { + this.cache.delete(normalized); + return null; + } + + return entry; + } + + /** + * Registra uma falha + */ + recordFailure( + query: string, + error: string, + partialResults?: any[], + attempts: number = 1 + ): void { + const normalized = this.normalizeQuery(query); + + // Limitar tamanho do cache + if (this.cache.size >= this.config.maxEntries) { + // Remover entrada mais antiga + const oldest = Array.from(this.cache.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp)[0]; + if (oldest) { + this.cache.delete(oldest[0]); + } + } + + this.cache.set(normalized, { + query: normalized, + timestamp: Date.now(), + error, + partialResults, + attempts, + }); + + console.log(`[FailureCache] Registrada falha para query: "${query.substring(0, 50)}..." (TTL: ${this.config.ttl}ms)`); + } + + /** + * Remove uma entrada do cache + */ + remove(query: string): void { + const normalized = this.normalizeQuery(query); + this.cache.delete(normalized); + } + + /** + * Limpa todas as entradas expiradas + */ + cleanExpired(): void { + const now = Date.now(); + let cleaned = 0; + + for (const [key, entry] of this.cache.entries()) { + const age = now - entry.timestamp; + if (age >= this.config.ttl) { + this.cache.delete(key); + cleaned++; + } + } + + if (cleaned > 0) { + console.log(`[FailureCache] Limpas ${cleaned} entradas expiradas`); + } + } + + /** + * Limpa todo o cache + */ + clear(): void { + this.cache.clear(); + } + + /** + * Obtém estatísticas do cache + */ + getStats(): { size: number; maxEntries: number; ttl: number } { + return { + size: this.cache.size, + maxEntries: this.config.maxEntries, + ttl: this.config.ttl, + }; + } +} + diff --git a/services/webSearch.ts b/services/webSearch.ts index 6df9fce..6949b3c 100644 --- a/services/webSearch.ts +++ b/services/webSearch.ts @@ -1,6 +1,15 @@ import { invoke } from '@tauri-apps/api/core'; import { SearchCategory, useSettingsStore } from '@/store/settings-store'; -import { retryWithBackoff, withTimeout, isRetryableError, type RetryConfig } from '@/lib/retry-utils'; +import { + retryWithBackoff, + withTimeout, + isRetryableError, + type RetryConfig, + calculateEngineTimeout, + TIMEOUT_CONFIG +} from '@/lib/retry-utils'; +import { EngineCircuitBreaker } from './engine-circuit-breaker'; +import { FailureCache } from './failure-cache'; export interface ScrapedContent { title: string; @@ -49,6 +58,8 @@ class WebSearchService { private lastSearchTime = 0; private pendingRequest: PendingRequest | null = null; private rateLimitQueue: PendingRequest[] = []; + private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker(); + private failureCache: FailureCache = new FailureCache(); constructor() { // Carregar cache do localStorage na inicialização @@ -221,16 +232,50 @@ class WebSearchService { /** * Executa busca de metadados (sem scraping) + * Agora com timeout adaptativo baseado em tentativas, circuit breaker e cache de falhas */ private async executeMetadataSearch( query: string, limit: number = 5, searchConfig?: SearchConfig, - timeoutMs: number = 15000 + timeoutMs: number = 10000, // Reduzido de 15s para 10s + attempt: number = 1 // Tentativa atual para timeout adaptativo ): Promise { + // Verificar cache de falhas + const failureEntry = this.failureCache.get(query); + if (failureEntry) { + console.warn(`[WebSearch] Query falhou recentemente (há ${Math.round((Date.now() - failureEntry.timestamp) / 1000)}s), retornando resultados parciais ou vazio`); + + // Retornar resultados parciais se disponíveis + if (failureEntry.partialResults && failureEntry.partialResults.length > 0) { + console.log(`[WebSearch] Retornando ${failureEntry.partialResults.length} resultados parciais do cache de falhas`); + return failureEntry.partialResults as SearchResultMetadata[]; + } + + // Se não há resultados parciais, retornar vazio imediatamente (sem retry) + return []; + } // Obter configurações de motores do store const settings = useSettingsStore.getState(); - const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage']; + const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage']; + let engineOrder = settings.webSearch.engineOrder || defaultEngines; + + // Priorizar motores usando circuit breaker + engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder); + + // Filtrar motores não disponíveis (circuit breaker aberto) + const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder); + + if (availableEngines.length === 0) { + console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`); + // Se todos estão abertos, tentar mesmo assim (pode ser temporário) + engineOrder = engineOrder; + } else { + engineOrder = availableEngines; + if (availableEngines.length < engineOrder.length) { + console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`); + } + } let rustConfig: any = undefined; if (searchConfig) { @@ -248,15 +293,23 @@ class WebSearchService { }; } + // Calcular timeout adaptativo baseado na tentativa + const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs); + // 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`); + console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`); - // Executar com retry e timeout + // Executar com retry e timeout adaptativo + let retryAttempt = 0; const retryResult = await retryWithBackoff( async () => { + retryAttempt++; const startTime = Date.now(); + // Timeout ainda mais reduzido em retries internos + const retryTimeout = calculateEngineTimeout(retryAttempt, adaptiveTimeout); + try { const promise = invoke('search_web_metadata', { query: query.trim(), @@ -267,18 +320,32 @@ class WebSearchService { const result = await withTimeout( promise, - timeoutMs, - `Timeout ao buscar metadados para "${query}"` + retryTimeout, + `Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})` ); const duration = Date.now() - startTime; console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`); + // Registrar sucesso no circuit breaker para cada motor usado + // (assumindo que o primeiro motor disponível foi usado) + if (engineOrder.length > 0) { + const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem + this.circuitBreaker.recordAttempt(usedEngine, true, duration); + } + 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); + console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg); + + // Registrar falha no circuit breaker para cada motor tentado + // (assumindo que tentou todos os motores na ordem) + for (const engine of engineOrder) { + this.circuitBreaker.recordAttempt(engine, false, duration); + } + throw error; } }, @@ -298,25 +365,39 @@ class WebSearchService { 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 + // Se falhou, registrar no cache de falhas if (retryResult.lastError) { - console.error( - `[WebSearch] Erro não recuperável:`, - retryResult.lastError.message + const errorMsg = retryResult.lastError.message; + const isRetryable = isRetryableError(retryResult.lastError); + + // Registrar falha no cache (mesmo que seja recuperável, para evitar retentar imediatamente) + this.failureCache.recordFailure( + query, + errorMsg, + [], // Sem resultados parciais neste ponto + retryResult.attempts ); - console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`); - console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); + + if (isRetryable) { + console.warn( + `[WebSearch] Falha após ${retryResult.attempts} tentativas (erro recuperável):`, + errorMsg + ); + console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`); + console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); + console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`); + // Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores + return []; + } else { + // Para erros não recuperáveis, logar detalhadamente mas retornar vazio + console.error( + `[WebSearch] Erro não recuperável:`, + errorMsg + ); + console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`); + console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`); + return []; + } } return []; @@ -403,23 +484,30 @@ class WebSearchService { /** * Busca em duas etapas: metadados → scraping das melhores URLs + * Agora com timeout adaptativo */ async smartSearchRag( query: string, limit: number = 3, searchConfig?: SearchConfig, - timeoutMs: number = 15000 + timeoutMs: number = TIMEOUT_CONFIG.initialTimeout, + round: number = 1 // Round atual para timeout escalonado ): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> { if (!query || !query.trim()) { return { metadata: [], contents: [] }; } - // Buscar metadados com retry e timeout (já implementado em executeMetadataSearch) + // Calcular timeout escalonado por round + const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils'); + const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs); + + // Buscar metadados com retry e timeout adaptativo const metas = await this.executeMetadataSearch( query, Math.max(limit * 2, 5), searchConfig, - timeoutMs + roundTimeout, + 1 // Primeira tentativa ); // Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente) @@ -431,22 +519,82 @@ class WebSearchService { } // Tentar fazer scraping mesmo se metadados foram parciais + // Usar timeout reduzido para scraping (50% do timeout de busca) + const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000); + try { const contents = await withTimeout( invoke('scrape_urls', { urls: topUrls }), - timeoutMs, + scrapingTimeout, `Timeout ao fazer scraping para "${query}"` ); return { metadata: metas, contents: contents || [] }; } catch (error) { // Logar erro mas retornar metadados disponíveis (resultado parcial) + // Graceful degradation: continuar com metadados mesmo se scraping falhar const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('Erro ao fazer scraping em lote (continuando com metadados):', errorMessage); + console.warn(`[WebSearch] Erro ao fazer scraping (continuando com metadados): ${errorMessage}`); + console.warn(`[WebSearch] Retornando ${metas.length} metadados sem conteúdo scraped (resultado parcial)`); - // Retornar metadados mesmo se scraping falhou + // Retornar metadados mesmo se scraping falhou - resultado parcial é melhor que nada return { metadata: metas, contents: [] }; } } + + /** + * Versão simplificada que aceita resultados parciais mais rapidamente + * Retorna assim que qualquer motor retornar resultados, sem esperar todos + */ + async smartSearchRagPartial( + query: string, + limit: number = 3, + searchConfig?: SearchConfig, + timeoutMs: number = TIMEOUT_CONFIG.initialTimeout, + round: number = 1 + ): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> { + if (!query || !query.trim()) { + return { metadata: [], contents: [] }; + } + + // Timeout ainda mais agressivo para resultados parciais + const partialTimeout = Math.max(timeoutMs * 0.7, 5000); // 70% do timeout normal + + try { + // Buscar metadados com timeout reduzido + const metas = await this.executeMetadataSearch( + query, + Math.max(limit * 2, 5), + searchConfig, + partialTimeout, + 1 + ); + + // Se temos metadados, tentar scraping rápido (timeout ainda menor) + if (metas.length > 0) { + const topUrls = metas.map(m => m.url).slice(0, limit); + const scrapingTimeout = Math.max(partialTimeout * 0.4, 3000); // 40% do timeout parcial + + try { + const contents = await withTimeout( + invoke('scrape_urls', { urls: topUrls }), + scrapingTimeout, + `Timeout parcial ao fazer scraping para "${query}"` + ); + return { metadata: metas, contents: contents || [] }; + } catch (error) { + // Aceitar resultado parcial: metadados sem scraping + console.warn(`[WebSearch] Scraping parcial falhou, retornando apenas metadados`); + return { metadata: metas, contents: [] }; + } + } + + return { metadata: metas, contents: [] }; + } catch (error) { + // Em caso de erro total, retornar vazio mas não quebrar + console.warn(`[WebSearch] Busca parcial falhou completamente:`, error); + return { metadata: [], contents: [] }; + } + } /** * Extrai conteúdo de uma URL específica diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9b8cf78..9a96164 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -115,6 +115,7 @@ dependencies = [ "regex", "reqwest 0.12.24", "rfd", + "rusqlite", "scraper", "serde", "serde_json", @@ -1338,6 +1339,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1902,12 +1915,30 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "headless_chrome" version = "1.0.18" @@ -2578,6 +2609,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -4160,6 +4202,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.39.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f61ea7e..9a8edf8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,3 +46,4 @@ walkdir = "2.5" tauri-plugin-autostart = "2" dirs = "5.0" rfd = "0.14" +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..0f0afaf --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,308 @@ +use rusqlite::{Connection, Result as SqliteResult, params}; +use serde::{Serialize, Deserialize}; +use chrono::{DateTime, Utc}; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChatSession { + pub id: String, + pub title: String, + pub emoji: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChatMessage { + pub id: Option, + pub session_id: String, + pub role: String, + pub content: String, + pub metadata: Option, + pub created_at: DateTime, +} + +pub struct Database { + conn: Connection, +} + +impl Database { + /// Cria ou abre conexão com o banco de dados + pub fn new(app_handle: &AppHandle) -> SqliteResult { + let app_data_dir = app_handle.path() + .app_data_dir() + .map_err(|e| { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR), + Some(format!("Failed to get app data dir: {}", e)) + ) + })?; + + std::fs::create_dir_all(&app_data_dir) + .map_err(|e| { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR), + Some(format!("Failed to create app data dir: {}", e)) + ) + })?; + + let db_path = app_data_dir.join("ollahub.db"); + let conn = Connection::open(&db_path)?; + + let db = Self { conn }; + db.init_schema()?; + + Ok(db) + } + + /// Inicializa o schema do banco de dados + fn init_schema(&self) -> SqliteResult<()> { + // Tabela de sessões + self.conn.execute( + "CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + emoji TEXT DEFAULT '💬', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + )?; + + // Tabela de mensagens + self.conn.execute( + "CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + )", + [], + )?; + + // Tabela de documentos RAG + self.conn.execute( + "CREATE TABLE IF NOT EXISTS rag_documents ( + id TEXT PRIMARY KEY, + session_id TEXT, + source_url TEXT, + content TEXT NOT NULL, + embedding BLOB, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + )", + [], + )?; + + // Índices para performance + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)", + [], + )?; + + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)", + [], + )?; + + Ok(()) + } + + /// Cria uma nova sessão de chat + pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> { + self.conn.execute( + "INSERT INTO sessions (id, title, emoji, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(id) DO UPDATE SET + title = ?2, + emoji = ?3, + updated_at = ?5", + params![ + session.id, + session.title, + session.emoji, + session.created_at.to_rfc3339(), + session.updated_at.to_rfc3339() + ], + )?; + Ok(()) + } + + /// Atualiza uma sessão existente + pub fn update_session(&self, session: &ChatSession) -> SqliteResult<()> { + self.conn.execute( + "UPDATE sessions SET title = ?1, emoji = ?2, updated_at = ?3 WHERE id = ?4", + params![ + session.title, + session.emoji, + session.updated_at.to_rfc3339(), + session.id + ], + )?; + Ok(()) + } + + /// Busca uma sessão por ID + pub fn get_session(&self, session_id: &str) -> SqliteResult> { + let mut stmt = self.conn.prepare( + "SELECT id, title, emoji, created_at, updated_at FROM sessions WHERE id = ?1" + )?; + + let mut rows = stmt.query_map(params![session_id], |row| { + Ok(ChatSession { + id: row.get(0)?, + title: row.get(1)?, + emoji: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + if let Some(row) = rows.next() { + row.map(Some) + } else { + Ok(None) + } + } + + /// Lista todas as sessões ordenadas por updated_at DESC + pub fn list_sessions(&self) -> SqliteResult> { + let mut stmt = self.conn.prepare( + "SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC" + )?; + + let rows = stmt.query_map([], |row| { + Ok(ChatSession { + id: row.get(0)?, + title: row.get(1)?, + emoji: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row?); + } + Ok(sessions) + } + + /// Deleta uma sessão e todas as suas mensagens + pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> { + self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?; + Ok(()) + } + + /// Adiciona uma mensagem a uma sessão + pub fn add_message(&self, message: &ChatMessage) -> SqliteResult { + self.conn.execute( + "INSERT INTO messages (session_id, role, content, metadata, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + message.session_id, + message.role, + message.content, + message.metadata, + message.created_at.to_rfc3339() + ], + )?; + + // Atualizar updated_at da sessão + self.conn.execute( + "UPDATE sessions SET updated_at = ?1 WHERE id = ?2", + params![ + message.created_at.to_rfc3339(), + message.session_id + ], + )?; + + Ok(self.conn.last_insert_rowid()) + } + + /// Busca todas as mensagens de uma sessão + pub fn get_messages(&self, session_id: &str) -> SqliteResult> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, role, content, metadata, created_at + FROM messages + WHERE session_id = ?1 + ORDER BY created_at ASC" + )?; + + let rows = stmt.query_map(params![session_id], |row| { + Ok(ChatMessage { + id: Some(row.get(0)?), + session_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + metadata: row.get(4)?, + created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?) + .map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc), + }) + })?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row?); + } + Ok(messages) + } + + /// Salva um documento RAG + pub fn save_rag_document( + &self, + id: &str, + session_id: Option<&str>, + source_url: Option<&str>, + content: &str, + embedding: Option<&[u8]>, + ) -> SqliteResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO rag_documents (id, session_id, source_url, content, embedding, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + id, + session_id, + source_url, + content, + embedding, + Utc::now().to_rfc3339() + ], + )?; + Ok(()) + } + + /// Busca documentos RAG por sessão + pub fn get_rag_documents(&self, session_id: &str) -> SqliteResult)>> { + let mut stmt = self.conn.prepare( + "SELECT id, content, source_url FROM rag_documents WHERE session_id = ?1" + )?; + + let rows = stmt.query_map(params![session_id], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + )) + })?; + + let mut docs = Vec::new(); + for row in rows { + docs.push(row?); + } + Ok(docs) + } +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b4cdbf..8b1a9fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod scheduler_loop; mod sources_config; mod system_monitor; mod intent_classifier; +mod db; use web_scraper::{ ScrapedContent, @@ -50,6 +51,27 @@ struct Message { metadata: Option, } +// 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" @@ -2599,6 +2621,272 @@ fn classify_intent(query: String) -> String { } } +/// Comando principal para streaming de chat via Rust +#[command] +async fn chat_stream( + window: Window, + app_handle: AppHandle, + session_id: Option, + messages: Vec, + model: String, + system_prompt: Option, + enable_rag: Option, +) -> Result { + 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::>().join(" ") + }, + Err(_) => { + log::warn!("Timeout ao gerar título. Usando fallback."); + user_input.split_whitespace().take(5).collect::>().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 + // IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo) + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let mut full_content = String::new(); + + 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::(&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()) { + // O Ollama envia apenas o NOVO conteúdo em cada chunk, não o acumulado + // Então podemos emitir diretamente + if !content.is_empty() { + full_content.push_str(content); + + // Emitir token para frontend + let token_event = ChatTokenEvent { + session_id: session_id.clone(), + content: content.to_string(), + done: false, + }; + + if let Err(e) = window.emit("chat-token", &token_event) { + log::warn!("Erro ao emitir token: {}", e); + } + } + } + } + + // Verificar se stream terminou + if is_done { + // 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) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -2678,6 +2966,7 @@ pub fn run() { .manage(Arc::new(Mutex::new(None::>)) as BrowserState) .manage(Arc::new(Mutex::new(HashMap::>>::new())) as FileLockMap) .invoke_handler(tauri::generate_handler![ + chat_stream, check_ollama_installed, check_ollama_running, get_system_specs, diff --git a/src-tauri/src/ollama_client.rs b/src-tauri/src/ollama_client.rs index 2e880f3..5ded89c 100644 --- a/src-tauri/src/ollama_client.rs +++ b/src-tauri/src/ollama_client.rs @@ -29,8 +29,8 @@ struct OllamaMessageResponse { /// Cliente Ollama headless (para execução em background) pub struct OllamaClient { - base_url: String, - client: reqwest::Client, + pub(crate) base_url: String, + pub(crate) client: reqwest::Client, } impl OllamaClient { @@ -148,5 +148,109 @@ impl OllamaClient { Ok(full_response.trim().to_string()) } + + /// Gera um título curto (3-5 palavras) para a pergunta do usuário + pub async fn generate_title(&self, model: &str, user_input: &str) -> Result { + let system_prompt = "Você é um gerador de títulos. Responda APENAS com um título de 3-5 palavras que resuma a pergunta. Nada mais, sem explicações."; + + let messages = vec![ + OllamaMessage { + role: "system".to_string(), + content: system_prompt.to_string(), + }, + OllamaMessage { + role: "user".to_string(), + content: format!("Gere um título para: {}", user_input), + }, + ]; + + let request = OllamaChatRequest { + model: model.to_string(), + messages, + stream: true, + }; + + let url = format!("{}/api/chat", self.base_url); + let response = self.client + .post(&url) + .json(&request) + .timeout(std::time::Duration::from_secs(10)) // Timeout curto para resposta rápida + .send() + .await + .map_err(|e| format!("Failed to send title request: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Ollama returned status: {}", response.status())); + } + + // Ler stream e acumular resposta (limitado a ~50 caracteres) + let mut full_response = String::new(); + let mut stream = response.bytes_stream(); + + use futures_util::StreamExt; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?; + let text = String::from_utf8_lossy(&chunk); + + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + + match serde_json::from_str::(line) { + Ok(chunk_data) => { + if let Some(message) = chunk_data.message { + full_response.push_str(&message.content); + + // Limitar tamanho para evitar respostas longas + if full_response.len() > 50 { + break; + } + } + + if chunk_data.done == Some(true) { + break; + } + } + Err(_) => continue, + } + } + + // Se já temos resposta suficiente, parar + if full_response.len() > 30 { + break; + } + } + + let title = full_response.trim().to_string(); + + // Fallback se título estiver vazio ou muito longo + if title.is_empty() || title.len() > 50 { + // Extrair primeiras palavras da pergunta como fallback + let words: Vec<&str> = user_input.split_whitespace().take(5).collect(); + Ok(words.join(" ")) + } else { + Ok(title) + } + } + + /// Gera emoji baseado no título + pub fn generate_emoji(title: &str) -> String { + let title_lower = title.to_lowercase(); + + // Keywords para emojis + if title_lower.contains("código") || title_lower.contains("program") || title_lower.contains("code") { + "💻".to_string() + } else if title_lower.contains("pergunta") || title_lower.contains("question") || title_lower.contains("como") { + "❓".to_string() + } else if title_lower.contains("explica") || title_lower.contains("explain") { + "📚".to_string() + } else if title_lower.contains("ajuda") || title_lower.contains("help") { + "🆘".to_string() + } else { + "💬".to_string() // Default + } + } }