From a44d1bba449c3a62ed72aad6bbeb5e08fc464eb1 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 20 Nov 2025 05:25:34 -0300 Subject: [PATCH] feat: enhance chat and settings functionality with debugging and new features - Integrated useRouter for navigation in chat and settings pages, improving user experience. - Added detailed debug logging for web context generation and chat payloads to assist in troubleshooting. - Updated settings page to include a hardware dashboard and enhanced sources configuration management. - Implemented auto-start feature for the application and improved task management with new scheduled task options. - Refined user interface components for better accessibility and usability across chat and settings pages. --- app/chat/page.tsx | 40 +- app/settings/page.tsx | 727 ++++++++++++++++------ app/tasks/page.tsx | 77 ++- components/settings/HardwareDashboard.tsx | 213 +++++++ data/prompts/default-format.md | 2 +- hooks/use-chat.ts | 21 + hooks/use-web-search.ts | 16 + lib/types.ts | 15 + src-tauri/Cargo.lock | 79 ++- src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 323 +++++++++- src-tauri/src/ollama_client.rs | 3 +- src-tauri/src/scheduler.rs | 4 +- src-tauri/src/scheduler_loop.rs | 2 +- src-tauri/src/sources_config.rs | 154 +++++ src-tauri/src/system_monitor.rs | 153 +++++ src-tauri/src/task_executor.rs | 2 - store/settings-store.ts | 82 ++- 18 files changed, 1692 insertions(+), 223 deletions(-) create mode 100644 components/settings/HardwareDashboard.tsx create mode 100644 lib/types.ts create mode 100644 src-tauri/src/sources_config.rs create mode 100644 src-tauri/src/system_monitor.rs diff --git a/app/chat/page.tsx b/app/chat/page.tsx index ab98f4f..1f7889a 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -25,6 +25,7 @@ import { useSettingsStore } from "@/store/settings-store"; import { invoke } from "@tauri-apps/api/core"; import { useState, useEffect, useRef, useCallback } from "react"; import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; import { ImperativePanelHandle } from "react-resizable-panels"; import { sanitizeWebSources } from "@/lib/sanitize-web-content"; // @ts-ignore @@ -32,6 +33,7 @@ import defaultFormatPrompt from "@/data/prompts/default-format.md"; export default function ChatPage() { + const router = useRouter(); const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat(); const { models } = useLocalModels(); const { theme, setTheme } = useTheme(); @@ -164,6 +166,25 @@ export default function ChatPage() { removeHiddenText: true, }); + // [DEBUG INJECTION START] + console.group('🔍 Debug: Web Context Generation'); + console.log('1. Raw Sources Count:', scrapedSources.length); + console.log('2. Web Context Length:', webContext.length); + console.log('3. Is Context Empty?', webContext === ''); + if (webContext.length > 0) { + console.log('4. Context Preview:', webContext.substring(0, 200) + '...'); + } else { + console.warn('⚠️ ALERT: Web Context is EMPTY even with sources!'); + console.log('4. Sources Details:', scrapedSources.map(s => ({ + url: s.url, + title: s.title, + hasMarkdown: !!s.markdown, + markdownLength: s.markdown?.length || 0 + }))); + } + console.groupEnd(); + // [DEBUG INJECTION END] + // Log para debug: verificar tamanho do contexto gerado const contextLength = webContext.length; console.debug(`Contexto web gerado (sanitizado): ${contextLength} caracteres de ${scrapedSources.length} fontes`); @@ -221,7 +242,7 @@ export default function ChatPage() { 3. **Uso Estrito de Fontes:** - Use SOMENTE as informações do bloco [CONTEXTO WEB RECUPERADO] abaixo para fatos recentes. Não alucine. - Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política"). - - Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web. + - NÃO cite fontes no meio do texto. Use as informações do contexto web naturalmente, sem mencionar [1], [2], [3] ou outras referências numéricas. - Se o contexto não for suficiente para responder completamente, diga isso claramente. 4. **Formato Jornalístico:** @@ -252,6 +273,16 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente `; } + // [DEBUG INJECTION START] + console.log('5. Enhanced System Prompt Length:', enhancedSystemPrompt.length); + console.log('6. Contains "CONTEXTO WEB"?', enhancedSystemPrompt.includes('CONTEXTO WEB RECUPERADO')); + if (enhancedSystemPrompt.includes('CONTEXTO WEB RECUPERADO')) { + const contextStart = enhancedSystemPrompt.indexOf('CONTEXTO WEB RECUPERADO'); + console.log('7. Web Context Position:', contextStart); + console.log('8. Web Context in Prompt Preview:', enhancedSystemPrompt.substring(contextStart, contextStart + 300) + '...'); + } + // [DEBUG INJECTION END] + await sendMessage(content, selectedModel, enhancedSystemPrompt); // Marcar como completo quando começar a streamar @@ -323,7 +354,12 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente > {theme === "dark" ? : } - diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 7137514..e3ffebf 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -21,10 +21,12 @@ import { } from '@/components/ui/dialog'; import { useSettingsStore } from '@/store/settings-store'; import { DomainTagsInput } from '@/components/settings/domain-tags-input'; +import { HardwareDashboard } from '@/components/settings/HardwareDashboard'; import { invoke } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; -import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign } from 'lucide-react'; +import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; +import { useRouter } from 'next/navigation'; +import { SourcesConfig, SourceCategory } from '@/lib/types'; interface Model { name: string; @@ -35,6 +37,7 @@ interface Model { export default function SettingsPage() { const settings = useSettingsStore(); const { toast } = useToast(); + const router = useRouter(); const [isCheckingConnection, setIsCheckingConnection] = useState(false); const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [availableModels, setAvailableModels] = useState([]); @@ -43,13 +46,37 @@ export default function SettingsPage() { const [showClearDialog, setShowClearDialog] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isClearing, setIsClearing] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [isSavingSources, setIsSavingSources] = useState(false); + const [recentLogs, setRecentLogs] = useState([]); + const [isLoadingLogs, setIsLoadingLogs] = useState(false); - // Load models and storage path on mount + // Load models, storage path, and sources config on mount useEffect(() => { loadModels(); loadStoragePath(); + settings.fetchSources(); }, [settings.ollamaUrl]); + // Load logs periodically + useEffect(() => { + const loadLogs = async () => { + try { + setIsLoadingLogs(true); + const logs = await invoke('get_recent_logs', { lines: 100 }); + setRecentLogs(logs); + } catch (error) { + console.error('Failed to load logs:', error); + } finally { + setIsLoadingLogs(false); + } + }; + + loadLogs(); + const interval = setInterval(loadLogs, 5000); // Refresh every 5 seconds + return () => clearInterval(interval); + }, []); + const loadModels = async () => { setIsLoadingModels(true); try { @@ -135,6 +162,26 @@ export default function SettingsPage() { } }; + const handleExportAllData = async () => { + setIsExporting(true); + try { + const zipPath = await invoke('export_all_data'); + toast({ + title: 'Backup completo exportado', + description: `Arquivo salvo em: ${zipPath}`, + }); + } catch (error) { + console.error('Failed to export all data:', error); + toast({ + title: 'Erro na exportação', + description: error instanceof Error ? error.message : 'Falha ao exportar', + variant: 'destructive', + }); + } finally { + setIsExporting(false); + } + }; + const handleClearHistory = async () => { setIsClearing(true); try { @@ -170,23 +217,50 @@ export default function SettingsPage() { return (
-
-

Configurações

-

- Gerencie as preferências do OllaHub -

+
+
+

Configurações

+

+ Gerencie as preferências do OllaHub +

+
+
- - - AI & Models + + + Visão Geral + General Web Search Sources & Knowledge - Data & Storage + Tasks & Scheduler + System & Logs - {/* Tab: AI & Models */} - + {/* Tab: Visão Geral (Hardware Dashboard) */} + + + + Dashboard de Hardware + + Monitoramento em tempo real do sistema (atualizado a cada 1 segundo) + + + + + + + + + {/* Tab: General */} + Conexão Ollama @@ -298,6 +372,30 @@ export default function SettingsPage() {
+ + + + Inicialização Automática + + Iniciar o OllaHub automaticamente ao iniciar o sistema + + + +
+
+ +

+ O aplicativo será iniciado automaticamente ao fazer login +

+
+ +
+
+
{/* Tab: Web Search */} @@ -344,18 +442,24 @@ export default function SettingsPage() {
- - settings.setWebSearchTimeout(Number(e.target.value))} - disabled={!settings.webSearch.enabled} - /> + +
+
+ 10s + {settings.webSearch.timeout}s + 60s +
+ settings.setWebSearchTimeout(value)} + min={10} + max={60} + step={5} + disabled={!settings.webSearch.enabled} + /> +

- Tempo máximo para carregar cada página + Tempo máximo para carregar cada página. Valores maiores podem resolver timeouts, mas aumentam o tempo total de busca.

@@ -372,166 +476,269 @@ export default function SettingsPage() { - Fontes de Conhecimento - - Configure categorias de busca e sites customizados para pesquisa avançada - +
+
+ Fontes de Conhecimento + + Configure categorias de busca salvas no sources.json (compartilhável) + +
+ +
- {/* Performance Settings */} -
-
- -
-
- 1 - {settings.webSearch.maxConcurrentTabs} - 10 -
- settings.setWebSearchMaxConcurrentTabs(value)} - min={1} - max={10} - step={1} - /> -
-

- Número máximo de páginas processadas simultaneamente (mais = mais rápido, mas mais uso de CPU) -

-
- -
- -
-
- Rápido (5) - {settings.webSearch.totalSourcesLimit} - Profundo (40) -
- settings.setWebSearchTotalSourcesLimit(value)} - min={5} - max={40} - step={5} - /> -
-

- Número total de fontes a buscar. Mais fontes = mais tempo, mas resultados mais completos. -

-
-
- - {/* Categories */} -
- -
- {settings.webSearch.categories.map((category) => { - const iconMap: Record = { - academic: , - news: , - tech: , - finance: , - }; - - return ( - - -
-
-
- {iconMap[category.id] || } -
-
-
- - settings.toggleCategory(category.id)} - /> -
-
- {category.baseSites.map((site) => ( - - {site} - - ))} -
-

- {category.baseSites.length} sites curados -

-
-
-
-
-
- ); - })} -
-
- - {/* Custom Sites */} -
- -

- Adicione seus próprios domínios favoritos para busca direcionada -

-
- { - if (e.key === 'Enter') { - const input = e.currentTarget; - const value = input.value.trim(); - if (value) { - settings.addCustomSite(value); - input.value = ''; + {settings.sourcesConfig ? ( +
+
+ + -
- {settings.webSearch.userCustomSites.length > 0 && ( -
- {settings.webSearch.userCustomSites.map((site) => ( - - {site} - - - ))} + }} + > + + Restaurar Padrões +
- )} +
+ {settings.sourcesConfig.categories.map((category) => { + const iconMap: Record = { + academico: , + news: , + tech: , + financeiro: , + }; + + return ( + + +
+
+
+ {iconMap[category.id] || } +
+
+
+ + { + const updated = { + ...settings.sourcesConfig!, + categories: settings.sourcesConfig!.categories.map((cat) => + cat.id === category.id ? { ...cat, enabled: !cat.enabled } : cat + ), + }; + try { + await settings.saveSources(updated); + } catch (error) { + toast({ + title: 'Erro ao salvar', + description: error instanceof Error ? error.message : 'Falha ao salvar', + variant: 'destructive', + }); + } + }} + /> +
+
+ {category.base_sites.map((site) => ( + + {site} + + ))} +
+

+ {category.base_sites.length} sites +

+
+
+ +
+
+
+ ); + })} +
+
+ ) : ( +
+ +

Carregando configuração de fontes...

+
+ )} + + + + {/* Edit Category Dialog */} + !open && setEditingCategory(null)}> + + + Editar Categoria: {editingCategory?.name} + + Adicione ou remova sites desta categoria + + + {editingCategory && ( + { + if (!settings.sourcesConfig) return; + const updated = { + ...settings.sourcesConfig, + categories: settings.sourcesConfig.categories.map((cat) => + cat.id === updatedCategory.id ? updatedCategory : cat + ), + }; + try { + setIsSavingSources(true); + await settings.saveSources(updated); + setEditingCategory(null); + toast({ + title: 'Categoria atualizada', + description: 'Alterações salvas com sucesso', + }); + } catch (error) { + toast({ + title: 'Erro ao salvar', + description: error instanceof Error ? error.message : 'Falha ao salvar', + variant: 'destructive', + }); + } finally { + setIsSavingSources(false); + } + }} + /> + )} + + + + + {/* Tab: Tasks & Scheduler */} + + + + Tarefas Agendadas + + Gerencie tarefas automáticas que executam em intervalos regulares + + + +
+
+

+ Visualize e gerencie tarefas agendadas no Task Manager +

+
+
- {/* Tab: Data & Storage */} - + {/* Tab: System & Logs */} + Armazenamento @@ -564,12 +771,12 @@ export default function SettingsPage() {
- -
+ +
+
+

+ O backup completo inclui: chats, tasks.json, sources.json e settings.json +

+ + + + Logs do Sistema + + Visualize os logs recentes do aplicativo (atualizado a cada 5 segundos) + + + +
+
+ {isLoadingLogs ? ( +
+ + Carregando logs... +
+ ) : recentLogs.length === 0 ? ( +
Nenhum log disponível
+ ) : ( + recentLogs.map((line, idx) => ( +
+ {line} +
+ )) + )} +
+
+
+
+ + + + Processos do Sistema + + Gerencie processos do Chrome/Chromium que podem ficar "zumbis" após buscas web + + + +
+ +

+ Se você notar processos Chrome "zumbis" consumindo memória após buscas web, use este botão para forçar o encerramento de todos os processos relacionados. +

+ +
+
+
@@ -636,3 +925,79 @@ export default function SettingsPage() { ); } +// Edit Category Dialog Component +function EditCategoryDialog({ + category, + onSave +}: { + category: SourceCategory; + onSave: (category: SourceCategory) => void; +}) { + const [sites, setSites] = useState(category.base_sites); + const [newSite, setNewSite] = useState(''); + + const handleAddSite = () => { + const normalized = newSite.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/$/, ''); + if (normalized && !sites.includes(normalized)) { + setSites([...sites, normalized]); + setNewSite(''); + } + }; + + const handleRemoveSite = (site: string) => { + setSites(sites.filter((s) => s !== site)); + }; + + const handleSave = () => { + onSave({ + ...category, + base_sites: sites, + }); + }; + + return ( +
+
+ +
+ setNewSite(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddSite(); + } + }} + /> + +
+
+ {sites.map((site) => ( + + {site} + + + ))} +
+
+ + + + +
+ ); +} + diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 3deb12a..894e3fe 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -6,7 +6,8 @@ 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 } from 'lucide-react'; +import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -157,13 +158,15 @@ export default function TasksPage() { {task.label} {task.enabled ? ( - + + Ativa - + ) : ( - + + Pausada - + )} @@ -222,9 +225,10 @@ export default function TasksPage() { function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) { const [label, setLabel] = useState(''); - const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'custom'>('daily'); + const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'hourly' | 'custom'>('daily'); const [hour, setHour] = useState('8'); const [minute, setMinute] = useState('0'); + const [intervalHours, setIntervalHours] = useState('1'); const [customCron, setCustomCron] = useState(''); const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize'); const [query, setQuery] = useState(''); @@ -238,8 +242,28 @@ function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) { if (scheduleType === 'custom') { return customCron; } - // Diário às hora:minuto - return `${minute} ${hour} * * *`; + if (scheduleType === 'hourly') { + return `0 */${intervalHours} * * *`; + } + if (scheduleType === 'daily') { + return `${minute} ${hour} * * *`; + } + // Weekly (every Monday at hour:minute) + return `${minute} ${hour} * * 1`; + }; + + const getCronPreview = (): string => { + const cron = generateCron(); + if (scheduleType === 'hourly') { + return `A cada ${intervalHours} hora(s)`; + } + if (scheduleType === 'daily') { + return `Todo dia às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; + } + if (scheduleType === 'weekly') { + return `Toda segunda-feira às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; + } + return cron; }; const handleSubmit = async (e: React.FormEvent) => { @@ -306,10 +330,23 @@ function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) { Diário Semanal - Cron Customizado + A cada X horas + Cron Customizado (Avançado)
+ + {/* Cron Preview */} +
+
+ + Agendamento: + {getCronPreview()} +
+
+ Cron: {generateCron()} +
+
{scheduleType === 'custom' ? (
@@ -322,13 +359,29 @@ function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) { required />

- Formato: minuto hora dia mês dia-da-semana + Formato: minuto hora dia mês dia-da-semana (ex: 0 8 * * * = todo dia às 8h) +

+
+ ) : scheduleType === 'hourly' ? ( +
+ + setIntervalHours(e.target.value)} + required + /> +

+ A task será executada a cada {intervalHours} hora(s)

) : (
- + void }) { />
- + (null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadStats = async () => { + try { + const data = await invoke('get_system_stats'); + setStats(data); + setIsLoading(false); + } catch (error) { + console.error('Failed to load system stats:', error); + setIsLoading(false); + } + }; + + loadStats(); + const interval = setInterval(loadStats, 1000); // Atualiza a cada 1 segundo + + return () => clearInterval(interval); + }, []); + + if (isLoading || !stats) { + return ( + + + Carregando estatísticas do sistema... + + + ); + } + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; + }; + + const formatUptime = (seconds: number): string => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + }; + + const getCpuColor = (usage: number): string => { + if (usage < 50) return 'bg-green-500'; + if (usage < 80) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + const getRamColor = (percent: number): string => { + if (percent < 60) return 'bg-blue-500'; + if (percent < 85) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + return ( +
+ {/* CPU Card */} + + +
+ + + CPU + + + {stats.cpu_usage.toFixed(1)}% + +
+
+ + +
+ {stats.cpu_name} +
+
+
+ + {/* RAM Card */} + + +
+ + + Memória RAM + + + {stats.ram_percent.toFixed(1)}% + +
+
+ + +
+ {formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)} + {formatBytes(stats.ram_total - stats.ram_used)} livre +
+
+
+ + {/* GPU Card */} + + + + + GPU + + + +
+ {stats.gpu_name ? ( + {stats.gpu_name} + ) : ( + Não detectada + )} +
+
+
+ + {/* System Info Card */} + + + + + Sistema + + + +
+ + + Uptime + + {formatUptime(stats.uptime)} +
+
+ Processos + {stats.processes_count.toLocaleString()} +
+
+
+ + {/* Visual RAM Usage */} + + + + + Uso de Memória + + + +
+
+ Usado + {stats.ram_percent.toFixed(1)}% +
+
+
+
+ {formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)} +
+
+
+ 0% + 100% +
+
+ + +
+ ); +} + diff --git a/data/prompts/default-format.md b/data/prompts/default-format.md index dfc81ae..b03b1e7 100644 --- a/data/prompts/default-format.md +++ b/data/prompts/default-format.md @@ -27,7 +27,7 @@ Você é o OllaHub AI, um assistente especializado em análise detalhada e factu - Você recebeu um bloco de texto marcado como [CONTEXTO WEB] ou [CONTEXTO WEB RECUPERADO]. - Use **SOMENTE** essas informações para fatos recentes. Não alucine. - Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política", "## Tecnologia"). -- Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web. +- **NÃO cite fontes no meio do texto.** Use as informações do contexto web naturalmente, sem mencionar [1], [2], [3] ou outras referências numéricas. As fontes já estão disponíveis na interface e não precisam ser mencionadas na resposta. - Se o contexto não for suficiente para responder completamente, diga isso claramente. ### 4. Formato Jornalístico diff --git a/hooks/use-chat.ts b/hooks/use-chat.ts index 049db41..07897ab 100644 --- a/hooks/use-chat.ts +++ b/hooks/use-chat.ts @@ -264,6 +264,27 @@ export function useChat() { content: m.content // Send only content, not metadata (unless we want to inject it back) })); + // [DEBUG INJECTION START] + const systemMsg = apiMessages.find(m => m.role === 'system'); + console.group('📦 Debug: Ollama Payload'); + console.log('1. Total Messages:', apiMessages.length); + console.log('2. Has System Message?', !!systemMsg); + if (systemMsg) { + console.log('3. System Message Length:', systemMsg.content.length); + console.log('4. System Content Preview:', systemMsg.content.substring(0, 300) + '...'); + console.log('5. Web Context Present in Payload?', systemMsg.content.includes('CONTEXTO WEB RECUPERADO')); + if (systemMsg.content.includes('CONTEXTO WEB RECUPERADO')) { + const contextStart = systemMsg.content.indexOf('CONTEXTO WEB RECUPERADO'); + console.log('6. Web Context Position in Payload:', contextStart); + console.log('7. Web Context Preview in Payload:', systemMsg.content.substring(contextStart, contextStart + 200) + '...'); + } + } else { + console.error('❌ CRITICAL: System Message is MISSING in payload!'); + console.log('Available roles:', apiMessages.map(m => m.role)); + } + console.groupEnd(); + // [DEBUG INJECTION END] + abortControllerRef.current = new AbortController(); try { diff --git a/hooks/use-web-search.ts b/hooks/use-web-search.ts index 84337b9..4f49ab9 100644 --- a/hooks/use-web-search.ts +++ b/hooks/use-web-search.ts @@ -83,6 +83,22 @@ export function useWebSearch() { const results = await webSearchService.search(query, limit, excludedDomains, searchConfig); + // [DEBUG INJECTION START] + console.log('🌐 Debug: Web Search Service Results', { + count: results.length, + firstResultHasMarkdown: results[0] ? !!results[0].markdown : 'N/A', + firstResultMarkdownLen: results[0]?.markdown?.length || 0, + allResults: results.map((r, idx) => ({ + index: idx, + url: r.url, + title: r.title, + hasMarkdown: !!r.markdown, + markdownLength: r.markdown?.length || 0, + markdownPreview: r.markdown?.substring(0, 150) || 'SEM MARKDOWN' + })) + }); + // [DEBUG INJECTION END] + setState(prev => ({ ...prev, status: 'completed', diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..0e95045 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,15 @@ +// Types matching Rust backend structures + +export interface SourceCategory { + id: string; + name: string; + base_sites: string[]; + enabled: boolean; +} + +export interface SourcesConfig { + version: number; + categories: SourceCategory[]; + last_updated: string; +} + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8d0382b..dbd16b7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -119,6 +119,7 @@ dependencies = [ "sysinfo", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-shell", @@ -127,6 +128,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "walkdir", "zip", ] @@ -296,6 +298,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "auto_generate_cdp" version = "0.4.5" @@ -1003,13 +1016,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1020,7 +1053,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -3772,6 +3805,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4840,7 +4884,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4890,7 +4934,7 @@ checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4962,6 +5006,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + [[package]] name = "tauri-plugin-log" version = "2.7.1" @@ -5514,7 +5572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -6526,6 +6584,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.50.0" @@ -6574,7 +6641,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc5ee7c..1e95136 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,3 +42,5 @@ rand = "0.8" tokio-cron-scheduler = "0.9" futures-util = "0.3" regex = "1.10" +walkdir = "2.5" +tauri-plugin-autostart = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0880efa..6025fb1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,11 +16,14 @@ mod scheduler; mod ollama_client; mod task_executor; mod scheduler_loop; +mod sources_config; +mod system_monitor; use web_scraper::{ScrapedContent, create_browser, search_and_scrape, search_and_scrape_with_config, scrape_url, SearchConfig}; use headless_chrome::Browser; use scheduler::{SentinelTask, SchedulerService, SchedulerState, TaskAction}; -use ollama_client::OllamaClient; +use sources_config::{SourcesConfig, load_sources_config, save_sources_config}; +use system_monitor::{SystemStats, SystemMonitorState}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -63,8 +66,10 @@ struct SystemSpecs { os_name: String, } +// SystemStats movido para system_monitor.rs +// Mantendo apenas para compatibilidade com start_system_monitor #[derive(serde::Serialize, Clone)] -struct SystemStats { +struct LegacySystemStats { cpu_usage: f32, memory_used: u64, memory_total: u64, @@ -443,7 +448,7 @@ fn start_system_monitor(window: Window) { let memory_used = sys.used_memory(); let memory_total = sys.total_memory(); - let stats = SystemStats { + let stats = LegacySystemStats { cpu_usage, memory_used, memory_total, @@ -1237,13 +1242,117 @@ fn reset_browser(state: State<'_, BrowserState>) -> Result<(), String> { Ok(()) } -/// Cleanup explícito do browser (chamado ao fechar app) +/// Força o encerramento apenas de processos Chrome/Chromium headless criados pelo app +/// Seguro: não mata o navegador pessoal do usuário #[command] -fn cleanup_browser(state: State<'_, BrowserState>) -> Result<(), String> { - let mut browser_opt = state.lock().map_err(|e| format!("Erro ao acessar estado do browser: {}", e))?; - *browser_opt = None; - log::info!("Browser cleanup executado"); - Ok(()) +fn force_kill_browser() -> Result { + let mut system = System::new_all(); + system.refresh_all(); + + let mut killed_count = 0; + let process_names = vec!["chrome", "chromium", "chromedriver", "headless_shell"]; + + for (pid, process) in system.processes() { + let name = process.name().to_string_lossy().to_lowercase(); + + // Verifica se o nome do processo corresponde + if !process_names.iter().any(|&pn| name.contains(pn)) { + continue; + } + + // SAFE KILL: Estratégia conservadora para identificar processos headless + // No Windows, tentamos usar wmic para obter a linha de comando completa + #[cfg(target_os = "windows")] + let is_headless = { + use std::process::Command; + // Tenta obter a linha de comando do processo via wmic + let cmd_output = Command::new("wmic") + .args(&["process", "where", &format!("ProcessId={}", pid), "get", "CommandLine", "/format:list"]) + .output(); + + if let Ok(output) = cmd_output { + if let Ok(cmd_str) = String::from_utf8(output.stdout) { + let cmd_lower = cmd_str.to_lowercase(); + // Só mata se tiver flags muito específicas de headless + cmd_lower.contains("--headless") + || cmd_lower.contains("--remote-debugging-port") + || (cmd_lower.contains("--disable-gpu") && cmd_lower.contains("--no-sandbox")) + } else { + false // Se não conseguir ler, não mata (seguro) + } + } else { + // Se wmic falhar, usa heurística conservadora: só mata se o nome for muito específico + name.contains("headless_shell") || name.contains("chromedriver") + } + }; + + #[cfg(not(target_os = "windows"))] + let is_headless = { + // No Linux/Mac, tenta ler /proc/PID/cmdline + use std::fs; + if let Ok(cmdline) = fs::read_to_string(format!("/proc/{}/cmdline", pid)) { + let cmd_lower = cmdline.to_lowercase(); + cmd_lower.contains("--headless") + || cmd_lower.contains("--remote-debugging-port") + || (cmd_lower.contains("--disable-gpu") && cmd_lower.contains("--no-sandbox")) + } else { + // Se não conseguir ler, usa heurística conservadora + name.contains("headless_shell") || name.contains("chromedriver") + } + }; + + if !is_headless { + log::debug!("Ignorando processo Chrome não-headless: PID {} ({})", pid, name); + continue; + } + + // Processo identificado como headless - pode matar com segurança + #[cfg(target_os = "windows")] + { + use std::process::Command; + match Command::new("taskkill") + .args(&["/F", "/PID", &pid.to_string()]) + .output() + { + Ok(output) => { + if output.status.success() { + killed_count += 1; + log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name); + } + } + Err(e) => { + log::warn!("Erro ao encerrar processo {}: {}", pid, e); + } + } + } + + #[cfg(not(target_os = "windows"))] + { + use std::process::Command; + match Command::new("kill") + .args(&["-9", &pid.to_string()]) + .output() + { + Ok(output) => { + if output.status.success() { + killed_count += 1; + log::info!("Processo Chrome headless encerrado: PID {} ({})", pid, name); + } + } + Err(e) => { + log::warn!("Erro ao encerrar processo {}: {}", pid, e); + } + } + } + } + + if killed_count > 0 { + log::info!("Total de {} processos Chrome headless encerrados (seguro)", killed_count); + } else { + log::info!("Nenhum processo Chrome headless encontrado para encerrar"); + } + + Ok(killed_count) } // ========== Storage Management Commands ========== @@ -1272,7 +1381,6 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result { let entries = fs::read_dir(&chats_dir) .map_err(|e| format!("Failed to read chats dir: {}", e))?; - let mut file_count = 0; for entry in entries { let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; let path = entry.path(); @@ -1289,8 +1397,6 @@ async fn export_chat_sessions(app_handle: AppHandle) -> Result { .map_err(|e| format!("Failed to add file to ZIP: {}", e))?; zip.write_all(file_content.as_bytes()) .map_err(|e| format!("Failed to write file to ZIP: {}", e))?; - - file_count += 1; } } @@ -1332,6 +1438,186 @@ fn get_app_data_dir(app_handle: AppHandle) -> Result { Ok(format!("{}", app_data_dir.display())) } +// ========== Sources Config Commands ========== + +/// Carrega a configuração de fontes de busca +#[command] +fn load_sources_config_command(app_handle: AppHandle) -> Result { + load_sources_config(&app_handle) +} + +/// Salva a configuração de fontes de busca +#[command] +fn save_sources_config_command(app_handle: AppHandle, config: SourcesConfig) -> Result<(), String> { + save_sources_config(&app_handle, config) +} + +// ========== Export & Backup Commands ========== + +/// Exporta todos os dados do app (chats, tasks, sources, settings) para um arquivo ZIP +#[command] +async fn export_all_data(app_handle: AppHandle) -> Result { + use walkdir::WalkDir; + + let app_data_dir = app_handle.path().app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + // Criar nome do arquivo com timestamp + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let zip_path = app_data_dir.join(format!("ollahub_backup_{}.zip", timestamp)); + + // Criar arquivo ZIP + let file = fs::File::create(&zip_path) + .map_err(|e| format!("Failed to create ZIP file: {}", e))?; + + let mut zip = ZipWriter::new(file); + let options = FileOptions::default() + .compression_method(CompressionMethod::Deflated) + .unix_permissions(0o755); + + // 1. Adicionar pasta chats/ inteira + let chats_dir = get_chats_dir(&app_handle)?; + if chats_dir.exists() { + for entry in WalkDir::new(&chats_dir) { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() { + // Obter caminho relativo a partir de chats_dir + let relative_path = path.strip_prefix(&chats_dir) + .map_err(|e| format!("Failed to get relative path: {}", e))?; + + // Construir caminho no ZIP como "chats/nome_arquivo.json" + let zip_path = format!("chats/{}", relative_path.to_string_lossy().replace('\\', "/")); + + let file_content = fs::read(path) + .map_err(|e| format!("Failed to read file {:?}: {}", path, e))?; + + zip.start_file(zip_path, options) + .map_err(|e| format!("Failed to add file to ZIP: {}", e))?; + zip.write_all(&file_content) + .map_err(|e| format!("Failed to write file to ZIP: {}", e))?; + } + } + } + + // 2. Adicionar tasks.json + let tasks_file = app_data_dir.join("tasks.json"); + if tasks_file.exists() { + let tasks_content = fs::read_to_string(&tasks_file) + .map_err(|e| format!("Failed to read tasks.json: {}", e))?; + + zip.start_file("tasks.json", options) + .map_err(|e| format!("Failed to add tasks.json to ZIP: {}", e))?; + zip.write_all(tasks_content.as_bytes()) + .map_err(|e| format!("Failed to write tasks.json to ZIP: {}", e))?; + } + + // 3. Adicionar sources.json + let sources_file = app_data_dir.join("sources.json"); + if sources_file.exists() { + let sources_content = fs::read_to_string(&sources_file) + .map_err(|e| format!("Failed to read sources.json: {}", e))?; + + zip.start_file("sources.json", options) + .map_err(|e| format!("Failed to add sources.json to ZIP: {}", e))?; + zip.write_all(sources_content.as_bytes()) + .map_err(|e| format!("Failed to write sources.json to ZIP: {}", e))?; + } else { + // Se não existir, criar um sources.json padrão no ZIP + let default_config = SourcesConfig::default(); + let default_json = serde_json::to_string_pretty(&default_config) + .map_err(|e| format!("Failed to serialize default sources config: {}", e))?; + + zip.start_file("sources.json", options) + .map_err(|e| format!("Failed to add default sources.json to ZIP: {}", e))?; + zip.write_all(default_json.as_bytes()) + .map_err(|e| format!("Failed to write default sources.json to ZIP: {}", e))?; + } + + // 4. Adicionar settings.json (se existir) + let settings_file = app_data_dir.join("settings.json"); + if settings_file.exists() { + let settings_content = fs::read_to_string(&settings_file) + .map_err(|e| format!("Failed to read settings.json: {}", e))?; + + zip.start_file("settings.json", options) + .map_err(|e| format!("Failed to add settings.json to ZIP: {}", e))?; + zip.write_all(settings_content.as_bytes()) + .map_err(|e| format!("Failed to write settings.json to ZIP: {}", e))?; + } + + // Finalizar ZIP + zip.finish() + .map_err(|e| format!("Failed to finalize ZIP: {}", e))?; + + log::info!("Backup completo exportado para: {}", zip_path.display()); + Ok(format!("{}", zip_path.display())) +} + +// ========== Logs Commands ========== + +/// Obtém as últimas N linhas dos logs do sistema +#[command] +fn get_recent_logs(app_handle: AppHandle, lines: usize) -> Result, String> { + // O tauri-plugin-log geralmente salva logs em app_data_dir/logs/ + let app_data_dir = app_handle.path().app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + let logs_dir = app_data_dir.join("logs"); + + // Se o diretório de logs não existir, retornar vazio + if !logs_dir.exists() { + return Ok(Vec::new()); + } + + // Procurar pelo arquivo de log mais recente + let mut log_files: Vec<_> = fs::read_dir(&logs_dir) + .map_err(|e| format!("Failed to read logs directory: {}", e))? + .filter_map(|entry| { + entry.ok().and_then(|e| { + let path = e.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("log") { + Some((path, e.metadata().ok()?.modified().ok()?)) + } else { + None + } + }) + }) + .collect(); + + // Ordenar por data de modificação (mais recente primeiro) + log_files.sort_by(|a, b| b.1.cmp(&a.1)); + + // Ler o arquivo mais recente + if let Some((log_file_path, _)) = log_files.first() { + let content = fs::read_to_string(log_file_path) + .map_err(|e| format!("Failed to read log file: {}", e))?; + + // Dividir em linhas e pegar as últimas N + let all_lines: Vec = content.lines().map(|s| s.to_string()).collect(); + let total_lines = all_lines.len(); + let start = if total_lines > lines { total_lines - lines } else { 0 }; + + Ok(all_lines[start..].to_vec()) + } else { + Ok(Vec::new()) + } +} + +// ========== System Monitor Commands ========== + +/// Obtém estatísticas do sistema em tempo real +#[command] +fn get_system_stats( + monitor_state: State<'_, Arc>>, +) -> Result { + let mut monitor = monitor_state.lock() + .map_err(|e| format!("Failed to lock monitor state: {}", e))?; + + Ok(monitor.get_stats()) +} + // ========== Task Scheduler Commands ========== #[command] @@ -1447,7 +1733,8 @@ pub fn run() { let scheduler_clone = scheduler_state.clone(); // BrowserState não é mais necessário - o scheduler criará o browser quando necessário - tokio::spawn(async move { + // Usar o runtime async do Tauri ao invés de tokio::spawn + tauri::async_runtime::spawn(async move { if let Err(e) = scheduler_loop::start_scheduler_loop( app_handle, scheduler_clone, @@ -1461,6 +1748,10 @@ pub fn run() { // Adicionar scheduler ao manage app.manage(scheduler_state.clone()); + // Inicializar System Monitor State + let monitor_state: Arc> = Arc::new(Mutex::new(SystemMonitorState::new())); + app.manage(monitor_state); + Ok(()) }) .manage(Arc::new(Mutex::new(None::>)) as BrowserState) @@ -1495,9 +1786,15 @@ pub fn run() { search_and_extract_content, extract_url_content, reset_browser, + force_kill_browser, export_chat_sessions, + export_all_data, clear_chat_history, get_app_data_dir, + load_sources_config_command, + save_sources_config_command, + get_recent_logs, + get_system_stats, create_task, list_tasks, update_task, diff --git a/src-tauri/src/ollama_client.rs b/src-tauri/src/ollama_client.rs index 082809b..2e880f3 100644 --- a/src-tauri/src/ollama_client.rs +++ b/src-tauri/src/ollama_client.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::collections::HashMap; /// Mensagem para o Ollama API #[derive(Debug, Serialize)] @@ -97,7 +96,7 @@ impl OllamaClient { }; let url = format!("{}/api/chat", self.base_url); - let mut response = self.client + let response = self.client .post(&url) .json(&request) .send() diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 2b73f1c..3b09a8e 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -6,7 +6,6 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use tauri::{AppHandle, Manager}; -use uuid::Uuid; /// Tipos de ações que uma task pode executar #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -49,7 +48,8 @@ pub type SchedulerState = Arc>; pub struct SchedulerService { tasks: HashMap, tasks_file: PathBuf, - app_handle: Option, + #[allow(dead_code)] + app_handle: Option, // Mantido para uso futuro (notificações, etc) } impl SchedulerService { diff --git a/src-tauri/src/scheduler_loop.rs b/src-tauri/src/scheduler_loop.rs index 20ac83c..a16b870 100644 --- a/src-tauri/src/scheduler_loop.rs +++ b/src-tauri/src/scheduler_loop.rs @@ -1,4 +1,4 @@ -use crate::scheduler::{SchedulerService, SchedulerState}; +use crate::scheduler::SchedulerState; use crate::task_executor::execute_task; use tokio_cron_scheduler::{Job, JobScheduler}; use std::sync::Arc; diff --git a/src-tauri/src/sources_config.rs b/src-tauri/src/sources_config.rs new file mode 100644 index 0000000..b5102ae --- /dev/null +++ b/src-tauri/src/sources_config.rs @@ -0,0 +1,154 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; +use chrono::Utc; + +/// Categoria de fonte de busca +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SourceCategory { + pub id: String, + pub name: String, + pub base_sites: Vec, + pub enabled: bool, +} + +/// Configuração completa de fontes de busca +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SourcesConfig { + #[serde(default = "default_version")] + pub version: u32, + pub categories: Vec, + #[serde(default = "default_last_updated")] + pub last_updated: String, +} + +fn default_version() -> u32 { + 1 +} + +fn default_last_updated() -> String { + Utc::now().to_rfc3339() +} + +impl Default for SourcesConfig { + fn default() -> Self { + Self { + version: 1, + last_updated: Utc::now().to_rfc3339(), + categories: vec![ + SourceCategory { + id: "academico".to_string(), + name: "Acadêmico".to_string(), + base_sites: vec![ + "scholar.google.com".to_string(), + "arxiv.org".to_string(), + "pubmed.ncbi.nlm.nih.gov".to_string(), + "ieee.org".to_string(), + "acm.org".to_string(), + ], + enabled: true, + }, + SourceCategory { + id: "tech".to_string(), + name: "Tech".to_string(), + base_sites: vec![ + "github.com".to_string(), + "stackoverflow.com".to_string(), + "dev.to".to_string(), + "medium.com".to_string(), + "reddit.com/r/programming".to_string(), + ], + enabled: true, + }, + SourceCategory { + id: "news".to_string(), + name: "News".to_string(), + base_sites: vec![ + "news.ycombinator.com".to_string(), + "techcrunch.com".to_string(), + "theverge.com".to_string(), + "arstechnica.com".to_string(), + ], + enabled: true, + }, + SourceCategory { + id: "financeiro".to_string(), + name: "Financeiro".to_string(), + base_sites: vec![ + "bloomberg.com".to_string(), + "reuters.com".to_string(), + "financialtimes.com".to_string(), + "wsj.com".to_string(), + ], + enabled: true, + }, + ], + } + } +} + +/// Helper para obter o caminho do arquivo sources.json +pub fn get_sources_config_path(app_handle: &AppHandle) -> Result { + let app_data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + Ok(app_data_dir.join("sources.json")) +} + +/// Carrega a configuração de fontes do arquivo +/// Se o arquivo não existir, retorna uma configuração padrão robusta +pub fn load_sources_config(app_handle: &AppHandle) -> Result { + let config_path = get_sources_config_path(app_handle)?; + + // Se o arquivo não existir, retornar Default + if !config_path.exists() { + log::info!("sources.json não encontrado, usando configuração padrão"); + return Ok(SourcesConfig::default()); + } + + // Tentar ler e parsear o arquivo + let content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read sources.json: {}", e))?; + + let config: SourcesConfig = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse sources.json: {}", e))?; + + Ok(config) +} + +/// Salva a configuração de fontes no arquivo +pub fn save_sources_config(app_handle: &AppHandle, config: SourcesConfig) -> Result<(), String> { + let config_path = get_sources_config_path(app_handle)?; + + // Garantir que o diretório pai existe + if let Some(parent) = config_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + } + + // Atualizar timestamp + let mut config_to_save = config; + config_to_save.last_updated = Utc::now().to_rfc3339(); + + // Serializar e escrever + let json = serde_json::to_string_pretty(&config_to_save) + .map_err(|e| format!("Failed to serialize sources config: {}", e))?; + + // Escrever em arquivo temporário primeiro, depois renomear (atomic write) + let temp_path = config_path.with_extension("json.tmp"); + fs::write(&temp_path, json) + .map_err(|e| format!("Failed to write temp sources config file: {}", e))?; + + // Renomear atomicamente + fs::rename(&temp_path, &config_path) + .map_err(|e| format!("Failed to rename temp file to sources.json: {}", e))?; + + log::info!("Sources config salvo com sucesso em {:?}", config_path); + Ok(()) +} + diff --git a/src-tauri/src/system_monitor.rs b/src-tauri/src/system_monitor.rs new file mode 100644 index 0000000..bf23d2f --- /dev/null +++ b/src-tauri/src/system_monitor.rs @@ -0,0 +1,153 @@ +use serde::Serialize; +use sysinfo::System; + +/// Estatísticas do sistema em tempo real +#[derive(Serialize, Clone, Debug)] +pub struct SystemStats { + pub cpu_usage: f32, + pub ram_used: u64, + pub ram_total: u64, + pub ram_percent: f32, + pub gpu_name: Option, + pub uptime: u64, + pub processes_count: usize, + pub cpu_name: String, +} + +/// Estado persistente do sistema para cálculo de CPU +pub struct SystemMonitorState { + system: System, + last_cpu_check: std::time::Instant, +} + +impl SystemMonitorState { + pub fn new() -> Self { + let mut system = System::new_all(); + system.refresh_all(); + + Self { + system, + last_cpu_check: std::time::Instant::now(), + } + } + + pub fn get_stats(&mut self) -> SystemStats { + // Refresh system info + self.system.refresh_all(); + + // Refresh CPU para cálculo preciso + self.system.refresh_cpu_all(); + + // Pequeno delay para cálculo preciso de CPU + std::thread::sleep(std::time::Duration::from_millis(100)); + self.system.refresh_cpu_all(); + + // CPU usage global + let cpu_usage = self.system.global_cpu_usage(); + + // RAM + let ram_total = self.system.total_memory(); + let ram_used = self.system.used_memory(); + let ram_percent = if ram_total > 0 { + (ram_used as f32 / ram_total as f32) * 100.0 + } else { + 0.0 + }; + + // CPU Name + let cpu_name = self.system + .cpus() + .first() + .map(|cpu| cpu.name().to_string()) + .unwrap_or_else(|| "Unknown CPU".to_string()); + + // GPU Name (tentativa básica - sysinfo não tem suporte direto) + let gpu_name = detect_gpu_name(); + + // Uptime do sistema (em segundos desde o boot) + let boot_time = System::boot_time(); + let uptime = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(boot_time); + + // Contagem de processos + let processes_count = self.system.processes().len(); + + SystemStats { + cpu_usage, + ram_used, + ram_total, + ram_percent, + gpu_name, + uptime, + processes_count, + cpu_name, + } + } +} + +/// Tenta detectar o nome da GPU (implementação básica) +fn detect_gpu_name() -> Option { + #[cfg(target_os = "windows")] + { + use std::process::Command; + + // Tenta usar wmic para detectar GPU no Windows + if let Ok(output) = Command::new("wmic") + .args(&["path", "win32_VideoController", "get", "name"]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + for line in stdout.lines() { + let line = line.trim(); + if !line.is_empty() && line != "Name" { + return Some(line.to_string()); + } + } + } + } + } + + #[cfg(target_os = "linux")] + { + use std::process::Command; + + // Tenta usar lspci no Linux + if let Ok(output) = Command::new("lspci") + .args(&["-v", "-s", "$(lspci | grep VGA | cut -d' ' -f1)"]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + for line in stdout.lines() { + if line.contains("VGA") || line.contains("Display") { + return Some(line.to_string()); + } + } + } + } + } + + #[cfg(target_os = "macos")] + { + use std::process::Command; + + // Tenta usar system_profiler no macOS + if let Ok(output) = Command::new("system_profiler") + .args(&["SPDisplaysDataType"]) + .output() + { + if let Ok(stdout) = String::from_utf8(output.stdout) { + for line in stdout.lines() { + if line.contains("Chipset Model") { + return Some(line.replace("Chipset Model:", "").trim().to_string()); + } + } + } + } + } + + None +} + diff --git a/src-tauri/src/task_executor.rs b/src-tauri/src/task_executor.rs index 8eccfb9..d49d613 100644 --- a/src-tauri/src/task_executor.rs +++ b/src-tauri/src/task_executor.rs @@ -103,7 +103,6 @@ async fn execute_search_and_summarize( // 5. Salvar como sessão de chat let session_id = uuid::Uuid::new_v4().to_string(); - let now = Utc::now(); let messages = vec![ Message { @@ -176,7 +175,6 @@ fn save_task_session_internal( ) -> Result<(), String> { let chats_dir = get_chats_dir(app_handle)?; let file_path = chats_dir.join(format!("{}.json", session_id)); - let file_path = chats_dir.join(format!("{}.json", session_id)); let now = Utc::now(); diff --git a/store/settings-store.ts b/store/settings-store.ts index 55edf82..008cd25 100644 --- a/store/settings-store.ts +++ b/store/settings-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { SourcesConfig } from '@/lib/types'; export interface SearchCategory { id: string; @@ -27,6 +28,18 @@ export interface SettingsState { userCustomSites: string[]; }; + // Sources Config (from Rust backend) + sourcesConfig: SourcesConfig | null; + + // AutoStart + autoStart: boolean; + + // Notifications + notifications: { + enabled: boolean; + keywords: string[]; + }; + // Actions setOllamaUrl: (url: string) => void; setSelectedModel: (model: string) => void; @@ -44,6 +57,14 @@ export interface SettingsState { addCustomSite: (site: string) => void; removeCustomSite: (site: string) => void; resetSettings: () => void; + + // New Actions for Sources & AutoStart + fetchSources: () => Promise; + saveSources: (config: SourcesConfig) => Promise; + toggleAutoStart: () => Promise; + setNotificationsEnabled: (enabled: boolean) => void; + addNotificationKeyword: (keyword: string) => void; + removeNotificationKeyword: (keyword: string) => void; } const defaultSystemPrompt = `Você é um assistente de IA local integrado ao OllaHub. @@ -59,7 +80,7 @@ const defaultSystemPrompt = `Você é um assistente de IA local integrado ao Oll ## CONTEXTO WEB - Quando informações da web forem fornecidas, use-as como fonte principal. -- Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web. +- NÃO cite fontes no meio do texto. Use as informações do contexto web naturalmente, sem mencionar [1], [2], [3] ou outras referências numéricas. - Se o contexto não for suficiente, diga isso claramente. ## FORMATO SUGERIDO @@ -142,6 +163,12 @@ const initialState = { categories: defaultCategories, userCustomSites: [], }, + sourcesConfig: null, + autoStart: false, + notifications: { + enabled: true, + keywords: [], + }, }; export const useSettingsStore = create()( @@ -232,6 +259,59 @@ export const useSettingsStore = create()( }, })), resetSettings: () => set(initialState), + + // Sources Config Actions + fetchSources: async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const config = await invoke('load_sources_config_command'); + set({ sourcesConfig: config }); + } catch (error) { + console.error('Failed to fetch sources config:', error); + } + }, + saveSources: async (config: SourcesConfig) => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('save_sources_config_command', { config }); + set({ sourcesConfig: config }); + } catch (error) { + console.error('Failed to save sources config:', error); + throw error; + } + }, + + // AutoStart Actions + toggleAutoStart: async () => { + // TODO: Implementar quando tauri-plugin-autostart estiver configurado + set((state) => ({ autoStart: !state.autoStart })); + }, + + // Notifications Actions + setNotificationsEnabled: (enabled) => + set((state) => ({ + notifications: { ...state.notifications, enabled }, + })), + addNotificationKeyword: (keyword) => + set((state) => { + const normalized = keyword.toLowerCase().trim(); + if (!normalized || state.notifications.keywords.includes(normalized)) { + return state; + } + return { + notifications: { + ...state.notifications, + keywords: [...state.notifications.keywords, normalized], + }, + }; + }), + removeNotificationKeyword: (keyword) => + set((state) => ({ + notifications: { + ...state.notifications, + keywords: state.notifications.keywords.filter((k) => k !== keyword), + }, + })), }), { name: 'ollahub-settings',