4 Commits
Author SHA1 Message Date
Evandro Fonseca Junior 0d19f496bb feat(chat): implement infinite scroll for message loading and optimize chat message rendering
- Added infinite scroll functionality to load older messages when the user scrolls near the top of the chat.
- Implemented pagination state management to track loading status and available messages.
- Enhanced ChatMessage component with React.memo for performance optimization during streaming.
- Updated database interactions to support paginated message retrieval, improving efficiency in loading chat history.
2025-11-27 21:53:27 -03:00
Evandro Fonseca Junior e53e49c8f8 feat(chat): implement speculative web search and embeddings commands
- Added speculative search functionality to enhance query processing by detecting when a web search may be needed based on user input patterns.
- Integrated commands for managing embeddings, including downloading models, checking availability, calculating relevance scores, generating embeddings, and pruning context.
- Optimized chat streaming by buffering tokens to reduce overhead and improve responsiveness.
- Implemented lazy initialization for the headless browser to conserve memory until needed.
- Enhanced web scraping capabilities with a static scraping method for faster content retrieval.
2025-11-27 21:19:16 -03:00
Evandro Fonseca Junior ba03131a78 feat(chat): enhance search functionality and message highlighting
- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
2025-11-27 20:39:39 -03:00
Evandro Fonseca Junior 1a64f5aa18 Merge pull request #2 from evandrodevbr/full-rust-backend
Full-rust-backend
2025-11-27 18:46:03 -03:00
12 changed files with 2555 additions and 166 deletions

No files matched your search

+237 -5
View File
@@ -48,11 +48,14 @@ export default function ChatPage() {
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
loadSessionHistory, loadSessionHistory,
loadMoreMessages,
paginationState,
saveSession, saveSession,
deleteSession, deleteSession,
isGeneratingTitle, isGeneratingTitle,
searchSessions, searchSessions,
isSearching isSearching,
searchQuery
} = useChatStorage(); } = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel(); const { isDownloading, progress } = useAutoLabelingModel();
@@ -121,6 +124,7 @@ export default function ChatPage() {
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedMessagesRef = useRef<number>(0); const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showContextDebug, setShowContextDebug] = useState(false); const [showContextDebug, setShowContextDebug] = useState(false);
const [showProcessDebug, setShowProcessDebug] = useState(false); const [showProcessDebug, setShowProcessDebug] = useState(false);
const [lastWebContext, setLastWebContext] = useState(''); const [lastWebContext, setLastWebContext] = useState('');
@@ -128,6 +132,14 @@ export default function ChatPage() {
const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const [logsCopied, setLogsCopied] = useState(false); const [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState(''); const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
// Resetar índice quando busca é limpa ou sessão muda
useEffect(() => {
if (!searchQuery || searchQuery.trim().length < 2) {
setSearchMatchIndex(0);
}
}, [searchQuery, currentSessionId]);
// Auto-select first model // Auto-select first model
useEffect(() => { useEffect(() => {
@@ -246,6 +258,34 @@ export default function ChatPage() {
} }
}, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]); }, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]);
// ========== EXECUÇÃO ESPECULATIVA DE BUSCA ==========
// Heurística rápida para detectar se a query provavelmente precisa de busca web
const mightNeedWebSearch = useCallback((text: string): boolean => {
if (!webSearch.isEnabled) return false;
const lowerText = text.toLowerCase();
// Padrões que indicam alta probabilidade de precisar de busca
const questionPatterns = /\b(o que|como|quando|onde|quem|porque|por que|qual|quais|quanto|quantos|me explique|me diga|existe|há)\b/i;
const recentPatterns = /\b(hoje|ontem|esta semana|este mês|este ano|atualmente|recentemente|últimas notícias|novo|novos|última|últimas|2024|2025)\b/i;
const searchPatterns = /\?$|buscar|pesquisar|encontrar|procurar|descobrir/i;
// Se contém padrões de pergunta ou busca, provavelmente precisa de pesquisa
if (questionPatterns.test(lowerText) || recentPatterns.test(lowerText) || searchPatterns.test(lowerText)) {
chatLog.debug('[SpeculativeSearch] Query might need web search (pattern match)');
return true;
}
// Se tem mais de 5 palavras e termina com ?, provavelmente é uma pergunta
const wordCount = text.trim().split(/\s+/).length;
if (wordCount > 5 && text.trim().endsWith('?')) {
chatLog.debug('[SpeculativeSearch] Query might need web search (question detected)');
return true;
}
return false;
}, [webSearch.isEnabled]);
const handleSend = async (content: string) => { const handleSend = async (content: string) => {
if (!selectedModel) return; if (!selectedModel) return;
@@ -262,6 +302,22 @@ export default function ChatPage() {
// Limpar erros anteriores // Limpar erros anteriores
setError(null); setError(null);
// ========== EXECUÇÃO ESPECULATIVA ==========
// Iniciar busca web especulativamente se a query parecer precisar
// Isso reduz latência sobrepondo busca com pré-processamento
let speculativeSearchPromise: Promise<ScrapedContent[]> | null = null;
const shouldSpeculate = mightNeedWebSearch(content);
if (shouldSpeculate) {
chatLog.info('[SpeculativeSearch] Starting speculative web search in parallel with preprocessing');
// Iniciar busca sem await - será usada depois se confirmada
speculativeSearchPromise = webSearch.smartSearchRag(content, settings.webSearch?.maxResults || 5)
.catch(err => {
chatLog.debug(`[SpeculativeSearch] Speculative search failed (will use normal flow): ${err}`);
return [] as ScrapedContent[];
});
}
// ========== PRÉ-PROCESSAMENTO ========== // ========== PRÉ-PROCESSAMENTO ==========
let preprocessed: PreprocessedQuery; let preprocessed: PreprocessedQuery;
let finalContent: string; let finalContent: string;
@@ -330,7 +386,8 @@ export default function ChatPage() {
// Função auxiliar para executar pesquisa web // Função auxiliar para executar pesquisa web
const executeWebResearch = async ( const executeWebResearch = async (
content: string, content: string,
preprocessed: PreprocessedQuery preprocessed: PreprocessedQuery,
speculativeResults?: ScrapedContent[] | null
): Promise<{ ): Promise<{
knowledgeBaseContext: string; knowledgeBaseContext: string;
scrapedSources: ScrapedContent[]; scrapedSources: ScrapedContent[];
@@ -344,6 +401,12 @@ export default function ChatPage() {
if (webSearch.isEnabled && preprocessed.shouldSearch) { if (webSearch.isEnabled && preprocessed.shouldSearch) {
chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---'); chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---');
// Se temos resultados especulativos, usá-los como seed inicial
if (speculativeResults && speculativeResults.length > 0) {
chatLog.info(`[SpeculativeSearch] Using ${speculativeResults.length} speculative results as seed`);
deepResearch.addToKnowledgeBase('speculative_seed', speculativeResults);
}
// Adicionar mensagem de processo: Pesquisa Web // Adicionar mensagem de processo: Pesquisa Web
addThinkingMessage('web-research', 'Pesquisando na Web...', 'running'); addThinkingMessage('web-research', 'Pesquisando na Web...', 'running');
const webResearchStart = Date.now(); const webResearchStart = Date.now();
@@ -605,10 +668,25 @@ export default function ChatPage() {
// ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ========== // ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ==========
// Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas
// Executar pesquisa web // Aguardar resultado da busca especulativa se ela foi iniciada e shouldSearch é true
let speculativeResults: ScrapedContent[] | null = null;
if (speculativeSearchPromise && preprocessed.shouldSearch) {
chatLog.info('[SpeculativeSearch] Waiting for speculative search results...');
try {
speculativeResults = await speculativeSearchPromise;
chatLog.info(`[SpeculativeSearch] Got ${speculativeResults.length} speculative results`);
} catch (err) {
chatLog.debug(`[SpeculativeSearch] Failed to get speculative results: ${err}`);
}
} else if (speculativeSearchPromise && !preprocessed.shouldSearch) {
chatLog.info('[SpeculativeSearch] Discarding speculative search (preprocessing determined search not needed)');
}
// Executar pesquisa web (usando resultados especulativos como seed se disponíveis)
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch( const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
content, content,
preprocessed preprocessed,
speculativeResults
); );
// Passo 5: Formular resposta // Passo 5: Formular resposta
@@ -803,8 +881,94 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
setMessages(history); setMessages(history);
setCurrentSessionId(id); setCurrentSessionId(id);
lastSavedMessagesRef.current = history.length; lastSavedMessagesRef.current = history.length;
// Se há busca ativa, encontrar matches e resetar índice
if (searchQuery && searchQuery.trim().length >= 2) {
setSearchMatchIndex(0);
// Os matches serão encontrados quando as mensagens renderizarem
}
}; };
// Handler para scroll infinito reverso (carregar mensagens mais antigas)
const handleScroll = useCallback(async () => {
const container = scrollContainerRef.current;
if (!container || !currentSessionId || paginationState.isLoadingMore) return;
// Verificar se o usuário está próximo do topo (threshold de 100px)
const isNearTop = container.scrollTop < 100;
if (isNearTop && paginationState.hasMore) {
// Salvar posição atual do scroll e altura do conteúdo
const scrollHeightBefore = container.scrollHeight;
// Carregar mais mensagens
const olderMessages = await loadMoreMessages(currentSessionId);
if (olderMessages.length > 0) {
// Prepend as mensagens antigas
setMessages(prev => [...olderMessages, ...prev]);
// Restaurar posição do scroll para manter o contexto visual
// (compensar o novo conteúdo adicionado no topo)
requestAnimationFrame(() => {
if (container) {
const scrollHeightAfter = container.scrollHeight;
const heightDiff = scrollHeightAfter - scrollHeightBefore;
container.scrollTop = container.scrollTop + heightDiff;
}
});
}
}
}, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]);
// Função para navegar entre matches
const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => {
if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) {
return;
}
// Encontrar todos os matches nas mensagens
const query = searchQuery.trim();
const matches: Array<{ messageIndex: number; matchIndex: number }> = [];
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
matches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
if (matches.length === 0) return;
// Navegar
setSearchMatchIndex(prev => {
const newIndex = direction === 'next'
? (prev + 1) % matches.length
: (prev - 1 + matches.length) % matches.length;
// Scroll para o match após renderização
setTimeout(() => {
const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar o match atual visualmente
const marks = messageElement.querySelectorAll('mark');
if (marks.length > matches[newIndex].matchIndex) {
marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
}
}, 150);
return newIndex;
});
}, [currentSessionId, searchQuery, messages]);
const handleNewChat = () => { const handleNewChat = () => {
stop(); stop();
clearChat(); clearChat();
@@ -947,6 +1111,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
onNewChat={handleNewChat} onNewChat={handleNewChat}
onSearch={searchSessions} onSearch={searchSessions}
isSearching={isSearching} isSearching={isSearching}
searchQuery={searchQuery}
onNavigateMatch={handleNavigateMatch}
/> />
</ResizablePanel> </ResizablePanel>
@@ -1050,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
{/* Messages Area with Floating Input Layout */} {/* Messages Area with Floating Input Layout */}
<div className="flex-1 relative h-full overflow-hidden flex flex-col"> <div className="flex-1 relative h-full overflow-hidden flex flex-col">
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"> <div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"
onScroll={handleScroll}
>
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}> <div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
{/* Indicador de carregamento de mensagens antigas */}
{paginationState.isLoadingMore && (
<div className="flex items-center justify-center py-4 mb-4">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mr-2" />
<span className="text-sm text-muted-foreground">Carregando mensagens anteriores...</span>
</div>
)}
{/* Indicador de que há mais mensagens para carregar */}
{paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && (
<div className="flex items-center justify-center py-2 mb-4">
<span className="text-xs text-muted-foreground/60">
Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes)
</span>
</div>
)}
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6"> <div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm"> <div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
@@ -1086,6 +1273,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
'type' in msg.metadata && 'type' in msg.metadata &&
msg.metadata.type === 'thinking'; msg.metadata.type === 'thinking';
// Pular mensagens de sistema que não são thinking (system prompt)
// Essas mensagens nunca devem aparecer no chat
const isSystemPrompt = msg.role === 'system' && !isThinkingMessage;
if (isSystemPrompt) {
continue;
}
if (isThinkingMessage) { if (isThinkingMessage) {
const thinkingMeta = msg.metadata as ThinkingMessageMetadata; const thinkingMeta = msg.metadata as ThinkingMessageMetadata;
currentThinkingGroup.push(thinkingMeta); currentThinkingGroup.push(thinkingMeta);
@@ -1132,6 +1326,25 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
}); });
} }
// Calcular posições de todos os matches uma vez
const allMatches: Array<{ messageIndex: number; matchIndex: number }> = [];
if (searchQuery && searchQuery.trim().length >= 2) {
const query = searchQuery.trim();
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
}
return groupedMessages.map((group, idx) => { return groupedMessages.map((group, idx) => {
if (group.type === 'thinking-group' && group.thinkingSteps) { if (group.type === 'thinking-group' && group.thinkingSteps) {
return ( return (
@@ -1143,11 +1356,30 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
); );
} else if (group.type === 'regular' && group.message) { } else if (group.type === 'regular' && group.message) {
// Encontrar índice do match atual para esta mensagem
let messageMatchIndex: number | undefined = undefined;
if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) {
// Encontrar matches desta mensagem
const messageMatches = allMatches.filter(m => m.messageIndex === group.index);
if (messageMatches.length > 0) {
// Verificar se o match atual está nesta mensagem
const currentMatch = allMatches[searchMatchIndex];
if (currentMatch && currentMatch.messageIndex === group.index) {
messageMatchIndex = currentMatch.matchIndex;
}
}
}
return ( return (
<div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500"> <div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<ChatMessage <ChatMessage
message={group.message} message={group.message}
isStreaming={group.isStreaming} isStreaming={group.isStreaming}
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
highlightIndex={messageMatchIndex}
messageIndex={group.index}
/> />
</div> </div>
); );
+122 -14
View File
@@ -1,6 +1,6 @@
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat"; import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react"; import { Bot, User } from "lucide-react";
@@ -13,9 +13,19 @@ import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
isStreaming?: boolean; isStreaming?: boolean;
highlightTerm?: string;
highlightIndex?: number;
messageIndex?: number;
} }
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) { /**
* Componente de mensagem do chat otimizado com React.memo
*
* Durante streaming, usa renderização simplificada (texto puro) para evitar
* recálculo custoso da árvore DOM e syntax highlight a cada token.
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
*/
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
// Verificar se é mensagem de processo de pensamento // Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata && const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' && typeof message.metadata === 'object' &&
@@ -52,6 +62,57 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
return content || message.content; return content || message.content;
}, [message.content]); }, [message.content]);
// Processar highlight separadamente (após limpeza de conteúdo)
// Desabilitar durante streaming para performance
const highlightedContent = useMemo(() => {
// Não processar highlight durante streaming
if (isStreaming) return null;
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
return null; // Não há highlight
}
const term = highlightTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi');
const content = processedContent;
// Encontrar todos os matches
const matches: Array<{ start: number; end: number; text: string }> = [];
let match;
while ((match = regex.exec(content)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
});
}
if (matches.length === 0) return null;
// Construir conteúdo com highlights
let highlighted = '';
let lastIndex = 0;
matches.forEach((match, idx) => {
// Adicionar texto antes do match
highlighted += content.substring(lastIndex, match.start);
// Adicionar match com highlight (laranja para atual, amarelo para outros)
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
const className = isCurrent
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
highlighted += `<mark class="${className}">${match.text}</mark>`;
lastIndex = match.end;
});
// Adicionar texto restante
highlighted += content.substring(lastIndex);
return highlighted;
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
return ( return (
<MessageBackground <MessageBackground
role={isUser ? 'user' : 'assistant'} role={isUser ? 'user' : 'assistant'}
@@ -75,11 +136,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden min-w-0 pt-1"> <div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? ( {isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior) // Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"> <div
{processedContent} className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
>
{!highlightedContent && processedContent}
</div> </div>
) : ( ) : (
// Estilo Assistant // Estilo Assistant
@@ -88,22 +152,37 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span> <span className="text-sm font-medium">Gerando resposta...</span>
</div> </div>
) : isStreaming ? (
// Renderização LEVE durante streaming: texto simples com formatação básica
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight",
"relative"
)}>
<div className="whitespace-pre-wrap font-sans">
{processedContent}
</div>
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
</div>
) : ( ) : (
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
<div className={cn( <div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90", "prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg", "prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:leading-7 prose-li:leading-7", "prose-p:leading-7 prose-li:leading-7",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl", "prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
isStreaming && "relative" "prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
)}> )}>
<ReactMarkdown {highlightedContent ? (
remarkPlugins={[remarkGfm]} <div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
components={markdownRenderers} ) : (
> <ReactMarkdown
{processedContent} remarkPlugins={[remarkGfm]}
</ReactMarkdown> components={markdownRenderers}
{isStreaming && !isEmpty && ( >
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" /> {processedContent}
</ReactMarkdown>
)} )}
</div> </div>
) )
@@ -123,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
); );
} }
/**
* ChatMessage com React.memo para evitar re-renders desnecessários
*
* Comparação customizada:
* - Compara conteúdo da mensagem
* - Compara estado de streaming
* - Compara termo de highlight e índice
*/
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
// Se streaming mudou, sempre re-renderizar
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
// Durante streaming, só re-renderizar se conteúdo mudou
if (nextProps.isStreaming) {
return prevProps.message.content === nextProps.message.content;
}
// Quando não está em streaming, comparar todas as props relevantes
return (
prevProps.message.content === nextProps.message.content &&
prevProps.message.role === nextProps.message.role &&
prevProps.highlightTerm === nextProps.highlightTerm &&
prevProps.highlightIndex === nextProps.highlightIndex &&
prevProps.messageIndex === nextProps.messageIndex
);
});
// Adicionar displayName para debugging
ChatMessage.displayName = 'ChatMessage';
+58 -15
View File
@@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react"; import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@@ -14,6 +14,8 @@ interface SidebarListProps {
onNewChat: () => void; onNewChat: () => void;
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
isSearching?: boolean; isSearching?: boolean;
searchQuery?: string;
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
} }
function formatDate(dateString: string) { function formatDate(dateString: string) {
@@ -58,9 +60,11 @@ export function SidebarList({
onDeleteSession, onDeleteSession,
onNewChat, onNewChat,
onSearch, onSearch,
isSearching = false isSearching = false,
searchQuery = '',
onNavigateMatch
}: SidebarListProps) { }: SidebarListProps) {
const [searchQuery, setSearchQuery] = useState(''); const [localSearchInput, setLocalSearchInput] = useState('');
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
@@ -71,7 +75,7 @@ export function SidebarList({
} }
// Se query vazia, limpar busca imediatamente // Se query vazia, limpar busca imediatamente
if (!searchQuery.trim()) { if (!localSearchInput.trim()) {
if (onSearch) { if (onSearch) {
onSearch(''); onSearch('');
} }
@@ -80,8 +84,8 @@ export function SidebarList({
// Debounce: aguardar antes de buscar // Debounce: aguardar antes de buscar
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
if (onSearch && searchQuery.trim().length >= 2) { if (onSearch && localSearchInput.trim().length >= 2) {
onSearch(searchQuery.trim()); onSearch(localSearchInput.trim());
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -91,10 +95,10 @@ export function SidebarList({
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
}; };
}, [searchQuery, onSearch]); }, [localSearchInput, onSearch]);
const handleClearSearch = () => { const handleClearSearch = () => {
setSearchQuery(''); setLocalSearchInput('');
if (onSearch) { if (onSearch) {
onSearch(''); onSearch('');
} }
@@ -111,14 +115,14 @@ export function SidebarList({
<Input <Input
type="text" type="text"
placeholder="Buscar conversas..." placeholder="Buscar conversas..."
value={searchQuery} value={localSearchInput}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setLocalSearchInput(e.target.value)}
className="pl-8 pr-8 h-9 text-sm" className="pl-8 pr-8 h-9 text-sm"
/> />
{isSearching && ( {isSearching && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" /> <Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
)} )}
{searchQuery && !isSearching && ( {localSearchInput && !isSearching && (
<button <button
onClick={handleClearSearch} onClick={handleClearSearch}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
@@ -190,16 +194,55 @@ export function SidebarList({
</div> </div>
{/* Texto do card */} {/* Texto do card */}
<div className="sidebar-chat-item-text"> <div className="sidebar-chat-item-text flex-1 min-w-0">
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5"> <h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
{session.title} {session.title}
</h3> </h3>
<p className="sidebar-chat-item-date text-xs text-muted-foreground"> <div className="flex items-center gap-2">
{formatDate(session.updated_at)} <p className="sidebar-chat-item-date text-xs text-muted-foreground">
</p> {formatDate(session.updated_at)}
</p>
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
</span>
)}
</div>
</div> </div>
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
<div className="flex flex-col gap-0.5 mr-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'prev');
}}
aria-label="Ocorrência anterior"
>
<ChevronUp className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'next');
}}
aria-label="Próxima ocorrência"
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</div>
)}
{/* Botão de deletar - aparece no hover */} {/* Botão de deletar - aparece no hover */}
<Button <Button
variant="ghost" variant="ghost"
+158 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { Message } from './use-chat'; import { Message } from './use-chat';
import { removeMetadataNoise } from '@/lib/metadata'; import { removeMetadataNoise } from '@/lib/metadata';
@@ -10,8 +10,31 @@ export interface SessionSummary {
updated_at: string; updated_at: string;
preview: string; preview: string;
platform: string; platform: string;
match_count?: number;
} }
/** Resultado de carregamento paginado do backend */
interface PaginatedHistoryResult {
messages: Array<{
role: string;
content: string;
metadata?: unknown;
}>;
total_count: number;
has_more: boolean;
}
/** Estado de paginação para uma sessão */
export interface PaginationState {
totalCount: number;
hasMore: boolean;
offset: number;
isLoadingMore: boolean;
}
/** Número de mensagens a carregar por página */
const MESSAGES_PER_PAGE = 30;
const TITLE_MODEL = "qwen2.5:0.5b"; const TITLE_MODEL = "qwen2.5:0.5b";
// Helper to generate fallback title from user's first message // Helper to generate fallback title from user's first message
@@ -41,6 +64,17 @@ export function useChatStorage() {
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
// Estado de paginação por sessão
const [paginationState, setPaginationState] = useState<PaginationState>({
totalCount: 0,
hasMore: false,
offset: 0,
isLoadingMore: false,
});
// Ref para evitar múltiplos loads simultâneos
const loadingMoreRef = useRef(false);
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const list = await invoke<SessionSummary[]>('load_chat_sessions'); const list = await invoke<SessionSummary[]>('load_chat_sessions');
@@ -73,23 +107,138 @@ export function useChatStorage() {
} }
}, [loadSessions]); }, [loadSessions]);
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => { /**
* Carrega histórico de uma sessão com paginação (lazy loading)
*
* Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens.
* Retorna as mensagens e atualiza o estado de paginação.
*/
const loadSessionHistory = useCallback(async (
id: string,
options?: { loadAll?: boolean }
): Promise<Message[]> => {
try { try {
const rawMessages = await invoke<any[]>('load_chat_history', { id }); // Reset pagination state
// Convert role from String to type-safe union and ensure proper types setPaginationState({
return rawMessages.map(msg => ({ totalCount: 0,
hasMore: false,
offset: 0,
isLoadingMore: false,
});
// Se loadAll = true, usa o comando antigo que carrega tudo
if (options?.loadAll) {
const rawMessages = await invoke<Array<{
role: string;
content: string;
metadata?: unknown;
}>>('load_chat_history', { id });
return rawMessages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
}
// Usar paginação: carregar últimas N mensagens
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
id,
limit: MESSAGES_PER_PAGE,
offset: 0
});
// Atualizar estado de paginação
setPaginationState({
totalCount: result.total_count,
hasMore: result.has_more,
offset: result.messages.length,
isLoadingMore: false,
});
// Converter para tipo Message
return result.messages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system' ? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const, // fallback : 'user' as const,
content: msg.content || '', content: msg.content || '',
metadata: msg.metadata || undefined metadata: msg.metadata || undefined
})); }));
} catch (error) { } catch (error) {
console.error("Failed to load history:", error); console.error("Failed to load history:", error);
return [];
// Fallback: tentar carregar tudo com o comando antigo
try {
const rawMessages = await invoke<Array<{
role: string;
content: string;
metadata?: unknown;
}>>('load_chat_history', { id });
return rawMessages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
} catch {
return [];
}
} }
}, []); }, []);
/**
* Carrega mais mensagens antigas (scroll infinito reverso)
*
* Chamado quando o usuário faz scroll para o topo do chat.
* Retorna as mensagens anteriores que devem ser prepend ao estado.
*/
const loadMoreMessages = useCallback(async (
sessionId: string
): Promise<Message[]> => {
// Evitar múltiplos loads simultâneos
if (loadingMoreRef.current || !paginationState.hasMore) {
return [];
}
loadingMoreRef.current = true;
setPaginationState(prev => ({ ...prev, isLoadingMore: true }));
try {
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
id: sessionId,
limit: MESSAGES_PER_PAGE,
offset: paginationState.offset
});
// Atualizar estado de paginação
setPaginationState(prev => ({
...prev,
hasMore: result.has_more,
offset: prev.offset + result.messages.length,
isLoadingMore: false,
}));
// Converter para tipo Message
return result.messages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
} catch (error) {
console.error("Failed to load more messages:", error);
setPaginationState(prev => ({ ...prev, isLoadingMore: false }));
return [];
} finally {
loadingMoreRef.current = false;
}
}, [paginationState.hasMore, paginationState.offset]);
const deleteSession = useCallback(async (id: string) => { const deleteSession = useCallback(async (id: string) => {
try { try {
await invoke('delete_chat_session', { id }); await invoke('delete_chat_session', { id });
@@ -396,6 +545,8 @@ export function useChatStorage() {
setCurrentSessionId, setCurrentSessionId,
loadSessions, loadSessions,
loadSessionHistory, loadSessionHistory,
loadMoreMessages,
paginationState,
saveSession, saveSession,
deleteSession, deleteSession,
isGeneratingTitle, isGeneratingTitle,
@@ -407,4 +558,3 @@ export function useChatStorage() {
isSearching isSearching
}; };
} }
+40 -26
View File
@@ -240,6 +240,29 @@ export function useChat() {
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 currentSessionIdRef = useRef<string | null>(null);
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
const renderScheduledRef = useRef<boolean>(false);
const rafIdRef = useRef<number | null>(null);
// Função para atualizar o estado visual com o conteúdo acumulado
const flushToState = () => {
renderScheduledRef.current = false;
const displayContent = stripMetadataTag(rawContentRef.current);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: displayContent }];
}
return [...prev, { role: 'assistant', content: displayContent }];
});
};
// Agendar flush com requestAnimationFrame (limita a ~60fps)
const scheduleFlush = () => {
if (!renderScheduledRef.current) {
renderScheduledRef.current = true;
rafIdRef.current = requestAnimationFrame(flushToState);
}
};
// Listener para eventos do Rust // Listener para eventos do Rust
useEffect(() => { useEffect(() => {
@@ -262,51 +285,38 @@ export function useChat() {
const { content, done } = event.payload; const { content, done } = event.payload;
if (content && content.length > 0) { if (content && content.length > 0) {
// Verificar se este é o mesmo token do último processado (evitar duplicação) // Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
// Isso pode acontecer se o evento for emitido múltiplas vezes
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) { if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
// Token duplicado, ignorar
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30)); console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
} }
return; return;
} }
// Atualizar referência do último token
lastTokenRef.current = content; lastTokenRef.current = content;
// Accumulate raw content (including metadata) in ref
rawContentRef.current += content; rawContentRef.current += content;
setMessages(prev => { // MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
const last = prev[prev.length - 1]; scheduleFlush();
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) { if (done) {
// Stream terminou, processar metadata final // Cancelar qualquer flush pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
renderScheduledRef.current = false;
}
// Flush IMEDIATO com processamento de metadata
setMessages(prev => { setMessages(prev => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current; const rawContent = rawContentRef.current;
const { content: finalContent, metadata } = parseMetadata(rawContent); const { content: finalContent, metadata } = parseMetadata(rawContent);
rawContentRef.current = ''; rawContentRef.current = '';
lastTokenRef.current = ''; // Reset last token tracker lastTokenRef.current = '';
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
return [
...prev.slice(0, -1),
{ ...last, content: finalContent || last.content, metadata }
];
} }
return prev; return prev;
}); });
@@ -331,6 +341,10 @@ export function useChat() {
unlistenCreated?.(); unlistenCreated?.();
unlistenToken?.(); unlistenToken?.();
unlistenError?.(); unlistenError?.();
// Cancelar RAF pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
}; };
}, []); }, []);
+504 -14
View File
@@ -39,6 +39,7 @@ dependencies = [
"cfg-if", "cfg-if",
"getrandom 0.3.4", "getrandom 0.3.4",
"once_cell", "once_cell",
"serde",
"version_check", "version_check",
"zerocopy", "zerocopy",
] ]
@@ -110,6 +111,8 @@ dependencies = [
"headless_chrome", "headless_chrome",
"html2text", "html2text",
"log", "log",
"ndarray",
"ort",
"rand 0.8.5", "rand 0.8.5",
"readability", "readability",
"regex", "regex",
@@ -126,6 +129,7 @@ dependencies = [
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-shell", "tauri-plugin-shell",
"tokenizers",
"tokio", "tokio",
"tokio-cron-scheduler", "tokio-cron-scheduler",
"url", "url",
@@ -363,7 +367,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"ureq", "ureq 2.12.1",
] ]
[[package]] [[package]]
@@ -372,6 +376,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -654,6 +664,15 @@ dependencies = [
"toml 0.9.8", "toml 0.9.8",
] ]
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.46" version = "1.2.46"
@@ -689,7 +708,7 @@ version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [ dependencies = [
"smallvec", "smallvec 1.15.1",
"target-lexicon", "target-lexicon",
] ]
@@ -739,6 +758,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "compact_str"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"serde",
"static_assertions",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -748,6 +782,19 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.1.5" version = "0.1.5"
@@ -858,6 +905,25 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -887,7 +953,7 @@ dependencies = [
"phf 0.10.1", "phf 0.10.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"smallvec", "smallvec 1.15.1",
"syn 1.0.109", "syn 1.0.109",
] ]
@@ -901,7 +967,7 @@ dependencies = [
"dtoa-short", "dtoa-short",
"itoa", "itoa",
"phf 0.11.3", "phf 0.11.3",
"smallvec", "smallvec 1.15.1",
] ]
[[package]] [[package]]
@@ -994,12 +1060,31 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "dary_heap"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.9.0" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"pem-rfc7468",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -1219,6 +1304,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.6" version = "3.0.6"
@@ -1239,6 +1330,12 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -1318,6 +1415,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "esaxx-rs"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.1"
@@ -1385,6 +1491,18 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
dependencies = [
"cfg-if",
"libc",
"libredox",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.5" version = "0.1.5"
@@ -1735,7 +1853,7 @@ dependencies = [
"libc", "libc",
"once_cell", "once_cell",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec 1.15.1",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -1771,7 +1889,7 @@ dependencies = [
"libc", "libc",
"memchr", "memchr",
"once_cell", "once_cell",
"smallvec", "smallvec 1.15.1",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@@ -2143,7 +2261,7 @@ dependencies = [
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"smallvec", "smallvec 1.15.1",
"tokio", "tokio",
"want", "want",
] ]
@@ -2289,7 +2407,7 @@ dependencies = [
"icu_normalizer_data", "icu_normalizer_data",
"icu_properties", "icu_properties",
"icu_provider", "icu_provider",
"smallvec", "smallvec 1.15.1",
"zerovec", "zerovec",
] ]
@@ -2347,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [ dependencies = [
"idna_adapter", "idna_adapter",
"smallvec", "smallvec 1.15.1",
"utf8_iter", "utf8_iter",
] ]
@@ -2384,6 +2502,19 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width 0.2.2",
"web-time",
]
[[package]] [[package]]
name = "infer" name = "infer"
version = "0.19.0" version = "0.19.0"
@@ -2437,6 +2568,15 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -2579,7 +2719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [ dependencies = [
"gtk-sys", "gtk-sys",
"libloading", "libloading 0.7.4",
"once_cell", "once_cell",
] ]
@@ -2599,6 +2739,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.10" version = "0.1.10"
@@ -2607,6 +2757,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"libc", "libc",
"redox_syscall",
] ]
[[package]] [[package]]
@@ -2668,6 +2819,22 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520"
dependencies = [
"macro_rules_attribute-proc_macro",
"paste",
]
[[package]]
name = "macro_rules_attribute-proc_macro"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -2734,6 +2901,16 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.7.6"
@@ -2782,6 +2959,28 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "monostate"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
dependencies = [
"monostate-impl",
"serde",
"serde_core",
]
[[package]]
name = "monostate-impl"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -2820,6 +3019,21 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "ndarray"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2921,6 +3135,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -2938,6 +3161,15 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -2978,6 +3210,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]] [[package]]
name = "objc" name = "objc"
version = "0.2.7" version = "0.2.7"
@@ -3287,6 +3525,28 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "onig"
version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
"bitflags 2.10.0",
"libc",
"once_cell",
"onig_sys",
]
[[package]]
name = "onig_sys"
version = "69.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
dependencies = [
"cc",
"pkg-config",
]
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.3" version = "5.3.3"
@@ -3359,6 +3619,32 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "ort"
version = "2.0.0-rc.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
dependencies = [
"libloading 0.8.9",
"ndarray",
"ort-sys",
"smallvec 2.0.0-alpha.10",
"tracing",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
dependencies = [
"flate2",
"pkg-config",
"sha2",
"tar",
"ureq 3.1.4",
]
[[package]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@@ -3419,7 +3705,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall",
"smallvec", "smallvec 1.15.1",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -3434,6 +3720,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -3452,6 +3744,15 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3677,6 +3978,21 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -3950,6 +4266,43 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-cond"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
dependencies = [
"either",
"itertools",
"rayon",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "readability" name = "readability"
version = "0.3.0" version = "0.3.0"
@@ -4213,7 +4566,7 @@ dependencies = [
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
"libsqlite3-sys", "libsqlite3-sys",
"smallvec", "smallvec 1.15.1",
] ]
[[package]] [[package]]
@@ -4445,7 +4798,7 @@ dependencies = [
"phf_codegen 0.8.0", "phf_codegen 0.8.0",
"precomputed-hash", "precomputed-hash",
"servo_arc 0.2.0", "servo_arc 0.2.0",
"smallvec", "smallvec 1.15.1",
] ]
[[package]] [[package]]
@@ -4464,7 +4817,7 @@ dependencies = [
"phf_codegen 0.10.0", "phf_codegen 0.10.0",
"precomputed-hash", "precomputed-hash",
"servo_arc 0.3.0", "servo_arc 0.3.0",
"smallvec", "smallvec 1.15.1",
] ]
[[package]] [[package]]
@@ -4761,6 +5114,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smallvec"
version = "2.0.0-alpha.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.10" version = "0.5.10"
@@ -4840,6 +5199,18 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "spm_precompiled"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
dependencies = [
"base64 0.13.1",
"nom",
"serde",
"unicode-segmentation",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -5074,6 +5445,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -5522,6 +5904,40 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokenizers"
version = "0.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476"
dependencies = [
"ahash 0.8.12",
"aho-corasick",
"compact_str",
"dary_heap",
"derive_builder",
"esaxx-rs",
"getrandom 0.3.4",
"indicatif",
"itertools",
"log",
"macro_rules_attribute",
"monostate",
"onig",
"paste",
"rand 0.9.2",
"rayon",
"rayon-cond",
"regex",
"regex-syntax",
"serde",
"serde_json",
"spm_precompiled",
"thiserror 2.0.17",
"unicode-normalization-alignments",
"unicode-segmentation",
"unicode_categories",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.48.0" version = "1.48.0"
@@ -5885,6 +6301,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization-alignments"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
dependencies = [
"smallvec 1.15.1",
]
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.12.0" version = "1.12.0"
@@ -5903,6 +6328,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -5926,6 +6357,36 @@ dependencies = [
"webpki-roots 0.26.11", "webpki-roots 0.26.11",
] ]
[[package]]
name = "ureq"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64 0.22.1",
"der",
"log",
"native-tls",
"percent-encoding",
"rustls-pki-types",
"socks",
"ureq-proto",
"utf-8",
"webpki-root-certs",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64 0.22.1",
"http 1.3.1",
"httparse",
"log",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"
@@ -6151,6 +6612,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webkit2gtk" name = "webkit2gtk"
version = "2.0.1" version = "2.0.1"
@@ -6195,6 +6666,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"
@@ -6911,6 +7391,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "xdg-home" name = "xdg-home"
version = "1.3.0" version = "1.3.0"
+7
View File
@@ -47,3 +47,10 @@ tauri-plugin-autostart = "2"
dirs = "5.0" dirs = "5.0"
rfd = "0.14" rfd = "0.14"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
# ONNX Runtime para embeddings locais (ranking de relevância)
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
# entre ort (dynamic) e tokenizers/esaxx-rs (static)
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
ndarray = "0.16"
tokenizers = "0.21"
+257 -60
View File
@@ -22,6 +22,13 @@ pub struct ChatMessage {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Resultado de busca de sessões com contagem de matches
#[derive(Debug, Clone)]
pub struct SearchSessionResult {
pub session: ChatSession,
pub match_count: i64,
}
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
} }
@@ -49,6 +56,19 @@ impl Database {
let db_path = app_data_dir.join("ollahub.db"); let db_path = app_data_dir.join("ollahub.db");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
// Otimizações de performance do SQLite
// WAL mode permite leituras e escritas simultâneas (evita bloqueio da UI)
// synchronous=NORMAL reduz fsync sem perder segurança
// cache_size maior acelera operações frequentes
// temp_store=MEMORY usa RAM para tabelas temporárias
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=10000;
PRAGMA temp_store=MEMORY;
PRAGMA foreign_keys=ON;"
)?;
let db = Self { conn }; let db = Self { conn };
db.init_schema()?; db.init_schema()?;
@@ -370,6 +390,54 @@ impl Database {
Ok(self.conn.last_insert_rowid()) Ok(self.conn.last_insert_rowid())
} }
/// Salva múltiplas mensagens de uma sessão em uma transação
///
/// Remove mensagens existentes da sessão antes de inserir as novas
/// para garantir que não haja duplicatas.
pub fn save_messages_batch(
&self,
session_id: &str,
messages: &[ChatMessage],
) -> SqliteResult<()> {
// Usar execute_batch para executar múltiplas operações atomicamente
// WAL mode permite isso de forma segura mesmo sem transação explícita
// Remover mensagens existentes da sessão (para evitar duplicatas)
self.conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id],
)?;
// Inserir todas as mensagens
let mut stmt = self.conn.prepare(
"INSERT INTO messages (session_id, role, content, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)"
)?;
for message in messages {
stmt.execute(params![
message.session_id,
message.role,
message.content,
message.metadata,
message.created_at.to_rfc3339()
])?;
}
// Atualizar updated_at da sessão com a data da última mensagem
if let Some(last_message) = messages.last() {
self.conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![
last_message.created_at.to_rfc3339(),
session_id
],
)?;
}
Ok(())
}
/// Busca todas as mensagens de uma sessão /// Busca todas as mensagens de uma sessão
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> { pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
@@ -399,6 +467,88 @@ impl Database {
Ok(messages) Ok(messages)
} }
/// Busca mensagens de uma sessão com paginação (lazy loading)
///
/// Retorna as últimas `limit` mensagens a partir do `offset`.
/// O offset conta do final (0 = últimas mensagens, 20 = 20 mensagens antes das últimas).
///
/// Parâmetros:
/// - session_id: ID da sessão
/// - limit: número máximo de mensagens a retornar
/// - offset: número de mensagens a pular do final (0 = começar das últimas)
///
/// Retorna: (mensagens em ordem ASC, total_count, has_more)
pub fn get_messages_paginated(
&self,
session_id: &str,
limit: usize,
offset: usize,
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
// Primeiro, obter o total de mensagens
let total_count: usize = self.conn.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
// Se não há mensagens, retornar vazio
if total_count == 0 {
return Ok((Vec::new(), 0, false));
}
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
// depois ordenar ASC para manter ordem cronológica
//
// Se offset=0 e limit=30: queremos as últimas 30 mensagens
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
if real_limit == 0 {
return Ok((Vec::new(), total_count, false));
}
// Query: pegar as últimas (offset + limit) mensagens ordenadas DESC,
// depois ordenar ASC e pegar as primeiras 'limit' (que são as mais antigas do conjunto)
let mut stmt = self.conn.prepare(
"SELECT id, session_id, role, content, metadata, created_at
FROM (
SELECT id, session_id, role, content, metadata, created_at
FROM messages
WHERE session_id = ?1
ORDER BY created_at DESC
LIMIT ?2
) AS recent_messages
ORDER BY created_at ASC
LIMIT ?3"
)?;
// Precisamos pegar (offset + limit) mensagens do final para depois pegar as primeiras 'limit'
let fetch_limit = offset + limit;
let rows = stmt.query_map(params![session_id, fetch_limit, real_limit], |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?);
}
// has_more = ainda há mensagens mais antigas para carregar
let has_more = offset + messages.len() < total_count;
Ok((messages, total_count, has_more))
}
/// Salva um documento RAG /// Salva um documento RAG
pub fn save_rag_document( pub fn save_rag_document(
&self, &self,
@@ -446,89 +596,136 @@ impl Database {
/// Busca sessões por query (título ou conteúdo de mensagens) /// Busca sessões por query (título ou conteúdo de mensagens)
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo) /// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<ChatSession>> { /// Inclui contagem de matches para navegação
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<SearchSessionResult>> {
if query.trim().is_empty() { if query.trim().is_empty() {
// Se query vazia, retornar todas as sessões ordenadas por updated_at // Se query vazia, retornar todas as sessões ordenadas por updated_at com match_count = 0
return self.list_sessions(); let sessions = self.list_sessions()?;
return Ok(sessions.into_iter().map(|session| SearchSessionResult {
session,
match_count: 0,
}).collect());
} }
// Escapar caracteres especiais para FTS5 // Escapar caracteres especiais para FTS5
let escaped_query = query.replace('"', "\"\""); let escaped_query = query.replace('"', "\"\"");
let fts_query = format!("\"{}\"", escaped_query); let fts_query = format!("\"{}\"", escaped_query);
// Busca combinada: título (peso 1.0) + conteúdo (peso 0.5) // Busca simplificada: primeiro buscar por título, depois por conteúdo
let mut stmt = self.conn.prepare( // Usando abordagem em duas etapas para evitar problemas com bm25 em CTEs
"WITH title_matches AS (
SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(sessions_fts) as rank
FROM sessions s
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
WHERE sessions_fts MATCH ?1
ORDER BY rank
LIMIT ?2
),
content_matches AS (
SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(messages_fts) * 0.5 as rank
FROM sessions s
JOIN messages m ON s.id = m.session_id
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
ORDER BY rank
LIMIT ?2
)
SELECT id, title, emoji, created_at, updated_at
FROM (
SELECT * FROM title_matches
UNION
SELECT * FROM content_matches
)
ORDER BY rank DESC, updated_at DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![fts_query, limit], |row| { // Etapa 1: Buscar sessões por título (FTS5)
Ok(ChatSession { let mut title_sessions: Vec<SearchSessionResult> = Vec::new();
id: row.get(0)?, {
title: row.get(1)?, let mut stmt = self.conn.prepare(
emoji: row.get(2)?, "SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) FROM sessions s
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? JOIN sessions_fts ON s.rowid = sessions_fts.rowid
.with_timezone(&Utc), WHERE sessions_fts MATCH ?1
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) ORDER BY s.updated_at DESC
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? LIMIT ?2"
.with_timezone(&Utc), )?;
})
})?;
let mut sessions = Vec::new(); let rows = stmt.query_map(params![&fts_query, limit], |row| {
for row in rows { Ok(SearchSessionResult {
sessions.push(row?); session: 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),
},
match_count: 1, // Match no título conta como 1
})
})?;
for row in rows {
title_sessions.push(row?);
}
} }
// Etapa 2: Buscar sessões por conteúdo de mensagens (FTS5)
let mut content_sessions: Vec<(String, i64)> = Vec::new(); // (session_id, match_count)
{
let mut stmt = self.conn.prepare(
"SELECT m.session_id, COUNT(*) as match_count
FROM messages m
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
GROUP BY m.session_id
ORDER BY match_count DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
content_sessions.push(row?);
}
}
// Combinar resultados: priorizar matches por título, adicionar matches por conteúdo
let mut session_map: std::collections::HashMap<String, SearchSessionResult> = std::collections::HashMap::new();
// Adicionar sessões encontradas por título
for session in title_sessions {
session_map.insert(session.session.id.clone(), session);
}
// Adicionar ou atualizar com sessões encontradas por conteúdo
for (session_id, match_count) in content_sessions {
if let Some(existing) = session_map.get_mut(&session_id) {
// Atualizar match_count se já existe
existing.match_count = match_count;
} else {
// Buscar dados da sessão
if let Ok(Some(session)) = self.get_session(&session_id) {
session_map.insert(session_id, SearchSessionResult {
session,
match_count,
});
}
}
}
// Converter para vetor e ordenar por updated_at
let mut sessions: Vec<SearchSessionResult> = session_map.into_values().collect();
sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at));
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback) // Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
if sessions.is_empty() { if sessions.is_empty() {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at "SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
COUNT(CASE WHEN m.content LIKE ?1 THEN 1 END) as match_count
FROM sessions s FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id LEFT JOIN messages m ON s.id = m.session_id
WHERE s.title LIKE ?1 OR m.content LIKE ?1 WHERE s.title LIKE ?1 OR m.content LIKE ?1
GROUP BY s.id, s.title, s.emoji, s.created_at, s.updated_at
ORDER BY s.updated_at DESC ORDER BY s.updated_at DESC
LIMIT ?2" LIMIT ?2"
)?; )?;
let like_query = format!("%{}%", query); let like_query = format!("%{}%", query);
let rows = stmt.query_map(params![like_query, limit], |row| { let rows = stmt.query_map(params![like_query, limit], |row| {
Ok(ChatSession { Ok(SearchSessionResult {
id: row.get(0)?, session: ChatSession {
title: row.get(1)?, id: row.get(0)?,
emoji: row.get(2)?, title: row.get(1)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) emoji: row.get(2)?,
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.with_timezone(&Utc), .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) .with_timezone(&Utc),
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.with_timezone(&Utc), .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
},
match_count: row.get(5)?,
}) })
})?; })?;
+566
View File
@@ -0,0 +1,566 @@
//! Módulo de Embeddings ONNX para ranking de relevância
//!
//! Usa o modelo all-MiniLM-L6-v2 para gerar embeddings de texto
//! e calcular similaridade de cosseno para ranking de resultados de busca.
use anyhow::{Result, anyhow};
use ndarray::Array2;
use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::value::Value;
use std::io::Read;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use tokenizers::Tokenizer;
/// Flag para controlar se o ort já foi inicializado
static ORT_INITIALIZED: OnceLock<Result<(), String>> = OnceLock::new();
/// Dimensão dos embeddings do modelo all-MiniLM-L6-v2
pub const EMBEDDING_DIM: usize = 384;
/// Tamanho máximo de tokens para o modelo
const MAX_SEQ_LENGTH: usize = 256;
/// Global lazy-initialized embedding model (com Mutex para permitir mutabilidade)
static EMBEDDING_MODEL: OnceLock<Result<Arc<Mutex<EmbeddingModel>>, String>> = OnceLock::new();
/// Modelo de embeddings para cálculo de similaridade semântica
pub struct EmbeddingModel {
session: Session,
tokenizer: Tokenizer,
}
impl EmbeddingModel {
/// Carrega o modelo ONNX e tokenizer
pub fn new(model_path: &str, tokenizer_path: &str) -> Result<Self> {
// Verificar se arquivos existem
if !Path::new(model_path).exists() {
return Err(anyhow!("Model file not found: {}", model_path));
}
if !Path::new(tokenizer_path).exists() {
return Err(anyhow!("Tokenizer file not found: {}", tokenizer_path));
}
log::info!("[Embeddings] Loading ONNX model from: {}", model_path);
// Criar sessão ONNX
let session = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.commit_from_file(model_path)?;
// Carregar tokenizer
let tokenizer = Tokenizer::from_file(tokenizer_path)
.map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?;
log::info!("[Embeddings] Model loaded successfully");
Ok(Self { session, tokenizer })
}
/// Gera embedding para um texto
pub fn embed(&mut self, text: &str) -> Result<Vec<f32>> {
// Tokenizar texto
let encoding = self.tokenizer
.encode(text, true)
.map_err(|e| anyhow!("Tokenization failed: {}", e))?;
let mut input_ids: Vec<i64> = encoding.get_ids()
.iter()
.map(|&id| id as i64)
.collect();
let mut attention_mask: Vec<i64> = encoding.get_attention_mask()
.iter()
.map(|&m| m as i64)
.collect();
let mut token_type_ids: Vec<i64> = encoding.get_type_ids()
.iter()
.map(|&t| t as i64)
.collect();
// Truncar/pad para MAX_SEQ_LENGTH
input_ids.truncate(MAX_SEQ_LENGTH);
attention_mask.truncate(MAX_SEQ_LENGTH);
token_type_ids.truncate(MAX_SEQ_LENGTH);
while input_ids.len() < MAX_SEQ_LENGTH {
input_ids.push(0);
attention_mask.push(0);
token_type_ids.push(0);
}
// Criar arrays para inferência (batch size = 1)
let input_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), input_ids)?;
let attention_mask_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), attention_mask)?;
let token_type_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), token_type_ids)?;
// Criar inputs ONNX
let input_ids_value = Value::from_array(input_ids_array)?;
let attention_mask_value = Value::from_array(attention_mask_array)?;
let token_type_ids_value = Value::from_array(token_type_ids_array)?;
// Executar inferência usando vetor de inputs
let inputs: Vec<(std::borrow::Cow<str>, ort::session::SessionInputValue)> = vec![
("input_ids".into(), input_ids_value.into()),
("attention_mask".into(), attention_mask_value.into()),
("token_type_ids".into(), token_type_ids_value.into()),
];
let outputs = self.session.run(inputs)?;
// Extrair output (last_hidden_state ou sentence_embedding dependendo do modelo)
// Para all-MiniLM-L6-v2, fazemos mean pooling do last_hidden_state
let output = outputs.get("last_hidden_state")
.or_else(|| outputs.get("sentence_embedding"))
.ok_or_else(|| anyhow!("Output tensor not found"))?;
let (shape, raw_data) = output.try_extract_tensor::<f32>()?;
// Shape implementa Deref para [i64], então podemos usar diretamente
let dims: &[i64] = &*shape;
let data: &[f32] = raw_data;
// Mean pooling: média ao longo da dimensão de sequência
let embedding = if dims.len() == 3 {
// Shape: (batch, seq_len, hidden_dim)
let seq_len = dims[1] as usize;
let hidden_dim = dims[2] as usize;
let mut pooled = vec![0.0f32; hidden_dim];
for i in 0..seq_len {
for j in 0..hidden_dim {
let idx = i * hidden_dim + j;
pooled[j] += data[idx];
}
}
for v in &mut pooled {
*v /= seq_len as f32;
}
// Normalizar L2
let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for v in &mut pooled {
*v /= norm;
}
}
pooled
} else {
// Shape: (batch, hidden_dim) - já pooled
let hidden_dim = dims[1] as usize;
let mut embedding = Vec::with_capacity(hidden_dim);
for j in 0..hidden_dim {
embedding.push(data[j]);
}
// Normalizar L2
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for v in &mut embedding {
*v /= norm;
}
}
embedding
};
Ok(embedding)
}
/// Calcula embeddings em batch (mais eficiente para múltiplos textos)
pub fn embed_batch(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
// Para simplificar, processa um por um (pode ser otimizado para batch real)
let mut results = Vec::with_capacity(texts.len());
for text in texts {
results.push(self.embed(text)?);
}
Ok(results)
}
}
/// Calcula similaridade de cosseno entre dois vetores
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
/// URLs para download do modelo (Hugging Face)
const MODEL_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
const TOKENIZER_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
/// URL para download da biblioteca ONNX Runtime (Windows x64)
#[cfg(target_os = "windows")]
const ORT_DLL_URL: &str = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip";
/// Inicializa o ONNX Runtime com a biblioteca dinâmica
fn init_ort_runtime(app_data_dir: &Path) -> Result<()> {
let result = ORT_INITIALIZED.get_or_init(|| {
let ort_dir = app_data_dir.join("ort");
#[cfg(target_os = "windows")]
let dll_path = ort_dir.join("onnxruntime.dll");
#[cfg(target_os = "macos")]
let dll_path = ort_dir.join("libonnxruntime.dylib");
#[cfg(target_os = "linux")]
let dll_path = ort_dir.join("libonnxruntime.so");
if dll_path.exists() {
// Definir variável de ambiente para o ort encontrar a DLL
std::env::set_var("ORT_DYLIB_PATH", &dll_path);
log::info!("[Embeddings] ORT_DYLIB_PATH set to: {:?}", dll_path);
Ok(())
} else {
// Tentar usar a biblioteca do sistema
log::warn!("[Embeddings] ONNX Runtime library not found at {:?}, will try system path", dll_path);
Ok(())
}
});
match result {
Ok(()) => Ok(()),
Err(e) => Err(anyhow!("{}", e))
}
}
/// Baixa um arquivo de uma URL para o caminho especificado
async fn download_file(url: &str, path: &Path) -> Result<()> {
log::info!("[Embeddings] Downloading: {} -> {:?}", url, path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) // 5 min timeout
.build()?;
let response = client.get(url).send().await?;
if !response.status().is_success() {
return Err(anyhow!("Download failed with status: {}", response.status()));
}
let bytes = response.bytes().await?;
// Criar diretório pai se não existir
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
log::info!("[Embeddings] Downloaded successfully: {:?}", path);
Ok(())
}
/// Baixa e extrai a biblioteca ONNX Runtime
#[cfg(target_os = "windows")]
async fn ensure_ort_library(app_data_dir: &Path) -> Result<()> {
let ort_dir = app_data_dir.join("ort");
let dll_path = ort_dir.join("onnxruntime.dll");
if dll_path.exists() {
log::info!("[Embeddings] ONNX Runtime library already exists");
return Ok(());
}
log::info!("[Embeddings] Downloading ONNX Runtime library...");
// Criar diretório
std::fs::create_dir_all(&ort_dir)?;
// Baixar arquivo zip
let zip_path = ort_dir.join("onnxruntime.zip");
download_file(ORT_DLL_URL, &zip_path).await?;
// Extrair DLL do zip
log::info!("[Embeddings] Extracting ONNX Runtime library...");
let file = std::fs::File::open(&zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
// Procurar pela DLL dentro do zip
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
if name.ends_with("onnxruntime.dll") {
let mut outfile = std::fs::File::create(&dll_path)?;
std::io::copy(&mut entry, &mut outfile)?;
log::info!("[Embeddings] Extracted onnxruntime.dll");
break;
}
}
// Remover arquivo zip
let _ = std::fs::remove_file(&zip_path);
if !dll_path.exists() {
return Err(anyhow!("Failed to extract onnxruntime.dll from archive"));
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn ensure_ort_library(_app_data_dir: &Path) -> Result<()> {
// Em outros sistemas, assumimos que a biblioteca está no sistema
log::info!("[Embeddings] Using system ONNX Runtime library");
Ok(())
}
/// Garante que os arquivos do modelo existem, baixando se necessário
pub async fn ensure_model_files(app_data_dir: &Path) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
let models_dir = app_data_dir.join("models");
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = models_dir.join("tokenizer.json");
// Criar diretório de modelos
std::fs::create_dir_all(&models_dir)?;
// Baixar biblioteca ONNX Runtime se necessário (apenas Windows)
ensure_ort_library(app_data_dir).await?;
// Inicializar ort com o caminho da DLL
init_ort_runtime(app_data_dir)?;
// Baixar modelo se não existir
if !model_path.exists() {
log::info!("[Embeddings] Model not found, downloading...");
download_file(MODEL_URL, &model_path).await?;
}
// Baixar tokenizer se não existir
if !tokenizer_path.exists() {
log::info!("[Embeddings] Tokenizer not found, downloading...");
download_file(TOKENIZER_URL, &tokenizer_path).await?;
}
Ok((model_path, tokenizer_path))
}
/// Verifica se o modelo está disponível
pub fn is_model_available(app_data_dir: &Path) -> bool {
let models_dir = app_data_dir.join("models");
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = models_dir.join("tokenizer.json");
model_path.exists() && tokenizer_path.exists()
}
/// Obtém ou inicializa o modelo global de embeddings
pub fn get_or_init_model(app_data_dir: &Path) -> Result<Arc<Mutex<EmbeddingModel>>> {
// Inicializar ort com o caminho da DLL antes de criar o modelo
init_ort_runtime(app_data_dir)?;
let result = EMBEDDING_MODEL.get_or_init(|| {
let model_path = app_data_dir.join("models").join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = app_data_dir.join("models").join("tokenizer.json");
match EmbeddingModel::new(
model_path.to_str().unwrap_or(""),
tokenizer_path.to_str().unwrap_or("")
) {
Ok(model) => Ok(Arc::new(Mutex::new(model))),
Err(e) => Err(format!("Failed to load embedding model: {}", e))
}
});
match result {
Ok(model) => Ok(model.clone()),
Err(e) => Err(anyhow!("{}", e))
}
}
/// Calcula scores de relevância para múltiplos textos em relação a uma query
pub fn rank_by_relevance(
model: &mut EmbeddingModel,
query: &str,
texts: &[&str],
) -> Result<Vec<(usize, f32)>> {
let query_embedding = model.embed(query)?;
let mut scores: Vec<(usize, f32)> = Vec::with_capacity(texts.len());
for (idx, text) in texts.iter().enumerate() {
let text_embedding = model.embed(text).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
let score = cosine_similarity(&query_embedding, &text_embedding);
scores.push((idx, score));
}
// Ordenar por score decrescente
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(scores)
}
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
///
/// Argumentos:
/// - model: Modelo de embeddings
/// - query: Query do usuário
/// - context: Contexto completo (texto separado por parágrafos)
/// - max_tokens: Número máximo de tokens (aproximado por palavras)
/// - min_score: Score mínimo de relevância (0.0 a 1.0)
///
/// Retorna: Contexto podado com os parágrafos mais relevantes
pub fn prune_context(
model: &mut EmbeddingModel,
query: &str,
context: &str,
max_tokens: usize,
min_score: f32,
) -> Result<String> {
// Dividir contexto em parágrafos
let paragraphs: Vec<&str> = context
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty() && p.len() > 20) // Filtrar parágrafos muito curtos
.collect();
if paragraphs.is_empty() {
return Ok(context.to_string());
}
// Calcular embedding da query
let query_embedding = model.embed(query)?;
// Calcular scores para cada parágrafo
let mut scored_paragraphs: Vec<(f32, &str, usize)> = Vec::with_capacity(paragraphs.len());
for (idx, &p) in paragraphs.iter().enumerate() {
let embedding = model.embed(p).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
let score = cosine_similarity(&query_embedding, &embedding);
scored_paragraphs.push((score, p, idx));
}
// Ordenar por score decrescente
scored_paragraphs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
// Filtrar por score mínimo
scored_paragraphs.retain(|(score, _, _)| *score >= min_score);
// Selecionar parágrafos até atingir max_tokens
let mut result = Vec::new();
let mut total_tokens = 0;
for (score, paragraph, original_idx) in scored_paragraphs {
let paragraph_tokens = paragraph.split_whitespace().count();
if total_tokens + paragraph_tokens > max_tokens {
// Se ainda não temos nenhum parágrafo, incluir o primeiro mesmo que exceda
if result.is_empty() {
result.push((original_idx, paragraph));
}
break;
}
result.push((original_idx, paragraph));
total_tokens += paragraph_tokens;
}
// Ordenar pelo índice original para manter a ordem do contexto
result.sort_by_key(|(idx, _)| *idx);
// Reconstruir contexto
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
log::info!(
"[Embeddings] Context pruned: {} paragraphs -> {} paragraphs, {} tokens",
paragraphs.len(),
pruned.len(),
total_tokens
);
Ok(pruned.join("\n\n"))
}
/// Versão simplificada de poda usando apenas BM25-like (sem embeddings)
/// Útil quando o modelo não está disponível
pub fn prune_context_bm25(
query: &str,
context: &str,
max_tokens: usize,
) -> String {
let paragraphs: Vec<&str> = context
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty() && p.len() > 20)
.collect();
if paragraphs.is_empty() {
return context.to_string();
}
let query_lower = query.to_lowercase();
let query_terms: Vec<&str> = query_lower
.split_whitespace()
.filter(|w| w.len() > 2)
.collect();
// Calcular score BM25-like simples
let mut scored: Vec<(f32, &str, usize)> = paragraphs.iter()
.enumerate()
.map(|(idx, &p)| {
let p_lower = p.to_lowercase();
let mut score = 0.0f32;
for term in &query_terms {
let count = p_lower.matches(term).count();
if count > 0 {
// TF com saturação logarítmica
score += (1.0 + (count as f32).ln()) * (1.0 / (1.0 + query_terms.len() as f32));
}
}
(score, p, idx)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut result = Vec::new();
let mut total_tokens = 0;
for (_, paragraph, original_idx) in scored {
let paragraph_tokens = paragraph.split_whitespace().count();
if total_tokens + paragraph_tokens > max_tokens {
if result.is_empty() {
result.push((original_idx, paragraph));
}
break;
}
result.push((original_idx, paragraph));
total_tokens += paragraph_tokens;
}
result.sort_by_key(|(idx, _)| *idx);
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
pruned.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cosine_similarity() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
let c = vec![0.0, 1.0, 0.0];
assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
let d = vec![-1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &d) - (-1.0)).abs() < 0.001);
}
}
+499 -15
View File
@@ -21,6 +21,7 @@ mod sources_config;
mod system_monitor; mod system_monitor;
mod intent_classifier; mod intent_classifier;
mod db; mod db;
mod embeddings;
use web_scraper::{ use web_scraper::{
ScrapedContent, ScrapedContent,
@@ -114,6 +115,8 @@ struct SessionSummary {
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
preview: String, preview: String,
platform: String, platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
match_count: Option<usize>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -395,6 +398,57 @@ fn save_chat_session(
fs::rename(&temp_path, &file_path) fs::rename(&temp_path, &file_path)
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?; .map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
// Também salvar no SQLite (sistema novo) para melhor performance e paginação
// Se falhar, apenas logar erro mas não falhar a operação (compatibilidade)
use db::Database;
match Database::new(&app_handle) {
Ok(db) => {
// Criar/atualizar sessão no SQLite
let db_session = db::ChatSession {
id: session.id.clone(),
title: session.title.clone(),
emoji: "💬".to_string(), // Emoji padrão
created_at: session.created_at,
updated_at: session.updated_at,
};
if let Err(e) = db.save_session(&db_session) {
log::warn!("Failed to save session to SQLite (continuing with JSON only): {}", e);
} else {
// Converter Message para ChatMessage e salvar no SQLite
// Preservar ordem usando timestamps incrementais baseados no índice
let chat_messages: Vec<db::ChatMessage> = session.messages.iter().enumerate().map(|(idx, msg)| {
let metadata_str = msg.metadata.as_ref()
.and_then(|m| serde_json::to_string(m).ok());
// Criar timestamp incremental para preservar ordem das mensagens
// Usar created_at da sessão como base e adicionar segundos baseados no índice
// Isso garante que a ordem seja mantida quando ordenado por created_at ASC
let base_time = session.created_at;
let msg_created_at = base_time + chrono::Duration::seconds(idx as i64);
db::ChatMessage {
id: None,
session_id: session.id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
metadata: metadata_str,
created_at: msg_created_at,
}
}).collect();
if let Err(e) = db.save_messages_batch(&session.id, &chat_messages) {
log::warn!("Failed to save messages to SQLite (continuing with JSON only): {}", e);
} else {
log::debug!("Successfully saved {} messages to SQLite for session {}", chat_messages.len(), session.id);
}
}
}
Err(e) => {
log::debug!("Failed to open database for saving (JSON saved successfully): {}", e);
}
}
// Lock é liberado automaticamente quando _guard sai de escopo // Lock é liberado automaticamente quando _guard sai de escopo
Ok(()) Ok(())
} }
@@ -407,7 +461,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
.map_err(|e| format!("Failed to open database: {}", e))?; .map_err(|e| format!("Failed to open database: {}", e))?;
let search_limit = limit.unwrap_or(50); let search_limit = limit.unwrap_or(50);
let sessions = db.search_sessions(&query, search_limit) let search_results = db.search_sessions(&query, search_limit)
.map_err(|e| format!("Search failed: {}", e))?; .map_err(|e| format!("Search failed: {}", e))?;
// Validar existência de cada sessão antes de retornar // Validar existência de cada sessão antes de retornar
@@ -415,7 +469,9 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
let mut summaries = Vec::new(); let mut summaries = Vec::new();
let mut orphan_count = 0; let mut orphan_count = 0;
for session in sessions { for search_result in search_results {
let session = search_result.session;
let match_count = search_result.match_count;
// Verificar se sessão existe no SQLite (já temos) // Verificar se sessão existe no SQLite (já temos)
let exists_in_sqlite = db.get_session(&session.id) let exists_in_sqlite = db.get_session(&session.id)
.ok() .ok()
@@ -465,6 +521,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
updated_at: session.updated_at, // Já é DateTime<Utc> updated_at: session.updated_at, // Já é DateTime<Utc>
preview, preview,
platform: String::new(), // Platform não está no SQLite ainda platform: String::new(), // Platform não está no SQLite ainda
match_count: Some(match_count as usize),
}); });
} }
@@ -514,6 +571,7 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
updated_at: session.updated_at, updated_at: session.updated_at,
preview: last_msg, preview: last_msg,
platform: session.platform, platform: session.platform,
match_count: None,
}); });
} }
} }
@@ -600,6 +658,299 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>,
Ok(session.messages) Ok(session.messages)
} }
/// Resultado de carregamento paginado de histórico
#[derive(serde::Serialize)]
struct PaginatedHistory {
messages: Vec<Message>,
total_count: usize,
has_more: bool,
}
/// Carrega histórico de chat com paginação (lazy loading)
///
/// Parâmetros:
/// - id: ID da sessão
/// - limit: número máximo de mensagens a retornar (default: 20)
/// - offset: número de mensagens a pular do final (default: 0)
#[command]
fn load_chat_history_paginated(
app_handle: AppHandle,
id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<PaginatedHistory, String> {
use db::Database;
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
match Database::new(&app_handle) {
Ok(db) => {
match db.get_messages_paginated(&id, limit, offset) {
Ok((messages, total_count, has_more)) => {
// Se SQLite retornou 0 mensagens, tentar fallback para JSON (sistema legado)
if total_count == 0 {
log::debug!("No messages in SQLite for session {}, trying JSON fallback", id);
// Tentar carregar do JSON
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e) => {
log::debug!("Failed to get chats dir for JSON fallback: {}", e);
// Retornar vazio se não conseguir acessar diretório
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Aplicar paginação em memória (simular comportamento do SQLite)
// offset=0 significa últimas N mensagens
// offset>0 significa mensagens antes das últimas N
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e) => {
log::debug!("Failed to parse JSON session: {}", e);
}
}
}
Err(e) => {
log::debug!("Failed to read JSON file: {}", e);
}
}
}
// Se chegou aqui, não encontrou mensagens nem no SQLite nem no JSON
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Converter ChatMessage para Message
let result: Vec<Message> = messages.into_iter().map(|msg| {
let role = if msg.role == "user" {
"user"
} else if msg.role == "assistant" {
"assistant"
} else {
"system"
};
let metadata = msg.metadata.and_then(|m| {
serde_json::from_str::<serde_json::Value>(&m).ok()
});
let metadata_value = metadata
.and_then(|m| {
if m.is_object() && !m.as_object().unwrap().is_empty() {
Some(m)
} else {
None
}
});
Message {
role: role.to_string(),
content: msg.content,
metadata: metadata_value,
}
}).collect();
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from SQLite for session {}",
result.len(), offset, total_count, has_more, id
);
Ok(PaginatedHistory {
messages: result,
total_count,
has_more,
})
}
Err(e) => {
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
// Fallback para JSON em caso de erro também
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(_) => {
return Err(format!("Failed to load paginated history: {}", e));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (error case) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to load paginated history: {}", e))
}
}
}
Err(e) => {
log::debug!("Failed to open database: {}, trying JSON fallback", e);
// Fallback para JSON se não conseguir abrir banco
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e2) => {
return Err(format!("Failed to open database: {} (chats dir error: {})", e, e2));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (db open error) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to open database: {}", e))
}
}
}
#[command] #[command]
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> { fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> {
use db::Database; use db::Database;
@@ -2978,12 +3329,18 @@ async fn chat_stream(
return Err(error_msg); return Err(error_msg);
} }
// 5. Processar stream e emitir tokens // 5. Processar stream e emitir tokens COM BUFFERING
// IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo) // OTIMIZAÇÃO: Acumular tokens e emitir em batches para reduzir overhead da bridge
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut buffer = String::new(); let mut buffer = String::new();
let mut full_content = String::new(); let mut full_content = String::new();
// Buffer de tokens para reduzir eventos na bridge
let mut token_buffer = String::new();
let mut last_emit = std::time::Instant::now();
const EMIT_INTERVAL_MS: u64 = 16; // ~60fps para sincronizar com RAF do frontend
const MAX_BUFFER_CHARS: usize = 50; // Emitir quando buffer tiver ~50 chars
while let Some(chunk_result) = stream.next().await { while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk); let chunk_str = String::from_utf8_lossy(&chunk);
@@ -3007,20 +3364,23 @@ async fn chat_stream(
// Extrair conteúdo do chunk (Ollama envia tokens incrementais) // Extrair conteúdo do chunk (Ollama envia tokens incrementais)
if let Some(message) = json.get("message") { if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) { 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() { if !content.is_empty() {
full_content.push_str(content); full_content.push_str(content);
token_buffer.push_str(content);
// Emitir token para frontend // Emitir buffer quando: tempo >= 16ms OU buffer >= 50 chars
let token_event = ChatTokenEvent { let elapsed = last_emit.elapsed().as_millis() as u64;
session_id: session_id.clone(), if elapsed >= EMIT_INTERVAL_MS || token_buffer.len() >= MAX_BUFFER_CHARS {
content: content.to_string(), let token_event = ChatTokenEvent {
done: false, session_id: session_id.clone(),
}; content: std::mem::take(&mut token_buffer),
done: false,
};
if let Err(e) = window.emit("chat-token", &token_event) { if let Err(e) = window.emit("chat-token", &token_event) {
log::warn!("Erro ao emitir token: {}", e); log::warn!("Erro ao emitir token: {}", e);
}
last_emit = std::time::Instant::now();
} }
} }
} }
@@ -3028,6 +3388,16 @@ async fn chat_stream(
// Verificar se stream terminou // Verificar se stream terminou
if is_done { if is_done {
// Flush do buffer residual antes de finalizar
if !token_buffer.is_empty() {
let flush_event = ChatTokenEvent {
session_id: session_id.clone(),
content: std::mem::take(&mut token_buffer),
done: false,
};
let _ = window.emit("chat-token", &flush_event);
}
// Emitir evento final // Emitir evento final
let final_event = ChatTokenEvent { let final_event = ChatTokenEvent {
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -3121,6 +3491,113 @@ async fn chat_stream(
Ok(session_id) Ok(session_id)
} }
// ============== COMANDOS DE EMBEDDINGS ==============
/// Baixa o modelo de embeddings se não existir
#[command]
async fn download_embedding_model(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
match embeddings::ensure_model_files(&app_data_dir).await {
Ok(_) => {
log::info!("[Embeddings] Model files ready");
Ok(true)
}
Err(e) => {
log::error!("[Embeddings] Failed to ensure model files: {}", e);
Err(format!("Failed to download model: {}", e))
}
}
}
/// Verifica se o modelo de embeddings está disponível
#[command]
fn is_embedding_model_available(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
Ok(embeddings::is_model_available(&app_data_dir))
}
/// Calcula scores de relevância para textos em relação a uma query
#[command]
fn calculate_relevance_scores(
app_handle: AppHandle,
query: String,
texts: Vec<String>,
) -> Result<Vec<(usize, f32)>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
embeddings::rank_by_relevance(&mut model, &query, &text_refs)
.map_err(|e| format!("Failed to calculate relevance: {}", e))
}
/// Gera embedding para um texto
#[command]
fn generate_embedding(
app_handle: AppHandle,
text: String,
) -> Result<Vec<f32>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
model.embed(&text)
.map_err(|e| format!("Failed to generate embedding: {}", e))
}
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
#[command]
fn prune_context(
app_handle: AppHandle,
query: String,
context: String,
max_tokens: Option<usize>,
min_score: Option<f32>,
) -> Result<String, String> {
let max_tokens = max_tokens.unwrap_or(2000);
let min_score = min_score.unwrap_or(0.3);
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Tentar usar embeddings se modelo disponível
if embeddings::is_model_available(&app_data_dir) {
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
embeddings::prune_context(&mut model, &query, &context, max_tokens, min_score)
.map_err(|e| format!("Failed to prune context: {}", e))
} else {
// Fallback para BM25-like
log::info!("[PruneContext] Using BM25 fallback (embedding model not available)");
Ok(embeddings::prune_context_bm25(&query, &context, max_tokens))
}
}
#[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()
@@ -3219,6 +3696,7 @@ pub fn run() {
load_chat_sessions, load_chat_sessions,
search_chat_sessions, search_chat_sessions,
load_chat_history, load_chat_history,
load_chat_history_paginated,
delete_chat_session, delete_chat_session,
cleanup_orphan_sessions, cleanup_orphan_sessions,
load_mcp_config, load_mcp_config,
@@ -3261,7 +3739,13 @@ pub fn run() {
get_downloaded_installer_path, get_downloaded_installer_path,
check_ollama_full, check_ollama_full,
auto_start_ollama, auto_start_ollama,
classify_intent classify_intent,
// Embeddings commands
download_embedding_model,
is_embedding_model_available,
calculate_relevance_scores,
generate_embedding,
prune_context
]) ])
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap) .manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+5 -6
View File
@@ -85,14 +85,13 @@ pub async fn reload_scheduled_tasks(
return; return;
} }
// Obter browser - precisa acessar via app_handle // Obter browser - usando lazy initialization global
let browser_arc = { let browser_arc = {
// Criar browser diretamente se necessário use crate::web_scraper::get_or_create_browser;
use crate::web_scraper::create_browser; match get_or_create_browser() {
match create_browser() { Ok(b) => b,
Ok(b) => Arc::new(b),
Err(e) => { Err(e) => {
log::error!("Erro ao criar browser para task {}: {}", task_id, e); log::error!("Erro ao obter browser para task {}: {}", task_id, e);
return; return;
} }
} }
+102 -3
View File
@@ -2,7 +2,7 @@ use anyhow::Result;
use headless_chrome::{Browser, LaunchOptions, Tab}; use headless_chrome::{Browser, LaunchOptions, Tab};
use reqwest::header::USER_AGENT; use reqwest::header::USER_AGENT;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use std::sync::Arc; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use url::Url; use url::Url;
use rand::Rng; use rand::Rng;
@@ -10,6 +10,37 @@ use tokio::sync::Semaphore;
use regex::Regex; use regex::Regex;
use std::time::Instant; use std::time::Instant;
/// Lazy-initialized global browser instance
/// Evita criar o browser no startup, economizando ~500MB de RAM até ser necessário
static LAZY_BROWSER: OnceLock<Mutex<Option<Arc<Browser>>>> = OnceLock::new();
/// Obtém ou cria a instância global do browser (lazy initialization)
pub fn get_or_create_browser() -> Result<Arc<Browser>> {
let mutex = LAZY_BROWSER.get_or_init(|| Mutex::new(None));
let mut guard = mutex.lock().map_err(|e| anyhow::anyhow!("Browser mutex poisoned: {}", e))?;
if guard.is_none() {
log::info!("[LazyBrowser] Initializing headless browser on first use...");
let browser = create_browser()?;
*guard = Some(Arc::new(browser));
log::info!("[LazyBrowser] Browser initialized successfully");
}
Ok(guard.as_ref().unwrap().clone())
}
/// Limpa a instância do browser (para liberar memória quando não em uso)
pub fn clear_browser() {
if let Some(mutex) = LAZY_BROWSER.get() {
if let Ok(mut guard) = mutex.lock() {
if guard.is_some() {
log::info!("[LazyBrowser] Clearing browser instance to free memory");
*guard = None;
}
}
}
}
/// Resultado da extração de conteúdo de uma URL /// Resultado da extração de conteúdo de uma URL
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct ScrapedContent { pub struct ScrapedContent {
@@ -1302,7 +1333,7 @@ pub async fn search_and_scrape_with_config(
if connection_closed && !failed_urls.is_empty() { if connection_closed && !failed_urls.is_empty() {
let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1)); let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1));
let semaphore = Arc::new(Semaphore::new(retry_concurrency)); let semaphore = Arc::new(Semaphore::new(retry_concurrency));
let browser_new = Arc::new(create_browser()?); let browser_new = get_or_create_browser()?;
let mut retry_handles = Vec::new(); let mut retry_handles = Vec::new();
for url in failed_urls.clone() { for url in failed_urls.clone() {
let browser_clone = browser_new.clone(); let browser_clone = browser_new.clone();
@@ -1341,11 +1372,79 @@ pub async fn search_and_scrape_with_config(
Ok(results) Ok(results)
} }
/// Busca e extrai conteúdo de uma única URL /// Scraping estático usando apenas reqwest (sem headless browser)
/// Muito mais rápido (~100ms vs ~3s) e consome menos RAM
/// Retorna None se o conteúdo for insuficiente (SPA/JavaScript-heavy)
pub async fn scrape_url_static(url: &str) -> Result<Option<ScrapedContent>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.redirect(reqwest::redirect::Policy::limited(5))
.build()?;
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::debug!("[StaticScrape] Fetching: {}", url);
let response = match client
.get(url)
.header(USER_AGENT, user_agent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7")
.send()
.await
{
Ok(r) => r,
Err(e) => {
log::debug!("[StaticScrape] Request failed for {}: {}", url, e);
return Ok(None);
}
};
if !response.status().is_success() {
log::debug!("[StaticScrape] HTTP {} for {}", response.status(), url);
return Ok(None);
}
let html = match response.text().await {
Ok(t) => t,
Err(e) => {
log::debug!("[StaticScrape] Failed to read body for {}: {}", url, e);
return Ok(None);
}
};
let duration = start_time.elapsed().as_millis();
// Usar extract_paragraph_fallback existente
let result = extract_paragraph_fallback(url, &html);
if let Some(ref content) = result {
log::info!("[StaticScrape] Success for {} ({} chars, {}ms)",
url, content.content.len(), duration);
} else {
log::debug!("[StaticScrape] Insufficient content for {} ({}ms)", url, duration);
}
Ok(result)
}
/// Busca e extrai conteúdo de uma única URL (híbrido: tenta estático primeiro)
pub async fn scrape_url( pub async fn scrape_url(
url: &str, url: &str,
browser: Arc<Browser>, browser: Arc<Browser>,
) -> Result<ScrapedContent> { ) -> Result<ScrapedContent> {
// OTIMIZAÇÃO: Tentar scraping estático primeiro (muito mais rápido)
if let Ok(Some(content)) = scrape_url_static(url).await {
// Se conseguiu conteúdo suficiente (>500 chars), usar resultado estático
if content.content.len() > 500 {
log::info!("[ScrapeHybrid] Using static result for {} ({} chars)", url, content.content.len());
return Ok(content);
}
}
// Fallback: usar headless browser para SPAs/JS-heavy pages
log::info!("[ScrapeHybrid] Falling back to headless for {}", url);
let browser_clone = browser.clone(); let browser_clone = browser.clone();
let url_str = url.to_string(); let url_str = url.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {