Merge pull request #2 from evandrodevbr/full-rust-backend

Full-rust-backend
This commit is contained in:
Evandro Fonseca Junior authored and GitHub committed 2025-11-27 18:46:03 -03:00
commit 1a64f5aa18
22 files changed
+2532 -421

No files matched your search

+43 -40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+27 -10
View File
@@ -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>
)}
+90 -19
View File
@@ -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>
+123
View File
@@ -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>
);
}
+30 -1
View File
@@ -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
View File
@@ -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 };
+5 -3
View File
@@ -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
+38
View File
@@ -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;
+57 -9
View File
@@ -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,
+191
View File
@@ -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));
}
}
+160
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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"
+1
View File
@@ -46,3 +46,4 @@ walkdir = "2.5"
tauri-plugin-autostart = "2"
dirs = "5.0"
rfd = "0.14"
rusqlite = { version = "0.31", features = ["bundled"] }
+8 -1
View File
@@ -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"
]
}
+543
View File
@@ -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
View File
@@ -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,
+106 -2
View File
@@ -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
}
}
}
+5 -3
View File
@@ -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": {