Merge pull request #2 from evandrodevbr/full-rust-backend
Full-rust-backend
This commit is contained in:
22 files changed
+2531
-420
No files matched your search
+43
-40
@@ -3,13 +3,12 @@
|
|||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe, RefreshCw, Cpu } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe } from "lucide-react";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { ChatMessage } from "@/components/chat/chat-message";
|
import { ChatMessage } from "@/components/chat/chat-message";
|
||||||
import { ReasoningChain } from "@/components/chat/reasoning-chain";
|
import { ReasoningChain } from "@/components/chat/reasoning-chain";
|
||||||
|
import { TitleBar } from "@/components/titlebar";
|
||||||
|
|
||||||
import { SidebarList } from "@/components/chat/sidebar-list";
|
import { SidebarList } from "@/components/chat/sidebar-list";
|
||||||
import { useChat } from "@/hooks/use-chat";
|
import { useChat } from "@/hooks/use-chat";
|
||||||
@@ -25,18 +24,16 @@ import { useDeepResearch } from "@/hooks/use-deep-research";
|
|||||||
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
|
import { DEEP_RESEARCH_PROMPTS } from "@/data/prompts/deep-research";
|
||||||
import { useSettingsStore } from "@/store/settings-store";
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
import type { ScrapedContent } from "@/services/webSearch";
|
import type { ScrapedContent } from "@/services/webSearch";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
import { ModelDownloadDialog } from "@/components/chat/model-download-dialog";
|
||||||
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
|
|
||||||
import { chatLog } from "@/lib/terminal-logger";
|
import { chatLog } from "@/lib/terminal-logger";
|
||||||
import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor";
|
import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor";
|
||||||
import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
|
import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
|
||||||
import { executeProgressiveSearch, type FallbackResult } from "@/lib/web-search-fallback";
|
import { executeProgressiveSearch } from "@/lib/web-search-fallback";
|
||||||
// @ts-ignore
|
// @ts-expect-error - MD file import
|
||||||
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
||||||
|
|
||||||
|
|
||||||
@@ -51,15 +48,16 @@ export default function ChatPage() {
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
setCurrentSessionId,
|
setCurrentSessionId,
|
||||||
loadSessionHistory,
|
loadSessionHistory,
|
||||||
loadSessions,
|
|
||||||
saveSession,
|
saveSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle
|
isGeneratingTitle,
|
||||||
|
searchSessions,
|
||||||
|
isSearching
|
||||||
} = useChatStorage();
|
} = useChatStorage();
|
||||||
|
|
||||||
const { isDownloading, progress } = useAutoLabelingModel();
|
const { isDownloading, progress } = useAutoLabelingModel();
|
||||||
const webSearch = useWebSearch();
|
const webSearch = useWebSearch();
|
||||||
const { generateQuery, isGenerating: isGeneratingQuery } = useQueryGenerator();
|
const { generateQuery } = useQueryGenerator();
|
||||||
const deepResearch = useDeepResearch();
|
const deepResearch = useDeepResearch();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { preprocess } = useQueryPreprocessor();
|
const { preprocess } = useQueryPreprocessor();
|
||||||
@@ -89,7 +87,7 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
setMessages(prev => [...prev, thinkingMessage]);
|
setMessages(prev => [...prev, thinkingMessage]);
|
||||||
return messageId;
|
return messageId;
|
||||||
}, []);
|
}, [setMessages]);
|
||||||
|
|
||||||
const updateThinkingMessage = useCallback((
|
const updateThinkingMessage = useCallback((
|
||||||
stepType: ThinkingStepType,
|
stepType: ThinkingStepType,
|
||||||
@@ -108,23 +106,16 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [setMessages]);
|
||||||
|
|
||||||
const removeThinkingMessage = useCallback((stepType: ThinkingStepType) => {
|
|
||||||
setMessages(prev => prev.filter(msg => {
|
|
||||||
const metadata = msg.metadata as ThinkingMessageMetadata | undefined;
|
|
||||||
return !(metadata?.type === 'thinking' && metadata.stepType === stepType);
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [selectedModel, setSelectedModel] = useState("");
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const initializedRef = useRef(false);
|
const initializedRef = useRef(false);
|
||||||
// Initialize with default format prompt
|
// Initialize with default format prompt
|
||||||
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
const [systemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
||||||
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(true); // Iniciar colapsada
|
||||||
// Removido: thinkingStep e processSteps - agora usando mensagens thinking agrupadas
|
// Removido: thinkingStep e processSteps - agora usando mensagens thinking agrupadas
|
||||||
const [error, setError] = useState<string | Error | null>(null);
|
const [, setError] = useState<string | Error | null>(null);
|
||||||
|
|
||||||
const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
|
const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -155,7 +146,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
initializedRef.current = true;
|
initializedRef.current = true;
|
||||||
}, [settings.selectedModel, models]);
|
}, [settings, models]);
|
||||||
|
|
||||||
// Auto-scroll para o final durante streaming
|
// Auto-scroll para o final durante streaming
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -228,7 +219,7 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
// Se inválida, retornar erro
|
// Se inválida, retornar erro
|
||||||
if (!preprocessed.validation.isValid) {
|
if (!preprocessed.validation.isValid) {
|
||||||
chatLog.error('Query validation failed:', preprocessed.validation.errors);
|
chatLog.error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`);
|
||||||
throw new Error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`);
|
throw new Error(`Query validation failed: ${preprocessed.validation.errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +244,7 @@ export default function ChatPage() {
|
|||||||
};
|
};
|
||||||
return { preprocessed, finalContent: content };
|
return { preprocessed, finalContent: content };
|
||||||
}
|
}
|
||||||
}, [settings.queryPreprocessing, webSearch.isEnabled]);
|
}, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]);
|
||||||
|
|
||||||
const handleSend = async (content: string) => {
|
const handleSend = async (content: string) => {
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
@@ -295,19 +286,20 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Atualizar step com erro
|
// Atualizar step com erro
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
updateThinkingMessage('preprocessing', {
|
updateThinkingMessage('preprocessing', {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: errorMessage,
|
error: errorMsg,
|
||||||
duration: Date.now() - preprocessingStart,
|
duration: Date.now() - preprocessingStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Removido: setProcessSteps - agora usando mensagens thinking agrupadas
|
// Removido: setProcessSteps - agora usando mensagens thinking agrupadas
|
||||||
|
|
||||||
// Definir erro global
|
// Definir erro global
|
||||||
setError(error);
|
const errorObj = error instanceof Error ? error : new Error(String(error));
|
||||||
|
setError(errorObj);
|
||||||
|
|
||||||
chatLog.error('Preprocessing failed:', error);
|
chatLog.error(`Preprocessing failed: ${errorObj.message}`);
|
||||||
// Erro já será mostrado no chat-input, apenas retornar
|
// Erro já será mostrado no chat-input, apenas retornar
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -397,8 +389,9 @@ export default function ChatPage() {
|
|||||||
// Executar busca progressiva com semantic search se disponível
|
// Executar busca progressiva com semantic search se disponível
|
||||||
const fallbackResult = await executeProgressiveSearch(
|
const fallbackResult = await executeProgressiveSearch(
|
||||||
query,
|
query,
|
||||||
async (q, limit) => {
|
async (q, limit, round = 1) => {
|
||||||
return await webSearch.smartSearchRag(q, limit);
|
// Passar round para timeout adaptativo
|
||||||
|
return await webSearch.smartSearchRag(q, limit, round);
|
||||||
},
|
},
|
||||||
selectedModel,
|
selectedModel,
|
||||||
{
|
{
|
||||||
@@ -410,6 +403,9 @@ export default function ChatPage() {
|
|||||||
useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas
|
useSemanticSearch: !!enrichedQueries, // Usar semantic search se temos queries enriquecidas
|
||||||
context: context || undefined,
|
context: context || undefined,
|
||||||
enrichedQueries: enrichedQueries || undefined,
|
enrichedQueries: enrichedQueries || undefined,
|
||||||
|
initialTimeout: settings.webSearch.timeout || 10000, // Usar timeout das configurações
|
||||||
|
maxTimeoutPerQuery: 25000, // 25s máximo por query
|
||||||
|
maxTimeoutPerRound: 15000, // 15s máximo por round
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -471,6 +467,7 @@ export default function ChatPage() {
|
|||||||
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
scrapedSources = deepResearch.state.knowledgeBase.map(entry => ({
|
||||||
url: entry.sourceUrl,
|
url: entry.sourceUrl,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
|
content: entry.content,
|
||||||
markdown: entry.content,
|
markdown: entry.content,
|
||||||
snippet: entry.content.substring(0, 200)
|
snippet: entry.content.substring(0, 200)
|
||||||
}));
|
}));
|
||||||
@@ -526,7 +523,7 @@ export default function ChatPage() {
|
|||||||
const validation = validateCondensedContext(optimizedResult.result, content);
|
const validation = validateCondensedContext(optimizedResult.result, content);
|
||||||
|
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
chatLog.error('Context validation failed:', validation.warnings.join(', '));
|
chatLog.error(`Context validation failed: ${validation.warnings.join(', ')}`);
|
||||||
} else if (validation.warnings.length > 0) {
|
} else if (validation.warnings.length > 0) {
|
||||||
validation.warnings.forEach(warning => {
|
validation.warnings.forEach(warning => {
|
||||||
chatLog.warn(`⚠️ ${warning}`);
|
chatLog.warn(`⚠️ ${warning}`);
|
||||||
@@ -608,7 +605,6 @@ 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
|
||||||
|
|
||||||
const researchStart = Date.now();
|
|
||||||
// Executar pesquisa web
|
// Executar pesquisa web
|
||||||
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
|
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
|
||||||
content,
|
content,
|
||||||
@@ -879,9 +875,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full bg-background overflow-hidden flex">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col overflow-x-hidden">
|
||||||
{/* Left Sidebar (Nav) */}
|
{/* Custom Title Bar */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<TitleBar />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
|
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={4}
|
defaultSize={4}
|
||||||
@@ -945,6 +945,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
}}
|
}}
|
||||||
onDeleteSession={deleteSession}
|
onDeleteSession={deleteSession}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
|
onSearch={searchSessions}
|
||||||
|
isSearching={isSearching}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@@ -1014,8 +1016,9 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<SelectItem value={m.name} className="min-w-0">
|
<SelectItem value={m.name} className="min-w-0">
|
||||||
<span className="flex items-center justify-between w-full min-w-0 gap-2">
|
<span className="flex items-center justify-between w-full min-w-0 gap-2">
|
||||||
<span className="truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
|
<span className="flex items-center gap-2 truncate flex-1 min-w-0" style={{ maxWidth: '70vw' }}>
|
||||||
{m.name}
|
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{m.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
|
<span className="ml-2 text-xs text-muted-foreground shrink-0">{m.size}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1039,7 +1042,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
|
||||||
<Loader2 className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1109,7 +1112,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adicionar mensagem regular
|
// Adicionar mensagem regular
|
||||||
const isLastMessage = i === messages.length - (isLoading ? 1 : 0);
|
|
||||||
const isAssistantStreaming = i === messages.length - 1 && msg.role === 'assistant' && isLoading;
|
const isAssistantStreaming = i === messages.length - 1 && msg.role === 'assistant' && isLoading;
|
||||||
|
|
||||||
groupedMessages.push({
|
groupedMessages.push({
|
||||||
@@ -1255,6 +1257,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
|
|||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
{/* Mobile Sidebar Overlay */}
|
||||||
{!isChatsSidebarCollapsed && (
|
{!isChatsSidebarCollapsed && (
|
||||||
|
|||||||
+49
-1
@@ -145,11 +145,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +237,43 @@
|
|||||||
padding: clamp(8px, 2vw, 16px);
|
padding: clamp(8px, 2vw, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar chat item - prevent overflow and ensure proper truncation */
|
||||||
|
.sidebar-chat-item {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-title {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-date {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive font sizes */
|
/* Responsive font sizes */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.responsive-text {
|
.responsive-text {
|
||||||
|
|||||||
+65
-38
@@ -28,6 +28,7 @@ import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, P
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
import { SourcesConfig, SourceCategory } from '@/lib/types';
|
||||||
|
import { TitleBar } from '@/components/titlebar';
|
||||||
|
|
||||||
interface Model {
|
interface Model {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -217,32 +218,50 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-4xl">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<TitleBar />
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto">
|
||||||
<h1 className="text-3xl font-bold">Configurações</h1>
|
<div className="container mx-auto py-4 sm:py-6 md:py-8 px-3 sm:px-4 md:px-6 max-w-4xl w-full overflow-x-hidden">
|
||||||
<p className="text-muted-foreground mt-2">
|
<div className="mb-4 sm:mb-6">
|
||||||
Gerencie as preferências do OllaHub
|
<div className="flex items-start justify-between gap-3">
|
||||||
</p>
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold truncate">Configurações</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">
|
||||||
|
Gerencie as preferências do OllaHub
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push('/chat')}
|
||||||
|
className="rounded-lg shrink-0"
|
||||||
|
title="Fechar configurações"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.push('/chat')}
|
|
||||||
className="rounded-lg"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
<Tabs defaultValue="overview" className="space-y-4 sm:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-6">
|
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-1 sm:gap-2 h-auto">
|
||||||
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
<TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
Visão Geral
|
||||||
<TabsTrigger value="web">Web Search</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="sources">Sources & Knowledge</TabsTrigger>
|
<TabsTrigger value="general" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
<TabsTrigger value="tasks">Tasks & Scheduler</TabsTrigger>
|
General
|
||||||
<TabsTrigger value="system">System & Logs</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="web" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
|
Web Search
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sources" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
|
Sources
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tasks" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
|
Tasks
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
|
||||||
|
System
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab: Visão Geral (Hardware Dashboard) */}
|
{/* Tab: Visão Geral (Hardware Dashboard) */}
|
||||||
@@ -272,17 +291,19 @@ export default function SettingsPage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ollama-url">Ollama Endpoint</Label>
|
<Label htmlFor="ollama-url">Ollama Endpoint</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="ollama-url"
|
id="ollama-url"
|
||||||
value={settings.ollamaUrl}
|
value={settings.ollamaUrl}
|
||||||
onChange={(e) => settings.setOllamaUrl(e.target.value)}
|
onChange={(e) => settings.setOllamaUrl(e.target.value)}
|
||||||
placeholder="http://localhost:11434"
|
placeholder="http://localhost:11434"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={checkConnection}
|
onClick={checkConnection}
|
||||||
disabled={isCheckingConnection}
|
disabled={isCheckingConnection}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{isCheckingConnection ? (
|
{isCheckingConnection ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -404,8 +425,8 @@ export default function SettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-0">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5 flex-1 min-w-0">
|
||||||
<Label htmlFor="autostart">Iniciar com o Sistema</Label>
|
<Label htmlFor="autostart">Iniciar com o Sistema</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
O aplicativo será iniciado automaticamente ao fazer login
|
O aplicativo será iniciado automaticamente ao fazer login
|
||||||
@@ -415,6 +436,7 @@ export default function SettingsPage() {
|
|||||||
id="autostart"
|
id="autostart"
|
||||||
checked={settings.autoStart}
|
checked={settings.autoStart}
|
||||||
onCheckedChange={settings.toggleAutoStart}
|
onCheckedChange={settings.toggleAutoStart}
|
||||||
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -448,7 +470,7 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="max-results">Máximo de Resultados</Label>
|
<Label htmlFor="max-results">Máximo de Resultados</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Input
|
<Input
|
||||||
id="max-results"
|
id="max-results"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -458,12 +480,12 @@ export default function SettingsPage() {
|
|||||||
const clamped = Math.min(100, Math.max(1, isNaN(v) ? 1 : v));
|
const clamped = Math.min(100, Math.max(1, isNaN(v) ? 1 : v));
|
||||||
settings.setWebSearchMaxResults(clamped);
|
settings.setWebSearchMaxResults(clamped);
|
||||||
}}
|
}}
|
||||||
className="w-24"
|
className="w-20 sm:w-24 shrink-0"
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={100}
|
||||||
disabled={!settings.webSearch.enabled}
|
disabled={!settings.webSearch.enabled}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<Slider
|
<Slider
|
||||||
value={[settings.webSearch.maxResults]}
|
value={[settings.webSearch.maxResults]}
|
||||||
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
|
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
|
||||||
@@ -788,15 +810,15 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={category.id} className={category.enabled ? 'border-primary' : ''}>
|
<Card key={category.id} className={category.enabled ? 'border-primary' : ''}>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-4 sm:pt-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col sm:flex-row items-start justify-between gap-3 sm:gap-0">
|
||||||
<div className="flex items-start gap-3 flex-1">
|
<div className="flex items-start gap-2 sm:gap-3 flex-1 min-w-0 w-full">
|
||||||
<div className="mt-1">
|
<div className="mt-1 shrink-0">
|
||||||
{iconMap[category.id] || <BookOpen className="w-4 h-4" />}
|
{iconMap[category.id] || <BookOpen className="w-4 h-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Label className="font-semibold">{category.name}</Label>
|
<Label className="font-semibold truncate">{category.name}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={category.enabled}
|
checked={category.enabled}
|
||||||
onCheckedChange={async () => {
|
onCheckedChange={async () => {
|
||||||
@@ -834,6 +856,7 @@ export default function SettingsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditingCategory(category)}
|
onClick={() => setEditingCategory(category)}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -855,7 +878,7 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* Edit Category Dialog */}
|
{/* Edit Category Dialog */}
|
||||||
<Dialog open={!!editingCategory} onOpenChange={(open) => !open && setEditingCategory(null)}>
|
<Dialog open={!!editingCategory} onOpenChange={(open) => !open && setEditingCategory(null)}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Editar Categoria: {editingCategory?.name}</DialogTitle>
|
<DialogTitle>Editar Categoria: {editingCategory?.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -1077,18 +1100,19 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{/* Clear History Dialog */}
|
{/* Clear History Dialog */}
|
||||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-[95vw] sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Confirmar Exclusão</DialogTitle>
|
<DialogTitle>Confirmar Exclusão</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Esta ação não pode ser desfeita. Todos os chats serão permanentemente removidos.
|
Esta ação não pode ser desfeita. Todos os chats serão permanentemente removidos.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowClearDialog(false)}
|
onClick={() => setShowClearDialog(false)}
|
||||||
disabled={isClearing}
|
disabled={isClearing}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1096,6 +1120,7 @@ export default function SettingsPage() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleClearHistory}
|
onClick={handleClearHistory}
|
||||||
disabled={isClearing}
|
disabled={isClearing}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isClearing ? (
|
{isClearing ? (
|
||||||
<>
|
<>
|
||||||
@@ -1109,6 +1134,8 @@ export default function SettingsPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-35
@@ -6,7 +6,9 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle } from 'lucide-react';
|
import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle, X, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { TitleBar } from '@/components/titlebar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -32,6 +34,7 @@ interface Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -100,36 +103,28 @@ export default function TasksPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-6xl">
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<TitleBar />
|
||||||
<div>
|
<div className="flex-1 overflow-y-auto">
|
||||||
<h1 className="text-3xl font-bold">Tarefas Agendadas</h1>
|
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
||||||
<p className="text-muted-foreground mt-1">
|
<div className="mb-4 sm:mb-6">
|
||||||
Gerencie tarefas que executam automaticamente em segundo plano
|
<div className="flex items-start justify-between gap-3">
|
||||||
</p>
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold truncate">Tarefas Agendadas</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||||
|
Gerencie tarefas que executam automaticamente em segundo plano
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push('/chat')}
|
||||||
|
className="rounded-lg shrink-0"
|
||||||
|
title="Voltar para o chat"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Nova Task
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Criar Nova Task</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure uma tarefa para executar automaticamente
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<TaskFormDialog
|
|
||||||
onSuccess={() => {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
loadTasks();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -139,16 +134,59 @@ export default function TasksPage() {
|
|||||||
) : tasks.length === 0 ? (
|
) : tasks.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-12 text-center">
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-6">
|
||||||
Nenhuma task agendada ainda.
|
Nenhuma task agendada ainda.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setIsDialogOpen(true)}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<DialogTrigger asChild>
|
||||||
Criar Primeira Task
|
<Button size="lg" className="w-full max-w-md">
|
||||||
</Button>
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Criar Primeira Task
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure uma tarefa para executar automaticamente
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TaskFormDialog
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
loadTasks();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="lg" className="w-full">
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nova Task
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure uma tarefa para executar automaticamente
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TaskFormDialog
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
loadTasks();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{tasks.map(task => (
|
{tasks.map(task => (
|
||||||
<Card key={task.id}>
|
<Card key={task.id}>
|
||||||
@@ -218,7 +256,10 @@ export default function TasksPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle } from "lucide-react";
|
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle, GraduationCap, Newspaper, Terminal, TrendingUp } from "lucide-react";
|
||||||
import { useState, useRef, useEffect, useMemo } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -169,15 +169,32 @@ export function ChatInput({
|
|||||||
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
|
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
|
||||||
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => {
|
||||||
<DropdownMenuCheckboxItem
|
// Mapeamento de ícones por categoria
|
||||||
key={cat.id}
|
const getCategoryIcon = (categoryId: string) => {
|
||||||
checked={cat.enabled}
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
|
'academic': <GraduationCap className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
>
|
'news': <Newspaper className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
{cat.name}
|
'tech': <Terminal className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
</DropdownMenuCheckboxItem>
|
'finance': <TrendingUp className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
))}
|
};
|
||||||
|
return iconMap[categoryId] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={cat.id}
|
||||||
|
checked={cat.enabled}
|
||||||
|
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
|
||||||
|
className="pl-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{getCategoryIcon(cat.id)}
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { SessionSummary } from "@/hooks/use-chat-storage";
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Trash2, Plus, MessageSquare } from "lucide-react";
|
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface SidebarListProps {
|
interface SidebarListProps {
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
@@ -10,6 +12,8 @@ interface SidebarListProps {
|
|||||||
onSelectSession: (id: string) => void;
|
onSelectSession: (id: string) => void;
|
||||||
onDeleteSession: (id: string) => void;
|
onDeleteSession: (id: string) => void;
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
isSearching?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string) {
|
function formatDate(dateString: string) {
|
||||||
@@ -52,16 +56,83 @@ export function SidebarList({
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onNewChat
|
onNewChat,
|
||||||
|
onSearch,
|
||||||
|
isSearching = false
|
||||||
}: SidebarListProps) {
|
}: SidebarListProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Limpar timeout anterior
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se query vazia, limpar busca imediatamente
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: aguardar antes de buscar
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (onSearch && searchQuery.trim().length >= 2) {
|
||||||
|
onSearch(searchQuery.trim());
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchQuery, onSearch]);
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
|
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar w-full overflow-x-hidden">
|
||||||
{/* Header com botão Nova Conversa */}
|
{/* Header com busca e botão Nova Conversa */}
|
||||||
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0">
|
<div className="p-3 border-b border-sidebar-border flex-shrink-0 space-y-2.5">
|
||||||
|
{/* Campo de busca */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar conversas..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-8 pr-8 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
{searchQuery && !isSearching && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Limpar busca"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Nova Conversa */}
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewChat}
|
onClick={onNewChat}
|
||||||
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
|
className="w-full justify-start gap-2 text-sm font-medium h-9"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 shrink-0" />
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
@@ -71,15 +142,17 @@ export function SidebarList({
|
|||||||
|
|
||||||
{/* Lista de chats com scroll customizado */}
|
{/* Lista de chats com scroll customizado */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
||||||
<div className="p-3 sm:p-4 space-y-2 sm:space-y-2.5">
|
<div className="p-2 space-y-1.5">
|
||||||
{sessions.length === 0 && (
|
{sessions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Nenhuma conversa salva
|
{searchQuery ? 'Nenhuma conversa encontrada' : 'Nenhuma conversa salva'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
Comece uma nova conversa para começar
|
{searchQuery
|
||||||
|
? 'Tente buscar por outro termo'
|
||||||
|
: 'Comece uma nova conversa para começar'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -93,7 +166,7 @@ export function SidebarList({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
"sidebar-chat-item group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
||||||
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
|
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
isSelected && "border-primary/40 shadow-sm bg-accent/50",
|
isSelected && "border-primary/40 shadow-sm bg-accent/50",
|
||||||
@@ -110,21 +183,19 @@ export function SidebarList({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Conteúdo do card */}
|
{/* Conteúdo do card */}
|
||||||
<div className="p-3 sm:p-3.5 flex items-start gap-3 overflow-hidden">
|
<div className="sidebar-chat-item-content p-2.5">
|
||||||
{/* Ícone/Emoji */}
|
{/* Ícone/Emoji */}
|
||||||
<div className="shrink-0 flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-md bg-muted/50 text-base sm:text-lg">
|
<div className="shrink-0 flex items-center justify-center w-8 h-8 rounded-md bg-muted/50 text-base">
|
||||||
{session.emoji || '💬'}
|
{session.emoji || '💬'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Texto do card */}
|
{/* Texto do card */}
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="sidebar-chat-item-text">
|
||||||
<h3
|
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
||||||
className="text-sm sm:text-base font-medium text-card-foreground truncate leading-tight mb-1"
|
|
||||||
>
|
|
||||||
{session.title}
|
{session.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||||
{formatDate(session.updated_at)}
|
{formatDate(session.updated_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +205,7 @@ export function SidebarList({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-7 w-7 sm:h-8 sm:w-8 shrink-0 transition-all duration-200",
|
"h-7 w-7 shrink-0 transition-all duration-200",
|
||||||
"opacity-0 group-hover:opacity-100",
|
"opacity-0 group-hover:opacity-100",
|
||||||
"hover:bg-destructive/10 hover:text-destructive",
|
"hover:bg-destructive/10 hover:text-destructive",
|
||||||
"active:scale-95"
|
"active:scale-95"
|
||||||
@@ -146,7 +217,7 @@ export function SidebarList({
|
|||||||
}}
|
}}
|
||||||
aria-label={`Deletar conversa "${session.title}"`}
|
aria-label={`Deletar conversa "${session.title}"`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { Minus, Square, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function TitleBar() {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const checkMaximized = async () => {
|
||||||
|
try {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const maximized = await appWindow.isMaximized();
|
||||||
|
if (isMounted) {
|
||||||
|
setIsMaximized(maximized);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check window state:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMaximized();
|
||||||
|
|
||||||
|
// Listener para mudanças de estado
|
||||||
|
const setupListener = async () => {
|
||||||
|
try {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const unlisten = await appWindow.onResized(async () => {
|
||||||
|
if (isMounted) {
|
||||||
|
const maximized = await appWindow.isMaximized();
|
||||||
|
setIsMaximized(maximized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unlisten;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup resize listener:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let unlistenFn: (() => void) | null = null;
|
||||||
|
setupListener().then(fn => {
|
||||||
|
unlistenFn = fn;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (unlistenFn) {
|
||||||
|
unlistenFn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMinimize = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.minimize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
if (isMaximized) {
|
||||||
|
await appWindow.unmaximize();
|
||||||
|
} else {
|
||||||
|
await appWindow.maximize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
{/* Área de arrasto com logo/título */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 h-full flex-1"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-sidebar-foreground">
|
||||||
|
OllaHub
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controles de janela */}
|
||||||
|
<div className="flex items-center h-full">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||||
|
title="Minimizar"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4 text-sidebar-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMaximize}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||||
|
title={isMaximized ? "Restaurar" : "Maximizar"}
|
||||||
|
>
|
||||||
|
<Square className={cn(
|
||||||
|
"w-3.5 h-3.5 text-sidebar-foreground",
|
||||||
|
isMaximized && "opacity-60"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-destructive transition-colors"
|
||||||
|
title="Fechar"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-sidebar-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ export function useChatStorage() {
|
|||||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +50,29 @@ export function useChatStorage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchSessions = useCallback(async (query: string) => {
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
// Se query muito curta, carregar todas as sessões
|
||||||
|
await loadSessions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await invoke<SessionSummary[]>('search_chat_sessions', {
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
setSessions(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search sessions:", error);
|
||||||
|
// Em caso de erro, carregar todas as sessões
|
||||||
|
await loadSessions();
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
||||||
try {
|
try {
|
||||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
||||||
@@ -375,7 +400,11 @@ export function useChatStorage() {
|
|||||||
deleteSession,
|
deleteSession,
|
||||||
isGeneratingTitle,
|
isGeneratingTitle,
|
||||||
setIsGeneratingTitle,
|
setIsGeneratingTitle,
|
||||||
generateTitleFromUserMessage
|
generateTitleFromUserMessage,
|
||||||
|
searchSessions,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
isSearching
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
+151
-224
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { removeMetadataNoise } from '@/lib/metadata';
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
import { useSettingsStore } from '@/store/settings-store';
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
@@ -214,11 +215,124 @@ export async function executeToolCall(toolCall: ToolCall): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tipos para eventos Tauri
|
||||||
|
interface ChatCreatedEvent {
|
||||||
|
session_id: string;
|
||||||
|
title: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatTokenEvent {
|
||||||
|
session_id: string;
|
||||||
|
content: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatErrorEvent {
|
||||||
|
session_id: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||||
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
|
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
||||||
|
|
||||||
|
// Listener para eventos do Rust
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenCreated: (() => void) | null = null;
|
||||||
|
let unlistenToken: (() => void) | null = null;
|
||||||
|
let unlistenError: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Listener para chat-created (título gerado)
|
||||||
|
listen<ChatCreatedEvent>('chat-created', (event) => {
|
||||||
|
const { session_id, title, emoji } = event.payload;
|
||||||
|
currentSessionIdRef.current = session_id;
|
||||||
|
// TODO: Atualizar sidebar com novo chat (será implementado no componente de sidebar)
|
||||||
|
console.log('Chat criado:', { session_id, title, emoji });
|
||||||
|
}).then(unlisten => {
|
||||||
|
unlistenCreated = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para chat-token (streaming de tokens)
|
||||||
|
listen<ChatTokenEvent>('chat-token', (event) => {
|
||||||
|
const { content, done } = event.payload;
|
||||||
|
|
||||||
|
if (content && content.length > 0) {
|
||||||
|
// Verificar se este é o mesmo token do último processado (evitar duplicação)
|
||||||
|
// Isso pode acontecer se o evento for emitido múltiplas vezes
|
||||||
|
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
||||||
|
// Token duplicado, ignorar
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar referência do último token
|
||||||
|
lastTokenRef.current = content;
|
||||||
|
|
||||||
|
// Accumulate raw content (including metadata) in ref
|
||||||
|
rawContentRef.current += content;
|
||||||
|
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
// For display: strip incomplete metadata tag if present
|
||||||
|
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, content: displayContent }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Se não existe mensagem do assistente, criar uma
|
||||||
|
return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// Stream terminou, processar metadata final
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
const rawContent = rawContentRef.current;
|
||||||
|
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
||||||
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = ''; // Reset last token tracker
|
||||||
|
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, content: finalContent || last.content, metadata }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}).then(unlisten => {
|
||||||
|
unlistenToken = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para chat-error
|
||||||
|
listen<ChatErrorEvent>('chat-error', (event) => {
|
||||||
|
const { error } = event.payload;
|
||||||
|
console.error('Erro no chat:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
// TODO: Mostrar erro ao usuário
|
||||||
|
}).then(unlisten => {
|
||||||
|
unlistenError = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
unlistenCreated?.();
|
||||||
|
unlistenToken?.();
|
||||||
|
unlistenError?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const sendMessage = async (
|
const sendMessage = async (
|
||||||
content: string,
|
content: string,
|
||||||
@@ -325,235 +439,47 @@ export function useChat() {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
// [DEBUG INJECTION END]
|
// [DEBUG INJECTION END]
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
// Reset raw content buffer for this message
|
||||||
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = ''; // Reset last token tracker
|
||||||
|
|
||||||
|
// Add empty assistant message to start streaming into
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant' && last.content === '') {
|
||||||
|
// Já existe mensagem vazia, não adicionar outra
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
// Adicionar mensagem vazia do assistente
|
||||||
|
return [...prev, { role: 'assistant', content: '' }];
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Função auxiliar para tentar enviar mensagem (usada no retry)
|
// Verificar se Ollama está rodando antes de fazer requisição
|
||||||
const attemptSend = async (): Promise<void> => {
|
try {
|
||||||
// Verificar se Ollama está rodando antes de fazer requisição
|
const isRunning = await invoke<boolean>('check_ollama_running');
|
||||||
try {
|
if (!isRunning) {
|
||||||
const isRunning = await invoke<boolean>('check_ollama_running');
|
throw new Error('OllamaNotRunning');
|
||||||
if (!isRunning) {
|
|
||||||
throw new Error('OllamaNotRunning');
|
|
||||||
}
|
|
||||||
} catch (checkError: any) {
|
|
||||||
if (checkError.message === 'OllamaNotRunning') {
|
|
||||||
throw checkError;
|
|
||||||
}
|
|
||||||
console.warn('Erro ao verificar status do Ollama:', checkError);
|
|
||||||
// Continuar mesmo se a verificação falhar
|
|
||||||
}
|
}
|
||||||
|
} catch (checkError: any) {
|
||||||
// Timeout configurável (padrão 5 minutos)
|
if (checkError.message === 'OllamaNotRunning') {
|
||||||
const timeoutMs = 5 * 60 * 1000; // 5 minutos
|
throw checkError;
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (abortControllerRef.current) {
|
|
||||||
abortControllerRef.current.abort();
|
|
||||||
}
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch('http://localhost:11434/api/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
messages: apiMessages,
|
|
||||||
stream: true,
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
} catch (fetchError: any) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (fetchError.name === 'AbortError') {
|
|
||||||
throw new Error('RequestTimeout');
|
|
||||||
}
|
|
||||||
if (fetchError.message?.includes('Failed to fetch') || fetchError.message?.includes('NetworkError')) {
|
|
||||||
throw new Error('ConnectionError');
|
|
||||||
}
|
|
||||||
throw fetchError;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 404) {
|
|
||||||
throw new Error('ModelNotFound');
|
|
||||||
}
|
|
||||||
if (response.status === 500) {
|
|
||||||
throw new Error('ServerError');
|
|
||||||
}
|
|
||||||
throw new Error(`HTTPError: ${response.status}`);
|
|
||||||
}
|
|
||||||
if (!response.body) throw new Error('NoResponseBody');
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
// Reset raw content buffer for this message
|
|
||||||
rawContentRef.current = '';
|
|
||||||
|
|
||||||
// Add empty assistant message to start streaming into
|
|
||||||
// Verificar se já existe uma mensagem vazia do assistente (adicionada pelo handleSend)
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last && last.role === 'assistant' && last.content === '') {
|
|
||||||
// Já existe mensagem vazia, não adicionar outra
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Adicionar mensagem vazia do assistente
|
|
||||||
return [...prev, { role: 'assistant', content: '' }];
|
|
||||||
});
|
|
||||||
|
|
||||||
let streamFinished = false;
|
|
||||||
let metadataProcessed = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
streamFinished = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Validação básica: verificar se a linha parece ser JSON válido
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
if (!trimmedLine || trimmedLine.length === 0) {
|
|
||||||
continue; // Linha vazia, ignorar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se começa com { e termina com } (formato básico de JSON)
|
|
||||||
if (!trimmedLine.startsWith('{') || !trimmedLine.endsWith('}')) {
|
|
||||||
// Linha parcial ou inválida, ignorar silenciosamente
|
|
||||||
// Isso pode acontecer quando o streaming divide JSON em múltiplas chunks
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(trimmedLine);
|
|
||||||
if (json.message?.content) {
|
|
||||||
// Accumulate raw content (including metadata) in ref
|
|
||||||
rawContentRef.current += json.message.content;
|
|
||||||
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last.role === 'assistant') {
|
|
||||||
// For display: strip incomplete metadata tag if present (opening tag but not closed)
|
|
||||||
// This prevents showing "<metadata>..." in the UI while streaming
|
|
||||||
const displayContent = stripMetadataTag(rawContentRef.current);
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: displayContent }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (json.done) {
|
|
||||||
streamFinished = true;
|
|
||||||
metadataProcessed = true;
|
|
||||||
|
|
||||||
// Process metadata FIRST before setting isLoading to false
|
|
||||||
// This prevents race condition with saveSession useEffect
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last.role === 'assistant') {
|
|
||||||
// Parse metadata from raw accumulated content
|
|
||||||
const rawContent = rawContentRef.current;
|
|
||||||
const { content, metadata } = parseMetadata(rawContent);
|
|
||||||
|
|
||||||
// Fallback: if content is empty after parse but we had content before,
|
|
||||||
// keep the stripped version (without metadata tag) to preserve user-visible text
|
|
||||||
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
|
|
||||||
|
|
||||||
// Clear ref for next message
|
|
||||||
rawContentRef.current = '';
|
|
||||||
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: finalContent, metadata }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set loading to false AFTER processing metadata
|
|
||||||
setIsLoading(false);
|
|
||||||
break; // Exit inner loop when done
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// Log apenas em desenvolvimento para debug, não quebrar o fluxo
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
||||||
if (!errorMessage.includes('Unexpected end') && !errorMessage.includes('Unexpected token')) {
|
|
||||||
console.warn('Failed to parse JSON chunk:', errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Continuar processamento mesmo se uma linha falhar
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If stream finished without explicit json.done, process metadata anyway
|
|
||||||
if (streamFinished && !metadataProcessed && rawContentRef.current) {
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last && last.role === 'assistant') {
|
|
||||||
const rawContent = rawContentRef.current;
|
|
||||||
const { content, metadata } = parseMetadata(rawContent);
|
|
||||||
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
|
|
||||||
rawContentRef.current = '';
|
|
||||||
|
|
||||||
return [
|
|
||||||
...prev.slice(0, -1),
|
|
||||||
{ ...last, content: finalContent, metadata }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
} else if (streamFinished && !metadataProcessed) {
|
|
||||||
// Stream finished but no content to process, just set loading to false
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tentar enviar com retry logic
|
|
||||||
let lastError: any = null;
|
|
||||||
const maxRetries = 2; // Máximo de 2 retries (3 tentativas no total)
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
await attemptSend();
|
|
||||||
return; // Sucesso, sair do loop
|
|
||||||
} catch (error: any) {
|
|
||||||
lastError = error;
|
|
||||||
|
|
||||||
// Não fazer retry para certos erros
|
|
||||||
if (error.message === 'ModelNotFound' ||
|
|
||||||
error.message === 'OllamaNotRunning' ||
|
|
||||||
error.message === 'ConnectionError' ||
|
|
||||||
error.name === 'AbortError' ||
|
|
||||||
attempt === maxRetries) { // Última tentativa
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aguardar antes de tentar novamente (backoff exponencial)
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
|
|
||||||
console.log(`Tentativa ${attempt + 1} falhou, tentando novamente em ${delay}ms...`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
console.warn('Erro ao verificar status do Ollama:', checkError);
|
||||||
|
// Continuar mesmo se a verificação falhar
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se chegou aqui, todas as tentativas falharam
|
// Chamar comando Rust para streaming
|
||||||
throw lastError;
|
await invoke('chat_stream', {
|
||||||
|
sessionId: currentSessionIdRef.current || null,
|
||||||
|
messages: apiMessages,
|
||||||
|
model,
|
||||||
|
systemPrompt: systemPrompt || null,
|
||||||
|
enableRag: false, // TODO: Implementar RAG depois
|
||||||
|
});
|
||||||
|
|
||||||
|
// O streaming será processado pelos listeners de eventos
|
||||||
|
// Não precisamos fazer mais nada aqui, os eventos cuidam do resto
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Chat error:', error);
|
console.error('Chat error:', error);
|
||||||
|
|
||||||
@@ -598,6 +524,7 @@ export function useChat() {
|
|||||||
const clearChat = () => {
|
const clearChat = () => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
rawContentRef.current = '';
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ export function useWebSearch() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pipeline em duas etapas: metadados → scraping das top URLs
|
* Pipeline em duas etapas: metadados → scraping das top URLs
|
||||||
|
* Agora suporta timeout adaptativo por round
|
||||||
*/
|
*/
|
||||||
const smartSearchRag = useCallback(async (query: string, limit: number = 3): Promise<ScrapedContent[]> => {
|
const smartSearchRag = useCallback(async (query: string, limit: number = 3, round?: number): Promise<ScrapedContent[]> => {
|
||||||
if (!query || !query.trim()) {
|
if (!query || !query.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -147,12 +148,13 @@ export function useWebSearch() {
|
|||||||
|
|
||||||
// Etapa 1: metadados
|
// Etapa 1: metadados
|
||||||
setState(prev => ({ ...prev, status: 'searching' }));
|
setState(prev => ({ ...prev, status: 'searching' }));
|
||||||
const timeoutMs = settings.webSearch.timeout || 15000;
|
const timeoutMs = settings.webSearch.timeout || 10000; // Reduzido de 15s para 10s
|
||||||
const { metadata, contents } = await webSearchService.smartSearchRag(
|
const { metadata, contents } = await webSearchService.smartSearchRag(
|
||||||
query,
|
query,
|
||||||
limit,
|
limit,
|
||||||
searchConfig,
|
searchConfig,
|
||||||
timeoutMs
|
timeoutMs,
|
||||||
|
round || 1
|
||||||
);
|
);
|
||||||
|
|
||||||
// Etapa 2: scraping
|
// Etapa 2: scraping
|
||||||
|
|||||||
@@ -127,5 +127,43 @@ export function withTimeout<T>(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula timeout adaptativo baseado no round
|
||||||
|
* Reduz 20% a cada round: 10s → 8s → 6s → 4.8s
|
||||||
|
*/
|
||||||
|
export function calculateAdaptiveTimeout(
|
||||||
|
round: number,
|
||||||
|
initialTimeout: number = 10000,
|
||||||
|
reductionRate: number = 0.2
|
||||||
|
): number {
|
||||||
|
const timeout = initialTimeout * Math.pow(1 - reductionRate, round - 1);
|
||||||
|
return Math.max(timeout, 5000); // Mínimo de 5s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula timeout por motor baseado em tentativas
|
||||||
|
* Reduz após cada falha: 10s → 7s → 5s
|
||||||
|
*/
|
||||||
|
export function calculateEngineTimeout(
|
||||||
|
attempt: number,
|
||||||
|
initialTimeout: number = 10000,
|
||||||
|
reductionRate: number = 0.3
|
||||||
|
): number {
|
||||||
|
const timeout = initialTimeout * Math.pow(1 - reductionRate, attempt - 1);
|
||||||
|
return Math.max(timeout, 3000); // Mínimo de 3s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configurações otimizadas de timeout
|
||||||
|
*/
|
||||||
|
export const TIMEOUT_CONFIG = {
|
||||||
|
initialTimeout: 10000, // 10s (reduzido de 15s)
|
||||||
|
timeoutReductionPerRound: 0.2, // 20%
|
||||||
|
maxTimeoutPerQuery: 25000, // 25s total por query
|
||||||
|
maxTimeoutPerRound: 15000, // 15s por round
|
||||||
|
minTimeout: 5000, // Mínimo absoluto
|
||||||
|
minEngineTimeout: 3000, // Mínimo por motor
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import type { ScrapedContent } from '@/services/webSearch';
|
|||||||
import type { QueryContext } from './contextual-analyzer';
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
import type { EnrichedQueries } from './query-enricher';
|
import type { EnrichedQueries } from './query-enricher';
|
||||||
import { chatLog } from '@/lib/terminal-logger';
|
import { chatLog } from '@/lib/terminal-logger';
|
||||||
|
import { calculateAdaptiveTimeout, TIMEOUT_CONFIG } from '@/lib/retry-utils';
|
||||||
|
|
||||||
export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual';
|
export type SearchStrategy = 'literal' | 'semantic' | 'related' | 'expanded' | 'contextual';
|
||||||
|
|
||||||
@@ -29,6 +30,12 @@ export interface FallbackConfig {
|
|||||||
useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false)
|
useSemanticSearch?: boolean; // Usar multi-round semantic search (padrão: false)
|
||||||
context?: QueryContext; // Contexto analisado (para semantic search)
|
context?: QueryContext; // Contexto analisado (para semantic search)
|
||||||
enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search)
|
enrichedQueries?: EnrichedQueries; // Queries enriquecidas (para semantic search)
|
||||||
|
initialTimeout?: number; // Timeout inicial em ms (padrão: 10000)
|
||||||
|
maxTimeoutPerQuery?: number; // Timeout máximo total por query (padrão: 25000)
|
||||||
|
maxTimeoutPerRound?: number; // Timeout máximo por round (padrão: 15000)
|
||||||
|
minResultsForEarlyExit?: number; // Mínimo de resultados para early exit (padrão: 5)
|
||||||
|
minRelevanceForEarlyExit?: number; // Relevância mínima para early exit (padrão: 0.7)
|
||||||
|
maxRoundsBeforePartial?: number; // Máximo de rounds antes de aceitar parciais (padrão: 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FallbackAttempt {
|
export interface FallbackAttempt {
|
||||||
@@ -58,6 +65,12 @@ const DEFAULT_CONFIG: FallbackConfig = {
|
|||||||
maxTotalResults: 40,
|
maxTotalResults: 40,
|
||||||
minRelevanceScore: 0.3,
|
minRelevanceScore: 0.3,
|
||||||
enableQueryExpansion: true,
|
enableQueryExpansion: true,
|
||||||
|
initialTimeout: TIMEOUT_CONFIG.initialTimeout,
|
||||||
|
maxTimeoutPerQuery: TIMEOUT_CONFIG.maxTimeoutPerQuery,
|
||||||
|
maxTimeoutPerRound: TIMEOUT_CONFIG.maxTimeoutPerRound,
|
||||||
|
minResultsForEarlyExit: 5,
|
||||||
|
minRelevanceForEarlyExit: 0.7,
|
||||||
|
maxRoundsBeforePartial: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,7 +276,7 @@ function createSemanticRounds(
|
|||||||
*/
|
*/
|
||||||
export async function executeProgressiveSearch(
|
export async function executeProgressiveSearch(
|
||||||
originalQuery: string,
|
originalQuery: string,
|
||||||
searchFn: (query: string, limit: number) => Promise<ScrapedContent[]>,
|
searchFn: (query: string, limit: number, round?: number) => Promise<ScrapedContent[]>,
|
||||||
model: string,
|
model: string,
|
||||||
config: Partial<FallbackConfig> = {}
|
config: Partial<FallbackConfig> = {}
|
||||||
): Promise<FallbackResult> {
|
): Promise<FallbackResult> {
|
||||||
@@ -272,10 +285,13 @@ export async function executeProgressiveSearch(
|
|||||||
let totalResultsAnalyzed = 0;
|
let totalResultsAnalyzed = 0;
|
||||||
let allResults: ScrapedContent[] = [];
|
let allResults: ScrapedContent[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
|
const queryStartTime = Date.now();
|
||||||
|
const maxQueryTimeout = finalConfig.maxTimeoutPerQuery || TIMEOUT_CONFIG.maxTimeoutPerQuery;
|
||||||
|
|
||||||
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
chatLog.info(`\n=== PROGRESSIVE SEARCH START ===`);
|
||||||
chatLog.info(`Original Query: "${originalQuery}"`);
|
chatLog.info(`Original Query: "${originalQuery}"`);
|
||||||
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
|
chatLog.info(`Config: ${JSON.stringify({ ...finalConfig, context: finalConfig.context ? 'present' : 'none', enrichedQueries: finalConfig.enrichedQueries ? 'present' : 'none' })}`);
|
||||||
|
chatLog.info(`Timeout config: initial=${finalConfig.initialTimeout}ms, max per query=${maxQueryTimeout}ms, max per round=${finalConfig.maxTimeoutPerRound}ms`);
|
||||||
|
|
||||||
// Se usar semantic search, criar rounds baseados em estratégias
|
// Se usar semantic search, criar rounds baseados em estratégias
|
||||||
let rounds: Array<{ round: number; config: RoundConfig }> = [];
|
let rounds: Array<{ round: number; config: RoundConfig }> = [];
|
||||||
@@ -305,13 +321,26 @@ export async function executeProgressiveSearch(
|
|||||||
rounds = rounds.slice(0, finalConfig.maxRounds);
|
rounds = rounds.slice(0, finalConfig.maxRounds);
|
||||||
|
|
||||||
for (const { round, config: roundConfig } of rounds) {
|
for (const { round, config: roundConfig } of rounds) {
|
||||||
|
// Verificar timeout total da query
|
||||||
|
const elapsedTime = Date.now() - queryStartTime;
|
||||||
|
if (elapsedTime >= maxQueryTimeout) {
|
||||||
|
chatLog.warn(`[Round ${round}] Timeout total da query atingido (${elapsedTime}ms >= ${maxQueryTimeout}ms), parando busca`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular timeout adaptativo para este round
|
||||||
|
const roundTimeout = calculateAdaptiveTimeout(round, finalConfig.initialTimeout || TIMEOUT_CONFIG.initialTimeout);
|
||||||
|
const maxRoundTimeout = finalConfig.maxTimeoutPerRound || TIMEOUT_CONFIG.maxTimeoutPerRound;
|
||||||
|
const actualRoundTimeout = Math.min(roundTimeout, maxRoundTimeout);
|
||||||
|
|
||||||
chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
|
chatLog.info(`\n--- Round ${round}/${rounds.length} [${roundConfig.strategy}] ---`);
|
||||||
|
chatLog.info(`Round timeout: ${actualRoundTimeout}ms (adaptativo: ${roundTimeout}ms, máximo: ${maxRoundTimeout}ms)`);
|
||||||
|
|
||||||
const roundStartTime = Date.now();
|
const roundStartTime = Date.now();
|
||||||
let roundResults: ScrapedContent[] = [];
|
let roundResults: ScrapedContent[] = [];
|
||||||
let roundQueries: string[] = [];
|
let roundQueries: string[] = [];
|
||||||
|
|
||||||
// Executar todas as queries do round em paralelo
|
// Executar todas as queries do round em paralelo com timeout por round
|
||||||
const queryPromises = roundConfig.queries.map(async (query) => {
|
const queryPromises = roundConfig.queries.map(async (query) => {
|
||||||
// Expandir query se necessário (modo tradicional)
|
// Expandir query se necessário (modo tradicional)
|
||||||
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
|
const finalQuery = finalConfig.enableQueryExpansion && !finalConfig.useSemanticSearch
|
||||||
@@ -319,7 +348,8 @@ export async function executeProgressiveSearch(
|
|||||||
: query;
|
: query;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound);
|
// Passar round para searchFn para timeout adaptativo
|
||||||
|
const results = await searchFn(finalQuery, finalConfig.maxResultsPerRound, round);
|
||||||
return { query: finalQuery, results };
|
return { query: finalQuery, results };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error);
|
chatLog.warn(`[Round ${round}] Query "${finalQuery}" failed:`, error);
|
||||||
@@ -327,7 +357,23 @@ export async function executeProgressiveSearch(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryResults = await Promise.all(queryPromises);
|
// Adicionar timeout total para o round
|
||||||
|
const roundPromise = Promise.all(queryPromises);
|
||||||
|
type QueryResult = { query: string; results: ScrapedContent[] };
|
||||||
|
const timeoutPromise = new Promise<QueryResult[]>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Round ${round} timeout após ${actualRoundTimeout}ms`)), actualRoundTimeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
let queryResults: QueryResult[];
|
||||||
|
try {
|
||||||
|
queryResults = await Promise.race([roundPromise, timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
chatLog.warn(`[Round ${round}] Timeout do round após ${actualRoundTimeout}ms, usando resultados parciais`);
|
||||||
|
// Tentar obter resultados parciais das promises que já completaram
|
||||||
|
queryResults = await Promise.allSettled(queryPromises).then(results =>
|
||||||
|
results.map(r => r.status === 'fulfilled' ? r.value : { query: '', results: [] })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Agregar resultados de todas as queries do round
|
// Agregar resultados de todas as queries do round
|
||||||
for (const { query, results } of queryResults) {
|
for (const { query, results } of queryResults) {
|
||||||
@@ -388,12 +434,14 @@ export async function executeProgressiveSearch(
|
|||||||
allResults.push(...roundResults);
|
allResults.push(...roundResults);
|
||||||
totalResultsAnalyzed += roundResults.length;
|
totalResultsAnalyzed += roundResults.length;
|
||||||
|
|
||||||
// Verificar se encontrou resultados relevantes
|
// Verificar early exit: resultados suficientes com alta relevância
|
||||||
const queryForRelevance = roundQueries[0] || originalQuery;
|
const minResultsForEarlyExit = finalConfig.minResultsForEarlyExit || 5;
|
||||||
if (areResultsRelevant(roundResults, queryForRelevance, finalConfig.minRelevanceScore)) {
|
const minRelevanceForEarlyExit = finalConfig.minRelevanceForEarlyExit || 0.7;
|
||||||
chatLog.info(`[Round ${round}] ✓ Relevant results found!`);
|
|
||||||
|
if (roundResults.length >= minResultsForEarlyExit && relevanceScore >= minRelevanceForEarlyExit) {
|
||||||
|
chatLog.info(`[Round ${round}] 🎯 Early exit: resultados suficientes encontrados!`);
|
||||||
|
chatLog.info(`[Round ${round}] Results: ${roundResults.length} (mínimo: ${minResultsForEarlyExit}), Relevance: ${relevanceScore.toFixed(2)} (mínimo: ${minRelevanceForEarlyExit})`);
|
||||||
chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
chatLog.info(`[Round ${round}] Total results: ${allResults.length}, Total analyzed: ${totalResultsAnalyzed}`);
|
||||||
chatLog.info(`[Round ${round}] Final queries: ${roundQueries.join(', ')}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Circuit Breaker para motores de busca
|
||||||
|
* Desabilita motores que estão falhando consistentemente
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EngineHealth {
|
||||||
|
engine: string;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
totalAttempts: number;
|
||||||
|
avgLatency: number; // ms
|
||||||
|
lastFailure: number | null; // timestamp
|
||||||
|
lastSuccess: number | null; // timestamp
|
||||||
|
isOpen: boolean; // circuit breaker aberto?
|
||||||
|
openedAt: number | null; // quando foi aberto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircuitBreakerConfig {
|
||||||
|
failureThreshold: number; // Taxa de falha para abrir (0-1, padrão: 0.5 = 50%)
|
||||||
|
cooldownPeriod: number; // Tempo em ms antes de tentar novamente (padrão: 5 minutos)
|
||||||
|
trackingWindow: number; // Número de tentativas para rastrear (padrão: 10)
|
||||||
|
minAttempts: number; // Mínimo de tentativas antes de considerar (padrão: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: CircuitBreakerConfig = {
|
||||||
|
failureThreshold: 0.5, // 50% de falha
|
||||||
|
cooldownPeriod: 5 * 60 * 1000, // 5 minutos
|
||||||
|
trackingWindow: 10,
|
||||||
|
minAttempts: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EngineCircuitBreaker {
|
||||||
|
private health: Map<string, EngineHealth> = new Map();
|
||||||
|
private config: CircuitBreakerConfig;
|
||||||
|
|
||||||
|
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra uma tentativa de busca
|
||||||
|
*/
|
||||||
|
recordAttempt(engine: string, success: boolean, latency: number): void {
|
||||||
|
const health = this.getOrCreateHealth(engine);
|
||||||
|
|
||||||
|
health.totalAttempts++;
|
||||||
|
health.avgLatency = (health.avgLatency * (health.totalAttempts - 1) + latency) / health.totalAttempts;
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
health.successCount++;
|
||||||
|
health.lastSuccess = Date.now();
|
||||||
|
|
||||||
|
// Se teve sucesso e circuit estava aberto há tempo suficiente, tentar fechar
|
||||||
|
if (health.isOpen && health.openedAt) {
|
||||||
|
const timeSinceOpen = Date.now() - health.openedAt;
|
||||||
|
if (timeSinceOpen >= this.config.cooldownPeriod) {
|
||||||
|
health.isOpen = false;
|
||||||
|
health.openedAt = null;
|
||||||
|
console.log(`[CircuitBreaker] ${engine}: Circuit fechado após sucesso (${timeSinceOpen}ms desde abertura)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
health.failureCount++;
|
||||||
|
health.lastFailure = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitar histórico às últimas N tentativas
|
||||||
|
if (health.totalAttempts > this.config.trackingWindow) {
|
||||||
|
// Remover tentativas mais antigas (simplificado: resetar contadores)
|
||||||
|
const successRate = health.successCount / health.totalAttempts;
|
||||||
|
health.successCount = Math.floor(successRate * this.config.trackingWindow);
|
||||||
|
health.failureCount = this.config.trackingWindow - health.successCount;
|
||||||
|
health.totalAttempts = this.config.trackingWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se deve abrir circuit breaker
|
||||||
|
if (!health.isOpen && health.totalAttempts >= this.config.minAttempts) {
|
||||||
|
const failureRate = health.failureCount / health.totalAttempts;
|
||||||
|
if (failureRate >= this.config.failureThreshold) {
|
||||||
|
health.isOpen = true;
|
||||||
|
health.openedAt = Date.now();
|
||||||
|
console.warn(`[CircuitBreaker] ${engine}: Circuit aberto (taxa de falha: ${(failureRate * 100).toFixed(1)}%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.health.set(engine, health);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se um motor está disponível (circuit não está aberto)
|
||||||
|
*/
|
||||||
|
isAvailable(engine: string): boolean {
|
||||||
|
const health = this.getOrCreateHealth(engine);
|
||||||
|
|
||||||
|
if (!health.isOpen) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se está aberto, verificar se passou tempo suficiente para tentar novamente
|
||||||
|
if (health.openedAt) {
|
||||||
|
const timeSinceOpen = Date.now() - health.openedAt;
|
||||||
|
if (timeSinceOpen >= this.config.cooldownPeriod) {
|
||||||
|
// Tentar fechar (half-open state)
|
||||||
|
health.isOpen = false;
|
||||||
|
this.health.set(engine, health);
|
||||||
|
console.log(`[CircuitBreaker] ${engine}: Tentando fechar circuit (half-open state)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prioriza motores baseado em saúde
|
||||||
|
*/
|
||||||
|
prioritizeEngines(engines: string[]): string[] {
|
||||||
|
return [...engines].sort((a, b) => {
|
||||||
|
const healthA = this.getOrCreateHealth(a);
|
||||||
|
const healthB = this.getOrCreateHealth(b);
|
||||||
|
|
||||||
|
// Motores não disponíveis vão para o final
|
||||||
|
if (healthA.isOpen && !healthB.isOpen) return 1;
|
||||||
|
if (!healthA.isOpen && healthB.isOpen) return -1;
|
||||||
|
|
||||||
|
// Comparar taxa de sucesso
|
||||||
|
const successRateA = healthA.totalAttempts > 0
|
||||||
|
? healthA.successCount / healthA.totalAttempts
|
||||||
|
: 0.5;
|
||||||
|
const successRateB = healthB.totalAttempts > 0
|
||||||
|
? healthB.successCount / healthB.totalAttempts
|
||||||
|
: 0.5;
|
||||||
|
|
||||||
|
if (Math.abs(successRateA - successRateB) > 0.1) {
|
||||||
|
return successRateB - successRateA; // Maior taxa de sucesso primeiro
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se taxa similar, comparar latência
|
||||||
|
return healthA.avgLatency - healthB.avgLatency; // Menor latência primeiro
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém estatísticas de um motor
|
||||||
|
*/
|
||||||
|
getHealth(engine: string): EngineHealth | null {
|
||||||
|
return this.health.get(engine) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém ou cria health para um motor
|
||||||
|
*/
|
||||||
|
private getOrCreateHealth(engine: string): EngineHealth {
|
||||||
|
if (!this.health.has(engine)) {
|
||||||
|
this.health.set(engine, {
|
||||||
|
engine,
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
totalAttempts: 0,
|
||||||
|
avgLatency: 0,
|
||||||
|
lastFailure: null,
|
||||||
|
lastSuccess: null,
|
||||||
|
isOpen: false,
|
||||||
|
openedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.health.get(engine)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reseta estatísticas de um motor
|
||||||
|
*/
|
||||||
|
reset(engine: string): void {
|
||||||
|
this.health.delete(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reseta todas as estatísticas
|
||||||
|
*/
|
||||||
|
resetAll(): void {
|
||||||
|
this.health.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém todos os motores disponíveis
|
||||||
|
*/
|
||||||
|
getAvailableEngines(engines: string[]): string[] {
|
||||||
|
return engines.filter(engine => this.isAvailable(engine));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Cache de falhas para evitar retentar queries que falharam recentemente
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FailureCacheEntry {
|
||||||
|
query: string;
|
||||||
|
timestamp: number;
|
||||||
|
error: string;
|
||||||
|
partialResults?: any[]; // Resultados parciais se houver
|
||||||
|
attempts: number; // Número de tentativas antes de falhar
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailureCacheConfig {
|
||||||
|
ttl: number; // Time to live em ms (padrão: 5 minutos)
|
||||||
|
maxEntries: number; // Máximo de entradas no cache (padrão: 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: FailureCacheConfig = {
|
||||||
|
ttl: 5 * 60 * 1000, // 5 minutos
|
||||||
|
maxEntries: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FailureCache {
|
||||||
|
private cache: Map<string, FailureCacheEntry> = new Map();
|
||||||
|
private config: FailureCacheConfig;
|
||||||
|
|
||||||
|
constructor(config: Partial<FailureCacheConfig> = {}) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
// Limpar entradas expiradas periodicamente
|
||||||
|
setInterval(() => this.cleanExpired(), 60000); // A cada 1 minuto
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza uma query para usar como chave
|
||||||
|
*/
|
||||||
|
private normalizeQuery(query: string): string {
|
||||||
|
return query.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma query falhou recentemente
|
||||||
|
*/
|
||||||
|
hasFailed(query: string): boolean {
|
||||||
|
const normalized = this.normalizeQuery(query);
|
||||||
|
const entry = this.cache.get(normalized);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se expirou
|
||||||
|
const age = Date.now() - entry.timestamp;
|
||||||
|
if (age >= this.config.ttl) {
|
||||||
|
this.cache.delete(normalized);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém entrada de falha (se existir e não expirada)
|
||||||
|
*/
|
||||||
|
get(query: string): FailureCacheEntry | null {
|
||||||
|
const normalized = this.normalizeQuery(query);
|
||||||
|
const entry = this.cache.get(normalized);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se expirou
|
||||||
|
const age = Date.now() - entry.timestamp;
|
||||||
|
if (age >= this.config.ttl) {
|
||||||
|
this.cache.delete(normalized);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra uma falha
|
||||||
|
*/
|
||||||
|
recordFailure(
|
||||||
|
query: string,
|
||||||
|
error: string,
|
||||||
|
partialResults?: any[],
|
||||||
|
attempts: number = 1
|
||||||
|
): void {
|
||||||
|
const normalized = this.normalizeQuery(query);
|
||||||
|
|
||||||
|
// Limitar tamanho do cache
|
||||||
|
if (this.cache.size >= this.config.maxEntries) {
|
||||||
|
// Remover entrada mais antiga
|
||||||
|
const oldest = Array.from(this.cache.entries())
|
||||||
|
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
||||||
|
if (oldest) {
|
||||||
|
this.cache.delete(oldest[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(normalized, {
|
||||||
|
query: normalized,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
error,
|
||||||
|
partialResults,
|
||||||
|
attempts,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[FailureCache] Registrada falha para query: "${query.substring(0, 50)}..." (TTL: ${this.config.ttl}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove uma entrada do cache
|
||||||
|
*/
|
||||||
|
remove(query: string): void {
|
||||||
|
const normalized = this.normalizeQuery(query);
|
||||||
|
this.cache.delete(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa todas as entradas expiradas
|
||||||
|
*/
|
||||||
|
cleanExpired(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const [key, entry] of this.cache.entries()) {
|
||||||
|
const age = now - entry.timestamp;
|
||||||
|
if (age >= this.config.ttl) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned > 0) {
|
||||||
|
console.log(`[FailureCache] Limpas ${cleaned} entradas expiradas`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa todo o cache
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém estatísticas do cache
|
||||||
|
*/
|
||||||
|
getStats(): { size: number; maxEntries: number; ttl: number } {
|
||||||
|
return {
|
||||||
|
size: this.cache.size,
|
||||||
|
maxEntries: this.config.maxEntries,
|
||||||
|
ttl: this.config.ttl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+179
-31
@@ -1,6 +1,15 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { SearchCategory, useSettingsStore } from '@/store/settings-store';
|
import { SearchCategory, useSettingsStore } from '@/store/settings-store';
|
||||||
import { retryWithBackoff, withTimeout, isRetryableError, type RetryConfig } from '@/lib/retry-utils';
|
import {
|
||||||
|
retryWithBackoff,
|
||||||
|
withTimeout,
|
||||||
|
isRetryableError,
|
||||||
|
type RetryConfig,
|
||||||
|
calculateEngineTimeout,
|
||||||
|
TIMEOUT_CONFIG
|
||||||
|
} from '@/lib/retry-utils';
|
||||||
|
import { EngineCircuitBreaker } from './engine-circuit-breaker';
|
||||||
|
import { FailureCache } from './failure-cache';
|
||||||
|
|
||||||
export interface ScrapedContent {
|
export interface ScrapedContent {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -49,6 +58,8 @@ class WebSearchService {
|
|||||||
private lastSearchTime = 0;
|
private lastSearchTime = 0;
|
||||||
private pendingRequest: PendingRequest | null = null;
|
private pendingRequest: PendingRequest | null = null;
|
||||||
private rateLimitQueue: PendingRequest[] = [];
|
private rateLimitQueue: PendingRequest[] = [];
|
||||||
|
private circuitBreaker: EngineCircuitBreaker = new EngineCircuitBreaker();
|
||||||
|
private failureCache: FailureCache = new FailureCache();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Carregar cache do localStorage na inicialização
|
// Carregar cache do localStorage na inicialização
|
||||||
@@ -221,16 +232,50 @@ class WebSearchService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Executa busca de metadados (sem scraping)
|
* Executa busca de metadados (sem scraping)
|
||||||
|
* Agora com timeout adaptativo baseado em tentativas, circuit breaker e cache de falhas
|
||||||
*/
|
*/
|
||||||
private async executeMetadataSearch(
|
private async executeMetadataSearch(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 5,
|
limit: number = 5,
|
||||||
searchConfig?: SearchConfig,
|
searchConfig?: SearchConfig,
|
||||||
timeoutMs: number = 15000
|
timeoutMs: number = 10000, // Reduzido de 15s para 10s
|
||||||
|
attempt: number = 1 // Tentativa atual para timeout adaptativo
|
||||||
): Promise<SearchResultMetadata[]> {
|
): Promise<SearchResultMetadata[]> {
|
||||||
|
// Verificar cache de falhas
|
||||||
|
const failureEntry = this.failureCache.get(query);
|
||||||
|
if (failureEntry) {
|
||||||
|
console.warn(`[WebSearch] Query falhou recentemente (há ${Math.round((Date.now() - failureEntry.timestamp) / 1000)}s), retornando resultados parciais ou vazio`);
|
||||||
|
|
||||||
|
// Retornar resultados parciais se disponíveis
|
||||||
|
if (failureEntry.partialResults && failureEntry.partialResults.length > 0) {
|
||||||
|
console.log(`[WebSearch] Retornando ${failureEntry.partialResults.length} resultados parciais do cache de falhas`);
|
||||||
|
return failureEntry.partialResults as SearchResultMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não há resultados parciais, retornar vazio imediatamente (sem retry)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
// Obter configurações de motores do store
|
// Obter configurações de motores do store
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const engineOrder = settings.webSearch.engineOrder || ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
const defaultEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'startpage'];
|
||||||
|
let engineOrder = settings.webSearch.engineOrder || defaultEngines;
|
||||||
|
|
||||||
|
// Priorizar motores usando circuit breaker
|
||||||
|
engineOrder = this.circuitBreaker.prioritizeEngines(engineOrder);
|
||||||
|
|
||||||
|
// Filtrar motores não disponíveis (circuit breaker aberto)
|
||||||
|
const availableEngines = this.circuitBreaker.getAvailableEngines(engineOrder);
|
||||||
|
|
||||||
|
if (availableEngines.length === 0) {
|
||||||
|
console.warn(`[WebSearch] Todos os motores estão com circuit breaker aberto, tentando todos mesmo assim`);
|
||||||
|
// Se todos estão abertos, tentar mesmo assim (pode ser temporário)
|
||||||
|
engineOrder = engineOrder;
|
||||||
|
} else {
|
||||||
|
engineOrder = availableEngines;
|
||||||
|
if (availableEngines.length < engineOrder.length) {
|
||||||
|
console.log(`[WebSearch] ${engineOrder.length - availableEngines.length} motor(es) com circuit breaker aberto, usando ${availableEngines.length} disponível(is)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rustConfig: any = undefined;
|
let rustConfig: any = undefined;
|
||||||
if (searchConfig) {
|
if (searchConfig) {
|
||||||
@@ -248,15 +293,23 @@ class WebSearchService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcular timeout adaptativo baseado na tentativa
|
||||||
|
const adaptiveTimeout = calculateEngineTimeout(attempt, timeoutMs);
|
||||||
|
|
||||||
// Log da tentativa
|
// Log da tentativa
|
||||||
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
console.log(`[WebSearch] Executando busca multi-engine para: "${query}"`);
|
||||||
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
console.log(`[WebSearch] Engine order: ${engineOrder.join(' → ')}`);
|
||||||
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
console.log(`[WebSearch] Limit: ${limit}, Timeout: ${adaptiveTimeout}ms (tentativa ${attempt})`);
|
||||||
|
|
||||||
// Executar com retry e timeout
|
// Executar com retry e timeout adaptativo
|
||||||
|
let retryAttempt = 0;
|
||||||
const retryResult = await retryWithBackoff<SearchResultMetadata[]>(
|
const retryResult = await retryWithBackoff<SearchResultMetadata[]>(
|
||||||
async () => {
|
async () => {
|
||||||
|
retryAttempt++;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
// Timeout ainda mais reduzido em retries internos
|
||||||
|
const retryTimeout = calculateEngineTimeout(retryAttempt, adaptiveTimeout);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const promise = invoke<SearchResultMetadata[]>('search_web_metadata', {
|
const promise = invoke<SearchResultMetadata[]>('search_web_metadata', {
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
@@ -267,18 +320,32 @@ class WebSearchService {
|
|||||||
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
promise,
|
promise,
|
||||||
timeoutMs,
|
retryTimeout,
|
||||||
`Timeout ao buscar metadados para "${query}"`
|
`Timeout ao buscar metadados para "${query}" (tentativa ${retryAttempt})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
console.log(`[WebSearch] Busca concluída em ${duration}ms: ${result.length} resultados`);
|
||||||
|
|
||||||
|
// Registrar sucesso no circuit breaker para cada motor usado
|
||||||
|
// (assumindo que o primeiro motor disponível foi usado)
|
||||||
|
if (engineOrder.length > 0) {
|
||||||
|
const usedEngine = engineOrder[0]; // Simplificado: primeiro motor da ordem
|
||||||
|
this.circuitBreaker.recordAttempt(usedEngine, true, duration);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[WebSearch] Erro após ${duration}ms:`, errorMsg);
|
console.error(`[WebSearch] Erro após ${duration}ms (tentativa ${retryAttempt}):`, errorMsg);
|
||||||
|
|
||||||
|
// Registrar falha no circuit breaker para cada motor tentado
|
||||||
|
// (assumindo que tentou todos os motores na ordem)
|
||||||
|
for (const engine of engineOrder) {
|
||||||
|
this.circuitBreaker.recordAttempt(engine, false, duration);
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -298,25 +365,39 @@ class WebSearchService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se falhou mas é erro recuperável, logar detalhadamente
|
// Se falhou, registrar no cache de falhas
|
||||||
if (retryResult.lastError && isRetryableError(retryResult.lastError)) {
|
|
||||||
console.warn(
|
|
||||||
`[WebSearch] Falha após ${retryResult.attempts} tentativas:`,
|
|
||||||
retryResult.lastError.message
|
|
||||||
);
|
|
||||||
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
|
||||||
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para erros não recuperáveis, logar detalhadamente
|
|
||||||
if (retryResult.lastError) {
|
if (retryResult.lastError) {
|
||||||
console.error(
|
const errorMsg = retryResult.lastError.message;
|
||||||
`[WebSearch] Erro não recuperável:`,
|
const isRetryable = isRetryableError(retryResult.lastError);
|
||||||
retryResult.lastError.message
|
|
||||||
|
// Registrar falha no cache (mesmo que seja recuperável, para evitar retentar imediatamente)
|
||||||
|
this.failureCache.recordFailure(
|
||||||
|
query,
|
||||||
|
errorMsg,
|
||||||
|
[], // Sem resultados parciais neste ponto
|
||||||
|
retryResult.attempts
|
||||||
);
|
);
|
||||||
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${timeoutMs}ms`);
|
|
||||||
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
if (isRetryable) {
|
||||||
|
console.warn(
|
||||||
|
`[WebSearch] Falha após ${retryResult.attempts} tentativas (erro recuperável):`,
|
||||||
|
errorMsg
|
||||||
|
);
|
||||||
|
console.warn(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
|
console.warn(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
||||||
|
console.warn(`[WebSearch] Retornando resultados parciais (se houver) ou array vazio`);
|
||||||
|
// Retornar vazio mas não quebrar o fluxo - o caller pode continuar com outros motores
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
// Para erros não recuperáveis, logar detalhadamente mas retornar vazio
|
||||||
|
console.error(
|
||||||
|
`[WebSearch] Erro não recuperável:`,
|
||||||
|
errorMsg
|
||||||
|
);
|
||||||
|
console.error(`[WebSearch] Query: "${query}", Limit: ${limit}, Timeout: ${adaptiveTimeout}ms`);
|
||||||
|
console.error(`[WebSearch] Motores tentados: ${engineOrder.join(', ')}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -403,23 +484,30 @@ class WebSearchService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca em duas etapas: metadados → scraping das melhores URLs
|
* Busca em duas etapas: metadados → scraping das melhores URLs
|
||||||
|
* Agora com timeout adaptativo
|
||||||
*/
|
*/
|
||||||
async smartSearchRag(
|
async smartSearchRag(
|
||||||
query: string,
|
query: string,
|
||||||
limit: number = 3,
|
limit: number = 3,
|
||||||
searchConfig?: SearchConfig,
|
searchConfig?: SearchConfig,
|
||||||
timeoutMs: number = 15000
|
timeoutMs: number = TIMEOUT_CONFIG.initialTimeout,
|
||||||
|
round: number = 1 // Round atual para timeout escalonado
|
||||||
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
||||||
if (!query || !query.trim()) {
|
if (!query || !query.trim()) {
|
||||||
return { metadata: [], contents: [] };
|
return { metadata: [], contents: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar metadados com retry e timeout (já implementado em executeMetadataSearch)
|
// Calcular timeout escalonado por round
|
||||||
|
const { calculateAdaptiveTimeout } = await import('@/lib/retry-utils');
|
||||||
|
const roundTimeout = calculateAdaptiveTimeout(round, timeoutMs);
|
||||||
|
|
||||||
|
// Buscar metadados com retry e timeout adaptativo
|
||||||
const metas = await this.executeMetadataSearch(
|
const metas = await this.executeMetadataSearch(
|
||||||
query,
|
query,
|
||||||
Math.max(limit * 2, 5),
|
Math.max(limit * 2, 5),
|
||||||
searchConfig,
|
searchConfig,
|
||||||
timeoutMs
|
roundTimeout,
|
||||||
|
1 // Primeira tentativa
|
||||||
);
|
);
|
||||||
|
|
||||||
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
// Continuar mesmo se metadados estiverem vazios (pode ter falhado parcialmente)
|
||||||
@@ -431,23 +519,83 @@ class WebSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tentar fazer scraping mesmo se metadados foram parciais
|
// Tentar fazer scraping mesmo se metadados foram parciais
|
||||||
|
// Usar timeout reduzido para scraping (50% do timeout de busca)
|
||||||
|
const scrapingTimeout = Math.max(roundTimeout * 0.5, 5000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contents = await withTimeout(
|
const contents = await withTimeout(
|
||||||
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
||||||
timeoutMs,
|
scrapingTimeout,
|
||||||
`Timeout ao fazer scraping para "${query}"`
|
`Timeout ao fazer scraping para "${query}"`
|
||||||
);
|
);
|
||||||
return { metadata: metas, contents: contents || [] };
|
return { metadata: metas, contents: contents || [] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
// Logar erro mas retornar metadados disponíveis (resultado parcial)
|
||||||
|
// Graceful degradation: continuar com metadados mesmo se scraping falhar
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
console.warn('Erro ao fazer scraping em lote (continuando com metadados):', errorMessage);
|
console.warn(`[WebSearch] Erro ao fazer scraping (continuando com metadados): ${errorMessage}`);
|
||||||
|
console.warn(`[WebSearch] Retornando ${metas.length} metadados sem conteúdo scraped (resultado parcial)`);
|
||||||
|
|
||||||
// Retornar metadados mesmo se scraping falhou
|
// Retornar metadados mesmo se scraping falhou - resultado parcial é melhor que nada
|
||||||
return { metadata: metas, contents: [] };
|
return { metadata: metas, contents: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versão simplificada que aceita resultados parciais mais rapidamente
|
||||||
|
* Retorna assim que qualquer motor retornar resultados, sem esperar todos
|
||||||
|
*/
|
||||||
|
async smartSearchRagPartial(
|
||||||
|
query: string,
|
||||||
|
limit: number = 3,
|
||||||
|
searchConfig?: SearchConfig,
|
||||||
|
timeoutMs: number = TIMEOUT_CONFIG.initialTimeout,
|
||||||
|
round: number = 1
|
||||||
|
): Promise<{ metadata: SearchResultMetadata[]; contents: ScrapedContent[] }> {
|
||||||
|
if (!query || !query.trim()) {
|
||||||
|
return { metadata: [], contents: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout ainda mais agressivo para resultados parciais
|
||||||
|
const partialTimeout = Math.max(timeoutMs * 0.7, 5000); // 70% do timeout normal
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Buscar metadados com timeout reduzido
|
||||||
|
const metas = await this.executeMetadataSearch(
|
||||||
|
query,
|
||||||
|
Math.max(limit * 2, 5),
|
||||||
|
searchConfig,
|
||||||
|
partialTimeout,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Se temos metadados, tentar scraping rápido (timeout ainda menor)
|
||||||
|
if (metas.length > 0) {
|
||||||
|
const topUrls = metas.map(m => m.url).slice(0, limit);
|
||||||
|
const scrapingTimeout = Math.max(partialTimeout * 0.4, 3000); // 40% do timeout parcial
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contents = await withTimeout(
|
||||||
|
invoke<ScrapedContent[]>('scrape_urls', { urls: topUrls }),
|
||||||
|
scrapingTimeout,
|
||||||
|
`Timeout parcial ao fazer scraping para "${query}"`
|
||||||
|
);
|
||||||
|
return { metadata: metas, contents: contents || [] };
|
||||||
|
} catch (error) {
|
||||||
|
// Aceitar resultado parcial: metadados sem scraping
|
||||||
|
console.warn(`[WebSearch] Scraping parcial falhou, retornando apenas metadados`);
|
||||||
|
return { metadata: metas, contents: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata: metas, contents: [] };
|
||||||
|
} catch (error) {
|
||||||
|
// Em caso de erro total, retornar vazio mas não quebrar
|
||||||
|
console.warn(`[WebSearch] Busca parcial falhou completamente:`, error);
|
||||||
|
return { metadata: [], contents: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrai conteúdo de uma URL específica
|
* Extrai conteúdo de uma URL específica
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+56
@@ -115,6 +115,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.24",
|
"reqwest 0.12.24",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
"rusqlite",
|
||||||
"scraper",
|
"scraper",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1338,6 +1339,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1902,12 +1915,30 @@ dependencies = [
|
|||||||
"ahash 0.7.8",
|
"ahash 0.7.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash 0.8.12",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "headless_chrome"
|
name = "headless_chrome"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -2578,6 +2609,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -4160,6 +4202,20 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust_decimal"
|
name = "rust_decimal"
|
||||||
version = "1.39.0"
|
version = "1.39.0"
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ walkdir = "2.5"
|
|||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
rfd = "0.14"
|
rfd = "0.14"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
@@ -6,6 +6,13 @@
|
|||||||
"main"
|
"main"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default"
|
"core:default",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-start-resize-dragging",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-is-maximized"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
use rusqlite::{Connection, Result as SqliteResult, params};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChatSession {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub emoji: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub id: Option<i64>,
|
||||||
|
pub session_id: String,
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// Cria ou abre conexão com o banco de dados
|
||||||
|
pub fn new(app_handle: &AppHandle) -> SqliteResult<Self> {
|
||||||
|
let app_data_dir = app_handle.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| {
|
||||||
|
rusqlite::Error::SqliteFailure(
|
||||||
|
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
|
||||||
|
Some(format!("Failed to get app data dir: {}", e))
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&app_data_dir)
|
||||||
|
.map_err(|e| {
|
||||||
|
rusqlite::Error::SqliteFailure(
|
||||||
|
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
|
||||||
|
Some(format!("Failed to create app data dir: {}", e))
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let db_path = app_data_dir.join("ollahub.db");
|
||||||
|
let conn = Connection::open(&db_path)?;
|
||||||
|
|
||||||
|
let db = Self { conn };
|
||||||
|
db.init_schema()?;
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inicializa o schema do banco de dados
|
||||||
|
fn init_schema(&self) -> SqliteResult<()> {
|
||||||
|
// Tabela de sessões
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
emoji TEXT DEFAULT '💬',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Tabela de mensagens
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Tabela de documentos RAG
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS rag_documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
embedding BLOB,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Índices para performance
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Índice para ordenação por updated_at
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at DESC)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Inicializar FTS (Full-Text Search)
|
||||||
|
self.init_fts_schema()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inicializa tabelas FTS5 para busca de texto completo
|
||||||
|
fn init_fts_schema(&self) -> SqliteResult<()> {
|
||||||
|
// Tabela FTS para títulos de sessões
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
|
||||||
|
id UNINDEXED,
|
||||||
|
title,
|
||||||
|
content='sessions',
|
||||||
|
content_rowid='rowid'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Tabela FTS para conteúdo de mensagens
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
|
session_id UNINDEXED,
|
||||||
|
content,
|
||||||
|
content='messages',
|
||||||
|
content_rowid='rowid'
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Triggers para manter FTS sincronizado com tabelas principais
|
||||||
|
self.create_fts_triggers()?;
|
||||||
|
|
||||||
|
// Popular tabelas FTS com dados existentes (se necessário)
|
||||||
|
self.populate_fts_tables()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cria triggers para manter tabelas FTS sincronizadas
|
||||||
|
fn create_fts_triggers(&self) -> SqliteResult<()> {
|
||||||
|
// Trigger para inserir em sessions_fts quando nova sessão é criada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS sessions_fts_insert AFTER INSERT ON sessions BEGIN
|
||||||
|
INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Trigger para atualizar sessions_fts quando sessão é atualizada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS sessions_fts_update AFTER UPDATE ON sessions BEGIN
|
||||||
|
INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title);
|
||||||
|
INSERT INTO sessions_fts(rowid, id, title) VALUES (new.rowid, new.id, new.title);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Trigger para deletar de sessions_fts quando sessão é deletada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS sessions_fts_delete AFTER DELETE ON sessions BEGIN
|
||||||
|
INSERT INTO sessions_fts(sessions_fts, rowid, id, title) VALUES ('delete', old.rowid, old.id, old.title);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Trigger para inserir em messages_fts quando nova mensagem é criada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Trigger para atualizar messages_fts quando mensagem é atualizada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content);
|
||||||
|
INSERT INTO messages_fts(rowid, session_id, content) VALUES (new.rowid, new.session_id, new.content);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Trigger para deletar de messages_fts quando mensagem é deletada
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, session_id, content) VALUES ('delete', old.rowid, old.session_id, old.content);
|
||||||
|
END",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Popula tabelas FTS com dados existentes
|
||||||
|
fn populate_fts_tables(&self) -> SqliteResult<()> {
|
||||||
|
// Verificar se sessions_fts já tem dados
|
||||||
|
let count: i64 = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sessions_fts",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// Se vazio, popular com dados existentes
|
||||||
|
if count == 0 {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO sessions_fts(rowid, id, title)
|
||||||
|
SELECT rowid, id, title FROM sessions",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se messages_fts já tem dados
|
||||||
|
let count: i64 = self.conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM messages_fts",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
).unwrap_or(0);
|
||||||
|
|
||||||
|
// Se vazio, popular com dados existentes
|
||||||
|
if count == 0 {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO messages_fts(rowid, session_id, content)
|
||||||
|
SELECT rowid, session_id, content FROM messages",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cria uma nova sessão de chat
|
||||||
|
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO sessions (id, title, emoji, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = ?2,
|
||||||
|
emoji = ?3,
|
||||||
|
updated_at = ?5",
|
||||||
|
params![
|
||||||
|
session.id,
|
||||||
|
session.title,
|
||||||
|
session.emoji,
|
||||||
|
session.created_at.to_rfc3339(),
|
||||||
|
session.updated_at.to_rfc3339()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atualiza uma sessão existente
|
||||||
|
pub fn update_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sessions SET title = ?1, emoji = ?2, updated_at = ?3 WHERE id = ?4",
|
||||||
|
params![
|
||||||
|
session.title,
|
||||||
|
session.emoji,
|
||||||
|
session.updated_at.to_rfc3339(),
|
||||||
|
session.id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca uma sessão por ID
|
||||||
|
pub fn get_session(&self, session_id: &str) -> SqliteResult<Option<ChatSession>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, title, emoji, created_at, updated_at FROM sessions WHERE id = ?1"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
Ok(ChatSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
emoji: row.get(2)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next() {
|
||||||
|
row.map(Some)
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Salva uma sessão (create ou update)
|
||||||
|
pub fn save_session(&self, session: &ChatSession) -> SqliteResult<()> {
|
||||||
|
self.create_session(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista todas as sessões ordenadas por updated_at DESC
|
||||||
|
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok(ChatSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
emoji: row.get(2)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
sessions.push(row?);
|
||||||
|
}
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleta uma sessão e todas as suas mensagens
|
||||||
|
pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> {
|
||||||
|
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adiciona uma mensagem a uma sessão
|
||||||
|
pub fn add_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO messages (session_id, role, content, metadata, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![
|
||||||
|
message.session_id,
|
||||||
|
message.role,
|
||||||
|
message.content,
|
||||||
|
message.metadata,
|
||||||
|
message.created_at.to_rfc3339()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Atualizar updated_at da sessão
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
|
||||||
|
params![
|
||||||
|
message.created_at.to_rfc3339(),
|
||||||
|
message.session_id
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(self.conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca todas as mensagens de uma sessão
|
||||||
|
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, session_id, role, content, metadata, created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?1
|
||||||
|
ORDER BY created_at ASC"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
Ok(ChatMessage {
|
||||||
|
id: Some(row.get(0)?),
|
||||||
|
session_id: row.get(1)?,
|
||||||
|
role: row.get(2)?,
|
||||||
|
content: row.get(3)?,
|
||||||
|
metadata: row.get(4)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
messages.push(row?);
|
||||||
|
}
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Salva um documento RAG
|
||||||
|
pub fn save_rag_document(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
source_url: Option<&str>,
|
||||||
|
content: &str,
|
||||||
|
embedding: Option<&[u8]>,
|
||||||
|
) -> SqliteResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO rag_documents (id, session_id, source_url, content, embedding, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
id,
|
||||||
|
session_id,
|
||||||
|
source_url,
|
||||||
|
content,
|
||||||
|
embedding,
|
||||||
|
Utc::now().to_rfc3339()
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca documentos RAG por sessão
|
||||||
|
pub fn get_rag_documents(&self, session_id: &str) -> SqliteResult<Vec<(String, String, Option<String>)>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT id, content, source_url FROM rag_documents WHERE session_id = ?1"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![session_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
row.get(1)?,
|
||||||
|
row.get(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut docs = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
docs.push(row?);
|
||||||
|
}
|
||||||
|
Ok(docs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Busca sessões por query (título ou conteúdo de mensagens)
|
||||||
|
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
|
||||||
|
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<ChatSession>> {
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
// Se query vazia, retornar todas as sessões ordenadas por updated_at
|
||||||
|
return self.list_sessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escapar caracteres especiais para FTS5
|
||||||
|
let escaped_query = query.replace('"', "\"\"");
|
||||||
|
let fts_query = format!("\"{}\"", escaped_query);
|
||||||
|
|
||||||
|
// Busca combinada: título (peso 1.0) + conteúdo (peso 0.5)
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"WITH title_matches AS (
|
||||||
|
SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
|
||||||
|
bm25(sessions_fts) as rank
|
||||||
|
FROM sessions s
|
||||||
|
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
|
||||||
|
WHERE sessions_fts MATCH ?1
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?2
|
||||||
|
),
|
||||||
|
content_matches AS (
|
||||||
|
SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at,
|
||||||
|
bm25(messages_fts) * 0.5 as rank
|
||||||
|
FROM sessions s
|
||||||
|
JOIN messages m ON s.id = m.session_id
|
||||||
|
JOIN messages_fts ON m.rowid = messages_fts.rowid
|
||||||
|
WHERE messages_fts MATCH ?1
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT ?2
|
||||||
|
)
|
||||||
|
SELECT id, title, emoji, created_at, updated_at
|
||||||
|
FROM (
|
||||||
|
SELECT * FROM title_matches
|
||||||
|
UNION
|
||||||
|
SELECT * FROM content_matches
|
||||||
|
)
|
||||||
|
ORDER BY rank DESC, updated_at DESC
|
||||||
|
LIMIT ?2"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map(params![fts_query, limit], |row| {
|
||||||
|
Ok(ChatSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
emoji: row.get(2)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut sessions = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
sessions.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
|
||||||
|
if sessions.is_empty() {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at
|
||||||
|
FROM sessions s
|
||||||
|
LEFT JOIN messages m ON s.id = m.session_id
|
||||||
|
WHERE s.title LIKE ?1 OR m.content LIKE ?1
|
||||||
|
ORDER BY s.updated_at DESC
|
||||||
|
LIMIT ?2"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let like_query = format!("%{}%", query);
|
||||||
|
let rows = stmt.query_map(params![like_query, limit], |row| {
|
||||||
|
Ok(ChatSession {
|
||||||
|
id: row.get(0)?,
|
||||||
|
title: row.get(1)?,
|
||||||
|
emoji: row.get(2)?,
|
||||||
|
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
|
||||||
|
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
sessions.push(row?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(sessions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+528
-3
@@ -20,6 +20,7 @@ mod scheduler_loop;
|
|||||||
mod sources_config;
|
mod sources_config;
|
||||||
mod system_monitor;
|
mod system_monitor;
|
||||||
mod intent_classifier;
|
mod intent_classifier;
|
||||||
|
mod db;
|
||||||
|
|
||||||
use web_scraper::{
|
use web_scraper::{
|
||||||
ScrapedContent,
|
ScrapedContent,
|
||||||
@@ -50,6 +51,27 @@ struct Message {
|
|||||||
metadata: Option<serde_json::Value>,
|
metadata: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eventos para comunicação Frontend <-> Rust
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct ChatCreatedEvent {
|
||||||
|
session_id: String,
|
||||||
|
title: String,
|
||||||
|
emoji: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct ChatTokenEvent {
|
||||||
|
session_id: String,
|
||||||
|
content: String,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Clone)]
|
||||||
|
struct ChatErrorEvent {
|
||||||
|
session_id: String,
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct DownloadProgress {
|
struct DownloadProgress {
|
||||||
status: String, // "pulling", "verifying", "success"
|
status: String, // "pulling", "verifying", "success"
|
||||||
@@ -377,6 +399,82 @@ fn save_chat_session(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usize>) -> Result<Vec<SessionSummary>, String> {
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
|
let db = Database::new(&app_handle)
|
||||||
|
.map_err(|e| format!("Failed to open database: {}", e))?;
|
||||||
|
|
||||||
|
let search_limit = limit.unwrap_or(50);
|
||||||
|
let sessions = db.search_sessions(&query, search_limit)
|
||||||
|
.map_err(|e| format!("Search failed: {}", e))?;
|
||||||
|
|
||||||
|
// Validar existência de cada sessão antes de retornar
|
||||||
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
|
let mut summaries = Vec::new();
|
||||||
|
let mut orphan_count = 0;
|
||||||
|
|
||||||
|
for session in sessions {
|
||||||
|
// Verificar se sessão existe no SQLite (já temos)
|
||||||
|
let exists_in_sqlite = db.get_session(&session.id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
// Verificar se existe no JSON (sistema legado) para compatibilidade
|
||||||
|
let json_path = chats_dir.join(format!("{}.json", session.id));
|
||||||
|
let exists_in_json = json_path.exists();
|
||||||
|
|
||||||
|
// Sessão deve existir em pelo menos um sistema
|
||||||
|
if !exists_in_sqlite && !exists_in_json {
|
||||||
|
orphan_count += 1;
|
||||||
|
log::warn!("Found orphan session in search results: {} (title: {})", session.id, session.title);
|
||||||
|
continue; // Pular sessões órfãs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar primeira mensagem para preview
|
||||||
|
let preview = db.get_messages(&session.id)
|
||||||
|
.ok()
|
||||||
|
.and_then(|msgs| {
|
||||||
|
msgs.iter()
|
||||||
|
.find(|m| m.role == "user" || m.role == "assistant")
|
||||||
|
.map(|m| {
|
||||||
|
m.content.chars().take(50).collect::<String>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
// Fallback: tentar ler do JSON se não encontrou no SQLite
|
||||||
|
if exists_in_json {
|
||||||
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
|
if let Ok(session_data) = serde_json::from_str::<ChatSession>(&content) {
|
||||||
|
return session_data.messages.iter()
|
||||||
|
.find(|m| m.role == "user" || m.role == "assistant")
|
||||||
|
.map(|m| m.content.chars().take(50).collect::<String>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
summaries.push(SessionSummary {
|
||||||
|
id: session.id,
|
||||||
|
title: session.title,
|
||||||
|
emoji: session.emoji,
|
||||||
|
updated_at: session.updated_at, // Já é DateTime<Utc>
|
||||||
|
preview,
|
||||||
|
platform: String::new(), // Platform não está no SQLite ainda
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if orphan_count > 0 {
|
||||||
|
log::info!("Filtered out {} orphan sessions from search results", orphan_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, String> {
|
fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, String> {
|
||||||
let chats_dir = get_chats_dir(&app_handle)?;
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
@@ -431,6 +529,60 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
|
|||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>, String> {
|
fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>, String> {
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
|
// 1. Tentar carregar do SQLite primeiro (sistema novo)
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
match db.get_messages(&id) {
|
||||||
|
Ok(messages) if !messages.is_empty() => {
|
||||||
|
// Converter ChatMessage para Message
|
||||||
|
let result: Vec<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 from SQLite for session {}", result.len(), id);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// Sessão existe mas não tem mensagens, continuar para fallback
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to open database: {}, trying JSON fallback", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: tentar carregar do sistema legado (arquivos JSON)
|
||||||
let chats_dir = get_chats_dir(&app_handle)?;
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
let file_path = chats_dir.join(format!("{}.json", id));
|
let file_path = chats_dir.join(format!("{}.json", id));
|
||||||
|
|
||||||
@@ -438,23 +590,65 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>,
|
|||||||
return Err("Session not found".to_string());
|
return Err("Session not found".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = fs::read_to_string(file_path)
|
let content = fs::read_to_string(&file_path)
|
||||||
.map_err(|e| format!("Failed to read session file: {}", e))?;
|
.map_err(|e| format!("Failed to read session file: {}", e))?;
|
||||||
|
|
||||||
let session: ChatSession = serde_json::from_str(&content)
|
let session: ChatSession = serde_json::from_str(&content)
|
||||||
.map_err(|e| format!("Failed to parse session: {}", e))?;
|
.map_err(|e| format!("Failed to parse session: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("Loaded {} messages from JSON for session {}", session.messages.len(), id);
|
||||||
Ok(session.messages)
|
Ok(session.messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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;
|
||||||
|
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
// 1. Deletar do sistema legado (arquivos JSON)
|
||||||
let chats_dir = get_chats_dir(&app_handle)?;
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
let file_path = chats_dir.join(format!("{}.json", id));
|
let file_path = chats_dir.join(format!("{}.json", id));
|
||||||
|
|
||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
fs::remove_file(file_path)
|
if let Err(e) = fs::remove_file(&file_path) {
|
||||||
.map_err(|e| format!("Failed to delete session file: {}", e))?;
|
errors.push(format!("Failed to delete JSON file: {}", e));
|
||||||
|
} else {
|
||||||
|
log::info!("Deleted session JSON file: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deletar do SQLite (sistema novo)
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
if let Err(e) = db.delete_session(&id) {
|
||||||
|
errors.push(format!("Failed to delete from SQLite: {}", e));
|
||||||
|
} else {
|
||||||
|
log::info!("Deleted session from SQLite: {}", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.push(format!("Failed to open database: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se ambos falharam, retornar erro
|
||||||
|
if !errors.is_empty() && !file_path.exists() {
|
||||||
|
// Se arquivo JSON não existe, verificar se pelo menos deletou do SQLite
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
if db.get_session(&id).ok().flatten().is_none() {
|
||||||
|
// Sessão não existe em nenhum lugar, considerar sucesso
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se houve erros mas pelo menos um sistema foi atualizado, logar mas não falhar
|
||||||
|
if !errors.is_empty() {
|
||||||
|
log::warn!("Some errors during deletion of session {}: {:?}", id, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2025,8 +2219,11 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result<String, String> {
|
|||||||
/// Apaga todo o histórico de conversas
|
/// Apaga todo o histórico de conversas
|
||||||
#[command]
|
#[command]
|
||||||
fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> {
|
fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
let chats_dir = get_chats_dir(&app_handle)?;
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
|
|
||||||
|
// 1. Deletar todos os arquivos JSON
|
||||||
let entries = fs::read_dir(&chats_dir)
|
let entries = fs::read_dir(&chats_dir)
|
||||||
.map_err(|e| format!("Failed to read chats dir: {}", e))?;
|
.map_err(|e| format!("Failed to read chats dir: {}", e))?;
|
||||||
|
|
||||||
@@ -2042,10 +2239,69 @@ fn clear_chat_history(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Deletar todas as sessões do SQLite
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
match db.list_sessions() {
|
||||||
|
Ok(sessions) => {
|
||||||
|
let mut sqlite_deleted = 0;
|
||||||
|
for session in sessions {
|
||||||
|
if let Err(e) = db.delete_session(&session.id) {
|
||||||
|
log::warn!("Failed to delete session {} from SQLite: {}", session.id, e);
|
||||||
|
} else {
|
||||||
|
sqlite_deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::info!("Deleted {} sessions from SQLite", sqlite_deleted);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to list sessions from SQLite: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to open database: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Deleted {} chat session files", deleted_count);
|
log::info!("Deleted {} chat session files", deleted_count);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Limpa sessões órfãs do SQLite que não têm arquivo JSON correspondente
|
||||||
|
#[command]
|
||||||
|
fn cleanup_orphan_sessions(app_handle: AppHandle) -> Result<u32, String> {
|
||||||
|
use db::Database;
|
||||||
|
|
||||||
|
let db = Database::new(&app_handle)
|
||||||
|
.map_err(|e| format!("Failed to open database: {}", e))?;
|
||||||
|
|
||||||
|
let chats_dir = get_chats_dir(&app_handle)?;
|
||||||
|
let mut orphan_count = 0;
|
||||||
|
|
||||||
|
// Listar todas as sessões do SQLite
|
||||||
|
let sessions = db.list_sessions()
|
||||||
|
.map_err(|e| format!("Failed to list sessions: {}", e))?;
|
||||||
|
|
||||||
|
for session in sessions {
|
||||||
|
let json_path = chats_dir.join(format!("{}.json", session.id));
|
||||||
|
|
||||||
|
// Se não existe arquivo JSON correspondente, é uma sessão órfã
|
||||||
|
if !json_path.exists() {
|
||||||
|
log::info!("Found orphan session: {} (title: {}), removing from SQLite", session.id, session.title);
|
||||||
|
|
||||||
|
if let Err(e) = db.delete_session(&session.id) {
|
||||||
|
log::warn!("Failed to delete orphan session {}: {}", session.id, e);
|
||||||
|
} else {
|
||||||
|
orphan_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Cleaned up {} orphan sessions from SQLite", orphan_count);
|
||||||
|
Ok(orphan_count)
|
||||||
|
}
|
||||||
|
|
||||||
/// Retorna o caminho do diretório de dados do app
|
/// Retorna o caminho do diretório de dados do app
|
||||||
#[command]
|
#[command]
|
||||||
fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
|
fn get_app_data_dir(app_handle: AppHandle) -> Result<String, String> {
|
||||||
@@ -2599,6 +2855,272 @@ fn classify_intent(query: String) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Comando principal para streaming de chat via Rust
|
||||||
|
#[command]
|
||||||
|
async fn chat_stream(
|
||||||
|
window: Window,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
session_id: Option<String>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
model: String,
|
||||||
|
system_prompt: Option<String>,
|
||||||
|
enable_rag: Option<bool>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use uuid::Uuid;
|
||||||
|
use ollama_client::OllamaClient;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use db::{Database, ChatSession, ChatMessage};
|
||||||
|
|
||||||
|
// Gerar ou usar session_id existente
|
||||||
|
let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||||
|
let enable_rag = enable_rag.unwrap_or(false);
|
||||||
|
|
||||||
|
// Verificar se é nova sessão (apenas 1 mensagem do usuário)
|
||||||
|
let is_new_session = messages.len() == 1 && messages[0].role == "user";
|
||||||
|
|
||||||
|
// Variáveis para título e emoji (usadas depois na persistência)
|
||||||
|
let (title, emoji) = if is_new_session {
|
||||||
|
let user_input = &messages[0].content;
|
||||||
|
let ollama_client = OllamaClient::new(None);
|
||||||
|
|
||||||
|
// Tentar gerar título (com timeout curto)
|
||||||
|
let generated_title = match tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_secs(5),
|
||||||
|
ollama_client.generate_title(&model, user_input)
|
||||||
|
).await {
|
||||||
|
Ok(Ok(t)) => t,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!("Erro ao gerar título: {}. Usando fallback.", e);
|
||||||
|
// Fallback: primeiras palavras da pergunta
|
||||||
|
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("Timeout ao gerar título. Usando fallback.");
|
||||||
|
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let generated_emoji = OllamaClient::generate_emoji(&generated_title);
|
||||||
|
|
||||||
|
// Emitir evento de chat criado
|
||||||
|
let created_event = ChatCreatedEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
title: generated_title.clone(),
|
||||||
|
emoji: generated_emoji.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = window.emit("chat-created", &created_event) {
|
||||||
|
log::warn!("Erro ao emitir evento chat-created: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
(generated_title, generated_emoji)
|
||||||
|
} else {
|
||||||
|
(String::new(), "💬".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Preparar mensagens para Ollama
|
||||||
|
let mut ollama_messages = Vec::new();
|
||||||
|
|
||||||
|
// Adicionar system prompt se fornecido
|
||||||
|
if let Some(sys_prompt) = system_prompt {
|
||||||
|
ollama_messages.push(serde_json::json!({
|
||||||
|
"role": "system",
|
||||||
|
"content": sys_prompt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter mensagens para formato Ollama
|
||||||
|
for msg in &messages {
|
||||||
|
ollama_messages.push(serde_json::json!({
|
||||||
|
"role": msg.role,
|
||||||
|
"content": msg.content
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TODO: Classificar intent e aplicar RAG se necessário
|
||||||
|
// if enable_rag {
|
||||||
|
// let intent = classify_intent(messages.last().unwrap().content.clone());
|
||||||
|
// // Buscar contexto via RAG
|
||||||
|
// // Injetar no system prompt
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 4. Fazer requisição streaming para Ollama
|
||||||
|
let ollama_client = OllamaClient::new(None);
|
||||||
|
ollama_client.check_connection().await?;
|
||||||
|
|
||||||
|
let request = serde_json::json!({
|
||||||
|
"model": model,
|
||||||
|
"messages": ollama_messages,
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usar reqwest diretamente para streaming
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
let url = "http://localhost:11434/api/chat";
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let error_msg = format!("Ollama returned status: {}", response.status());
|
||||||
|
let error_event = ChatErrorEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
error: error_msg.clone(),
|
||||||
|
};
|
||||||
|
let _ = window.emit("chat-error", &error_event);
|
||||||
|
return Err(error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Processar stream e emitir tokens
|
||||||
|
// IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo)
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mut full_content = String::new();
|
||||||
|
|
||||||
|
while let Some(chunk_result) = stream.next().await {
|
||||||
|
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
|
||||||
|
let chunk_str = String::from_utf8_lossy(&chunk);
|
||||||
|
buffer.push_str(&chunk_str);
|
||||||
|
|
||||||
|
// Processar linhas completas (separadas por \n)
|
||||||
|
while let Some(pos) = buffer.find('\n') {
|
||||||
|
let line = buffer[..pos].trim().to_string();
|
||||||
|
buffer = buffer[pos + 1..].to_string();
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar deserializar como JSON do Ollama
|
||||||
|
match serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
|
Ok(json) => {
|
||||||
|
// Verificar se stream terminou primeiro
|
||||||
|
let is_done = json.get("done").and_then(|d| d.as_bool()) == Some(true);
|
||||||
|
|
||||||
|
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
|
||||||
|
if let Some(message) = json.get("message") {
|
||||||
|
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
||||||
|
// O Ollama envia apenas o NOVO conteúdo em cada chunk, não o acumulado
|
||||||
|
// Então podemos emitir diretamente
|
||||||
|
if !content.is_empty() {
|
||||||
|
full_content.push_str(content);
|
||||||
|
|
||||||
|
// Emitir token para frontend
|
||||||
|
let token_event = ChatTokenEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
content: content.to_string(),
|
||||||
|
done: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = window.emit("chat-token", &token_event) {
|
||||||
|
log::warn!("Erro ao emitir token: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se stream terminou
|
||||||
|
if is_done {
|
||||||
|
// Emitir evento final
|
||||||
|
let final_event = ChatTokenEvent {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
content: String::new(),
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
let _ = window.emit("chat-token", &final_event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Failed to parse JSON chunk: {} - Line: {}", e, line);
|
||||||
|
// Continuar mesmo com erro de parse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Persistir sessão e mensagens no SQLite
|
||||||
|
match Database::new(&app_handle) {
|
||||||
|
Ok(db) => {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
// Criar ou atualizar sessão
|
||||||
|
let session = if is_new_session && !title.is_empty() {
|
||||||
|
ChatSession {
|
||||||
|
id: session_id.clone(),
|
||||||
|
title,
|
||||||
|
emoji,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Buscar sessão existente ou criar nova
|
||||||
|
match db.get_session(&session_id) {
|
||||||
|
Ok(Some(mut existing)) => {
|
||||||
|
existing.updated_at = now;
|
||||||
|
existing
|
||||||
|
}
|
||||||
|
_ => ChatSession {
|
||||||
|
id: session_id.clone(),
|
||||||
|
title: "Nova Conversa".to_string(),
|
||||||
|
emoji: "💬".to_string(),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db.create_session(&session) {
|
||||||
|
log::warn!("Erro ao salvar sessão: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar mensagens do usuário
|
||||||
|
for msg in &messages {
|
||||||
|
let chat_msg = ChatMessage {
|
||||||
|
id: None,
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
role: msg.role.clone(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
metadata: msg.metadata.as_ref().and_then(|m| serde_json::to_string(m).ok()),
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db.add_message(&chat_msg) {
|
||||||
|
log::warn!("Erro ao salvar mensagem: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar mensagem final do assistente
|
||||||
|
if !full_content.is_empty() {
|
||||||
|
let assistant_msg = ChatMessage {
|
||||||
|
id: None,
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: full_content,
|
||||||
|
metadata: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = db.add_message(&assistant_msg) {
|
||||||
|
log::warn!("Erro ao salvar mensagem do assistente: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Erro ao inicializar banco de dados: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -2678,6 +3200,7 @@ pub fn run() {
|
|||||||
.manage(Arc::new(Mutex::new(None::<Arc<Browser>>)) as BrowserState)
|
.manage(Arc::new(Mutex::new(None::<Arc<Browser>>)) as BrowserState)
|
||||||
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
|
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
chat_stream,
|
||||||
check_ollama_installed,
|
check_ollama_installed,
|
||||||
check_ollama_running,
|
check_ollama_running,
|
||||||
get_system_specs,
|
get_system_specs,
|
||||||
@@ -2694,8 +3217,10 @@ pub fn run() {
|
|||||||
delete_model,
|
delete_model,
|
||||||
save_chat_session,
|
save_chat_session,
|
||||||
load_chat_sessions,
|
load_chat_sessions,
|
||||||
|
search_chat_sessions,
|
||||||
load_chat_history,
|
load_chat_history,
|
||||||
delete_chat_session,
|
delete_chat_session,
|
||||||
|
cleanup_orphan_sessions,
|
||||||
load_mcp_config,
|
load_mcp_config,
|
||||||
save_mcp_config,
|
save_mcp_config,
|
||||||
get_mcp_config_path_command,
|
get_mcp_config_path_command,
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ struct OllamaMessageResponse {
|
|||||||
|
|
||||||
/// Cliente Ollama headless (para execução em background)
|
/// Cliente Ollama headless (para execução em background)
|
||||||
pub struct OllamaClient {
|
pub struct OllamaClient {
|
||||||
base_url: String,
|
pub(crate) base_url: String,
|
||||||
client: reqwest::Client,
|
pub(crate) client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OllamaClient {
|
impl OllamaClient {
|
||||||
@@ -148,5 +148,109 @@ impl OllamaClient {
|
|||||||
|
|
||||||
Ok(full_response.trim().to_string())
|
Ok(full_response.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gera um título curto (3-5 palavras) para a pergunta do usuário
|
||||||
|
pub async fn generate_title(&self, model: &str, user_input: &str) -> Result<String, String> {
|
||||||
|
let system_prompt = "Você é um gerador de títulos. Responda APENAS com um título de 3-5 palavras que resuma a pergunta. Nada mais, sem explicações.";
|
||||||
|
|
||||||
|
let messages = vec![
|
||||||
|
OllamaMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: system_prompt.to_string(),
|
||||||
|
},
|
||||||
|
OllamaMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: format!("Gere um título para: {}", user_input),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let request = OllamaChatRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/api/chat", self.base_url);
|
||||||
|
let response = self.client
|
||||||
|
.post(&url)
|
||||||
|
.json(&request)
|
||||||
|
.timeout(std::time::Duration::from_secs(10)) // Timeout curto para resposta rápida
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to send title request: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ler stream e acumular resposta (limitado a ~50 caracteres)
|
||||||
|
let mut full_response = String::new();
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
|
||||||
|
let text = String::from_utf8_lossy(&chunk);
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match serde_json::from_str::<OllamaChunk>(line) {
|
||||||
|
Ok(chunk_data) => {
|
||||||
|
if let Some(message) = chunk_data.message {
|
||||||
|
full_response.push_str(&message.content);
|
||||||
|
|
||||||
|
// Limitar tamanho para evitar respostas longas
|
||||||
|
if full_response.len() > 50 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk_data.done == Some(true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se já temos resposta suficiente, parar
|
||||||
|
if full_response.len() > 30 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = full_response.trim().to_string();
|
||||||
|
|
||||||
|
// Fallback se título estiver vazio ou muito longo
|
||||||
|
if title.is_empty() || title.len() > 50 {
|
||||||
|
// Extrair primeiras palavras da pergunta como fallback
|
||||||
|
let words: Vec<&str> = user_input.split_whitespace().take(5).collect();
|
||||||
|
Ok(words.join(" "))
|
||||||
|
} else {
|
||||||
|
Ok(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gera emoji baseado no título
|
||||||
|
pub fn generate_emoji(title: &str) -> String {
|
||||||
|
let title_lower = title.to_lowercase();
|
||||||
|
|
||||||
|
// Keywords para emojis
|
||||||
|
if title_lower.contains("código") || title_lower.contains("program") || title_lower.contains("code") {
|
||||||
|
"💻".to_string()
|
||||||
|
} else if title_lower.contains("pergunta") || title_lower.contains("question") || title_lower.contains("como") {
|
||||||
|
"❓".to_string()
|
||||||
|
} else if title_lower.contains("explica") || title_lower.contains("explain") {
|
||||||
|
"📚".to_string()
|
||||||
|
} else if title_lower.contains("ajuda") || title_lower.contains("help") {
|
||||||
|
"🆘".to_string()
|
||||||
|
} else {
|
||||||
|
"💬".to_string() // Default
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,10 +13,12 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "OllaHub",
|
"title": "OllaHub",
|
||||||
"width": 800,
|
"width": 1200,
|
||||||
"height": 600,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false
|
"fullscreen": false,
|
||||||
|
"decorations": false,
|
||||||
|
"transparent": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
Reference in new issue
Block a user