From 8c546f6474c2bfb3a2b24c3e9949d40518e18858 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 17:32:46 -0300 Subject: [PATCH 1/4] 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 + } + } } From 6a7eeaf7af96f7d95e911d5f5ce0255b1e584540 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 17:58:33 -0300 Subject: [PATCH 2/4] feat(chat): implement search functionality for chat sessions - Added search capability to the chat interface, allowing users to find sessions by title or message content. - Integrated debounce logic for search input to enhance performance and user experience. - Updated the sidebar to include a search input field and loading indicator during search operations. - Implemented full-text search in the database for efficient session retrieval based on user queries. - Enhanced session management by filtering out orphan sessions during search results. --- app/chat/page.tsx | 6 +- components/chat/sidebar-list.tsx | 85 ++++++++++- hooks/use-chat-storage.ts | 31 +++- src-tauri/src/db.rs | 235 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 244 ++++++++++++++++++++++++++++++- 5 files changed, 589 insertions(+), 12 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 486161d..c1e5cba 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -54,7 +54,9 @@ export default function ChatPage() { loadSessions, saveSession, deleteSession, - isGeneratingTitle + isGeneratingTitle, + searchSessions, + isSearching } = useChatStorage(); const { isDownloading, progress } = useAutoLabelingModel(); @@ -949,6 +951,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }} onDeleteSession={deleteSession} onNewChat={handleNewChat} + onSearch={searchSessions} + isSearching={isSearching} /> diff --git a/components/chat/sidebar-list.tsx b/components/chat/sidebar-list.tsx index f3fc98e..96158c5 100644 --- a/components/chat/sidebar-list.tsx +++ b/components/chat/sidebar-list.tsx @@ -1,8 +1,10 @@ import { SessionSummary } from "@/hooks/use-chat-storage"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { Trash2, Plus, MessageSquare } from "lucide-react"; +import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useState, useEffect, useRef } from "react"; interface SidebarListProps { sessions: SessionSummary[]; @@ -10,6 +12,8 @@ interface SidebarListProps { onSelectSession: (id: string) => void; onDeleteSession: (id: string) => void; onNewChat: () => void; + onSearch?: (query: string) => void; + isSearching?: boolean; } function formatDate(dateString: string) { @@ -52,13 +56,80 @@ export function SidebarList({ currentSessionId, onSelectSession, onDeleteSession, - onNewChat + onNewChat, + onSearch, + isSearching = false }: SidebarListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const searchTimeoutRef = useRef(null); + const DEBOUNCE_MS = 300; + + useEffect(() => { + // Limpar timeout anterior + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Se query vazia, limpar busca imediatamente + if (!searchQuery.trim()) { + if (onSearch) { + onSearch(''); + } + return; + } + + // Debounce: aguardar antes de buscar + searchTimeoutRef.current = setTimeout(() => { + if (onSearch && searchQuery.trim().length >= 2) { + onSearch(searchQuery.trim()); + } + }, DEBOUNCE_MS); + + // Cleanup + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery, onSearch]); + + const handleClearSearch = () => { + setSearchQuery(''); + if (onSearch) { + onSearch(''); + } + }; + return (
- {/* Header com botão Nova Conversa */} -
+ {/* Header com busca e botão Nova Conversa */} +
+ {/* Campo de busca */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 pr-8 h-9 text-sm" + /> + {isSearching && ( + + )} + {searchQuery && !isSearching && ( + + )} +
+ + {/* Botão Nova Conversa */}
)} diff --git a/hooks/use-chat-storage.ts b/hooks/use-chat-storage.ts index 223b95c..8aa8030 100644 --- a/hooks/use-chat-storage.ts +++ b/hooks/use-chat-storage.ts @@ -38,6 +38,8 @@ export function useChatStorage() { const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearching, setIsSearching] = useState(false); const loadSessions = useCallback(async () => { try { @@ -47,6 +49,29 @@ export function useChatStorage() { console.error("Failed to load sessions:", error); } }, []); + + const searchSessions = useCallback(async (query: string) => { + if (!query || query.trim().length < 2) { + // Se query muito curta, carregar todas as sessões + await loadSessions(); + return; + } + + setIsSearching(true); + try { + const results = await invoke('search_chat_sessions', { + query: query.trim(), + limit: 50 + }); + setSessions(results); + } catch (error) { + console.error("Failed to search sessions:", error); + // Em caso de erro, carregar todas as sessões + await loadSessions(); + } finally { + setIsSearching(false); + } + }, [loadSessions]); const loadSessionHistory = useCallback(async (id: string): Promise => { try { @@ -375,7 +400,11 @@ export function useChatStorage() { deleteSession, isGeneratingTitle, setIsGeneratingTitle, - generateTitleFromUserMessage + generateTitleFromUserMessage, + searchSessions, + searchQuery, + setSearchQuery, + isSearching }; } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0f0afaf..bbcc262 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -108,6 +108,140 @@ impl Database { [], )?; + // Índice para ordenação por updated_at + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC)", + [], + )?; + + // Inicializar FTS (Full-Text Search) + self.init_fts_schema()?; + + Ok(()) + } + + /// Inicializa tabelas FTS5 para busca de texto completo + fn init_fts_schema(&self) -> SqliteResult<()> { + // Tabela FTS para títulos de sessões + self.conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5( + id UNINDEXED, + title, + content='sessions', + content_rowid='rowid' + )", + [], + )?; + + // Tabela FTS para conteúdo de mensagens + self.conn.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + session_id UNINDEXED, + content, + content='messages', + content_rowid='rowid' + )", + [], + )?; + + // Triggers para manter FTS sincronizado com tabelas principais + self.create_fts_triggers()?; + + // Popular tabelas FTS com dados existentes (se necessário) + self.populate_fts_tables()?; + + Ok(()) + } + + /// Cria triggers para manter tabelas FTS sincronizadas + fn create_fts_triggers(&self) -> SqliteResult<()> { + // Trigger para inserir em sessions_fts quando nova sessão é criada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON sessions BEGIN + INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title); + END", + [], + )?; + + // Trigger para atualizar sessions_fts quando sessão é atualizada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON sessions BEGIN + INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title); + INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title); + END", + [], + )?; + + // Trigger para deletar de sessions_fts quando sessão é deletada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON sessions BEGIN + INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title); + END", + [], + )?; + + // Trigger para inserir em messages_fts quando nova mensagem é criada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content); + END", + [], + )?; + + // Trigger para atualizar messages_fts quando mensagem é atualizada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content); + INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content); + END", + [], + )?; + + // Trigger para deletar de messages_fts quando mensagem é deletada + self.conn.execute( + "CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content); + END", + [], + )?; + + Ok(()) + } + + /// Popula tabelas FTS com dados existentes + fn populate_fts_tables(&self) -> SqliteResult<()> { + // Verificar se sessions_fts já tem dados + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM sessions_fts", + [], + |row| row.get(0), + ).unwrap_or(0); + + // Se vazio, popular com dados existentes + if count == 0 { + self.conn.execute( + "INSERT INTO sessions_fts(rowid, id, title) + SELECT rowid, id, title FROM sessions", + [], + )?; + } + + // Verificar se messages_fts já tem dados + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM messages_fts", + [], + |row| row.get(0), + ).unwrap_or(0); + + // Se vazio, popular com dados existentes + if count == 0 { + self.conn.execute( + "INSERT INTO messages_fts(rowid, session_id, content) + SELECT rowid, session_id, content FROM messages", + [], + )?; + } + Ok(()) } @@ -172,6 +306,11 @@ impl Database { } } + /// Salva uma sessão (create ou update) + pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> { + self.create_session(session) + } + /// Lista todas as sessões ordenadas por updated_at DESC pub fn list_sessions(&self) -> SqliteResult> { let mut stmt = self.conn.prepare( @@ -304,5 +443,101 @@ impl Database { } Ok(docs) } + + /// Busca sessões por query (título ou conteúdo de mensagens) + /// Retorna resultados ordenados por relevância (match no título > match no conteúdo) + pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult> { + if query.trim().is_empty() { + // Se query vazia, retornar todas as sessões ordenadas por updated_at + return self.list_sessions(); + } + + // Escapar caracteres especiais para FTS5 + let escaped_query = query.replace('"', "\"\""); + let fts_query = format!("\"{}\"", escaped_query); + + // Busca combinada: título (peso 1.0) + conteúdo (peso 0.5) + let mut stmt = self.conn.prepare( + "WITH title_matches AS ( + SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at, + bm25(sessions_fts) as rank + FROM sessions s + JOIN sessions_fts ON s.rowid = sessions_fts.rowid + WHERE sessions_fts MATCH ?1 + ORDER BY rank + LIMIT ?2 + ), + content_matches AS ( + SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at, + bm25(messages_fts) * 0.5 as rank + FROM sessions s + JOIN messages m ON s.id = m.session_id + JOIN messages_fts ON m.rowid = messages_fts.rowid + WHERE messages_fts MATCH ?1 + ORDER BY rank + LIMIT ?2 + ) + SELECT id, title, emoji, created_at, updated_at + FROM ( + SELECT * FROM title_matches + UNION + SELECT * FROM content_matches + ) + ORDER BY rank DESC, updated_at DESC + LIMIT ?2" + )?; + + let rows = stmt.query_map(params![fts_query, limit], |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?); + } + + // Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback) + if sessions.is_empty() { + let mut stmt = self.conn.prepare( + "SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at + FROM sessions s + LEFT JOIN messages m ON s.id = m.session_id + WHERE s.title LIKE ?1 OR m.content LIKE ?1 + ORDER BY s.updated_at DESC + LIMIT ?2" + )?; + + let like_query = format!("%{}%", query); + let rows = stmt.query_map(params![like_query, limit], |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), + }) + })?; + + for row in rows { + sessions.push(row?); + } + } + + Ok(sessions) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b1a9fb..3bc903a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -399,6 +399,82 @@ fn save_chat_session( Ok(()) } +#[command] +fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option) -> Result, 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 sessions = 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 session in sessions { + // 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::() + }) + }) + .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::(&content) { + return session_data.messages.iter() + .find(|m| m.role == "user" || m.role == "assistant") + .map(|m| m.content.chars().take(50).collect::()); + } + } + } + None + }) + .unwrap_or_default(); + + summaries.push(SessionSummary { + id: session.id, + title: session.title, + emoji: session.emoji, + updated_at: session.updated_at, // Já é DateTime + preview, + platform: String::new(), // Platform não está no SQLite ainda + }); + } + + 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, String> { let chats_dir = get_chats_dir(&app_handle)?; @@ -453,6 +529,60 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result, Stri #[command] fn load_chat_history(app_handle: AppHandle, id: String) -> Result, 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 = 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::(&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)); @@ -460,23 +590,65 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result, return Err("Session not found".to_string()); } - let content = fs::read_to_string(file_path) + 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) } #[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() { - fs::remove_file(file_path) - .map_err(|e| format!("Failed to delete session file: {}", e))?; + 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(()) @@ -2047,8 +2219,11 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result { /// 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))?; @@ -2064,10 +2239,69 @@ fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> { } } + // 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 { + 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 { @@ -2983,8 +3217,10 @@ pub fn run() { delete_model, save_chat_session, load_chat_sessions, + search_chat_sessions, load_chat_history, delete_chat_session, + cleanup_orphan_sessions, load_mcp_config, save_mcp_config, get_mcp_config_path_command, From ad2ce1e8874302619c3ea9d898f35418f3b1a89a Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 18:16:20 -0300 Subject: [PATCH 3/4] feat: enhance chat and settings UI with responsive design and new components - Updated global styles to improve layout and prevent overflow in chat and settings pages. - Introduced new sidebar chat item styles for better truncation and overflow handling. - Enhanced settings page layout for improved responsiveness and user experience. - Added icons to category selection in chat input for better visual representation. - Refactored task management UI for improved accessibility and usability. --- app/chat/page.tsx | 61 ++++++++---------- app/globals.css | 50 ++++++++++++++- app/settings/page.tsx | 97 +++++++++++++++++----------- app/tasks/page.tsx | 105 ++++++++++++++++++++----------- components/chat/chat-input.tsx | 37 ++++++++--- components/chat/sidebar-list.tsx | 26 ++++---- 6 files changed, 242 insertions(+), 134 deletions(-) diff --git a/app/chat/page.tsx b/app/chat/page.tsx index c1e5cba..430cf3c 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -3,9 +3,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; -import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe } from "lucide-react"; +import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe, RefreshCw, Cpu } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; @@ -25,18 +23,16 @@ import { useDeepResearch } from "@/hooks/use-deep-research"; import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research"; import { useSettingsStore } from "@/store/settings-store"; import type { ScrapedContent } from "@/services/webSearch"; -import { invoke } from "@tauri-apps/api/core"; -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import { ImperativePanelHandle } from "react-resizable-panels"; import { ModelDownloadDialog } from "@/components/chat/model-download-dialog"; -import { sanitizeWebSources } from "@/lib/sanitize-web-content"; import { chatLog } from "@/lib/terminal-logger"; import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor"; import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat"; -import { executeProgressiveSearch, type FallbackResult } from "@/lib/web-search-fallback"; -// @ts-ignore +import { executeProgressiveSearch } from "@/lib/web-search-fallback"; +// @ts-expect-error - MD file import import defaultFormatPrompt from "@/data/prompts/default-format.md"; @@ -51,7 +47,6 @@ export default function ChatPage() { currentSessionId, setCurrentSessionId, loadSessionHistory, - loadSessions, saveSession, deleteSession, isGeneratingTitle, @@ -61,7 +56,7 @@ export default function ChatPage() { const { isDownloading, progress } = useAutoLabelingModel(); const webSearch = useWebSearch(); - const { generateQuery, isGenerating: isGeneratingQuery } = useQueryGenerator(); + const { generateQuery } = useQueryGenerator(); const deepResearch = useDeepResearch(); const settings = useSettingsStore(); const { preprocess } = useQueryPreprocessor(); @@ -91,7 +86,7 @@ export default function ChatPage() { setMessages(prev => [...prev, thinkingMessage]); return messageId; - }, []); + }, [setMessages]); const updateThinkingMessage = useCallback(( stepType: ThinkingStepType, @@ -110,23 +105,16 @@ export default function ChatPage() { } return msg; })); - }, []); + }, [setMessages]); - const removeThinkingMessage = useCallback((stepType: ThinkingStepType) => { - setMessages(prev => prev.filter(msg => { - const metadata = msg.metadata as ThinkingMessageMetadata | undefined; - return !(metadata?.type === 'thinking' && metadata.stepType === stepType); - })); - }, []); - const [selectedModel, setSelectedModel] = useState(""); const [mounted, setMounted] = useState(false); const initializedRef = useRef(false); // Initialize with default format prompt - const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); + const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada // Removido: thinkingStep e processSteps - agora usando mensagens thinking agrupadas - const [error, setError] = useState(null); + const [, setError] = useState(null); const chatsSidebarRef = useRef(null); const saveTimeoutRef = useRef(null); @@ -157,7 +145,7 @@ export default function ChatPage() { } } initializedRef.current = true; - }, [settings.selectedModel, models]); + }, [settings, models]); // Auto-scroll para o final durante streaming useEffect(() => { @@ -230,7 +218,7 @@ export default function ChatPage() { // Se inválida, retornar erro if (!preprocessed.validation.isValid) { - chatLog.error('Query validation failed:', preprocessed.validation.errors); + chatLog.error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`); throw new Error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`); } @@ -255,7 +243,7 @@ export default function ChatPage() { }; return { preprocessed, finalContent: content }; } - }, [settings.queryPreprocessing, webSearch.isEnabled]); + }, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]); const handleSend = async (content: string) => { if (!selectedModel) return; @@ -297,19 +285,20 @@ export default function ChatPage() { }); // Atualizar step com erro - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMsg = error instanceof Error ? error.message : String(error); updateThinkingMessage('preprocessing', { status: 'error', - error: errorMessage, + error: errorMsg, duration: Date.now() - preprocessingStart, }); // Removido: setProcessSteps - agora usando mensagens thinking agrupadas // Definir erro global - setError(error); + const errorObj = error instanceof Error ? error : new Error(String(error)); + setError(errorObj); - chatLog.error('Preprocessing failed:', error); + chatLog.error(`Preprocessing failed: ${errorObj.message}`); // Erro já será mostrado no chat-input, apenas retornar return; } @@ -401,7 +390,7 @@ export default function ChatPage() { query, async (q, limit, round = 1) => { // Passar round para timeout adaptativo - return await webSearch.smartSearchRag(q, limit, undefined, undefined, round); + return await webSearch.smartSearchRag(q, limit, round); }, selectedModel, { @@ -477,6 +466,7 @@ export default function ChatPage() { scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({ url: entry.sourceUrl, title: entry.title, + content: entry.content, markdown: entry.content, snippet: entry.content.substring(0, 200) })); @@ -532,7 +522,7 @@ export default function ChatPage() { const validation = validateCondensedContext(optimizedResult.result, content); if (!validation.isValid) { - chatLog.error('Context validation failed:', validation.warnings.join(', ')); + chatLog.error(`Context validation failed: ${validation.warnings.join(', ')}`); } else if (validation.warnings.length > 0) { validation.warnings.forEach(warning => { chatLog.warn(`⚠️ ${warning}`); @@ -614,7 +604,6 @@ export default function ChatPage() { // ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ========== // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas - const researchStart = Date.now(); // Executar pesquisa web const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch( content, @@ -885,7 +874,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }; return ( -
+
{/* Left Sidebar (Nav) */} @@ -1022,8 +1011,9 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente - - {m.name} + + + {m.name} {m.size} @@ -1047,7 +1037,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
@@ -1117,7 +1107,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente } // Adicionar mensagem regular - const isLastMessage = i === messages.length - (isLoading ? 1 : 0); const isAssistantStreaming = i === messages.length - 1 && msg.role === 'assistant' && isLoading; groupedMessages.push({ diff --git a/app/globals.css b/app/globals.css index fc84304..c200a2f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,11 +145,22 @@ } @layer base { - * { + *, + *::before, + *::after { @apply border-border outline-ring/50; + box-sizing: border-box; } + + html { + overflow-x: hidden; + } + body { @apply bg-background text-foreground; + overflow-x: hidden; + width: 100%; + max-width: 100vw; } } @@ -226,6 +237,43 @@ padding: clamp(8px, 2vw, 16px); } +/* Sidebar chat item - prevent overflow and ensure proper truncation */ +.sidebar-chat-item { + width: 100%; + min-width: 0; + overflow: hidden; +} + +.sidebar-chat-item-content { + display: flex; + align-items: flex-start; + gap: 0.625rem; + width: 100%; + min-width: 0; +} + +.sidebar-chat-item-text { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.sidebar-chat-item-title { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + +.sidebar-chat-item-date { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + /* Responsive font sizes */ @media (max-width: 640px) { .responsive-text { diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ffbbbe7..97e8f3f 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -217,32 +217,47 @@ export default function SettingsPage() { }; return ( -
-
-
-

Configurações

-

- Gerencie as preferências do OllaHub -

+
+
+
+
+

Configurações

+

+ Gerencie as preferências do OllaHub +

+
+
-
- - - Visão Geral - General - Web Search - Sources & Knowledge - Tasks & Scheduler - System & Logs + + + + Visão Geral + + + General + + + Web Search + + + Sources + + + Tasks + + + System + {/* Tab: Visão Geral (Hardware Dashboard) */} @@ -272,17 +287,19 @@ export default function SettingsPage() {
-
+
settings.setOllamaUrl(e.target.value)} placeholder="http://localhost:11434" + className="flex-1 min-w-0" /> @@ -855,7 +874,7 @@ export default function SettingsPage() { {/* Edit Category Dialog */} !open && setEditingCategory(null)}> - + Editar Categoria: {editingCategory?.name} @@ -1077,18 +1096,19 @@ export default function SettingsPage() { {/* Clear History Dialog */} - + Confirmar Exclusão Esta ação não pode ser desfeita. Todos os chats serão permanentemente removidos. - + @@ -1096,6 +1116,7 @@ export default function SettingsPage() { variant="destructive" onClick={handleClearHistory} disabled={isClearing} + className="w-full sm:w-auto" > {isClearing ? ( <> diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 894e3fe..6e22122 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle } from 'lucide-react'; +import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle, X, ArrowLeft } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; @@ -32,6 +33,7 @@ interface Task { } export default function TasksPage() { + const router = useRouter(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -100,36 +102,25 @@ export default function TasksPage() { }; return ( -
-
-
-

Tarefas Agendadas

-

- Gerencie tarefas que executam automaticamente em segundo plano -

+
+
+
+
+

Tarefas Agendadas

+

+ Gerencie tarefas que executam automaticamente em segundo plano +

+
+
- - - - - - - Criar Nova Task - - Configure uma tarefa para executar automaticamente - - - { - setIsDialogOpen(false); - loadTasks(); - }} - /> - -
{loading ? ( @@ -139,16 +130,59 @@ export default function TasksPage() { ) : tasks.length === 0 ? ( -

+

Nenhuma task agendada ainda.

- + + + + + + + Criar Nova Task + + Configure uma tarefa para executar automaticamente + + + { + setIsDialogOpen(false); + loadTasks(); + }} + /> + +
) : ( + <> +
+ + + + + + + Criar Nova Task + + Configure uma tarefa para executar automaticamente + + + { + setIsDialogOpen(false); + loadTasks(); + }} + /> + + +
{tasks.map(task => ( @@ -218,6 +252,7 @@ export default function TasksPage() { ))}
+ )}
); diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx index 024ad07..1a30786 100644 --- a/components/chat/chat-input.tsx +++ b/components/chat/chat-input.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle } from "lucide-react"; +import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle, GraduationCap, Newspaper, Terminal, TrendingUp } from "lucide-react"; import { useState, useRef, useEffect, useMemo } from "react"; import { Tooltip, @@ -169,15 +169,32 @@ export function ChatInput({ Fontes de conhecimento - {categories.map((cat) => ( - onToggleCategory(cat.id, !!checked)} - > - {cat.name} - - ))} + {categories.map((cat) => { + // Mapeamento de ícones por categoria + const getCategoryIcon = (categoryId: string) => { + const iconMap: Record = { + 'academic': , + 'news': , + 'tech': , + 'finance': , + }; + return iconMap[categoryId] || null; + }; + + return ( + onToggleCategory(cat.id, !!checked)} + className="pl-8" + > +
+ {getCategoryIcon(cat.id)} + {cat.name} +
+
+ ); + })}
)} diff --git a/components/chat/sidebar-list.tsx b/components/chat/sidebar-list.tsx index 96158c5..6f7b685 100644 --- a/components/chat/sidebar-list.tsx +++ b/components/chat/sidebar-list.tsx @@ -102,9 +102,9 @@ export function SidebarList({ return ( -
+
{/* Header com busca e botão Nova Conversa */} -
+
{/* Campo de busca */}
@@ -132,7 +132,7 @@ export function SidebarList({ {/* Botão Nova Conversa */}
From 2b094625387c868db74dfc11a0f17dfa9b17899a Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Nov 2025 18:30:34 -0300 Subject: [PATCH 4/4] feat: integrate TitleBar component across chat, settings, and tasks pages - Added a new TitleBar component to enhance the UI consistency across chat, settings, and tasks pages. - Updated layout structure to support responsive design and improve user experience. - Adjusted container styles to ensure proper overflow handling and visual appeal. - Enhanced Tauri configuration for improved window dimensions and capabilities. --- app/chat/page.tsx | 12 ++- app/settings/page.tsx | 8 +- app/tasks/page.tsx | 8 +- components/titlebar.tsx | 123 ++++++++++++++++++++++++++++ src-tauri/capabilities/default.json | 9 +- src-tauri/tauri.conf.json | 8 +- 6 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 components/titlebar.tsx diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 430cf3c..1970738 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { ChatInput } from "@/components/chat/chat-input"; import { ChatMessage } from "@/components/chat/chat-message"; import { ReasoningChain } from "@/components/chat/reasoning-chain"; +import { TitleBar } from "@/components/titlebar"; import { SidebarList } from "@/components/chat/sidebar-list"; import { useChat } from "@/hooks/use-chat"; @@ -874,9 +875,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente }; return ( -
- {/* Left Sidebar (Nav) */} - +
+ {/* Custom Title Bar */} + + + {/* Main Content Area */} +
+ +
{/* Mobile Sidebar Overlay */} {!isChatsSidebarCollapsed && ( diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 97e8f3f..40472ea 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -28,6 +28,7 @@ import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, P import { useToast } from '@/hooks/use-toast'; import { useRouter } from 'next/navigation'; import { SourcesConfig, SourceCategory } from '@/lib/types'; +import { TitleBar } from '@/components/titlebar'; interface Model { name: string; @@ -217,7 +218,10 @@ export default function SettingsPage() { }; return ( -
+
+ +
+
@@ -1130,6 +1134,8 @@ export default function SettingsPage() {
+
+
); } diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 6e22122..73de553 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -8,6 +8,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle, X, ArrowLeft } from 'lucide-react'; import { useRouter } from 'next/navigation'; +import { TitleBar } from '@/components/titlebar'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; @@ -102,7 +103,10 @@ export default function TasksPage() { }; return ( -
+
+ +
+
@@ -254,6 +258,8 @@ export default function TasksPage() {
)} +
+
); } diff --git a/components/titlebar.tsx b/components/titlebar.tsx new file mode 100644 index 0000000..227766e --- /dev/null +++ b/components/titlebar.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { Minus, Square, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export function TitleBar() { + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + let isMounted = true; + + const checkMaximized = async () => { + try { + const appWindow = getCurrentWindow(); + const maximized = await appWindow.isMaximized(); + if (isMounted) { + setIsMaximized(maximized); + } + } catch (error) { + console.error('Failed to check window state:', error); + } + }; + + checkMaximized(); + + // Listener para mudanças de estado + const setupListener = async () => { + try { + const appWindow = getCurrentWindow(); + const unlisten = await appWindow.onResized(async () => { + if (isMounted) { + const maximized = await appWindow.isMaximized(); + setIsMaximized(maximized); + } + }); + + return unlisten; + } catch (error) { + console.error('Failed to setup resize listener:', error); + return null; + } + }; + + let unlistenFn: (() => void) | null = null; + setupListener().then(fn => { + unlistenFn = fn; + }); + + return () => { + isMounted = false; + if (unlistenFn) { + unlistenFn(); + } + }; + }, []); + + const handleMinimize = async () => { + const appWindow = getCurrentWindow(); + await appWindow.minimize(); + }; + + const handleMaximize = async () => { + const appWindow = getCurrentWindow(); + if (isMaximized) { + await appWindow.unmaximize(); + } else { + await appWindow.maximize(); + } + }; + + const handleClose = async () => { + const appWindow = getCurrentWindow(); + await appWindow.close(); + }; + + return ( +
+ {/* Área de arrasto com logo/título */} +
+ + OllaHub + +
+ + {/* Controles de janela */} +
+ + + +
+
+ ); +} + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..1ba7fc8 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,13 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-unmaximize", + "core:window:allow-close", + "core:window:allow-is-maximized" ] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 15f5a64..2bc7910 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,10 +13,12 @@ "windows": [ { "title": "OllaHub", - "width": 800, - "height": 600, + "width": 1200, + "height": 800, "resizable": true, - "fullscreen": false + "fullscreen": false, + "decorations": false, + "transparent": false } ], "security": {