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
|
// Executar busca progressiva com semantic search se disponível
|
||||||
const fallbackResult = await executeProgressiveSearch(
|
const fallbackResult = await executeProgressiveSearch(
|
||||||
query,
|
query,
|
||||||
async (q, limit) => {
|
async (q, limit, round = 1) => {
|
||||||
return await webSearch.smartSearchRag(q, limit);
|
// Passar round para timeout adaptativo
|
||||||
|
return await webSearch.smartSearchRag(q, limit, undefined, undefined, round);
|
||||||
},
|
},
|
||||||
selectedModel,
|
selectedModel,
|
||||||
{
|
{
|
||||||
@@ -410,6 +411,9 @@ export default function ChatPage() {
|
|||||||
useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas
|
useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas
|
||||||
context: context || undefined,
|
context: context || undefined,
|
||||||
enrichedQueries: enrichedQueries || 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 { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { removeMetadataNoise } from '@/lib/metadata';
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
import { useSettingsStore } from '@/store/settings-store';
|
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() {
|
export function useChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
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 (
|
const sendMessage = async (
|
||||||
content: string,
|
content: string,
|
||||||
@@ -325,235 +439,47 @@ export function useChat() {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
// [DEBUG INJECTION END]
|
// [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 {
|
try {
|
||||||
// Função auxiliar para tentar enviar mensagem (usada no retry)
|
// Verificar se Ollama está rodando antes de fazer requisição
|
||||||
const attemptSend = async (): Promise<void> => {
|
try {
|
||||||
// Verificar se Ollama está rodando antes de fazer requisição
|
const isRunning = await invoke<boolean>('check_ollama_running');
|
||||||
try {
|
if (!isRunning) {
|
||||||
const isRunning = await invoke<boolean>('check_ollama_running');
|
throw new Error('OllamaNotRunning');
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
} catch (checkError: any) {
|
||||||
// Timeout configurável (padrão 5 minutos)
|
if (checkError.message === 'OllamaNotRunning') {
|
||||||
const timeoutMs = 5 * 60 * 1000; // 5 minutos
|
throw checkError;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// O streaming será processado pelos listeners de eventos
|
||||||
throw lastError;
|
// Não precisamos fazer mais nada aqui, os eventos cuidam do resto
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Chat error:', error);
|
console.error('Chat error:', error);
|
||||||
|
|
||||||
@@ -598,6 +524,7 @@ export function useChat() {
|
|||||||
const clearChat = () => {
|
const clearChat = () => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
rawContentRef.current = '';
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ export function useWebSearch() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pipeline em duas etapas: metadados → scraping das top URLs
|
* 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()) {
|
if (!query || !query.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -147,12 +148,13 @@ export function useWebSearch() {
|
|||||||
|
|
||||||
// Etapa 1: metadados
|
// Etapa 1: metadados
|
||||||
setState(prev => ({ ...prev, status: 'searching' }));
|
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(
|
const { metadata, contents } = await webSearchService.smartSearchRag(
|
||||||
query,
|
query,
|
||||||
limit,
|
limit,
|
||||||
searchConfig,
|
searchConfig,
|
||||||
timeoutMs
|
timeoutMs,
|
||||||
|
round || 1
|
||||||
);
|
);
|
||||||
|
|
||||||
// Etapa 2: scraping
|
// 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 { QueryContext } from './contextual-analyzer';
|
||||||
import type { EnrichedQueries } from './query-enricher';
|
import type { EnrichedQueries } from './query-enricher';
|
||||||
import { chatLog } from '@/lib/terminal-logger';
|
import { chatLog } from '@/lib/terminal-logger';
|
||||||
|
import { calculateAdaptiveTimeout, TIMEOUT_CONFIG } from '@/lib/retry-utils';
|
||||||
|
|
||||||
export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual';
|
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)
|
useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false)
|
||||||
context?: QueryContext; // Contexto analisado (para semantic search)
|
context?: QueryContext; // Contexto analisado (para semantic search)
|
||||||
enrichedQueries?: EnrichedQueries; // Queries enriquecidas (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 {
|
export interface FallbackAttempt {
|
||||||
@@ -58,6 +65,12 @@ const DEFAULT_CONFIG: FallbackConfig = {
|
|||||||
maxTotalResults: 40,
|
maxTotalResults: 40,
|
||||||
minRelevanceScore: 0.3,
|
minRelevanceScore: 0.3,
|
||||||
enableQueryExpansion: true,
|
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(
|
export async function executeProgressiveSearch(
|
||||||
originalQuery: string,
|
originalQuery: string,
|
||||||
searchFn: (query: string, limit: number) => Promise<ScrapedContent[]>,
|
searchFn: (query: string, limit: number, round?: number) => Promise<ScrapedContent[]>,
|
||||||
model: string,
|
model: string,
|
||||||
config: Partial<FallbackConfig> = {}
|
config: Partial<FallbackConfig> = {}
|
||||||
): Promise<FallbackResult> {
|
): Promise<FallbackResult> {
|
||||||
@@ -272,10 +285,13 @@ export async function executeProgressiveSearch(
|
|||||||
let totalResultsAnalyzed = 0;
|
let totalResultsAnalyzed = 0;
|
||||||
let allResults: ScrapedContent[] = [];
|
let allResults: ScrapedContent[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
|
const queryStartTime = Date.now();
|
||||||
|
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
||||||
|
|
||||||
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
||||||
chatLog.info(`Original Query: "${originalQuery}"`);
|
chatLog.info(`Original Query: "${originalQuery}"`);
|
||||||
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
|
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
|
// Se usar semantic search, criar rounds baseados em estratégias
|
||||||
let rounds: Array<{ round: number; config: RoundConfig }> = [];
|
let rounds: Array<{ round: number; config: RoundConfig }> = [];
|
||||||
@@ -305,13 +321,26 @@ export async function executeProgressiveSearch(
|
|||||||
rounds = rounds.slice(0, finalConfig.maxRounds);
|
rounds = rounds.slice(0, finalConfig.maxRounds);
|
||||||
|
|
||||||
for (const { round, config: roundConfig } of rounds) {
|
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(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
|
||||||
|
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
||||||
|
|
||||||
const roundStartTime = Date.now();
|
const roundStartTime = Date.now();
|
||||||
let roundResults: ScrapedContent[] = [];
|
let roundResults: ScrapedContent[] = [];
|
||||||
let roundQueries: string[] = [];
|
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) => {
|
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||||
// Expandir query se necessário (modo tradicional)
|
// Expandir query se necessário (modo tradicional)
|
||||||
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
|
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
|
||||||
@@ -319,7 +348,8 @@ export async function executeProgressiveSearch(
|
|||||||
: query;
|
: query;
|
||||||
|
|
||||||
try {
|
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 };
|
return { query: finalQuery, results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, 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
|
// Agregar resultados de todas as queries do round
|
||||||
for (const { query, results } of queryResults) {
|
for (const { query, results } of queryResults) {
|
||||||
@@ -388,12 +434,14 @@ export async function executeProgressiveSearch(
|
|||||||
allResults.push(...roundResults);
|
allResults.push(...roundResults);
|
||||||
totalResultsAnalyzed += roundResults.length;
|
totalResultsAnalyzed += roundResults.length;
|
||||||
|
|
||||||
// Verificar se encontrou resultados relevantes
|
// Verificar early exit: resultados suficientes com alta relevância
|
||||||
const queryForRelevance = roundQueries[0] || originalQuery;
|
const minResultsForEarlyExit = finalConfig.minResultsForEarlyExit || 5;
|
||||||
if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) {
|
const minRelevanceForEarlyExit = finalConfig.minRelevanceForEarlyExit || 0.7;
|
||||||
chatLog.info(`[Round ${round}] ✓ Relevant results found!`);
|
|
||||||
|
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}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
||||||
chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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 { invoke } from '@tauri-apps/api/core';
|
||||||
import { SearchCategory, useSettingsStore } from '@/store/settings-store';
|
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 {
|
export interface ScrapedContent {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -49,6 +58,8 @@ class WebSearchService {
|
|||||||
private lastSearchTime = 0;
|
private lastSearchTime = 0;
|
||||||
private pendingRequest: PendingRequest | null = null;
|
private pendingRequest: PendingRequest | null = null;
|
||||||
private rateLimitQueue: PendingRequest[] = [];
|
private rateLimitQueue: PendingRequest[] = [];
|
||||||
|
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
|
||||||
|
private failureCache: FailureCache = new FailureCache();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Carregar cache do localStorage na inicialização
|
// Carregar cache do localStorage na inicialização
|
||||||
@@ -221,16 +232,50 @@ class WebSearchService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Executa busca de metadados (sem scraping)
|
* Executa busca de metadados (sem scraping)
|
||||||
|
* Agora com timeout adaptativo baseado em tentativas, circuit breaker e cache de falhas
|
||||||
*/
|
*/
|
||||||
private async executeMetadataSearch(
|
private async executeMetadataSearch(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 5,
|
limit: number = 5,
|
||||||
searchConfig?: SearchConfig,
|
searchConfig?: SearchConfig,
|
||||||
timeoutMs: number = 15000
|
timeoutMs: number = 10000, // Reduzido de 15s para 10s
|
||||||
|
attempt: number = 1 // Tentativa atual para timeout adaptativo
|
||||||
): Promise<SearchResultMetadata[]> {
|
): 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
|
// Obter configurações de motores do store
|
||||||
const settings = useSettingsStore.getState();
|
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;
|
let rustConfig: any = undefined;
|
||||||
if (searchConfig) {
|
if (searchConfig) {
|
||||||
@@ -248,15 +293,23 @@ class WebSearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcular timeout adaptativo baseado na tentativa
|
||||||
|
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
|
||||||
|
|
||||||
// Log da tentativa
|
// Log da tentativa
|
||||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
||||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
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[]>(
|
const retryResult = await retryWithBackoff<SearchResultMetadata[]>(
|
||||||
async () => {
|
async () => {
|
||||||
|
retryAttempt++;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
// Timeout ainda mais reduzido em retries internos
|
||||||
|
const retryTimeout = calculateEngineTimeout(retryAttempt, adaptiveTimeout);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const promise = invoke<SearchResultMetadata[]>('search_web_metadata', {
|
const promise = invoke<SearchResultMetadata[]>('search_web_metadata', {
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
@@ -267,18 +320,32 @@ class WebSearchService {
|
|||||||
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
promise,
|
promise,
|
||||||
timeoutMs,
|
retryTimeout,
|
||||||
`Timeout ao buscar metadados para "${query}"`
|
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -298,25 +365,39 @@ class WebSearchService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se falhou mas é erro recuperável, logar detalhadamente
|
// Se falhou, registrar no cache de falhas
|
||||||
if (retryResult.lastError && isRetryableError(retryResult.lastError)) {
|
|
||||||
console.warn(
|
|
||||||
`[WebSearch] Falha após ${retryResult.attempts} tentativas:`,
|
|
||||||
retryResult.lastError.message
|
|
||||||
);
|
|
||||||
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
|
||||||
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para erros não recuperáveis, logar detalhadamente
|
|
||||||
if (retryResult.lastError) {
|
if (retryResult.lastError) {
|
||||||
console.error(
|
const errorMsg = retryResult.lastError.message;
|
||||||
`[WebSearch] Erro não recuperável:`,
|
const isRetryable = isRetryableError(retryResult.lastError);
|
||||||
retryResult.lastError.message
|
|
||||||
|
// 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 [];
|
return [];
|
||||||
@@ -403,23 +484,30 @@ class WebSearchService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca em duas etapas: metadados → scraping das melhores URLs
|
* Busca em duas etapas: metadados → scraping das melhores URLs
|
||||||
|
* Agora com timeout adaptativo
|
||||||
*/
|
*/
|
||||||
async smartSearchRag(
|
async smartSearchRag(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 3,
|
limit: number = 3,
|
||||||
searchConfig?: SearchConfig,
|
searchConfig?: SearchConfig,
|
||||||
timeoutMs: number = 15000
|
timeoutMs: number = TIMEOUT_CONFIG.initialTimeout,
|
||||||
|
round: number = 1 // Round atual para timeout escalonado
|
||||||
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
||||||
if (!query || !query.trim()) {
|
if (!query || !query.trim()) {
|
||||||
return { metadata: [], contents: [] };
|
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(
|
const metas = await this.executeMetadataSearch(
|
||||||
query,
|
query,
|
||||||
Math.max(limit * 2, 5),
|
Math.max(limit * 2, 5),
|
||||||
searchConfig,
|
searchConfig,
|
||||||
timeoutMs
|
roundTimeout,
|
||||||
|
1 // Primeira tentativa
|
||||||
);
|
);
|
||||||
|
|
||||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||||
@@ -431,22 +519,82 @@ class WebSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
// 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 {
|
try {
|
||||||
const contents = await withTimeout(
|
const contents = await withTimeout(
|
||||||
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
||||||
timeoutMs,
|
scrapingTimeout,
|
||||||
`Timeout ao fazer scraping para "${query}"`
|
`Timeout ao fazer scraping para "${query}"`
|
||||||
);
|
);
|
||||||
return { metadata: metas, contents: contents || [] };
|
return { metadata: metas, contents: contents || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
// 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);
|
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: [] };
|
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
|
* Extrai conteúdo de uma URL específica
|
||||||
|
|||||||
Generated
+56
@@ -115,6 +115,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.24",
|
"reqwest 0.12.24",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
"rusqlite",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1338,6 +1339,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1902,12 +1915,30 @@ dependencies = [
|
|||||||
"ahash 0.7.8",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
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]]
|
[[package]]
|
||||||
name = "headless_chrome"
|
name = "headless_chrome"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -2578,6 +2609,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -4160,6 +4202,20 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"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]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.39.0"
|
version = "1.39.0"
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ walkdir = "2.5"
|
|||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
rfd = "0.14"
|
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 sources_config;
|
||||||
mod system_monitor;
|
mod system_monitor;
|
||||||
mod intent_classifier;
|
mod intent_classifier;
|
||||||
|
mod db;
|
||||||
|
|
||||||
use web_scraper::{
|
use web_scraper::{
|
||||||
ScrapedContent,
|
ScrapedContent,
|
||||||
@@ -50,6 +51,27 @@ struct Message {
|
|||||||
metadata: Option<serde_json::Value>,
|
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)]
|
#[derive(serde::Serialize)]
|
||||||
struct DownloadProgress {
|
struct DownloadProgress {
|
||||||
status: String, // "pulling", "verifying", "success"
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
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(None::<Arc<Browser>>)) as BrowserState)
|
||||||
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
|
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
chat_stream,
|
||||||
check_ollama_installed,
|
check_ollama_installed,
|
||||||
check_ollama_running,
|
check_ollama_running,
|
||||||
get_system_specs,
|
get_system_specs,
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ struct OllamaMessageResponse {
|
|||||||
|
|
||||||
/// Cliente Ollama headless (para execução em background)
|
/// Cliente Ollama headless (para execução em background)
|
||||||
pub struct OllamaClient {
|
pub struct OllamaClient {
|
||||||
base_url: String,
|
pub(crate) base_url: String,
|
||||||
client: reqwest::Client,
|
pub(crate) client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OllamaClient {
|
impl OllamaClient {
|
||||||
@@ -148,5 +148,109 @@ impl OllamaClient {
|
|||||||
|
|
||||||
Ok(full_response.trim().to_string())
|
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