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