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.
This commit is contained in:
1 parent
f2e8a1b933
commit
8c546f6474
13 files changed
+1547
-271
No files matched your search
+6
-2
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+151
-224
@@ -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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const lastTokenRef = useRef<string>(''); // 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<ChatCreatedEvent>('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<ChatTokenEvent>('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<ChatErrorEvent>('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<void> => {
|
||||
// Verificar se Ollama está rodando antes de fazer requisição
|
||||
try {
|
||||
const isRunning = await invoke<boolean>('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<boolean>('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 "<metadata>..." 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 };
|
||||
|
||||
@@ -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<ScrapedContent[]> => {
|
||||
const smartSearchRag = useCallback(async (query: string, limit: number = 3, round?: number): Promise<ScrapedContent[]> => {
|
||||
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
|
||||
|
||||
@@ -127,5 +127,43 @@ export function withTimeout<T>(
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
||||
|
||||
@@ -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<ScrapedContent[]>,
|
||||
searchFn: (query: string, limit: number, round?: number) => Promise<ScrapedContent[]>,
|
||||
model: string,
|
||||
config: Partial<FallbackConfig> = {}
|
||||
): Promise<FallbackResult> {
|
||||
@@ -272,10 +285,13 @@ export async function executeProgressiveSearch(
|
||||
let totalResultsAnalyzed = 0;
|
||||
let allResults: ScrapedContent[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
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<QueryResult[]>((_, 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,
|
||||
|
||||
@@ -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<string, EngineHealth> = new Map();
|
||||
private config: CircuitBreakerConfig;
|
||||
|
||||
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, FailureCacheEntry> = new Map();
|
||||
private config: FailureCacheConfig;
|
||||
|
||||
constructor(config: Partial<FailureCacheConfig> = {}) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+179
-31
@@ -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<SearchResultMetadata[]> {
|
||||
// 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<SearchResultMetadata[]>(
|
||||
async () => {
|
||||
retryAttempt++;
|
||||
const startTime = Date.now();
|
||||
// Timeout ainda mais reduzido em retries internos
|
||||
const retryTimeout = calculateEngineTimeout(retryAttempt, adaptiveTimeout);
|
||||
|
||||
try {
|
||||
const promise = invoke<SearchResultMetadata[]>('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<ScrapedContent[]>('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<ScrapedContent[]>('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
|
||||
|
||||
Generated
+56
@@ -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"
|
||||
|
||||
@@ -46,3 +46,4 @@ walkdir = "2.5"
|
||||
tauri-plugin-autostart = "2"
|
||||
dirs = "5.0"
|
||||
rfd = "0.14"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
@@ -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<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub id: Option<i64>,
|
||||
pub session_id: String,
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub metadata: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Cria ou abre conexão com o banco de dados
|
||||
pub fn new(app_handle: &AppHandle) -> SqliteResult<Self> {
|
||||
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<Option<ChatSession>> {
|
||||
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<Vec<ChatSession>> {
|
||||
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<i64> {
|
||||
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<Vec<ChatMessage>> {
|
||||
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<Vec<(String, String, Option<String>)>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<serde_json::Value>,
|
||||
}
|
||||
|
||||
// Eventos para comunicação Frontend <-> Rust
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct ChatCreatedEvent {
|
||||
session_id: String,
|
||||
title: String,
|
||||
emoji: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct ChatTokenEvent {
|
||||
session_id: String,
|
||||
content: String,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct ChatErrorEvent {
|
||||
session_id: String,
|
||||
error: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct DownloadProgress {
|
||||
status: String, // "pulling", "verifying", "success"
|
||||
@@ -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<String>,
|
||||
messages: Vec<Message>,
|
||||
model: String,
|
||||
system_prompt: Option<String>,
|
||||
enable_rag: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
use uuid::Uuid;
|
||||
use ollama_client::OllamaClient;
|
||||
use futures_util::StreamExt;
|
||||
use db::{Database, ChatSession, ChatMessage};
|
||||
|
||||
// Gerar ou usar session_id existente
|
||||
let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let enable_rag = enable_rag.unwrap_or(false);
|
||||
|
||||
// Verificar se é nova sessão (apenas 1 mensagem do usuário)
|
||||
let is_new_session = messages.len() == 1 && messages[0].role == "user";
|
||||
|
||||
// Variáveis para título e emoji (usadas depois na persistência)
|
||||
let (title, emoji) = if is_new_session {
|
||||
let user_input = &messages[0].content;
|
||||
let ollama_client = OllamaClient::new(None);
|
||||
|
||||
// Tentar gerar título (com timeout curto)
|
||||
let generated_title = match tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(5),
|
||||
ollama_client.generate_title(&model, user_input)
|
||||
).await {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("Erro ao gerar título: {}. Usando fallback.", e);
|
||||
// Fallback: primeiras palavras da pergunta
|
||||
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
|
||||
},
|
||||
Err(_) => {
|
||||
log::warn!("Timeout ao gerar título. Usando fallback.");
|
||||
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
};
|
||||
|
||||
let generated_emoji = OllamaClient::generate_emoji(&generated_title);
|
||||
|
||||
// Emitir evento de chat criado
|
||||
let created_event = ChatCreatedEvent {
|
||||
session_id: session_id.clone(),
|
||||
title: generated_title.clone(),
|
||||
emoji: generated_emoji.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = window.emit("chat-created", &created_event) {
|
||||
log::warn!("Erro ao emitir evento chat-created: {}", e);
|
||||
}
|
||||
|
||||
(generated_title, generated_emoji)
|
||||
} else {
|
||||
(String::new(), "💬".to_string())
|
||||
};
|
||||
|
||||
// 2. Preparar mensagens para Ollama
|
||||
let mut ollama_messages = Vec::new();
|
||||
|
||||
// Adicionar system prompt se fornecido
|
||||
if let Some(sys_prompt) = system_prompt {
|
||||
ollama_messages.push(serde_json::json!({
|
||||
"role": "system",
|
||||
"content": sys_prompt
|
||||
}));
|
||||
}
|
||||
|
||||
// Converter mensagens para formato Ollama
|
||||
for msg in &messages {
|
||||
ollama_messages.push(serde_json::json!({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. TODO: Classificar intent e aplicar RAG se necessário
|
||||
// if enable_rag {
|
||||
// let intent = classify_intent(messages.last().unwrap().content.clone());
|
||||
// // Buscar contexto via RAG
|
||||
// // Injetar no system prompt
|
||||
// }
|
||||
|
||||
// 4. Fazer requisição streaming para Ollama
|
||||
let ollama_client = OllamaClient::new(None);
|
||||
ollama_client.check_connection().await?;
|
||||
|
||||
let request = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": ollama_messages,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
// Usar reqwest diretamente para streaming
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let url = "http://localhost:11434/api/chat";
|
||||
let response = client
|
||||
.post(url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let error_msg = format!("Ollama returned status: {}", response.status());
|
||||
let error_event = ChatErrorEvent {
|
||||
session_id: session_id.clone(),
|
||||
error: error_msg.clone(),
|
||||
};
|
||||
let _ = window.emit("chat-error", &error_event);
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
// 5. Processar stream e emitir tokens
|
||||
// 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::<serde_json::Value>(&line) {
|
||||
Ok(json) => {
|
||||
// Verificar se stream terminou primeiro
|
||||
let is_done = json.get("done").and_then(|d| d.as_bool()) == Some(true);
|
||||
|
||||
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
|
||||
if let Some(message) = json.get("message") {
|
||||
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
||||
// 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::<Arc<Browser>>)) as BrowserState)
|
||||
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
chat_stream,
|
||||
check_ollama_installed,
|
||||
check_ollama_running,
|
||||
get_system_specs,
|
||||
|
||||
@@ -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<String, String> {
|
||||
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::<OllamaChunk>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user