feat: update dependencies and enhance chat functionality

- Added new Radix UI components including checkbox, label, popover, slider, switch, and tooltip to improve UI interactions.
- Integrated react-syntax-highlighter for better code rendering in chat messages.
- Updated package.json and pnpm-lock.yaml to include new dependencies and ensure compatibility.
- Enhanced chat input to support web search toggling, improving user experience during message composition.
- Implemented auto-saving of chat sessions and improved session management features.
- Refactored chat message rendering to clean up metadata and improve display quality.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-20 04:26:35 -03:00
1 parent 032722e21b
commit 8834ef2b4f
55 files changed
+20030 -159

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
# Guia de Implementação: System Tray, Background Mode e Task Scheduler
## Status da Implementação
### ✅ Concluído
1. **Estrutura de Tasks** (`src-tauri/src/scheduler.rs`)
- `SentinelTask` com suporte a múltiplos tipos de ações
- Persistência em `tasks.json`
- Gerenciamento de estado thread-safe
2. **Cliente Ollama Headless** (`src-tauri/src/ollama_client.rs`)
- Comunicação direta com Ollama via `reqwest`
- Suporte a streaming
- Verificação de conexão
3. **Executor de Tasks** (`src-tauri/src/task_executor.rs`)
- Execução de `SearchAndSummarize`
- Execução de `JustPing`
- Execução de `CustomPrompt`
- Integração com file locks para escrita segura
4. **Scheduler Loop** (`src-tauri/src/scheduler_loop.rs`)
- Integração com `tokio-cron-scheduler`
- Recarregamento dinâmico de tasks
- Execução assíncrona em background
### 🔨 Pendente (Próximos Passos)
#### 1. Comandos Tauri para CRUD de Tasks
Adicione ao `src-tauri/src/lib.rs`:
```rust
use scheduler::{SchedulerService, SchedulerState, SentinelTask, TaskAction};
use chrono::Utc;
use uuid::Uuid;
#[command]
async fn create_task(
scheduler: State<'_, SchedulerState>,
label: String,
cron_schedule: String,
action: TaskAction,
) -> Result<String, String> {
let task = SentinelTask {
id: Uuid::new_v4().to_string(),
label,
cron_schedule,
action,
enabled: true,
last_run: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let mut sched = scheduler.lock().await;
sched.upsert_task(task.clone())?;
Ok(task.id)
}
#[command]
async fn list_tasks(
scheduler: State<'_, SchedulerState>,
) -> Result<Vec<SentinelTask>, String> {
let sched = scheduler.lock().await;
Ok(sched.list_tasks())
}
#[command]
async fn update_task(
scheduler: State<'_, SchedulerState>,
task: SentinelTask,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
let mut updated = task;
updated.updated_at = Utc::now();
sched.upsert_task(updated)
}
#[command]
async fn delete_task(
scheduler: State<'_, SchedulerState>,
id: String,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
sched.remove_task(&id)
}
#[command]
async fn toggle_task(
scheduler: State<'_, SchedulerState>,
id: String,
enabled: bool,
) -> Result<(), String> {
let mut sched = scheduler.lock().await;
if let Some(mut task) = sched.get_task(&id).cloned() {
task.enabled = enabled;
task.updated_at = Utc::now();
sched.upsert_task(task)
} else {
Err("Task not found".to_string())
}
}
```
#### 2. System Tray Setup
No `src-tauri/src/lib.rs`, modifique o `.setup()`:
```rust
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem};
.setup(|app| {
// ... código existente ...
// Criar System Tray
let tray_menu = SystemTrayMenu::new()
.add_item(SystemTrayMenuItem::with_id("show", "Mostrar OllaHub", true, None))
.add_item(SystemTrayMenuItem::with_id("pause", "Pausar Tasks", true, None))
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(SystemTrayMenuItem::with_id("quit", "Sair", true, None));
let system_tray = SystemTray::new().with_menu(tray_menu);
app.handle().system_tray(system_tray)?;
// Handler de eventos do Tray
app.handle().on_system_tray_event(|app, event| {
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
match id.as_str() {
"show" => {
if let Some(window) = app.get_window("main") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
"pause" => {
// Implementar lógica de pausar tasks
log::info!("Pausar tasks - implementar");
}
"quit" => {
app.exit(0);
}
_ => {}
}
}
});
// Modificar comportamento de fechar janela
if let Some(window) = app.get_window("main") {
window.on_window_event(|event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Ocultar ao invés de fechar
if let Some(window) = event.window() {
window.hide().unwrap();
}
api.prevent_close();
}
});
}
Ok(())
})
```
#### 3. Inicializar Scheduler no Setup
No mesmo `.setup()`, após criar o scheduler:
```rust
// Inicializar scheduler
let scheduler_service = SchedulerService::new(app.handle().clone())?;
let scheduler_state: SchedulerState = Arc::new(Mutex::new(scheduler_service));
let browser_state: BrowserState = app.state();
// Iniciar loop do scheduler em background
let app_handle = app.handle().clone();
let scheduler_clone = scheduler_state.clone();
let browser_clone = browser_state.clone();
tokio::spawn(async move {
if let Err(e) = scheduler_loop::start_scheduler_loop(
app_handle,
scheduler_clone,
browser_clone,
None, // Ollama URL - pode vir do settings
).await {
log::error!("Erro ao iniciar scheduler: {}", e);
}
});
// Adicionar ao manage
.manage(scheduler_state)
```
#### 4. Adicionar Comandos ao Invoke Handler
```rust
.invoke_handler(tauri::generate_handler![
// ... comandos existentes ...
create_task,
list_tasks,
update_task,
delete_task,
toggle_task,
])
```
#### 5. Interface React (app/tasks/page.tsx)
Crie uma página básica para gerenciar tasks:
```tsx
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Task {
id: string;
label: string;
cron_schedule: string;
action: any;
enabled: boolean;
last_run: string | null;
}
export default function TasksPage() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
const loaded = await invoke<Task[]>('list_tasks');
setTasks(loaded);
} catch (error) {
console.error('Erro ao carregar tasks:', error);
}
};
const toggleTask = async (id: string, enabled: boolean) => {
try {
await invoke('toggle_task', { id, enabled: !enabled });
await loadTasks();
} catch (error) {
console.error('Erro ao alternar task:', error);
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Tarefas Agendadas</h1>
<div className="space-y-4">
{tasks.map(task => (
<Card key={task.id}>
<CardHeader>
<CardTitle>{task.label}</CardTitle>
</CardHeader>
<CardContent>
<p>Cron: {task.cron_schedule}</p>
<p>Status: {task.enabled ? 'Ativa' : 'Pausada'}</p>
<Button onClick={() => toggleTask(task.id, task.enabled)}>
{task.enabled ? 'Pausar' : 'Ativar'}
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
}
```
## Dependências Adicionais Necessárias
No `Cargo.toml`, já foram adicionadas:
- `tokio-cron-scheduler = "0.9"`
- `futures-util = "0.3"`
- `tauri-plugin-notification = "2"`
**Nota para Linux:** Para System Tray funcionar, pode ser necessário:
```bash
# Ubuntu/Debian
sudo apt-get install libappindicator3-1
# Arch/Manjaro
sudo pacman -S libappindicator-gtk3
```
## Próximos Passos
1. Compilar e testar: `cd src-tauri && cargo build`
2. Corrigir erros de compilação
3. Testar criação de task via frontend
4. Verificar se scheduler executa corretamente
5. Testar System Tray em diferentes plataformas
## Notas Importantes
- **File Locking**: Já implementado - tasks usam o mesmo sistema de locks que salvar sessões
- **Concorrência**: Scheduler roda em thread separada, não bloqueia UI
- **Persistência**: Tasks são salvas em `app_data_dir/tasks.json`
- **Notificações**: Usa `tauri-plugin-notification` para notificações nativas
+391 -49
View File
@@ -5,30 +5,63 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2 } from "lucide-react";
import { ChatInput } from "@/components/chat/chat-input"; import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message"; import { ChatMessage } from "@/components/chat/chat-message";
import { SystemPanel } from "@/components/chat/system-panel"; import { SystemPanel } from "@/components/chat/system-panel";
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator"; import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
import { SidebarList } from "@/components/chat/sidebar-list";
import { useChat } from "@/hooks/use-chat"; import { useChat } from "@/hooks/use-chat";
import { useLocalModels } from "@/hooks/use-local-models"; import { useLocalModels } from "@/hooks/use-local-models";
import { useState, useEffect, useRef } from "react"; import { useChatStorage } from "@/hooks/use-chat-storage";
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
import { useWebSearch } from "@/hooks/use-web-search";
import { SearchProgress } from "@/components/chat/search-progress";
import { ThinkingIndicator, ThinkingStep } from "@/components/chat/thinking-indicator";
import { useQueryGenerator } from "@/hooks/use-query-generator";
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 { useTheme } from "next-themes";
import { ImperativePanelHandle } from "react-resizable-panels"; import { ImperativePanelHandle } from "react-resizable-panels";
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
// @ts-ignore // @ts-ignore
import defaultFormatPrompt from "@/data/prompts/default-format.md"; import defaultFormatPrompt from "@/data/prompts/default-format.md";
export default function ChatPage() { export default function ChatPage() {
const { messages, sendMessage, isLoading, stop } = useChat(); const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
const { models } = useLocalModels(); const { models } = useLocalModels();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const {
sessions,
currentSessionId,
setCurrentSessionId,
loadSessionHistory,
loadSessions,
saveSession,
deleteSession,
isGeneratingTitle
} = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel();
const webSearch = useWebSearch();
const { generateQuery, isGenerating: isGeneratingQuery } = useQueryGenerator();
const settings = useSettingsStore();
const [selectedModel, setSelectedModel] = useState(""); const [selectedModel, setSelectedModel] = useState("");
// Initialize with default format prompt // Initialize with default format prompt
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo."); const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
const [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
const sidebarRef = useRef<ImperativePanelHandle>(null); const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-select first model // Auto-select first model
useEffect(() => { useEffect(() => {
@@ -37,15 +70,226 @@ export default function ChatPage() {
} }
}, [models, selectedModel]); }, [models, selectedModel]);
const handleSend = (content: string) => { // Auto-scroll para o final durante streaming
useEffect(() => {
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
// Pequeno delay para garantir que o DOM foi atualizado
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [messages, isLoading]);
// Save session when loading finishes (with debounce to avoid race conditions)
useEffect(() => {
// Clear any pending save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Only save if:
// 1. Not currently loading
// 2. We have a session ID
// 3. We have messages
// 4. Messages actually changed (by count or reference)
if (!isLoading && currentSessionId && messages.length > 0) {
// Check if messages actually changed (simple length check, could be improved)
const currentMessageCount = messages.length;
if (currentMessageCount !== lastSavedMessagesRef.current) {
// Small delay to ensure metadata processing is complete
saveTimeoutRef.current = setTimeout(() => {
lastSavedMessagesRef.current = currentMessageCount;
saveSession(currentSessionId, messages);
}, 100);
}
}
// Cleanup timeout on unmount or dependency change
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [isLoading, currentSessionId, messages, saveSession]);
const handleSend = async (content: string) => {
if (!selectedModel) return; if (!selectedModel) return;
sendMessage(content, selectedModel, systemPrompt);
// Generate ID if new session (but don't create card yet - wait for title)
if (!currentSessionId) {
const newId = crypto.randomUUID();
setCurrentSessionId(newId);
// Card will be created after title is generated (after first AI response)
}
// ========== LÓGICA DE AGENTE DE PESQUISA ==========
let webContext = '';
let searchQuery = '';
let scrapedSources: any[] = [];
// Iniciar indicador de pensamento
setThinkingStep('analyzing');
if (webSearch.isEnabled) {
try {
webSearch.reset(); // Resetar estado anterior
// Passo 1: Gerar query otimizada
setThinkingStep('analyzing');
searchQuery = await generateQuery(content, selectedModel);
// Passo 2: Se precisar de busca, executar
if (searchQuery && searchQuery !== 'NO_SEARCH') {
setThinkingStep('searching');
// Buscar fontes usando configurações do store
const maxResults = settings.webSearch.maxResults;
const excludedDomains = settings.webSearch.excludedDomains;
scrapedSources = await webSearch.search(searchQuery, maxResults, excludedDomains);
// Atualizar indicador enquanto lê fontes
setThinkingStep('reading');
if (scrapedSources.length > 0) {
// Sanitizar e combinar markdown de todas as fontes
// Priorizamos o markdown completo (não apenas snippets) para garantir densidade de informação
// O scraper Rust já filtra conteúdo muito curto (< 200 chars) automaticamente
// Sanitização previne prompt injection e limita tamanho
webContext = sanitizeWebSources(scrapedSources, {
maxLength: 8000, // ~8k chars por fonte (ajustável)
removeControlChars: true,
removeHiddenText: true,
});
// Log para debug: verificar tamanho do contexto gerado
const contextLength = webContext.length;
console.debug(`Contexto web gerado (sanitizado): ${contextLength} caracteres de ${scrapedSources.length} fontes`);
}
}
} catch (error) {
console.error('Erro na pesquisa web:', error);
// Continuar mesmo se a pesquisa falhar
}
}
// Passo 3: Formular resposta
setThinkingStep('formulating');
// ========== MONTAR SYSTEM PROMPT COM CONTEXTO TEMPORAL ==========
// Usar timezone do sistema do usuário (não fixo)
const now = new Date();
const currentDateTime = now.toLocaleString('pt-BR', {
dateStyle: 'full',
timeStyle: 'short',
// Não especificar timeZone - usar o do sistema
});
// Formato adicional mais explícito para garantir que a IA entenda
const currentDateISO = now.toISOString();
const currentDateExplicit = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
let enhancedSystemPrompt = systemPrompt;
// Adicionar contexto temporal e web
if (webContext) {
enhancedSystemPrompt = `${systemPrompt}
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
**DATA/HORA FORMATADA:** ${currentDateTime}
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
**ISO 8601:** ${currentDateISO}
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}. Não use "amanhã" ou "ontem" a menos que seja explicitamente solicitado pelo usuário.
## INSTRUÇÕES ESPECÍFICAS PARA RESPOSTAS COM CONTEXTO WEB
1. **Contextualização Temporal Obrigatória:**
- Inicie respostas sobre notícias ou eventos citando a data atual EXATA fornecida acima.
- Use EXATAMENTE: "${currentDateTime}" (não invente outra data).
- Exemplo: "Com base nas notícias de ${currentDateTime}, os principais destaques são..."
2. **Densidade e Profundidade:**
- NUNCA responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
- Cada ponto deve conter detalhes específicos: Números, Nomes Próprios, Locais e Citações Diretas do contexto.
- Explique o *contexto* da notícia, não apenas a manchete.
- Prefira parágrafos explicativos com "quem, quando, onde, porquê".
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.
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
4. **Formato Jornalístico:**
- Use títulos Markdown (##) para separar seções temáticas.
- Texto corrido para explicações detalhadas.
- Bullets apenas para listar dados brutos (números, estatísticas).
## CONTEXTO WEB RECUPERADO
${webContext}
---
`;
} else {
// Mesmo sem contexto web, adicionar data/hora e instruções temporais
enhancedSystemPrompt = `${systemPrompt}
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
**DATA/HORA FORMATADA:** ${currentDateTime}
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
**ISO 8601:** ${currentDateISO}
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}.
Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente a data EXATA fornecida acima (${currentDateTime}). Forneça detalhes ricos (nomes, valores, locais) extraídos do seu conhecimento. Evite resumos de uma linha - prefira explicações detalhadas e contextualizadas.
---
`;
}
await sendMessage(content, selectedModel, enhancedSystemPrompt);
// Marcar como completo quando começar a streamar
setTimeout(() => setThinkingStep('complete'), 100);
}; };
const toggleSidebar = () => { const handleSelectSession = async (id: string) => {
const panel = sidebarRef.current; if (id === currentSessionId) return;
// Clear any pending saves
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
const history = await loadSessionHistory(id);
setMessages(history);
setCurrentSessionId(id);
lastSavedMessagesRef.current = history.length;
};
const handleNewChat = () => {
stop();
clearChat();
setCurrentSessionId(null);
// Reset save tracking
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
lastSavedMessagesRef.current = 0;
};
const toggleChatsSidebar = () => {
const panel = chatsSidebarRef.current;
if (panel) { if (panel) {
if (isSidebarCollapsed) { if (isChatsSidebarCollapsed) {
panel.expand(); panel.expand();
} else { } else {
panel.collapse(); panel.collapse();
@@ -59,15 +303,10 @@ export default function ChatPage() {
<ResizablePanelGroup direction="horizontal" className="flex-1"> <ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel <ResizablePanel
ref={sidebarRef}
defaultSize={4} defaultSize={4}
minSize={4} minSize={4}
maxSize={15} maxSize={4}
collapsible={true} className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]"
collapsedSize={0}
onCollapse={() => setIsSidebarCollapsed(true)}
onExpand={() => setIsSidebarCollapsed(false)}
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 min-w-[60px]"
> >
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary"> <Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
<MessageSquare className="w-5 h-5" /> <MessageSquare className="w-5 h-5" />
@@ -91,23 +330,54 @@ export default function ChatPage() {
<ResizableHandle /> <ResizableHandle />
{/* Sessions List */}
<ResizablePanel
ref={chatsSidebarRef}
defaultSize={15}
minSize={10}
maxSize={25}
collapsible={true}
collapsedSize={0}
onCollapse={() => setIsChatsSidebarCollapsed(true)}
onExpand={() => setIsChatsSidebarCollapsed(false)}
className="min-w-[200px]"
>
<SidebarList
sessions={sessions}
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
onDeleteSession={deleteSession}
onNewChat={handleNewChat}
/>
</ResizablePanel>
<ResizableHandle />
{/* Main Chat Area */} {/* Main Chat Area */}
<ResizablePanel defaultSize={75} minSize={50}> <ResizablePanel defaultSize={60} minSize={40}>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4"> <div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isSidebarCollapsed && ( {isChatsSidebarCollapsed ? (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8"> <Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
<PanelLeftOpen className="w-4 h-4" /> <PanelLeftOpen className="w-4 h-4" />
</Button> </Button>
)} ) : (
{!isSidebarCollapsed && ( <Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8 text-muted-foreground">
<PanelLeftClose className="w-4 h-4" /> <PanelLeftClose className="w-4 h-4" />
</Button> </Button>
)} )}
<div className="font-semibold">Chat</div> <div className="font-semibold flex items-center gap-2">
Chat
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
{isDownloading && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" />
{progress}
</span>
)}
</div>
</div> </div>
<div className="flex-1 max-w-[300px]"> <div className="flex-1 max-w-[300px]">
@@ -134,24 +404,93 @@ export default function ChatPage() {
<p>Inicie uma conversa com {selectedModel}</p> <p>Inicie uma conversa com {selectedModel}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col pb-4"> <div className="flex flex-col pb-4 max-w-4xl mx-auto w-full">
{messages.map((msg, i) => ( {messages.map((msg, i) => {
<ChatMessage key={i} message={msg} /> // Verificar se a mensagem anterior teve busca web
))} const hasWebSources = i > 0 &&
{isLoading && ( messages[i - 1].role === 'user' &&
webSearch.scrapedSources.length > 0 &&
webSearch.status === 'completed';
return (
<div key={i}>
<ChatMessage message={msg} />
{/* Mostrar fontes após resposta do assistente que usou web search */}
{msg.role === 'assistant' && hasWebSources && (
<div className="px-6 pb-4">
<div className="pt-2 border-t border-muted">
<p className="text-xs font-medium text-muted-foreground mb-2">
Fontes consultadas:
</p>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{webSearch.scrapedSources.map((source, idx) => (
<a
key={idx}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-muted/50 hover:bg-muted/80 transition-colors border border-muted text-xs max-w-[200px]"
>
<img
src={`https://www.google.com/s2/favicons?domain=${new URL(source.url).hostname}&sz=16`}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<span className="truncate font-medium">
{source.title || `Fonte ${idx + 1}`}
</span>
</a>
))}
</div>
</div>
</div>
)}
</div>
);
})}
{/* Web Search Progress - mostrar antes da resposta da IA */}
{(webSearch.status === 'searching' ||
webSearch.status === 'scraping' ||
webSearch.status === 'completed' ||
webSearch.status === 'error') && (
<SearchProgress
status={webSearch.status}
query={webSearch.currentQuery}
sources={webSearch.scrapedSources}
error={webSearch.error}
/>
)}
{isLoading && thinkingStep === 'complete' && (
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse"> <div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
Gerando resposta... Gerando resposta...
</div> </div>
)} )}
</div> </div>
)} )}
{/* Elemento invisível para scroll automático */}
<div ref={messagesEndRef} className="h-1" />
</div> </div>
{/* Thinking Indicator */}
{thinkingStep && thinkingStep !== 'complete' && (
<ThinkingIndicator
currentStep={thinkingStep}
searchQuery={webSearch.currentQuery}
sourcesRead={webSearch.scrapedSources.length}
totalSources={5}
/>
)}
{/* Input */} {/* Input */}
<ChatInput <ChatInput
onSend={handleSend} onSend={handleSend}
onStop={stop} onStop={stop}
isLoading={isLoading} isLoading={isLoading}
webSearchEnabled={webSearch.isEnabled}
onWebSearchToggle={webSearch.setEnabled}
/> />
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -159,8 +498,8 @@ export default function ChatPage() {
<ResizableHandle withHandle /> <ResizableHandle withHandle />
{/* Right Sidebar (Settings & Monitor) */} {/* Right Sidebar (Settings & Monitor) */}
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="bg-muted/10"> <ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="bg-muted/10">
<Tabs defaultValue="params" className="h-full flex flex-col"> <Tabs defaultValue="params" className="h-full flex flex-col overflow-hidden">
<div className="border-b px-4"> <div className="border-b px-4">
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4"> <TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
<TabsTrigger <TabsTrigger
@@ -178,28 +517,31 @@ export default function ChatPage() {
</TabsList> </TabsList>
</div> </div>
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto m-0"> <TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto overflow-x-hidden m-0">
<div className="space-y-2"> <div className="space-y-6">
<div className="flex items-center justify-between"> {/* System Prompt */}
<label className="text-sm font-medium">System Prompt</label> <div className="space-y-2">
<PromptGeneratorDialog <div className="flex items-center justify-between">
defaultModel={selectedModel} <label className="text-sm font-medium">System Prompt</label>
onPromptGenerated={setSystemPrompt} <PromptGeneratorDialog
defaultModel={selectedModel}
onPromptGenerated={setSystemPrompt}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/> />
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div> </div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/>
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="system" className="flex-1 m-0 overflow-hidden"> <TabsContent value="system" className="flex-1 m-0 overflow-y-auto overflow-x-hidden">
<SystemPanel /> <SystemPanel />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+638
View File
@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useSettingsStore } from '@/store/settings-store';
import { DomainTagsInput } from '@/components/settings/domain-tags-input';
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 { useToast } from '@/hooks/use-toast';
interface Model {
name: string;
modified_at: string;
size: number;
}
export default function SettingsPage() {
const settings = useSettingsStore();
const { toast } = useToast();
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [availableModels, setAvailableModels] = useState<Model[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [storagePath, setStoragePath] = useState<string>('');
const [showClearDialog, setShowClearDialog] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isClearing, setIsClearing] = useState(false);
// Load models and storage path on mount
useEffect(() => {
loadModels();
loadStoragePath();
}, [settings.ollamaUrl]);
const loadModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
setAvailableModels(data.models || []);
} else {
setAvailableModels([]);
}
} catch (error) {
console.error('Failed to load models:', error);
setAvailableModels([]);
} finally {
setIsLoadingModels(false);
}
};
const loadStoragePath = async () => {
try {
const path = await invoke<string>('get_app_data_dir');
setStoragePath(path);
} catch (error) {
console.error('Failed to load storage path:', error);
setStoragePath('N/A');
}
};
const checkConnection = async () => {
setIsCheckingConnection(true);
setConnectionStatus('idle');
try {
const response = await fetch(`${settings.ollamaUrl}/api/tags`, {
method: 'HEAD',
});
if (response.ok) {
setConnectionStatus('success');
toast({
title: 'Conexão bem-sucedida',
description: 'Ollama está acessível neste endpoint.',
});
// Reload models after successful connection
loadModels();
} else {
setConnectionStatus('error');
toast({
title: 'Erro de conexão',
description: `Status: ${response.status}`,
variant: 'destructive',
});
}
} catch (error) {
setConnectionStatus('error');
toast({
title: 'Erro de conexão',
description: error instanceof Error ? error.message : 'Falha ao conectar',
variant: 'destructive',
});
} finally {
setIsCheckingConnection(false);
}
};
const handleExportChats = async () => {
setIsExporting(true);
try {
await invoke('export_chat_sessions');
toast({
title: 'Exportação concluída',
description: 'Todos os chats foram exportados com sucesso.',
});
} catch (error) {
console.error('Failed to export chats:', 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 {
await invoke('clear_chat_history');
toast({
title: 'Histórico apagado',
description: 'Todas as conversas foram removidas.',
});
setShowClearDialog(false);
} catch (error) {
console.error('Failed to clear history:', error);
toast({
title: 'Erro ao apagar',
description: error instanceof Error ? error.message : 'Falha ao apagar histórico',
variant: 'destructive',
});
} finally {
setIsClearing(false);
}
};
const copyStoragePath = async () => {
try {
await navigator.clipboard.writeText(storagePath);
toast({
title: 'Caminho copiado',
description: 'O caminho foi copiado para a área de transferência.',
});
} catch (error) {
console.error('Failed to copy path:', error);
}
};
return (
<div className="container mx-auto py-8 max-w-4xl">
<div className="mb-6">
<h1 className="text-3xl font-bold">Configurações</h1>
<p className="text-muted-foreground mt-2">
Gerencie as preferências do OllaHub
</p>
</div>
<Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="ai">AI & Models</TabsTrigger>
<TabsTrigger value="web">Web Search</TabsTrigger>
<TabsTrigger value="sources">Sources & Knowledge</TabsTrigger>
<TabsTrigger value="storage">Data & Storage</TabsTrigger>
</TabsList>
{/* Tab: AI & Models */}
<TabsContent value="ai" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Conexão Ollama</CardTitle>
<CardDescription>
Configure o endpoint do Ollama local
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url">Ollama Endpoint</Label>
<div className="flex gap-2">
<Input
id="ollama-url"
value={settings.ollamaUrl}
onChange={(e) => settings.setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
/>
<Button
onClick={checkConnection}
disabled={isCheckingConnection}
variant="outline"
>
{isCheckingConnection ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : connectionStatus === 'success' ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : connectionStatus === 'error' ? (
<XCircle className="w-4 h-4 text-red-500" />
) : (
'Verificar'
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="model-select">Modelo Padrão</Label>
{isLoadingModels ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Carregando modelos...
</div>
) : availableModels.length > 0 ? (
<Select
value={settings.selectedModel}
onValueChange={settings.setSelectedModel}
>
<SelectTrigger id="model-select">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="model-input"
value={settings.selectedModel}
onChange={(e) => settings.setSelectedModel(e.target.value)}
placeholder="Digite o nome do modelo..."
/>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>System Prompt</CardTitle>
<CardDescription>
Defina a personalidade e comportamento padrão da IA
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={settings.systemPrompt}
onChange={(e) => settings.setSystemPrompt(e.target.value)}
className="min-h-[300px] font-mono text-sm"
placeholder="Você é um assistente útil..."
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Context Window</CardTitle>
<CardDescription>
Tamanho máximo do contexto (tokens)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>2048</span>
<span className="font-medium">{settings.contextWindow.toLocaleString()}</span>
<span>32768</span>
</div>
<Slider
value={[settings.contextWindow]}
onValueChange={([value]) => settings.setContextWindow(value)}
min={2048}
max={32768}
step={512}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Web Search */}
<TabsContent value="web" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Web Search</CardTitle>
<CardDescription>
Configure o sistema de busca e scraping na web
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="web-search-enabled">Ativar Web Search</Label>
<p className="text-xs text-muted-foreground">
Busca automática na web antes de responder
</p>
</div>
<Switch
id="web-search-enabled"
checked={settings.webSearch.enabled}
onCheckedChange={settings.setWebSearchEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-results">Máximo de Resultados</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>1</span>
<span className="font-medium">{settings.webSearch.maxResults}</span>
<span>10</span>
</div>
<Slider
value={[settings.webSearch.maxResults]}
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
min={1}
max={10}
step={1}
disabled={!settings.webSearch.enabled}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (segundos)</Label>
<Input
id="timeout"
type="number"
min={5}
max={30}
value={settings.webSearch.timeout}
onChange={(e) => settings.setWebSearchTimeout(Number(e.target.value))}
disabled={!settings.webSearch.enabled}
/>
<p className="text-xs text-muted-foreground">
Tempo máximo para carregar cada página
</p>
</div>
<DomainTagsInput
domains={settings.webSearch.excludedDomains}
onAdd={settings.addExcludedDomain}
onRemove={settings.removeExcludedDomain}
/>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Sources & Knowledge */}
<TabsContent value="sources" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Fontes de Conhecimento</CardTitle>
<CardDescription>
Configure categorias de busca e sites customizados para pesquisa avançada
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Performance Settings */}
<div className="space-y-4 pb-4 border-b">
<div className="space-y-2">
<Label htmlFor="max-concurrent">Abas Simultâneas (Performance)</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>1</span>
<span className="font-medium">{settings.webSearch.maxConcurrentTabs}</span>
<span>10</span>
</div>
<Slider
value={[settings.webSearch.maxConcurrentTabs]}
onValueChange={([value]) => settings.setWebSearchMaxConcurrentTabs(value)}
min={1}
max={10}
step={1}
/>
</div>
<p className="text-xs text-muted-foreground">
Número máximo de páginas processadas simultaneamente (mais = mais rápido, mas mais uso de CPU)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="depth-research">Profundidade da Pesquisa</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Rápido (5)</span>
<span className="font-medium">{settings.webSearch.totalSourcesLimit}</span>
<span>Profundo (40)</span>
</div>
<Slider
value={[settings.webSearch.totalSourcesLimit]}
onValueChange={([value]) => settings.setWebSearchTotalSourcesLimit(value)}
min={5}
max={40}
step={5}
/>
</div>
<p className="text-xs text-muted-foreground">
Número total de fontes a buscar. Mais fontes = mais tempo, mas resultados mais completos.
</p>
</div>
</div>
{/* Categories */}
<div className="space-y-4">
<Label>Categorias de Busca</Label>
<div className="grid gap-4">
{settings.webSearch.categories.map((category) => {
const iconMap: Record<string, React.ReactNode> = {
academic: <GraduationCap className="w-4 h-4" />,
news: <Newspaper className="w-4 h-4" />,
tech: <Code className="w-4 h-4" />,
finance: <DollarSign className="w-4 h-4" />,
};
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">
{iconMap[category.id] || <BookOpen className="w-4 h-4" />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Label htmlFor={`category-${category.id}`} className="font-semibold">
{category.name}
</Label>
<Switch
id={`category-${category.id}`}
checked={category.enabled}
onCheckedChange={() => settings.toggleCategory(category.id)}
/>
</div>
<div className="flex flex-wrap gap-1.5">
{category.baseSites.map((site) => (
<Badge key={site} variant="secondary" className="text-xs">
{site}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">
{category.baseSites.length} sites curados
</p>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
{/* Custom Sites */}
<div className="space-y-4 pt-4 border-t">
<Label>Sites Customizados</Label>
<p className="text-xs text-muted-foreground">
Adicione seus próprios domínios favoritos para busca direcionada
</p>
<div className="flex gap-2">
<Input
id="custom-site-input"
placeholder="exemplo.com"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
settings.addCustomSite(value);
input.value = '';
}
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => {
const input = document.getElementById('custom-site-input') as HTMLInputElement;
const value = input?.value.trim();
if (value) {
settings.addCustomSite(value);
input.value = '';
}
}}
>
<Plus className="w-4 h-4" />
</Button>
</div>
{settings.webSearch.userCustomSites.length > 0 && (
<div className="flex flex-wrap gap-2">
{settings.webSearch.userCustomSites.map((site) => (
<Badge key={site} variant="outline" className="text-sm">
{site}
<button
onClick={() => settings.removeCustomSite(site)}
className="ml-2 hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Tab: Data & Storage */}
<TabsContent value="storage" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Armazenamento</CardTitle>
<CardDescription>
Gerencie os dados salvos localmente
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Localização dos Dados</Label>
<div className="flex items-center gap-2">
<Input
value={storagePath}
readOnly
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={copyStoragePath}
title="Copiar caminho"
>
<Copy className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
As conversas são salvas como arquivos JSON neste diretório
</p>
</div>
<div className="space-y-4 pt-4 border-t">
<div className="space-y-2">
<Label>Ações</Label>
<div className="flex gap-2">
<Button
onClick={handleExportChats}
disabled={isExporting}
variant="outline"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Exportando...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Exportar Todos os Chats
</>
)}
</Button>
<Button
onClick={() => setShowClearDialog(true)}
variant="destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Apagar Histórico
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Clear History Dialog */}
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
<DialogContent>
<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>
<Button
variant="outline"
onClick={() => setShowClearDialog(false)}
disabled={isClearing}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={handleClearHistory}
disabled={isClearing}
>
{isClearing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Apagando...
</>
) : (
'Confirmar Exclusão'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+457
View File
@@ -0,0 +1,457 @@
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Task {
id: string;
label: string;
cron_schedule: string;
action: {
type: 'search_and_summarize' | 'just_ping' | 'custom_prompt';
query?: string;
model?: string;
max_results?: number;
message?: string;
prompt?: string;
};
enabled: boolean;
last_run: string | null;
created_at: string;
updated_at: string;
}
export default function TasksPage() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
setLoading(true);
const loaded = await invoke<Task[]>('list_tasks');
setTasks(loaded);
} catch (error) {
console.error('Erro ao carregar tasks:', error);
} finally {
setLoading(false);
}
};
const toggleTask = async (id: string, currentEnabled: boolean) => {
try {
await invoke('toggle_task', { id, enabled: !currentEnabled });
await loadTasks();
} catch (error) {
console.error('Erro ao alternar task:', error);
}
};
const deleteTask = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir esta task?')) {
return;
}
try {
await invoke('delete_task', { id });
await loadTasks();
} catch (error) {
console.error('Erro ao excluir task:', error);
}
};
const formatCron = (cron: string) => {
// Formatação básica de cron para exibição
const parts = cron.split(' ');
if (parts.length === 5) {
const [min, hour, day, month, weekday] = parts;
if (min === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Todo dia às ${hour}:00`;
}
if (min === '0' && hour === '8' && day === '*' && month === '*' && weekday === '*') {
return 'Todo dia às 8:00';
}
}
return cron;
};
const formatLastRun = (lastRun: string | null) => {
if (!lastRun) return 'Nunca';
try {
const date = new Date(lastRun);
return date.toLocaleString('pt-BR');
} catch {
return lastRun;
}
};
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>
<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 ? (
<div className="text-center py-12 text-muted-foreground">
Carregando tasks...
</div>
) : tasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">
Nenhuma task agendada ainda.
</p>
<Button onClick={() => setIsDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Criar Primeira Task
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{tasks.map(task => (
<Card key={task.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{task.label}
{task.enabled ? (
<span className="text-xs bg-green-500/10 text-green-500 px-2 py-1 rounded">
Ativa
</span>
) : (
<span className="text-xs bg-gray-500/10 text-gray-500 px-2 py-1 rounded">
Pausada
</span>
)}
</CardTitle>
<CardDescription className="mt-2">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatCron(task.cron_schedule)}
</span>
<span>Última execução: {formatLastRun(task.last_run)}</span>
</div>
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Label htmlFor={`toggle-${task.id}`} className="text-sm">
{task.enabled ? 'Ativa' : 'Pausada'}
</Label>
<Switch
id={`toggle-${task.id}`}
checked={task.enabled}
onCheckedChange={() => toggleTask(task.id, task.enabled)}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
<p><strong>Tipo:</strong> {
task.action.type === 'search_and_summarize' ? 'Pesquisar e Resumir' :
task.action.type === 'just_ping' ? 'Notificação' :
'Prompt Customizado'
}</p>
{task.action.query && (
<p><strong>Query:</strong> {task.action.query}</p>
)}
{task.action.model && (
<p><strong>Modelo:</strong> {task.action.model}</p>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) {
const [label, setLabel] = useState('');
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'custom'>('daily');
const [hour, setHour] = useState('8');
const [minute, setMinute] = useState('0');
const [customCron, setCustomCron] = useState('');
const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize');
const [query, setQuery] = useState('');
const [model, setModel] = useState('llama3.2');
const [maxResults, setMaxResults] = useState(3);
const [message, setMessage] = useState('');
const [customPrompt, setCustomPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const generateCron = (): string => {
if (scheduleType === 'custom') {
return customCron;
}
// Diário às hora:minuto
return `${minute} ${hour} * * *`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
let action: any;
if (taskType === 'search_and_summarize') {
action = {
type: 'search_and_summarize',
query,
model,
max_results: maxResults,
};
} else if (taskType === 'just_ping') {
action = {
type: 'just_ping',
message,
};
} else {
action = {
type: 'custom_prompt',
prompt: customPrompt,
model,
};
}
await invoke('create_task', {
label,
cronSchedule: generateCron(),
action,
});
onSuccess();
} catch (error) {
console.error('Erro ao criar task:', error);
alert('Erro ao criar task. Verifique o console para mais detalhes.');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="label">Nome da Task</Label>
<Input
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Resumo de Notícias Diário"
required
/>
</div>
<div>
<Label htmlFor="schedule-type">Frequência</Label>
<Select value={scheduleType} onValueChange={(v: any) => setScheduleType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Diário</SelectItem>
<SelectItem value="weekly">Semanal</SelectItem>
<SelectItem value="custom">Cron Customizado</SelectItem>
</SelectContent>
</Select>
</div>
{scheduleType === 'custom' ? (
<div>
<Label htmlFor="cron">Expressão Cron</Label>
<Input
id="cron"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="0 8 * * * (todo dia às 8h)"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Formato: minuto hora dia mês dia-da-semana
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hour">Hora</Label>
<Input
id="hour"
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="minute">Minuto</Label>
<Input
id="minute"
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(e.target.value)}
required
/>
</div>
</div>
)}
<div>
<Label htmlFor="task-type">Tipo de Task</Label>
<Select value={taskType} onValueChange={(v: any) => setTaskType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search_and_summarize">Pesquisar e Resumir</SelectItem>
<SelectItem value="just_ping">Apenas Notificação</SelectItem>
<SelectItem value="custom_prompt">Prompt Customizado</SelectItem>
</SelectContent>
</Select>
</div>
{taskType === 'search_and_summarize' && (
<>
<div>
<Label htmlFor="query">Query de Pesquisa</Label>
<Input
id="query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ex: notícias sobre inteligência artificial hoje"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="model">Modelo</Label>
<Input
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
<div>
<Label htmlFor="max-results">Máx. Resultados</Label>
<Input
id="max-results"
type="number"
min="1"
max="10"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value))}
required
/>
</div>
</div>
</>
)}
{taskType === 'just_ping' && (
<div>
<Label htmlFor="message">Mensagem</Label>
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Mensagem da notificação"
required
/>
</div>
)}
{taskType === 'custom_prompt' && (
<>
<div>
<Label htmlFor="prompt">Prompt</Label>
<Textarea
id="prompt"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Seu prompt customizado aqui"
required
className="min-h-[100px]"
/>
</div>
<div>
<Label htmlFor="model-prompt">Modelo</Label>
<Input
id="model-prompt"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
</>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Criando...' : 'Criar Task'}
</Button>
</div>
</form>
);
}
+86 -27
View File
@@ -1,15 +1,29 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Send, Square } from "lucide-react"; import { Send, Square, Globe, WifiOff } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
onStop: () => void; onStop: () => void;
isLoading: boolean; isLoading: boolean;
webSearchEnabled?: boolean;
onWebSearchToggle?: (enabled: boolean) => void;
} }
export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) { export function ChatInput({
onSend,
onStop,
isLoading,
webSearchEnabled = true,
onWebSearchToggle,
}: ChatInputProps) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -36,31 +50,74 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
return ( return (
<div className="p-4 border-t bg-background"> <div className="p-4 border-t bg-background">
<div className="relative flex items-end gap-2 max-w-3xl mx-auto"> <div className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
<Textarea <div className="flex-1 relative">
ref={textareaRef} <Textarea
value={input} ref={textareaRef}
onChange={(e) => setInput(e.target.value)} value={input}
onKeyDown={handleKeyDown} onChange={(e) => setInput(e.target.value)}
placeholder="Digite sua mensagem..." onKeyDown={handleKeyDown}
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3" placeholder="Digite sua mensagem..."
rows={1} className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
/> rows={1}
<div className="absolute right-2 bottom-2"> />
{isLoading ? ( <div className="absolute right-2 bottom-2 flex items-center gap-2">
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8"> {onWebSearchToggle && (
<Square className="h-4 w-4 fill-current" /> <TooltipProvider>
</Button> <Tooltip>
) : ( <TooltipTrigger asChild>
<Button <Button
size="icon" size="icon"
onClick={handleSend} variant={webSearchEnabled ? "default" : "ghost"}
disabled={!input.trim()} onClick={() => {
className="h-8 w-8" if (!webSearchEnabled) {
> // Ao ativar, não precisa de confirmação
<Send className="h-4 w-4" /> onWebSearchToggle(true);
</Button> } else {
)} // Ao desativar, mostrar aviso
const confirmed = window.confirm(
"Atenção: Desativar a pesquisa web pode reduzir a qualidade das respostas para fatos recentes, especialmente em modelos menores. Deseja continuar?"
);
if (confirmed) {
onWebSearchToggle(false);
}
}
}}
className="h-8 w-8"
disabled={isLoading}
>
{webSearchEnabled ? (
<Globe className="h-4 w-4" />
) : (
<WifiOff className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{webSearchEnabled
? "Pesquisa web ativada"
: "Pesquisa web desativada"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</div> </div>
<div className="text-xs text-center text-muted-foreground mt-2"> <div className="text-xs text-center text-muted-foreground mt-2">
@@ -70,3 +127,5 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
); );
} }
+24 -1
View File
@@ -28,7 +28,30 @@ export function ChatMessage({ message }: { message: Message }) {
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={markdownRenderers} components={markdownRenderers}
> >
{message.content} {(() => {
let content = message.content || '';
// Remove metadata tags
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Also remove common prefixes if they appear at the end
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
return content || message.content;
})()}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
</div> </div>
+2
View File
@@ -43,3 +43,5 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
); );
} }
+2
View File
@@ -136,3 +136,5 @@ export const markdownRenderers: Components = {
), ),
}; };
+419
View File
@@ -0,0 +1,419 @@
'use client';
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Play, Square, RefreshCw, Save } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { McpJsonEditor } from '@/components/chat/mcp-json-editor';
interface McpServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
}
interface McpConfig {
mcpServers: Record<string, McpServerConfig>;
}
interface McpServerStatus {
name: string;
status: 'running' | 'stopped' | 'error';
pid?: number;
}
export function McpConfigPanel() {
const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
const [statuses, setStatuses] = useState<McpServerStatus[]>([]);
const [configJson, setConfigJson] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [configPath, setConfigPath] = useState('');
const { toast } = useToast();
useEffect(() => {
loadConfig();
loadStatus();
loadConfigPath();
// Refresh status every 5 seconds
const interval = setInterval(() => {
loadStatus();
}, 5000);
return () => clearInterval(interval);
}, []);
const loadConfig = async () => {
try {
setIsLoading(true);
const loadedConfig = await invoke<any>('load_mcp_config');
// Convert from Rust HashMap to TypeScript Record
// Always ensure minimum structure: { mcpServers: {} }
const config: McpConfig = {
mcpServers: loadedConfig.mcp_servers || loadedConfig.mcpServers || {}
};
setConfig(config);
setConfigJson(JSON.stringify(config, null, 2));
} catch (error) {
console.error('Failed to load MCP config:', error);
// On error, set minimum structure
const minConfig: McpConfig = { mcpServers: {} };
setConfig(minConfig);
setConfigJson(JSON.stringify(minConfig, null, 2));
toast({
title: 'Erro',
description: 'Falha ao carregar configuração MCP',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const loadStatus = async () => {
try {
const serverStatuses = await invoke<McpServerStatus[]>('list_mcp_server_status');
setStatuses(serverStatuses);
} catch (error) {
console.error('Failed to load MCP status:', error);
}
};
const loadConfigPath = async () => {
try {
const path = await invoke<string>('get_mcp_config_path_command');
setConfigPath(path);
} catch (error) {
console.error('Failed to load config path:', error);
}
};
const saveConfig = async () => {
try {
setIsSaving(true);
// Trim whitespace from JSON string
const trimmedJson = configJson.trim();
// Validate JSON structure
if (!trimmedJson) {
toast({
title: 'Erro',
description: 'JSON vazio. Por favor, insira uma configuração válida.',
variant: 'destructive',
});
setIsSaving(false);
return;
}
let parsedConfig: McpConfig;
try {
parsedConfig = JSON.parse(trimmedJson);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Erro desconhecido ao fazer parse do JSON';
console.error('JSON parse error:', e);
toast({
title: 'Erro de JSON',
description: `JSON inválido: ${errorMessage}`,
variant: 'destructive',
});
setIsSaving(false);
return;
}
// Validate required structure
if (!parsedConfig || typeof parsedConfig !== 'object') {
toast({
title: 'Erro',
description: 'Configuração inválida: deve ser um objeto JSON.',
variant: 'destructive',
});
setIsSaving(false);
return;
}
// Ensure mcpServers exists and is an object
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
parsedConfig.mcpServers = {};
}
// Always ensure minimum structure: { mcpServers: {} }
const normalizedConfig: McpConfig = {
mcpServers: parsedConfig.mcpServers || {}
};
// The Rust struct uses serde(rename = "mcpServers") so we can pass it directly
// No need to convert mcpServers to mcp_servers
await invoke('save_mcp_config', { config: normalizedConfig });
setConfig(normalizedConfig);
// Update JSON to reflect normalized structure
setConfigJson(JSON.stringify(normalizedConfig, null, 2));
toast({
title: 'Sucesso',
description: 'Configuração MCP salva com sucesso',
});
// Reload status after saving
loadStatus();
} catch (error) {
console.error('Failed to save MCP config:', error);
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
toast({
title: 'Erro',
description: `Falha ao salvar configuração MCP: ${errorMessage}`,
variant: 'destructive',
});
} finally {
setIsSaving(false);
}
};
const startServer = async (name: string) => {
try {
const serverConfig = config.mcpServers[name];
if (!serverConfig) {
toast({
title: 'Erro',
description: `Servidor '${name}' não encontrado na configuração`,
variant: 'destructive',
});
return;
}
await invoke('start_mcp_server', { name, config: serverConfig });
toast({
title: 'Sucesso',
description: `Servidor '${name}' iniciado`,
});
loadStatus();
} catch (error) {
console.error('Failed to start server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: 'Erro ao Iniciar Servidor',
description: errorMessage || `Falha ao iniciar servidor '${name}'`,
variant: 'destructive',
});
}
};
const stopServer = async (name: string) => {
try {
await invoke('stop_mcp_server', { name });
toast({
title: 'Sucesso',
description: `Servidor '${name}' parado`,
});
loadStatus();
} catch (error) {
console.error('Failed to stop server:', error);
toast({
title: 'Erro',
description: `Falha ao parar servidor '${name}'`,
variant: 'destructive',
});
}
};
const restartServer = async (name: string) => {
try {
await invoke('restart_mcp_server', { name });
toast({
title: 'Sucesso',
description: `Servidor '${name}' reiniciado`,
});
loadStatus();
} catch (error) {
console.error('Failed to restart server:', error);
toast({
title: 'Erro',
description: `Falha ao reiniciar servidor '${name}'`,
variant: 'destructive',
});
}
};
const restartAll = async () => {
try {
const started = await invoke<string[]>('restart_all_mcp_servers');
toast({
title: 'Sucesso',
description: `${started.length} servidor(es) iniciado(s)`,
});
loadStatus();
} catch (error) {
console.error('Failed to restart all servers:', error);
toast({
title: 'Erro',
description: 'Falha ao reiniciar servidores',
variant: 'destructive',
});
}
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'running':
return 'default';
case 'stopped':
return 'secondary';
case 'error':
return 'destructive';
default:
return 'secondary';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="h-full flex flex-col gap-4 p-4 overflow-hidden">
<div className="flex items-center justify-between flex-shrink-0">
<div>
<h2 className="text-xl font-semibold">MCP Servers</h2>
<p className="text-sm text-muted-foreground">
Gerencie servidores Model Context Protocol
</p>
{configPath && (
<p className="text-xs text-muted-foreground mt-1">
Config: <code className="text-xs bg-muted px-1 py-0.5 rounded">{configPath}</code>
</p>
)}
</div>
<Button onClick={restartAll} size="sm" variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
Reiniciar Todos
</Button>
</div>
{/* Server Status List */}
<div className="space-y-2 flex-shrink-0 overflow-y-auto">
{Object.keys(config.mcpServers).length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center">
Nenhum servidor MCP configurado. Adicione servidores no JSON abaixo.
</p>
</CardContent>
</Card>
) : (
Object.keys(config.mcpServers).map((name) => {
const status = statuses.find(s => s.name === name);
const isRunning = status?.status === 'running';
return (
<Card key={name}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{name}</CardTitle>
{status && (
<Badge variant={getStatusBadgeVariant(status.status)}>
{status.status}
</Badge>
)}
{status?.pid && (
<span className="text-xs text-muted-foreground">
PID: {status.pid}
</span>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => stopServer(name)}
>
<Square className="w-3 h-3 mr-1" />
Parar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => restartServer(name)}
>
<RefreshCw className="w-3 h-3 mr-1" />
Reiniciar
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => startServer(name)}
>
<Play className="w-3 h-3 mr-1" />
Iniciar
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm">
<div>
<span className="text-muted-foreground">Comando:</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{config.mcpServers[name].command} {config.mcpServers[name].args.join(' ')}
</code>
</div>
{config.mcpServers[name].env && (
<div>
<span className="text-muted-foreground">Env:</span>{' '}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{Object.keys(config.mcpServers[name].env || {}).join(', ')}
</code>
</div>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{/* JSON Editor */}
<Card className="flex flex-col flex-1 min-h-0 max-w-full overflow-hidden">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-base">Configuração JSON</CardTitle>
<CardDescription className="truncate">
Edite a configuração MCP no formato JSON padrão
</CardDescription>
<CardAction>
<Button onClick={saveConfig} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Salvar
</Button>
</CardAction>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 max-w-full overflow-hidden">
<McpJsonEditor
value={configJson}
onChange={setConfigJson}
placeholder='{\n "mcpServers": {\n "filesystem": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]\n }\n }\n}'
className="flex-1 max-w-full h-full"
/>
</CardContent>
</Card>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, Wand2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface McpJsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function McpJsonEditor({ value, onChange, placeholder, className }: McpJsonEditorProps) {
const { theme, resolvedTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isFormatted, setIsFormatted] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightContainerRef = useRef<HTMLDivElement>(null);
// Determine theme
const isDark = resolvedTheme === 'dark' || theme === 'dark';
const codeStyle = isDark ? vscDarkPlus : vs;
// Validate JSON and update state
const validateJson = (jsonString: string) => {
if (!jsonString.trim()) {
setIsValid(true);
setErrorMessage(null);
return;
}
try {
JSON.parse(jsonString);
setIsValid(true);
setErrorMessage(null);
} catch (e) {
setIsValid(false);
const error = e instanceof Error ? e.message : 'JSON inválido';
setErrorMessage(error);
}
};
// Format JSON
const formatJson = () => {
if (!value.trim()) return;
try {
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);
onChange(formatted);
setIsFormatted(true);
setTimeout(() => setIsFormatted(false), 2000);
} catch (e) {
// If invalid, show error
validateJson(value);
}
};
// Handle input changes
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
onChange(newValue);
validateJson(newValue);
setIsFormatted(false);
};
// Sync scroll between textarea and highlight
const handleScroll = () => {
if (textareaRef.current && highlightContainerRef.current) {
const scrollTop = textareaRef.current.scrollTop;
const scrollLeft = textareaRef.current.scrollLeft;
const pre = highlightContainerRef.current.querySelector('pre');
if (pre) {
pre.scrollTop = scrollTop;
pre.scrollLeft = scrollLeft;
}
}
};
// Validate on mount and when value changes externally
useEffect(() => {
validateJson(value);
}, [value]);
// Count lines for display
const lineCount = value.split('\n').length;
return (
<div className={cn('relative flex flex-col h-full min-h-0', className)}>
{/* Toolbar */}
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
disabled={!value.trim()}
className="h-7 text-xs"
>
<Wand2 className="w-3 h-3 mr-1" />
Formatar
</Button>
{isFormatted && (
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3 h-3" />
Formatado
</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{lineCount > 0 && <span>{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>}
{!isValid && errorMessage && (
<div className="flex items-center gap-1 text-destructive">
<AlertCircle className="w-3 h-3" />
<span className="max-w-[200px] truncate">{errorMessage}</span>
</div>
)}
{isValid && value.trim() && (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3 h-3" />
Válido
</div>
)}
</div>
</div>
{/* Editor Container */}
<div className="relative flex-1 min-h-[300px] border rounded-md overflow-hidden bg-background">
{/* Syntax Highlighted Background */}
<div ref={highlightContainerRef} className="absolute inset-0 pointer-events-none overflow-hidden">
<SyntaxHighlighter
language="json"
style={codeStyle}
customStyle={{
margin: 0,
padding: '0.75rem',
background: 'transparent',
fontSize: '0.875rem',
lineHeight: '1.5rem',
overflow: 'hidden',
height: '100%',
}}
PreTag="pre"
>
{value || placeholder || '{}'}
</SyntaxHighlighter>
</div>
{/* Editable Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onScroll={handleScroll}
placeholder={placeholder}
className={cn(
'relative w-full h-full min-h-[300px] p-3 font-mono text-sm leading-6',
'bg-transparent text-transparent caret-foreground',
'resize-none border-none outline-none',
'selection:bg-primary/20',
'[&::placeholder]:text-muted-foreground/50',
!isValid && 'ring-1 ring-destructive'
)}
spellCheck={false}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
overflow: 'auto',
overflowY: 'auto',
overflowX: 'auto',
}}
/>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { Server, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { McpServerInfo } from '@/hooks/use-mcp-servers';
interface McpServerSelectorProps {
servers: McpServerInfo[];
selectedServers: string[];
onSelectionChange: (servers: string[]) => void;
isLoading?: boolean;
className?: string;
}
export function McpServerSelector({
servers,
selectedServers,
onSelectionChange,
isLoading = false,
className,
}: McpServerSelectorProps) {
const [open, setOpen] = useState(false);
// Filter only running servers
const runningServers = servers.filter(s => s.status === 'running');
const handleToggle = (serverName: string) => {
if (selectedServers.includes(serverName)) {
onSelectionChange(selectedServers.filter(s => s !== serverName));
} else {
onSelectionChange([...selectedServers, serverName]);
}
};
const handleSelectAll = () => {
if (selectedServers.length === runningServers.length) {
onSelectionChange([]);
} else {
onSelectionChange(runningServers.map(s => s.name));
}
};
const getButtonText = () => {
if (selectedServers.length === 0) {
return 'Servidores MCP';
}
if (selectedServers.length === 1) {
return selectedServers[0];
}
return `${selectedServers.length} servidores`;
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-8 gap-2', className)}
disabled={isLoading}
>
<Server className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{getButtonText()}</span>
{selectedServers.length > 0 && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
{selectedServers.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Servidores MCP</span>
{selectedServers.length > 0 && (
<span className="text-xs font-normal text-muted-foreground">
{selectedServers.length} selecionado{selectedServers.length !== 1 ? 's' : ''}
</span>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={selectedServers.length === runningServers.length && runningServers.length > 0}
onCheckedChange={handleSelectAll}
className="font-medium"
>
Selecionar Todos
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{runningServers.map((server) => (
<DropdownMenuCheckboxItem
key={server.name}
checked={selectedServers.includes(server.name)}
onCheckedChange={() => handleToggle(server.name)}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span>{server.name}</span>
</div>
{server.toolCount !== undefined && server.toolCount > 0 && (
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-xs">
{server.toolCount}
</Badge>
)}
</DropdownMenuCheckboxItem>
))}
{runningServers.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Nenhum servidor rodando
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
+2
View File
@@ -79,3 +79,5 @@ export function MessageActions({ content, role }: MessageActionsProps) {
); );
} }
+152
View File
@@ -0,0 +1,152 @@
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ScrapedContent } from '@/services/webSearch';
import { SearchStatus } from '@/hooks/use-web-search';
import { useState } from 'react';
import { ExternalLink } from 'lucide-react';
interface SearchProgressProps {
status: SearchStatus;
query: string;
sources: ScrapedContent[];
error: string | null;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
export function SearchProgress({
status,
query,
sources,
error,
isExpanded: controlledExpanded,
onToggleExpand,
}: SearchProgressProps) {
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const toggleExpanded = onToggleExpand || (() => setInternalExpanded(!internalExpanded));
if (status === 'idle') return null;
const getStatusIcon = () => {
switch (status) {
case 'searching':
return <Search className="w-3 h-3 text-muted-foreground" />;
case 'scraping':
return <BookOpen className="w-3 h-3 text-muted-foreground" />;
case 'completed':
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
case 'error':
return <AlertCircle className="w-3 h-3 text-destructive" />;
default:
return null;
}
};
const getStatusText = () => {
switch (status) {
case 'searching':
return `Pesquisando: "${query}"`;
case 'scraping':
return `Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}`;
case 'completed':
return `✓ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} consultada${sources.length !== 1 ? 's' : ''}`;
case 'error':
return `Erro: ${error || 'Falha ao buscar'}`;
default:
return '';
}
};
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
} catch {
return '';
}
};
const isActive = status === 'searching' || status === 'scraping';
const showExpandButton = status === 'completed' && sources.length > 0;
return (
<Card className={cn(
"mx-4 mb-2 border border-border/50 bg-muted/30 transition-all",
status === 'completed' && "opacity-60 hover:opacity-100",
status === 'error' && "border-destructive/30 bg-destructive/5"
)}>
<CardContent className="p-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{isActive ? (
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
) : (
<div className="w-3 h-3">
{getStatusIcon()}
</div>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
{getStatusText()}
</span>
</div>
{showExpandButton && (
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-7 w-7 p-0 flex-shrink-0"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
)}
</div>
{/* Lista de fontes expandida */}
{isExpanded && sources.length > 0 && (
<div className="mt-3 pt-3 border-t space-y-2">
{sources.map((source, idx) => (
<div
key={idx}
className="flex items-start gap-2 p-2 rounded-md bg-background/50 hover:bg-background/80 transition-colors"
>
<img
src={getFaviconUrl(source.url)}
alt=""
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div className="flex-1 min-w-0">
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
>
{source.title || source.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{source.url}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button";
import { Trash2, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
interface SidebarListProps {
sessions: SessionSummary[];
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
onNewChat: () => void;
}
function formatDate(dateString: string) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
export function SidebarList({
sessions,
currentSessionId,
onSelectSession,
onDeleteSession,
onNewChat
}: SidebarListProps) {
return (
<div className="flex flex-col h-full border-r bg-muted/10">
<div className="p-4 border-b">
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
<Plus className="w-4 h-4" />
Nova Conversa
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-2">
{sessions.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
Nenhuma conversa salva
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={cn(
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
currentSessionId === session.id && "bg-accent"
)}
onClick={() => onSelectSession(session.id)}
>
<div className="flex items-start gap-3 overflow-hidden flex-1">
<span className="text-lg shrink-0 mt-0.5" role="img" aria-label="emoji">
{session.emoji || '💬'}
</span>
<div className="flex flex-col overflow-hidden flex-1">
<span className="text-sm font-medium truncate" title={session.title}>
{session.title}
</span>
<span className="text-xs text-muted-foreground truncate">
{formatDate(session.updated_at)}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
</div>
);
}
+2
View File
@@ -103,3 +103,5 @@ export function SystemPanel() {
); );
} }
+105
View File
@@ -0,0 +1,105 @@
import { Search, BookOpen, Brain, Loader2, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ThinkingStep =
| 'analyzing'
| 'searching'
| 'reading'
| 'formulating'
| 'complete';
interface ThinkingIndicatorProps {
currentStep: ThinkingStep;
searchQuery?: string;
sourcesRead?: number;
totalSources?: number;
onComplete?: () => void;
}
export function ThinkingIndicator({
currentStep,
searchQuery,
sourcesRead = 0,
totalSources = 0,
}: ThinkingIndicatorProps) {
const steps: Array<{
key: ThinkingStep;
label: string;
icon: React.ReactNode;
}> = [
{
key: 'analyzing',
label: 'Analisando intenção...',
icon: <Brain className="w-3 h-3" />,
},
{
key: 'searching',
label: searchQuery ? `Pesquisando: "${searchQuery}"` : 'Pesquisando na web...',
icon: <Search className="w-3 h-3" />,
},
{
key: 'reading',
label: totalSources > 0
? `Lendo ${sourcesRead}/${totalSources} fontes...`
: 'Lendo conteúdo...',
icon: <BookOpen className="w-3 h-3" />,
},
{
key: 'formulating',
label: 'Formulando resposta...',
icon: <Brain className="w-3 h-3" />,
},
];
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
const isComplete = currentStep === 'complete';
if (isComplete) {
return null; // Não mostrar quando completo
}
return (
<div className="px-6 py-3 border-b border-muted/50 bg-muted/20">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
const isActive = idx === currentStepIndex;
const isPast = idx < currentStepIndex;
return (
<div key={step.key} className="flex items-center gap-2">
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded-full transition-colors",
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-blue-500/20 text-blue-600 dark:text-blue-400 animate-pulse",
!isActive && !isPast && "bg-muted text-muted-foreground"
)}
>
{isPast ? (
<CheckCircle2 className="w-3 h-3" />
) : isActive ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
step.icon
)}
</div>
<span
className={cn(
"transition-colors",
isActive && "text-foreground font-medium",
isPast && "text-muted-foreground line-through opacity-60"
)}
>
{step.label}
</span>
{idx < steps.length - 1 && (
<div className="w-8 h-px bg-muted mx-1" />
)}
</div>
);
})}
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { Search, BookOpen, CheckCircle2, AlertCircle, ExternalLink, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ScrapedContent } from '@/services/webSearch';
import { SearchStatus } from '@/hooks/use-web-search';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface WebSearchStatusProps {
status: SearchStatus;
query: string;
sources: ScrapedContent[];
error: string | null;
onViewSources?: () => void;
}
export function WebSearchStatus({
status,
query,
sources,
error,
}: WebSearchStatusProps) {
const [isSourcesOpen, setIsSourcesOpen] = useState(false);
if (status === 'idle') return null;
const getStatusIcon = () => {
switch (status) {
case 'searching':
return <Search className="w-4 h-4 animate-pulse" />;
case 'scraping':
return <BookOpen className="w-4 h-4 animate-pulse" />;
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'error':
return <AlertCircle className="w-4 h-4 text-red-500" />;
default:
return null;
}
};
const getStatusText = () => {
switch (status) {
case 'searching':
return `🔍 Pesquisando por: "${query}"...`;
case 'scraping':
return `📖 Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}...`;
case 'completed':
return `✅ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} encontrada${sources.length !== 1 ? 's' : ''}`;
case 'error':
return `❌ Erro: ${error || 'Falha ao buscar'}`;
default:
return '';
}
};
const getFaviconUrl = (url: string): string => {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
} catch {
return '';
}
};
return (
<>
<Card className={cn(
"mx-4 mb-2 border-l-4",
status === 'completed' && "border-l-green-500",
status === 'error' && "border-l-red-500",
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
)}>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">
{status === 'searching' || status === 'scraping' ? (
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
) : (
getStatusIcon()
)}
</div>
<span className="text-sm font-medium truncate">
{getStatusText()}
</span>
</div>
{status === 'completed' && sources.length > 0 && (
<div className="flex items-center gap-2 flex-shrink-0">
{/* Favicons das fontes */}
<div className="flex items-center gap-1">
{sources.slice(0, 3).map((source, idx) => (
<img
key={idx}
src={getFaviconUrl(source.url)}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
))}
{sources.length > 3 && (
<span className="text-xs text-muted-foreground">+{sources.length - 3}</span>
)}
</div>
<Dialog open={isSourcesOpen} onOpenChange={setIsSourcesOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 text-xs">
Ver Fontes
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Fontes Encontradas</DialogTitle>
<DialogDescription>
{sources.length} fonte{sources.length !== 1 ? 's' : ''} analisada{sources.length !== 1 ? 's' : ''} para: "{query}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
{sources.map((source, idx) => (
<Card key={idx} className="border">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1 line-clamp-2">
{source.title}
</h4>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 truncate"
>
{source.url}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
<div className="prose dark:prose-invert prose-sm max-w-none max-h-[300px] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{source.markdown}
</ReactMarkdown>
</div>
</CardContent>
</Card>
))}
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
</CardContent>
</Card>
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, KeyboardEvent } from 'react';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface DomainTagsInputProps {
domains: string[];
onAdd: (domain: string) => void;
onRemove: (domain: string) => void;
placeholder?: string;
}
export function DomainTagsInput({
domains,
onAdd,
onRemove,
placeholder = 'Digite um domínio e pressione Enter...',
}: DomainTagsInputProps) {
const [inputValue, setInputValue] = useState('');
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault();
onAdd(inputValue.trim());
setInputValue('');
}
};
const normalizeDomain = (domain: string): string => {
return domain
.toLowerCase()
.trim()
.replace(/^https?:\/\//, '')
.replace(/\/$/, '')
.split('/')[0]; // Remove path
};
return (
<div className="space-y-2">
<Label>Domínios Bloqueados</Label>
<div className="flex flex-wrap gap-2 p-3 border rounded-md min-h-[50px] bg-background">
{domains.map((domain) => (
<Badge
key={domain}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{domain}
<button
type="button"
onClick={() => onRemove(domain)}
className="ml-1 rounded-full hover:bg-destructive/20 p-0.5 transition-colors"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={domains.length === 0 ? placeholder : ''}
className="flex-1 min-w-[200px] border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-auto p-0"
/>
</div>
<p className="text-xs text-muted-foreground">
Digite um domínio (ex: youtube.com) e pressione Enter para adicionar à lista negra.
</p>
</div>
);
}
+2
View File
@@ -10,3 +10,5 @@ export function ThemeProvider({
return <NextThemesProvider {...props}>{children}</NextThemesProvider> return <NextThemesProvider {...props}>{children}</NextThemesProvider>
} }
+2
View File
@@ -197,3 +197,5 @@ export {
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+2
View File
@@ -25,3 +25,5 @@ Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress } export { Progress }
+2
View File
@@ -157,3 +157,5 @@ export {
SelectScrollDownButton, SelectScrollDownButton,
} }
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+2
View File
@@ -21,3 +21,5 @@ Textarea.displayName = "Textarea"
export { Textarea } export { Textarea }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+46 -21
View File
@@ -1,29 +1,54 @@
Você é um assistente de IA avançado integrado ao OllaHub. Sua principal função é fornecer respostas úteis, precisas e bem formatadas. Você é o OllaHub AI, um assistente especializado em análise detalhada e factual.
## DIRETRIZES DE FORMATAÇÃO (MUITO IMPORTANTE) ## REGRAS ABSOLUTAS
Para garantir a melhor experiência de leitura, siga rigorosamente estas regras de Markdown: 1. **Nunca** escreva tags `<metadata>`, JSON oculto ou textos como "Metadados:". Toda resposta deve ser apenas texto visível ao usuário.
2. Use Markdown limpo: títulos, listas e blocos de código com linguagem indicada.
3. Comandos e códigos devem estar em blocos triplos (```bash, ```json, etc.).
4. Se receber um bloco [CONTEXTO WEB], use essas informações prioritariamente para responder.
### 1. Estrutura e Hierarquia ## DIRETRIZES DE RESPOSTA (RAG)
- Use **Títulos (H1, H2, H3)** para organizar seções. Nunca use apenas negrito para títulos.
- Use **Listas (Bulleted ou Numbered)** para passos, itens ou características.
- Use **Separadores (---)** para dividir grandes blocos de conteúdo.
### 2. Blocos de Código ### 1. Contextualização Temporal Obrigatória
- SEMPRE use blocos de código triplos (\`\`\`) para qualquer trecho de código, comando de terminal ou configuração.
- SEMPRE especifique a linguagem após os acentos graves (ex: \`\`\`python, \`\`\`bash, \`\`\`json).
- Para menções curtas de código no meio do texto, use `código inline`.
### 3. Ênfase e Destaque - **SEMPRE** inicie respostas sobre notícias, eventos ou fatos atuais citando a data atual explicitamente.
- Use **Negrito** para conceitos chave ou avisos importantes. - Exemplo: "Com base nas informações de hoje, [DATA], os principais destaques são..."
- Use *Itálico* para termos estrangeiros ou ênfase sutil. - Use a data fornecida no prompt como referência absoluta para "hoje", "ontem", "amanhã".
- Use > Blockquotes para citações ou notas laterais.
### 4. Links ### 2. Densidade e Profundidade
- Formate links corretamente: `[Texto do Link](URL)`.
## ESTILO DE RESPOSTA - **NUNCA** responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
- Seja direto e objetivo. Evite preâmbulos desnecessários ("Claro, aqui está..."). - Cada ponto deve conter detalhes específicos: **Números, Nomes Próprios, Locais e Citações Diretas** do contexto.
- Se a resposta for longa, comece com um resumo executivo. - Explique o *contexto* da notícia, não apenas a manchete.
- Ao explicar código, comente o que cada parte faz. - Prefira parágrafos explicativos com "quem, quando, onde, porquê" ao invés de bullets genéricos.
### 3. Uso Estrito de Fontes
- 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.
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
### 4. Formato Jornalístico
- Use títulos Markdown (##) para separar seções temáticas.
- Texto corrido para explicações detalhadas.
- Bullets apenas para listar dados brutos (números, estatísticas, listas de nomes).
- Tom objetivo, profissional e direto.
- Evite linguagem excessivamente técnica sem contexto.
## FORMATO SUGERIDO
- **Contextualização temporal** no início (quando relevante).
- Seções `##` para organizar o conteúdo por tema.
- Parágrafos explicativos ao invés de one-liners.
- Listas para dados brutos (números, estatísticas).
- Blocos de código com linguagem específica.
## ESTILO
- Cite limitações, hipóteses e próximos passos quando relevante.
- Inclua links apenas quando essencial.
- Respostas sempre limpas, sem metadados ou conteúdo oculto.
- Priorize informações recentes e precisas do contexto fornecido.
+59
View File
@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
const TARGET_MODEL = "qwen2.5:0.5b";
export function useAutoLabelingModel() {
const [isReady, setIsReady] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [progress, setProgress] = useState<string>("");
useEffect(() => {
checkAndDownload();
}, []);
const checkAndDownload = async () => {
try {
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
if (installed) {
setIsReady(true);
return;
}
setIsDownloading(true);
// Listen for progress
const unlisten = await listen<string>('download-progress', (event) => {
const payload = typeof event.payload === 'string' ? event.payload : String(event.payload);
// Try to parse as JSON, if fails use as plain text
try {
const data = JSON.parse(payload);
if (data.total && data.completed) {
const percent = Math.round((data.completed / data.total) * 100);
setProgress(`Baixando modelo de IA (${percent}%)`);
} else if (data.status) {
setProgress(data.status);
} else {
setProgress("Baixando...");
}
} catch {
// Use payload as-is if it's not JSON
setProgress(payload || "Baixando...");
}
});
await invoke('pull_model', { name: TARGET_MODEL });
unlisten();
setIsDownloading(false);
setIsReady(true);
} catch (error) {
console.error("Failed to setup auto-labeling model:", error);
setIsDownloading(false);
}
};
return { isReady, isDownloading, progress };
}
+381
View File
@@ -0,0 +1,381 @@
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Message } from './use-chat';
import { removeMetadataNoise } from '@/lib/metadata';
export interface SessionSummary {
id: string;
title: string;
emoji: string;
updated_at: string;
preview: string;
platform: string;
}
const TITLE_MODEL = "qwen2.5:0.5b";
// Helper to generate fallback title from user's first message
function getFallbackTitleFromMessages(messages: Message[]): string {
// Find the first user message
const firstUserMessage = messages.find(msg => msg.role === 'user');
if (firstUserMessage && firstUserMessage.content) {
// Clean the content (remove metadata tags if any)
let content = firstUserMessage.content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Truncate to 50 characters
if (content.length > 50) {
content = content.substring(0, 50).trim() + '...';
}
return content || "Nova Conversa";
}
return "Nova Conversa";
}
export function useChatStorage() {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
const loadSessions = useCallback(async () => {
try {
const list = await invoke<SessionSummary[]>('load_chat_sessions');
setSessions(list);
} catch (error) {
console.error("Failed to load sessions:", error);
}
}, []);
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
try {
const rawMessages = await invoke<any[]>('load_chat_history', { id });
// Convert role from String to type-safe union and ensure proper types
return rawMessages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const, // fallback
content: msg.content || '',
metadata: msg.metadata || undefined
}));
} catch (error) {
console.error("Failed to load history:", error);
return [];
}
}, []);
const deleteSession = useCallback(async (id: string) => {
try {
await invoke('delete_chat_session', { id });
await loadSessions();
if (currentSessionId === id) {
setCurrentSessionId(null);
}
} catch (error) {
console.error("Failed to delete session:", error);
}
}, [currentSessionId, loadSessions]);
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
// Generate title immediately from user's first message
try {
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: TITLE_MODEL,
messages: [
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
{ role: 'user', content: userMessage }
],
stream: false,
}),
});
if (!response.ok) {
// If model not available, use fallback
if (response.status === 404 || response.status >= 500) {
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
}
throw new Error(`Title generation failed with status: ${response.status}`);
}
const data = await response.json();
let title = data.message?.content?.trim() || "";
// If empty from start, use fallback
if (!title || title.length === 0) {
throw new Error("Empty title returned from model");
}
// Remove quotes if present (both single and double)
title = title.replace(/^["']+|["']+$/g, '').trim();
// Remove any metadata tags that might have leaked through
title = title.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Check if title is empty or contains only whitespace/punctuation
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
throw new Error("Title became empty or invalid after cleaning");
}
// Limit title length to reasonable size (max 60 chars for safety)
if (title.length > 60) {
title = title.substring(0, 60).trim() + '...';
}
return title;
} catch (error) {
console.error("Title generation failed:", error);
// Use user message as fallback (truncated)
let fallback = userMessage.trim();
if (fallback.length > 50) {
fallback = fallback.substring(0, 50).trim() + '...';
}
return fallback || "Nova Conversa";
}
};
const generateTitle = async (messages: Message[]): Promise<string> => {
// Only generate if we have enough context (e.g., 2 messages: user + assistant)
// and use a lightweight model
try {
const sanitizedMessages = messages.slice(0, 4).map(m => ({
...m,
content: removeMetadataNoise(m.content),
}));
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: TITLE_MODEL,
messages: [
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
...sanitizedMessages
],
stream: false,
}),
});
if (!response.ok) {
// If model not found or unavailable, throw to trigger fallback
if (response.status === 404 || response.status >= 500) {
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
}
// For other errors, use fallback
throw new Error(`Title generation failed with status: ${response.status}`);
}
const data = await response.json();
let title = removeMetadataNoise(data.message?.content?.trim() || "");
// If empty from start, throw to trigger fallback
if (!title || title.length === 0) {
throw new Error("Empty title returned from model");
}
// Remove quotes if present (both single and double)
title = title.replace(/^["']+|["']+$/g, '').trim();
// Remove any metadata tags that might have leaked through
title = removeMetadataNoise(title);
// Check if title is empty or contains only whitespace/punctuation
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
throw new Error("Title became empty or invalid after cleaning");
}
// Limit title length to reasonable size (max 60 chars for safety)
if (title.length > 60) {
title = title.substring(0, 60).trim() + '...';
}
return title;
} catch (error) {
console.error("Title generation failed:", error);
// Don't return "Nova Conversa" here - let the caller use the fallback
throw error;
}
};
const saveSession = useCallback(async (id: string, messages: Message[], currentTitle?: string) => {
if (messages.length === 0) return;
// Check if session already exists and has a valid title
const existingSession = sessions.find(s => s.id === id);
const existingTitle = existingSession?.title;
// If session already exists with a valid title, always update it
if (existingSession && existingTitle && existingTitle !== "Nova Conversa") {
const title = currentTitle || existingTitle;
// Extract memory context from metadata keywords
const memoryContext: string[] = [];
messages.forEach(msg => {
if (msg.role === 'assistant' && msg.metadata?.keywords) {
const keywords = Array.isArray(msg.metadata.keywords)
? msg.metadata.keywords
: [];
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
}
});
const uniqueMemoryContext = Array.from(new Set(memoryContext));
const platform = typeof navigator !== 'undefined'
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
: 'Unknown';
try {
await invoke('save_chat_session', {
id,
title,
messages,
platform: platform,
memory_context: uniqueMemoryContext
});
await loadSessions();
} catch (error) {
console.error("Failed to save session:", error);
}
return;
}
// For new sessions, only save after title is generated
// Check if we have at least user + assistant (first complete response)
if (messages.length < 2) {
// Don't save yet - wait for first AI response
return;
}
let title: string | null = currentTitle || null;
let shouldGenerateTitle = !currentTitle;
// Check for metadata title in assistant messages (prioritize title over summary)
if (shouldGenerateTitle) {
for (const msg of messages) {
if (msg.role === 'assistant' && msg.metadata) {
// Prioritize title, fallback to summary
if (msg.metadata.title) {
const titleStr = msg.metadata.title.trim();
if (titleStr && titleStr !== "") {
title = titleStr;
shouldGenerateTitle = false;
break;
}
} else if (msg.metadata.summary) {
const summary = msg.metadata.summary.trim();
if (summary && summary !== "") {
title = summary;
shouldGenerateTitle = false;
break;
}
}
}
}
}
// Extract memory context from metadata keywords
const memoryContext: string[] = [];
messages.forEach(msg => {
if (msg.role === 'assistant' && msg.metadata?.keywords) {
const keywords = Array.isArray(msg.metadata.keywords)
? msg.metadata.keywords
: [];
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
}
});
// Deduplicate
const uniqueMemoryContext = Array.from(new Set(memoryContext));
// Detect platform
const platform = typeof navigator !== 'undefined'
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
: 'Unknown';
// If we have title from metadata, save immediately
if (title && !shouldGenerateTitle) {
try {
await invoke('save_chat_session', {
id,
title,
messages,
platform: platform,
memory_context: uniqueMemoryContext
});
await loadSessions();
} catch (error) {
console.error("Failed to save session:", error);
}
return;
}
// Auto-generate title using separate model - only create card when title is ready
if (shouldGenerateTitle && !isGeneratingTitle) {
setIsGeneratingTitle(true);
generateTitle(messages)
.then(async (newTitle) => {
setIsGeneratingTitle(false);
// Now create the card with the generated title
try {
await invoke('save_chat_session', {
id,
title: newTitle,
messages,
platform: platform,
memory_context: uniqueMemoryContext
});
await loadSessions();
} catch (e) {
console.error("Failed to save session with generated title:", e);
setIsGeneratingTitle(false);
}
})
.catch((error) => {
setIsGeneratingTitle(false);
// On error, use fallback title from user's first message
// Only log if it's not an expected error (empty title, model unavailable)
const isExpectedError = error.message?.includes("Empty title") ||
error.message?.includes("not available") ||
error.message?.includes("invalid after cleaning");
if (!isExpectedError) {
console.error("Title generation error:", error);
} else {
console.log(`Title generation failed (expected): ${error.message}`);
}
const fallbackTitle = getFallbackTitleFromMessages(messages);
console.log(`Using fallback title from user message: "${fallbackTitle}"`);
invoke('save_chat_session', {
id,
title: fallbackTitle,
messages,
platform: platform,
memory_context: uniqueMemoryContext
}).then(() => loadSessions()).catch(e => console.error("Failed to save session with fallback:", e));
});
}
}, [loadSessions, isGeneratingTitle, sessions, generateTitle]);
// Initial load
useEffect(() => {
loadSessions();
}, [loadSessions]);
return {
sessions,
currentSessionId,
setCurrentSessionId,
loadSessions,
loadSessionHistory,
saveSession,
deleteSession,
isGeneratingTitle,
setIsGeneratingTitle,
generateTitleFromUserMessage
};
}
+315 -7
View File
@@ -1,16 +1,209 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { removeMetadataNoise } from '@/lib/metadata';
import { useSettingsStore } from '@/store/settings-store';
export interface Message { export interface Message {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
metadata?: any;
}
interface ToolCall {
server_name: string;
tool_name: string;
arguments: any;
}
function parseMetadata(fullContent: string): { content: string, metadata?: any } {
if (!fullContent || fullContent.trim() === '') {
return { content: fullContent || '' };
}
// More flexible regex that handles whitespace and line breaks (case-insensitive)
const metadataRegex = /<metadata>\s*([\s\S]*?)\s*<\/metadata>/gi;
// First, extract all metadata blocks
const metadataMatches: Array<{ match: string, json: any }> = [];
let match;
let lastIndex = 0;
while ((match = metadataRegex.exec(fullContent)) !== null) {
const metadataStr = match[1].trim();
try {
const json = JSON.parse(metadataStr);
metadataMatches.push({ match: match[0], json });
} catch (e) {
// Invalid JSON, just remove the tag
console.error("Failed to parse metadata JSON", e, "Raw:", metadataStr);
}
lastIndex = metadataRegex.lastIndex;
}
// Remove ALL metadata blocks from content
let content = fullContent.replace(/<metadata>\s*[\s\S]*?\s*<\/metadata>/gi, '').trim();
content = removeMetadataNoise(content);
// Also remove common prefixes if they appear at the end of the content
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(content)) {
content = content.replace(regex, '').trim();
}
}
// Ensure we don't return empty content if there was content before metadata
if (!content && fullContent.length > 0) {
// If all content was metadata, keep nothing (shouldn't happen but safe fallback)
content = '';
}
// Return first valid metadata (if any)
const metadata = metadataMatches.length > 0 ? metadataMatches[0].json : undefined;
return { content, metadata };
}
// Helper to strip metadata in real-time during streaming (for display)
function stripMetadataTag(content: string): string {
if (!content) return '';
// Common prefixes that models might output before the metadata block
const prefixes = [
'Bloco oculto de metadados:',
'Metadata:',
'Metadados:',
'Hidden metadata block:',
'JSON metadata:',
'---'
];
let cleaned = content;
// Remove any metadata tags (complete or incomplete) for real-time display
// Case-insensitive regex to catch variations
cleaned = cleaned.replace(/<metadata>[\s\S]*?$/gi, '').replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
// Aggressively strip known prefixes if they appear at the very end of the content
// This helps hide "Bloco oculto de metadados:" while the tag is being generated
for (const prefix of prefixes) {
const regex = new RegExp(`${prefix}\\s*$`, 'i');
if (regex.test(cleaned)) {
cleaned = cleaned.replace(regex, '').trim();
}
}
return removeMetadataNoise(cleaned);
}
// Helper to detect tool calls in LLM response
export function detectToolCalls(content: string, availableTools: any[]): ToolCall[] {
const toolCalls: ToolCall[] = [];
// Simple pattern matching for tool calls
// Pattern: "use tool X with args Y" or "execute X(args)"
// This is a basic implementation - can be enhanced with better NLP
// Look for common patterns
const patterns = [
// Pattern 1: "use [tool_name] with [args]" or "use [tool_name] (args)"
/(?:use|call|execute|run)\s+([a-z_]+)\s+(?:with|\(|\[)\s*([^\n)]+)/gi,
// Pattern 2: "[tool_name](args)"
/([a-z_]+)\s*\(([^)]+)\)/gi,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const potentialToolName = match[1].toLowerCase().trim();
const potentialArgs = match[2]?.trim() || '{}';
// Find matching tool in available tools
for (const toolInfo of availableTools) {
if (toolInfo.tool.name.toLowerCase() === potentialToolName) {
// Try to parse arguments
let args: any = {};
try {
// Try JSON first
args = JSON.parse(potentialArgs);
} catch {
// If not JSON, try to parse key-value pairs
try {
// Simple key=value parsing
const pairs = potentialArgs.split(',').map(p => p.trim());
args = {};
for (const pair of pairs) {
const [key, value] = pair.split('=').map(s => s.trim());
if (key && value) {
args[key] = value.replace(/^["']|["']$/g, '');
}
}
} catch {
// If all fails, use empty object
args = {};
}
}
toolCalls.push({
server_name: toolInfo.server_name,
tool_name: toolInfo.tool.name,
arguments: args,
});
break;
}
}
}
}
return toolCalls;
}
// Helper to execute a tool call
export async function executeToolCall(toolCall: ToolCall): Promise<string> {
try {
const result = await invoke<any>('call_mcp_tool', {
serverName: toolCall.server_name,
toolName: toolCall.tool_name,
arguments: toolCall.arguments,
});
// Format result for display
if (typeof result === 'string') {
return result;
} else if (result.content) {
return result.content;
} else {
return JSON.stringify(result, null, 2);
}
} catch (error) {
console.error('Tool execution error:', error);
return `Error executing tool ${toolCall.tool_name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
} }
export function useChat() { export function useChat() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
const sendMessage = async (content: string, model: string, systemPrompt?: string) => { const sendMessage = async (
content: string,
model: string,
systemPrompt?: string,
options?: {
onBeforeModelRequest?: (pendingMessages: Message[]) => Promise<void>;
}
) => {
setIsLoading(true); setIsLoading(true);
const newMessages: Message[] = [ const newMessages: Message[] = [
@@ -20,9 +213,56 @@ export function useChat() {
setMessages(newMessages); setMessages(newMessages);
// Prepare messages for API (include system prompt if present) // Prepare messages for API (include system prompt if present)
const apiMessages = systemPrompt // Strip metadata from previous messages before sending to context to save tokens?
? [{ role: 'system', content: systemPrompt }, ...newMessages] // Or keep it? The model might benefit from seeing previous metadata.
: newMessages; // Let's keep it for now, but we need to make sure we send what the API expects.
// Ollama API expects { role, content }. It ignores extra fields usually, but let's be safe.
if (options?.onBeforeModelRequest) {
await options.onBeforeModelRequest(newMessages);
}
// Aplicar janela deslizante para limitar contexto
const settings = useSettingsStore.getState();
const maxTokens = settings.contextWindow || 4096;
const maxMessages = 10; // Máximo de mensagens a manter
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// Aplicar janela deslizante: manter últimas N mensagens que cabem no limite de tokens
let truncatedMessages = [...newMessages];
let totalTokens = systemPrompt ? estimateTokens(systemPrompt) : 0;
// Sempre manter a última mensagem do usuário
const lastUserMessage = truncatedMessages[truncatedMessages.length - 1];
truncatedMessages = truncatedMessages.slice(0, -1);
// Adicionar mensagens de trás para frente até atingir o limite
const selectedMessages: Message[] = [];
for (let i = truncatedMessages.length - 1; i >= 0; i--) {
const msg = truncatedMessages[i];
const msgTokens = estimateTokens(msg.content);
// Limitar por número de mensagens E por tokens
if (selectedMessages.length >= maxMessages - 1) break; // -1 porque vamos adicionar a última depois
if (totalTokens + msgTokens > maxTokens * 0.9) break; // 90% do limite para margem de segurança
selectedMessages.unshift(msg);
totalTokens += msgTokens;
}
// Adicionar a última mensagem do usuário
selectedMessages.push(lastUserMessage);
totalTokens += estimateTokens(lastUserMessage.content);
const apiMessages = (systemPrompt
? [{ role: 'system', content: systemPrompt }, ...selectedMessages]
: selectedMessages).map(m => ({
role: m.role,
content: m.content // Send only content, not metadata (unless we want to inject it back)
}));
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
@@ -44,12 +284,21 @@ export function useChat() {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
// Reset raw content buffer for this message
rawContentRef.current = '';
// Add empty assistant message to start streaming into // Add empty assistant message to start streaming into
setMessages(prev => [...prev, { role: 'assistant', content: '' }]); setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
let streamFinished = false;
let metadataProcessed = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) {
streamFinished = true;
break;
}
const chunk = decoder.decode(value, { stream: true }); const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.trim() !== ''); const lines = chunk.split('\n').filter(line => line.trim() !== '');
@@ -58,25 +307,83 @@ export function useChat() {
try { try {
const json = JSON.parse(line); const json = JSON.parse(line);
if (json.message?.content) { if (json.message?.content) {
// Accumulate raw content (including metadata) in ref
rawContentRef.current += json.message.content;
setMessages(prev => { setMessages(prev => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last.role === 'assistant') { 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 [ return [
...prev.slice(0, -1), ...prev.slice(0, -1),
{ ...last, content: last.content + json.message.content } { ...last, content: displayContent }
]; ];
} }
return prev; return prev;
}); });
} }
if (json.done) { 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); setIsLoading(false);
break; // Exit inner loop when done
} }
} catch (e) { } catch (e) {
console.error('Error parsing chunk', e); console.error('Error parsing chunk', e);
} }
} }
} }
// 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);
}
} catch (error: any) { } catch (error: any) {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
console.log('Request aborted'); console.log('Request aborted');
@@ -99,8 +406,9 @@ export function useChat() {
const clearChat = () => { const clearChat = () => {
setMessages([]); setMessages([]);
rawContentRef.current = '';
}; };
return { messages, sendMessage, isLoading, stop, clearChat }; return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
} }
+2
View File
@@ -41,3 +41,5 @@ export function useLocalModels() {
return { models, loading, refresh: fetchModels, deleteModel }; return { models, loading, refresh: fetchModels, deleteModel };
} }
+73
View File
@@ -0,0 +1,73 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
export interface McpServerInfo {
name: string;
status: 'running' | 'stopped' | 'error';
pid?: number;
toolCount?: number;
}
export function useMcpServers() {
const [servers, setServers] = useState<McpServerInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadServers = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const serverStatuses = await invoke<McpServerInfo[]>('list_mcp_server_status');
// Get tool counts for each server
const allTools = await invoke<Array<{ server_name: string; tool: any }>>('get_all_mcp_tools');
// Count tools per server
const toolCounts = new Map<string, number>();
for (const toolInfo of allTools) {
toolCounts.set(toolInfo.server_name, (toolCounts.get(toolInfo.server_name) || 0) + 1);
}
// Enrich server info with tool counts
const enrichedServers = serverStatuses.map(server => ({
...server,
toolCount: toolCounts.get(server.name) || 0,
}));
setServers(enrichedServers);
} catch (err) {
console.error('Failed to load MCP servers:', err);
setError(err instanceof Error ? err.message : 'Failed to load MCP servers');
setServers([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadServers();
// Refresh servers every 10 seconds
const interval = setInterval(() => {
loadServers();
}, 10000);
return () => clearInterval(interval);
}, [loadServers]);
// Get only running servers
const getRunningServers = useCallback((): McpServerInfo[] => {
return servers.filter(server => server.status === 'running');
}, [servers]);
return {
servers,
runningServers: getRunningServers(),
isLoading,
error,
loadServers,
};
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
export interface McpTool {
name: string;
description: string;
input_schema?: any;
}
export interface McpToolInfo {
server_name: string;
tool: McpTool;
}
export function useMcpTools() {
const [tools, setTools] = useState<McpToolInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTools = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const allTools = await invoke<McpToolInfo[]>('get_all_mcp_tools');
setTools(allTools);
} catch (err) {
console.error('Failed to load MCP tools:', err);
setError(err instanceof Error ? err.message : 'Failed to load MCP tools');
setTools([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTools();
// Refresh tools every 30 seconds
const interval = setInterval(() => {
loadTools();
}, 30000);
return () => clearInterval(interval);
}, [loadTools]);
// Generate tools context for prompt injection
const generateToolsContext = useCallback((availableTools: McpToolInfo[], selectedServers?: string[]): string => {
// Filter tools by selected servers if provided
let filteredTools = availableTools;
if (selectedServers && selectedServers.length > 0) {
filteredTools = availableTools.filter(tool => selectedServers.includes(tool.server_name));
}
if (filteredTools.length === 0) {
return '';
}
let context = '\n\n## Ferramentas MCP Disponíveis\n\n';
const header = selectedServers && selectedServers.length > 0
? `Você está usando os servidores MCP selecionados: ${selectedServers.join(', ')}.\n`
: 'Você tem acesso aos servidores MCP listados abaixo.\n';
context += `${header}\n`;
context += '### REGRAS OBRIGATÓRIAS PARA MCP:\n\n';
context += '1. **PRIORIZE RESULTADOS DE MCP**: Se você vir mensagens anteriores marcadas com `[MCP servidor/ferramenta]`, USE-AS como base da sua resposta.\n';
context += '2. **IDENTIFIQUE FONTES**: Quando há dados de MCP disponíveis, cite-os explicitamente (ex: "De acordo com a busca realizada...").\n';
context += '3. **TRATE ERROS**: Se MCP retornou erro (mensagens com ⚠️), informe ao usuário sobre a limitação de forma clara.\n';
context += '4. **SOLICITE FERRAMENTAS**: Use o formato `<tool_name>(arg1=valor1, arg2=valor2)` para solicitar execução de ferramentas.\n';
context += '5. **PROIBIDO**: NUNCA gere blocos `<metadata>`, JSON oculto ou texto como "Metadados:". Apenas texto visível.\n\n';
context += '**Nota**: O sistema executa MCPs automaticamente antes de você receber a mensagem do usuário. Verifique sempre se há resultados MCP nas mensagens anteriores.\n\n';
// Group tools by server
const toolsByServer = new Map<string, McpTool[]>();
for (const toolInfo of filteredTools) {
if (!toolsByServer.has(toolInfo.server_name)) {
toolsByServer.set(toolInfo.server_name, []);
}
toolsByServer.get(toolInfo.server_name)!.push(toolInfo.tool);
}
// Format tools for each server
for (const [serverName, serverTools] of toolsByServer.entries()) {
context += `### Servidor: ${serverName}\n\n`;
for (const tool of serverTools) {
context += `- **${tool.name}**: ${tool.description}\n`;
// Add input schema info if available
if (tool.input_schema) {
const schema = tool.input_schema;
if (schema.properties) {
context += ` - Parâmetros: `;
const params: string[] = [];
for (const [key, value] of Object.entries(schema.properties)) {
const prop = value as any;
params.push(`${key} (${prop.type || 'any'})`);
}
context += params.join(', ') + '\n';
}
}
context += '\n';
}
}
context += '### Como responder quando há dados MCP:\n\n';
context += '1. Verifique se há mensagens com `[MCP ...]` no histórico recente\n';
context += '2. Analise os dados retornados (mesmo que sejam erros)\n';
context += '3. Baseie sua resposta PRINCIPALMENTE nos dados MCP\n';
context += '4. Complemente com seu conhecimento apenas se necessário\n';
context += '5. Apresente tudo em texto limpo, sem metadados ou JSON oculto\n\n';
context += '**Lembre-se**: Resultados MCP são dados em tempo real e devem ter prioridade sobre seu conhecimento interno.\n';
return context;
}, []);
// Get tools context string for prompt injection
const getToolsContext = useCallback((selectedServers?: string[]): string => {
return generateToolsContext(tools, selectedServers);
}, [tools, generateToolsContext]);
return {
tools,
isLoading,
error,
loadTools,
generateToolsContext,
getToolsContext,
};
}
+2
View File
@@ -68,3 +68,5 @@ export function usePromptGenerator() {
return { generatePrompt, isGenerating }; return { generatePrompt, isGenerating };
} }
+91
View File
@@ -0,0 +1,91 @@
import { useState, useCallback } from 'react';
/**
* Hook para gerar query de busca otimizada usando a LLM
*/
export function useQueryGenerator() {
const [isGenerating, setIsGenerating] = useState(false);
/**
* Gera uma query de busca otimizada baseada na pergunta do usuário
* Retorna 'NO_SEARCH' se não precisar de busca
*/
const generateQuery = useCallback(async (
userInput: string,
model: string
): Promise<string> => {
if (!userInput || !userInput.trim()) {
return 'NO_SEARCH';
}
setIsGenerating(true);
try {
// Prompt otimizado para gerar query
const prompt = `Baseado na pergunta do usuário abaixo, gere APENAS uma query de busca otimizada para o Google/DuckDuckGo.
Regras:
- Se a pergunta NÃO precisar de informações da web (ex: "Olá", "Como você está?", cálculos simples, perguntas sobre você), retorne EXATAMENTE: NO_SEARCH
- Se precisar de informações recentes, fatos, notícias, ou dados atualizados, gere uma query curta e direta (máximo 8 palavras)
- A query deve ser em português se a pergunta for em português
Pergunta do usuário: "${userInput}"
Query de busca (ou NO_SEARCH):`;
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
prompt,
stream: false,
options: {
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
num_predict: 50, // Limitar tokens para resposta rápida
},
}),
});
if (!response.ok) {
throw new Error('Falha ao gerar query');
}
const data = await response.json();
const generatedText = (data.response || '').trim();
// Limpar resposta (remover aspas, pontos finais, etc)
let query = generatedText
.replace(/^["']|["']$/g, '') // Remover aspas
.replace(/\.$/, '') // Remover ponto final
.trim();
// Verificar se é NO_SEARCH
if (query.toUpperCase().includes('NO_SEARCH') || query.length < 3) {
return 'NO_SEARCH';
}
return query;
} catch (error) {
console.error('Erro ao gerar query:', error);
// Em caso de erro, tentar usar a própria pergunta como query
// Mas apenas se parecer uma pergunta factual
const lowerInput = userInput.toLowerCase();
const needsSearch = lowerInput.includes('o que') ||
lowerInput.includes('quem') ||
lowerInput.includes('quando') ||
lowerInput.includes('onde') ||
lowerInput.includes('como') ||
lowerInput.includes('por que') ||
lowerInput.includes('qual') ||
lowerInput.includes('quais');
return needsSearch ? userInput : 'NO_SEARCH';
} finally {
setIsGenerating(false);
}
}, []);
return { generateQuery, isGenerating };
}
+2
View File
@@ -27,3 +27,5 @@ export function useSystemMonitor() {
return stats; return stats;
} }
+27
View File
@@ -0,0 +1,27 @@
'use client';
import { useState, useCallback } from 'react';
interface Toast {
title: string;
description?: string;
variant?: 'default' | 'destructive';
}
export function useToast() {
const showToast = useCallback(({ title, description, variant }: Toast) => {
// Simple implementation using console and alert for now
// Can be enhanced later with a proper toast component
if (variant === 'destructive') {
console.error(`[Toast] ${title}: ${description || ''}`);
alert(`Erro: ${title}\n${description || ''}`);
} else {
console.log(`[Toast] ${title}: ${description || ''}`);
}
}, []);
return {
toast: showToast,
};
}
+161
View File
@@ -0,0 +1,161 @@
import { useState, useCallback } from 'react';
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
import { useSettingsStore } from '@/store/settings-store';
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
export interface WebSearchState {
isEnabled: boolean;
status: SearchStatus;
currentQuery: string;
scrapedSources: ScrapedContent[];
error: string | null;
}
const DEFAULT_STATE: WebSearchState = {
isEnabled: true,
status: 'idle',
currentQuery: '',
scrapedSources: [],
error: null,
};
/**
* Hook para gerenciar estado do Web Search
*/
export function useWebSearch() {
const [state, setState] = useState<WebSearchState>(() => {
// Carregar preferência do localStorage
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('webSearchEnabled');
return {
...DEFAULT_STATE,
isEnabled: saved !== null ? saved === 'true' : true,
};
}
return DEFAULT_STATE;
});
/**
* Ativa/desativa Web Search
*/
const setEnabled = useCallback((enabled: boolean) => {
setState(prev => ({ ...prev, isEnabled: enabled }));
if (typeof window !== 'undefined') {
localStorage.setItem('webSearchEnabled', String(enabled));
}
}, []);
/**
* Executa busca
*/
const search = useCallback(async (query: string, limit: number = 3, excludedDomains: string[] = []): Promise<ScrapedContent[]> => {
if (!query || !query.trim()) {
return [];
}
setState(prev => ({
...prev,
status: 'searching',
currentQuery: query,
error: null,
scrapedSources: [],
}));
try {
// Buscar configurações do store
const settings = useSettingsStore.getState();
// Construir SearchConfig a partir das configurações
const searchConfig: SearchConfig = {
maxConcurrentTabs: settings.webSearch.maxConcurrentTabs,
totalSourcesLimit: settings.webSearch.totalSourcesLimit,
categories: settings.webSearch.categories,
userCustomSites: settings.webSearch.userCustomSites,
excludedDomains: excludedDomains.length > 0 ? excludedDomains : settings.webSearch.excludedDomains,
};
// Fase 1: Buscando
setState(prev => ({ ...prev, status: 'searching' }));
// Fase 2: Scraping (o serviço já faz isso internamente)
setState(prev => ({ ...prev, status: 'scraping' }));
const results = await webSearchService.search(query, limit, excludedDomains, searchConfig);
setState(prev => ({
...prev,
status: 'completed',
scrapedSources: results,
error: null,
}));
return results;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao buscar';
setState(prev => ({
...prev,
status: 'error',
error: errorMessage,
scrapedSources: [],
}));
throw error;
}
}, []);
/**
* Extrai conteúdo de uma URL
*/
const extractUrl = useCallback(async (url: string): Promise<ScrapedContent> => {
setState(prev => ({
...prev,
status: 'scraping',
error: null,
}));
try {
const result = await webSearchService.extractUrl(url);
setState(prev => ({
...prev,
status: 'completed',
error: null,
}));
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao extrair URL';
setState(prev => ({
...prev,
status: 'error',
error: errorMessage,
}));
throw error;
}
}, []);
/**
* Reseta o estado
*/
const reset = useCallback(() => {
setState({
...DEFAULT_STATE,
isEnabled: state.isEnabled, // Manter preferência
});
}, [state.isEnabled]);
/**
* Limpa cache
*/
const clearCache = useCallback(() => {
webSearchService.clearCache();
}, []);
return {
...state,
setEnabled,
search,
extractUrl,
reset,
clearCache,
};
}
+23
View File
@@ -0,0 +1,23 @@
const METADATA_BLOCK_REGEX = /<metadata>[\s\S]*?<\/metadata>/gi;
const METADATA_TAIL_REGEX = /(metadados?|metadata|hidden metadata block|bloco oculto de metadados|json metadata)\s*:?\s*/gi;
export function removeMetadataNoise(text: string): string {
if (!text) {
return '';
}
let cleaned = text.replace(METADATA_BLOCK_REGEX, '').trim();
// Remove any trailing sections that start with common metadata prefixes
cleaned = cleaned.replace(
/(metadados?|metadata|hidden metadata block|bloco oculto de metadados|json metadata)\s*:?\s*[\s\S]*$/gi,
'',
);
// Remove standalone prefixes that might appear inline
cleaned = cleaned.replace(METADATA_TAIL_REGEX, '');
return cleaned.trim();
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Sanitiza conteúdo extraído da web antes de injetar no prompt da IA
* Remove caracteres de controle, limita tamanho e previne prompt injection
*/
export interface SanitizeOptions {
maxLength?: number;
removeControlChars?: boolean;
removeHiddenText?: boolean;
}
const DEFAULT_OPTIONS: Required<SanitizeOptions> = {
maxLength: 10000, // Limite padrão de ~10k caracteres
removeControlChars: true,
removeHiddenText: true,
};
/**
* Remove caracteres de controle invisíveis que podem ser usados para prompt injection
*/
function removeControlCharacters(text: string): string {
// Remove caracteres de controle (0x00-0x1F, exceto \n, \r, \t)
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
/**
* Remove texto oculto (zero-width spaces, directional markers, etc)
*/
function removeHiddenText(text: string): string {
// Remove zero-width spaces e outros caracteres invisíveis
return text
.replace(/[\u200B-\u200D\uFEFF]/g, '') // Zero-width spaces
.replace(/[\u202A-\u202E]/g, '') // Directional markers
.replace(/[\u2060-\u206F]/g, ''); // Word joiners e outros
}
/**
* Remove tags HTML/XML que podem conter instruções maliciosas
*/
function removeSuspiciousTags(text: string): string {
// Remove tags script, style, meta que podem conter instruções
return text
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<meta[^>]*>/gi, '')
.replace(/<!--[\s\S]*?-->/g, ''); // Remove comentários HTML
}
/**
* Limita o tamanho do texto e adiciona indicador se foi truncado
*/
function limitLength(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
// Truncar em um ponto "seguro" (final de palavra/sentença)
const truncated = text.substring(0, maxLength);
const lastSpace = truncated.lastIndexOf(' ');
const lastNewline = truncated.lastIndexOf('\n');
const cutPoint = Math.max(lastSpace, lastNewline);
if (cutPoint > maxLength * 0.9) {
// Se encontrou um bom ponto de corte próximo ao limite
return truncated.substring(0, cutPoint) + '\n\n[... conteúdo truncado para economizar tokens ...]';
}
return truncated + '\n\n[... conteúdo truncado para economizar tokens ...]';
}
/**
* Sanitiza conteúdo web para prevenir prompt injection e controlar tamanho
*
* @param content - Conteúdo bruto extraído da web
* @param options - Opções de sanitização
* @returns Conteúdo sanitizado e seguro para injetar no prompt
*/
export function sanitizeWebContent(
content: string,
options: SanitizeOptions = {}
): string {
const opts = { ...DEFAULT_OPTIONS, ...options };
let sanitized = content;
// 1. Remover tags suspeitas primeiro
sanitized = removeSuspiciousTags(sanitized);
// 2. Remover caracteres de controle
if (opts.removeControlChars) {
sanitized = removeControlCharacters(sanitized);
}
// 3. Remover texto oculto
if (opts.removeHiddenText) {
sanitized = removeHiddenText(sanitized);
}
// 4. Normalizar espaços em branco excessivos
sanitized = sanitized.replace(/\s{3,}/g, ' '); // Múltiplos espaços -> um espaço
sanitized = sanitized.replace(/\n{4,}/g, '\n\n\n'); // Múltiplas quebras -> máximo 3
// 5. Limitar tamanho
sanitized = limitLength(sanitized, opts.maxLength);
return sanitized.trim();
}
/**
* Sanitiza múltiplas fontes e combina
*/
export function sanitizeWebSources(
sources: Array<{ markdown: string; url: string; title: string }>,
options: SanitizeOptions = {}
): string {
const sanitizedSources = sources.map((source, index) => {
const sanitized = sanitizeWebContent(source.markdown, options);
return `---\nFonte ${index + 1}: ${source.title}\nURL: ${source.url}\n---\n\n${sanitized}`;
});
return sanitizedSources.join('\n\n---\n\n');
}
+10 -1
View File
@@ -9,14 +9,21 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tauri-apps/api": "^2.9.0", "@tauri-apps/api": "^2.9.0",
"@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
@@ -27,8 +34,10 @@
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+329
View File
@@ -8,12 +8,21 @@ importers:
.: .:
dependencies: dependencies:
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.16 specifier: ^2.1.16
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-progress': '@radix-ui/react-progress':
specifier: ^1.1.8 specifier: ^1.1.8
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -23,15 +32,27 @@ importers:
'@radix-ui/react-separator': '@radix-ui/react-separator':
specifier: ^1.1.8 specifier: ^1.1.8
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slider':
specifier: ^1.3.6
version: 1.3.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/[email protected])([email protected]) version: 1.2.4(@types/[email protected])([email protected])
'@radix-ui/react-switch':
specifier: ^1.2.6
version: 1.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.13 specifier: ^1.1.13
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.9.0 specifier: ^2.9.0
version: 2.9.0 version: 2.9.0
'@types/react-syntax-highlighter':
specifier: ^15.5.13
version: 15.5.13
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -62,12 +83,18 @@ importers:
react-resizable-panels: react-resizable-panels:
specifier: ^3.0.6 specifier: ^3.0.6
version: 3.0.6([email protected]([email protected]))([email protected]) version: 3.0.6([email protected]([email protected]))([email protected])
react-syntax-highlighter:
specifier: ^16.1.0
version: 16.1.0([email protected])
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
tailwind-merge: tailwind-merge:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.0 version: 3.4.0
zustand:
specifier: ^5.0.8
version: 5.0.8(@types/[email protected])([email protected])
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@@ -164,6 +191,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/[email protected]':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]': '@babel/[email protected]':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -502,6 +533,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -621,6 +665,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies: peerDependencies:
@@ -634,6 +691,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies: peerDependencies:
@@ -751,6 +821,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies: peerDependencies:
@@ -769,6 +852,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies: peerDependencies:
@@ -782,6 +878,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies: peerDependencies:
@@ -1074,11 +1183,17 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
'@types/[email protected]':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
'@types/react': ^19.2.0 '@types/react': ^19.2.0
'@types/[email protected]':
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==}
@@ -1797,6 +1912,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
[email protected]:
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1829,6 +1947,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
[email protected]: [email protected]:
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
peerDependencies: peerDependencies:
@@ -1940,18 +2062,30 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
[email protected]: [email protected]:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
[email protected]:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
[email protected]: [email protected]:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
[email protected]:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
[email protected]:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2261,6 +2395,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2585,6 +2722,10 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
[email protected]:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2657,6 +2798,12 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==}
engines: {node: '>= 16.20.2'}
peerDependencies:
react: '>= 0.14.0'
[email protected]: [email protected]:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2665,6 +2812,9 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3086,6 +3236,24 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
[email protected]:
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -3170,6 +3338,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.28.5 '@babel/types': 7.28.5
'@babel/[email protected]': {}
'@babel/[email protected]': '@babel/[email protected]':
dependencies: dependencies:
'@babel/code-frame': 7.27.1 '@babel/code-frame': 7.27.1
@@ -3468,6 +3638,22 @@ snapshots:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected]) '@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
react: 19.2.0
react-dom: 19.2.0([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
@@ -3578,6 +3764,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.0
react-dom: 19.2.0([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3604,6 +3799,29 @@ snapshots:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected]) '@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-focus-guards': 1.1.3(@types/[email protected])([email protected])
'@radix-ui/react-focus-scope': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
aria-hidden: 1.2.6
react: 19.2.0
react-dom: 19.2.0([email protected])
react-remove-scroll: 2.7.1(@types/[email protected])([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.6([email protected]([email protected]))([email protected]) '@floating-ui/react-dom': 2.1.6([email protected]([email protected]))([email protected])
@@ -3725,6 +3943,25 @@ snapshots:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected]) '@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
react: 19.2.0
react-dom: 19.2.0([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected])([email protected])': '@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) '@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
@@ -3739,6 +3976,21 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
react: 19.2.0
react-dom: 19.2.0([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': '@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -3755,6 +4007,26 @@ snapshots:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected]) '@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.0
react-dom: 19.2.0([email protected])
optionalDependencies:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected])([email protected])': '@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
@@ -3987,10 +4259,16 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/[email protected]': {}
'@types/[email protected](@types/[email protected])': '@types/[email protected](@types/[email protected])':
dependencies: dependencies:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/[email protected]':
dependencies:
'@types/react': 19.2.6
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
@@ -4890,6 +5168,10 @@ snapshots:
dependencies: dependencies:
reusify: 1.1.0 reusify: 1.1.0
[email protected]:
dependencies:
format: 0.2.2
[email protected]([email protected]): [email protected]([email protected]):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
@@ -4918,6 +5200,8 @@ snapshots:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]): [email protected]([email protected]([email protected]))([email protected]):
dependencies: dependencies:
motion-dom: 12.23.23 motion-dom: 12.23.23
@@ -5021,6 +5305,10 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
[email protected]:
dependencies:
'@types/hast': 3.0.4
[email protected]: [email protected]:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -5045,12 +5333,24 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
[email protected]:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
[email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -5333,6 +5633,11 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
[email protected]:
dependencies:
fault: 1.0.4
highlight.js: 10.7.3
[email protected]: [email protected]:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -5871,6 +6176,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -5950,6 +6257,16 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.6 '@types/react': 19.2.6
[email protected]([email protected]):
dependencies:
'@babel/runtime': 7.28.4
highlight.js: 10.7.3
highlightjs-vue: 1.0.0
lowlight: 1.20.0
prismjs: 1.30.0
react: 19.2.0
refractor: 5.0.0
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -5963,6 +6280,13 @@ snapshots:
get-proto: 1.0.1 get-proto: 1.0.1
which-builtin-type: 1.2.1 which-builtin-type: 1.2.1
[email protected]:
dependencies:
'@types/hast': 3.0.4
'@types/prismjs': 1.26.5
hastscript: 9.0.1
parse-entities: 4.0.2
[email protected]: [email protected]:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -6561,4 +6885,9 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected](@types/[email protected])([email protected]):
optionalDependencies:
'@types/react': 19.2.6
react: 19.2.0
[email protected]: {} [email protected]: {}
+10168
View File
File diff suppressed because it is too large. Load diff
+330
View File
@@ -0,0 +1,330 @@
import { invoke } from '@tauri-apps/api/core';
import { SearchCategory } from '@/store/settings-store';
export interface ScrapedContent {
title: string;
url: string;
content: string;
markdown: string;
cached?: boolean;
}
export interface SearchConfig {
maxConcurrentTabs: number;
totalSourcesLimit: number;
categories: SearchCategory[];
userCustomSites: string[];
excludedDomains: string[];
}
interface CachedResult {
results: ScrapedContent[];
timestamp: number;
query: string;
}
interface PendingRequest {
query: string;
limit: number;
excludedDomains: string[];
searchConfig?: SearchConfig;
resolve: (results: ScrapedContent[]) => void;
reject: (error: Error) => void;
}
/**
* Serviço de Web Search com cache e rate limiting
*/
class WebSearchService {
private cache: Map<string, CachedResult> = new Map();
private readonly CACHE_TTL = 60 * 60 * 1000; // 1 hora
private readonly RATE_LIMIT_DELAY = 5000; // 5 segundos
private lastSearchTime = 0;
private pendingRequest: PendingRequest | null = null;
private rateLimitQueue: PendingRequest[] = [];
constructor() {
// Carregar cache do localStorage na inicialização
this.loadCacheFromStorage();
}
/**
* Carrega cache do localStorage
*/
private loadCacheFromStorage(): void {
// Verificar se estamos no ambiente do browser (não SSR)
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return;
}
try {
const stored = localStorage.getItem('webSearchCache');
if (stored) {
const parsed = JSON.parse(stored) as Array<[string, CachedResult]>;
const now = Date.now();
// Filtrar entradas expiradas
for (const [key, value] of parsed) {
if (now - value.timestamp < this.CACHE_TTL) {
this.cache.set(key, value);
}
}
}
} catch (error) {
console.warn('Erro ao carregar cache do Web Search:', error);
}
}
/**
* Salva cache no localStorage
*/
private saveCacheToStorage(): void {
// Verificar se estamos no ambiente do browser (não SSR)
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return;
}
try {
const entries = Array.from(this.cache.entries());
localStorage.setItem('webSearchCache', JSON.stringify(entries));
} catch (error) {
console.warn('Erro ao salvar cache do Web Search:', error);
}
}
/**
* Gera chave de cache baseada na query
*/
private getCacheKey(query: string, limit?: number): string {
const normalizedQuery = query.toLowerCase().trim();
return `search:${normalizedQuery}:${limit || 3}`;
}
/**
* Verifica se há resultado em cache válido
*/
private getCachedResult(query: string, limit?: number): ScrapedContent[] | null {
const key = this.getCacheKey(query, limit);
const cached = this.cache.get(key);
if (!cached) return null;
const now = Date.now();
if (now - cached.timestamp > this.CACHE_TTL) {
// Cache expirado, remover
this.cache.delete(key);
this.saveCacheToStorage();
return null;
}
return cached.results;
}
/**
* Salva resultado no cache
*/
private setCachedResult(query: string, results: ScrapedContent[], limit?: number): void {
const key = this.getCacheKey(query, limit);
this.cache.set(key, {
results,
timestamp: Date.now(),
query: query.toLowerCase().trim(),
});
this.saveCacheToStorage();
}
/**
* Processa fila de rate limiting
*/
private async processRateLimitQueue(): Promise<void> {
if (this.rateLimitQueue.length === 0) return;
const now = Date.now();
const timeSinceLastSearch = now - this.lastSearchTime;
if (timeSinceLastSearch < this.RATE_LIMIT_DELAY) {
// Aguardar antes de processar próximo
const waitTime = this.RATE_LIMIT_DELAY - timeSinceLastSearch;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
const nextRequest = this.rateLimitQueue.shift();
if (nextRequest) {
this.lastSearchTime = Date.now();
this.pendingRequest = nextRequest;
try {
const results = await this.executeSearch(
nextRequest.query,
nextRequest.limit,
nextRequest.excludedDomains,
nextRequest.searchConfig
);
nextRequest.resolve(results);
} catch (error) {
nextRequest.reject(error instanceof Error ? error : new Error('Erro desconhecido'));
} finally {
this.pendingRequest = null;
// Processar próximo da fila
this.processRateLimitQueue();
}
}
}
/**
* Executa busca no backend (sem cache/rate limit)
*/
private async executeSearch(
query: string,
limit: number = 3,
excludedDomains: string[] = [],
searchConfig?: SearchConfig
): Promise<ScrapedContent[]> {
try {
// Se SearchConfig foi fornecido, converter para formato Rust
let rustConfig: any = undefined;
if (searchConfig) {
rustConfig = {
max_concurrent_tabs: searchConfig.maxConcurrentTabs,
total_sources_limit: searchConfig.totalSourcesLimit,
categories: searchConfig.categories.map(cat => ({
id: cat.id,
name: cat.name,
base_sites: cat.baseSites,
enabled: cat.enabled,
})),
user_custom_sites: searchConfig.userCustomSites,
excluded_domains: searchConfig.excludedDomains,
};
}
const results = await invoke<ScrapedContent[]>('search_and_extract_content', {
query: query.trim(),
limit: searchConfig ? undefined : limit, // Só usar limit se não tiver config
excluded_domains: searchConfig ? undefined : (excludedDomains.length > 0 ? excludedDomains : undefined),
search_config: rustConfig,
});
return results || [];
} catch (error) {
console.error('Erro ao buscar conteúdo:', error);
throw error instanceof Error ? error : new Error('Falha ao buscar conteúdo na web');
}
}
/**
* Busca conteúdo na web com cache e rate limiting
*/
async search(
query: string,
limit: number = 3,
excludedDomains: string[] = [],
searchConfig?: SearchConfig
): Promise<ScrapedContent[]> {
if (!query || !query.trim()) {
return [];
}
// Verificar cache primeiro
const cached = this.getCachedResult(query, limit);
if (cached) {
return cached.map(r => ({ ...r, cached: true }));
}
// Se já há uma requisição pendente para a mesma query, aguardar
if (this.pendingRequest && this.pendingRequest.query.toLowerCase() === query.toLowerCase()) {
return new Promise((resolve, reject) => {
const checkPending = () => {
if (this.pendingRequest?.query.toLowerCase() === query.toLowerCase()) {
setTimeout(checkPending, 100);
} else {
// Tentar cache novamente após requisição completar
const cachedAfter = this.getCachedResult(query, limit);
if (cachedAfter) {
resolve(cachedAfter.map(r => ({ ...r, cached: true })));
} else {
reject(new Error('Requisição pendente cancelada'));
}
}
};
checkPending();
});
}
// Verificar rate limiting
const now = Date.now();
const timeSinceLastSearch = now - this.lastSearchTime;
if (timeSinceLastSearch < this.RATE_LIMIT_DELAY) {
// Adicionar à fila (armazenar excludedDomains na requisição)
return new Promise((resolve, reject) => {
this.rateLimitQueue.push({
query,
limit,
excludedDomains,
searchConfig,
resolve: (results) => {
this.setCachedResult(query, results, limit);
resolve(results);
},
reject,
});
this.processRateLimitQueue();
});
}
// Executar imediatamente
this.lastSearchTime = now;
this.pendingRequest = { query, limit, excludedDomains, searchConfig, resolve: () => {}, reject: () => {} };
try {
const results = await this.executeSearch(query, limit, excludedDomains, searchConfig);
this.setCachedResult(query, results, limit);
this.pendingRequest = null;
return results;
} catch (error) {
this.pendingRequest = null;
throw error;
} finally {
// Processar fila se houver
this.processRateLimitQueue();
}
}
/**
* Extrai conteúdo de uma URL específica
*/
async extractUrl(url: string): Promise<ScrapedContent> {
try {
const result = await invoke<ScrapedContent>('extract_url_content', { url });
return result;
} catch (error) {
console.error('Erro ao extrair URL:', error);
throw error instanceof Error ? error : new Error('Falha ao extrair conteúdo da URL');
}
}
/**
* Limpa o cache
*/
clearCache(): void {
this.cache.clear();
// Verificar se estamos no ambiente do browser (não SSR)
if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') {
localStorage.removeItem('webSearchCache');
}
}
/**
* Reseta o rate limiter (útil para testes ou após erros)
*/
resetRateLimit(): void {
this.lastSearchTime = 0;
this.pendingRequest = null;
this.rateLimitQueue = [];
}
}
// Singleton
export const webSearchService = new WebSearchService();
+1543 -48
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -23,5 +23,22 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4" log = "0.4"
tauri = { version = "2.9.2", features = [] } tauri = { version = "2.9.2", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
reqwest = { version = "0.12", features = ["json"] } tauri-plugin-shell = "2"
tauri-plugin-notification = "2"
reqwest = { version = "0.12", features = ["json", "stream"] }
sysinfo = "0.36.1" sysinfo = "0.36.1"
uuid = { version = "1.10", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["full"] }
headless_chrome = "1.0"
scraper = "0.18"
readability = "0.3"
html2text = "0.5"
anyhow = "1.0"
url = "2.5"
urlencoding = "2.1"
zip = "0.6"
rand = "0.8"
tokio-cron-scheduler = "0.9"
futures-util = "0.3"
regex = "1.10"
+1284 -4
View File
File diff suppressed because it is too large. Load diff
+153
View File
@@ -0,0 +1,153 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Mensagem para o Ollama API
#[derive(Debug, Serialize)]
struct OllamaMessage {
role: String,
content: String,
}
/// Request para chat do Ollama
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
stream: bool,
}
/// Response do Ollama (streaming)
#[derive(Debug, Deserialize)]
struct OllamaChunk {
message: Option<OllamaMessageResponse>,
done: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct OllamaMessageResponse {
content: String,
}
/// Cliente Ollama headless (para execução em background)
pub struct OllamaClient {
base_url: String,
client: reqwest::Client,
}
impl OllamaClient {
/// Cria novo cliente Ollama
pub fn new(base_url: Option<String>) -> Self {
let base = base_url.unwrap_or_else(|| "http://localhost:11434".to_string());
Self {
base_url: base,
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) // 5 minutos timeout
.build()
.expect("Failed to create HTTP client"),
}
}
/// Verifica se o Ollama está rodando
pub async fn check_connection(&self) -> Result<(), String> {
let url = format!("{}/api/tags", self.base_url);
let response = self.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
if response.status().is_success() {
Ok(())
} else {
Err(format!("Ollama returned status: {}", response.status()))
}
}
/// Envia prompt para o Ollama e retorna resposta completa (não streaming)
pub async fn query_ollama_headless(
&self,
model: &str,
system_prompt: Option<&str>,
user_prompt: &str,
) -> Result<String, String> {
// Verificar conexão primeiro
self.check_connection().await?;
let mut messages = Vec::new();
// Adicionar system prompt se fornecido
if let Some(sys_prompt) = system_prompt {
messages.push(OllamaMessage {
role: "system".to_string(),
content: sys_prompt.to_string(),
});
}
// Adicionar mensagem do usuário
messages.push(OllamaMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
});
let request = OllamaChatRequest {
model: model.to_string(),
messages,
stream: true, // Streaming para economizar memória
};
let url = format!("{}/api/chat", self.base_url);
let mut response = self.client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
// Ler stream e acumular resposta
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);
// Processar cada linha (Ollama envia JSON por linha)
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);
}
// Se done, parar
if chunk_data.done == Some(true) {
break;
}
}
Err(e) => {
log::debug!("Failed to parse Ollama chunk: {} - Line: {}", e, line);
// Continuar mesmo com erro de parse
}
}
}
}
if full_response.is_empty() {
return Err("Empty response from Ollama".to_string());
}
Ok(full_response.trim().to_string())
}
}
+163
View File
@@ -0,0 +1,163 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
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)]
#[serde(rename_all = "snake_case")]
pub enum TaskAction {
/// Pesquisar na web e resumir com IA
SearchAndSummarize {
query: String,
model: String,
max_results: usize,
},
/// Apenas enviar notificação (ping)
JustPing {
message: String,
},
/// Executar prompt customizado no Ollama
CustomPrompt {
prompt: String,
model: String,
},
}
/// Estrutura de uma Task agendada
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentinelTask {
pub id: String,
pub label: String,
pub cron_schedule: String, // Ex: "0 8 * * *" (Todo dia às 8h)
pub action: TaskAction,
pub enabled: bool,
pub last_run: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Estado do scheduler (gerenciado pelo Tauri)
pub type SchedulerState = Arc<Mutex<SchedulerService>>;
/// Serviço de agendamento de tarefas
pub struct SchedulerService {
tasks: HashMap<String, SentinelTask>,
tasks_file: PathBuf,
app_handle: Option<AppHandle>,
}
impl SchedulerService {
/// Cria novo serviço de scheduler
pub fn new(app_handle: AppHandle) -> Result<Self, String> {
let app_data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let tasks_file = app_data_dir.join("tasks.json");
// Carregar tasks existentes
let tasks = if tasks_file.exists() {
match fs::read_to_string(&tasks_file) {
Ok(content) => {
match serde_json::from_str::<Vec<SentinelTask>>(&content) {
Ok(loaded_tasks) => {
loaded_tasks
.into_iter()
.map(|task| (task.id.clone(), task))
.collect()
}
Err(e) => {
log::warn!("Failed to parse tasks.json: {}. Starting with empty tasks.", e);
HashMap::new()
}
}
}
Err(e) => {
log::warn!("Failed to read tasks.json: {}. Starting with empty tasks.", e);
HashMap::new()
}
}
} else {
HashMap::new()
};
Ok(Self {
tasks,
tasks_file,
app_handle: Some(app_handle),
})
}
/// Salva tasks no arquivo
fn save_tasks(&self) -> Result<(), String> {
let tasks_vec: Vec<&SentinelTask> = self.tasks.values().collect();
let json = serde_json::to_string_pretty(&tasks_vec)
.map_err(|e| format!("Failed to serialize tasks: {}", e))?;
// Escrever em arquivo temporário primeiro (atomic write)
let temp_file = self.tasks_file.with_extension("json.tmp");
fs::write(&temp_file, json)
.map_err(|e| format!("Failed to write temp tasks file: {}", e))?;
fs::rename(&temp_file, &self.tasks_file)
.map_err(|e| format!("Failed to rename temp file: {}", e))?;
Ok(())
}
/// Adiciona ou atualiza uma task
pub fn upsert_task(&mut self, task: SentinelTask) -> Result<(), String> {
self.tasks.insert(task.id.clone(), task);
self.save_tasks()?;
Ok(())
}
/// Remove uma task
pub fn remove_task(&mut self, id: &str) -> Result<(), String> {
self.tasks.remove(id);
self.save_tasks()?;
Ok(())
}
/// Lista todas as tasks
pub fn list_tasks(&self) -> Vec<SentinelTask> {
self.tasks.values().cloned().collect()
}
/// Obtém uma task por ID
pub fn get_task(&self, id: &str) -> Option<&SentinelTask> {
self.tasks.get(id)
}
/// Atualiza última execução de uma task
pub fn update_last_run(&mut self, id: &str, timestamp: DateTime<Utc>) -> Result<(), String> {
if let Some(task) = self.tasks.get_mut(id) {
task.last_run = Some(timestamp);
self.save_tasks()?;
}
Ok(())
}
/// Obtém tasks habilitadas
pub fn get_enabled_tasks(&self) -> Vec<&SentinelTask> {
self.tasks.values().filter(|t| t.enabled).collect()
}
}
/// Helper para obter diretório de tasks
pub fn get_tasks_file_path(app_handle: &AppHandle) -> Result<PathBuf, String> {
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("tasks.json"))
}
+128
View File
@@ -0,0 +1,128 @@
use crate::scheduler::{SchedulerService, SchedulerState};
use crate::task_executor::execute_task;
use tokio_cron_scheduler::{Job, JobScheduler};
use std::sync::Arc;
use tauri::AppHandle;
use chrono::Utc;
/// Inicia o loop do scheduler
pub async fn start_scheduler_loop(
app_handle: AppHandle,
scheduler_state: SchedulerState,
_browser_state: Option<()>, // Não usado mais - browser será criado quando necessário
ollama_url: Option<String>,
) -> Result<(), String> {
let mut sched = JobScheduler::new()
.await
.map_err(|e| format!("Failed to create job scheduler: {}", e))?;
// Carregar tasks e agendar
reload_scheduled_tasks(
&mut sched,
&app_handle,
&scheduler_state,
ollama_url.clone(),
).await?;
// Iniciar scheduler em background
tokio::spawn(async move {
if let Err(e) = sched.start().await {
log::error!("Scheduler error: {}", e);
}
});
log::info!("Scheduler loop iniciado");
Ok(())
}
/// Recarrega tasks do scheduler
pub async fn reload_scheduled_tasks(
sched: &mut JobScheduler,
app_handle: &AppHandle,
scheduler_state: &SchedulerState,
ollama_url: Option<String>,
) -> Result<(), String> {
// Limpar jobs existentes
sched.shutdown().await.ok();
*sched = JobScheduler::new()
.await
.map_err(|e| format!("Failed to recreate scheduler: {}", e))?;
let scheduler = scheduler_state.lock().await;
let enabled_tasks = scheduler.get_enabled_tasks();
for task in enabled_tasks {
// Clonar valores ANTES de mover para a closure
let task_id_for_job = task.id.clone();
let task_id_for_log = task.id.clone();
let task_label_for_job = task.label.clone();
let task_label_for_log = task.label.clone();
let cron_expr = task.cron_schedule.clone();
let app_handle_clone = app_handle.clone();
let scheduler_clone = scheduler_state.clone();
let ollama_url_clone = ollama_url.clone();
// Criar job para esta task
let job = Job::new_async(cron_expr.as_str(), move |_uuid, _l| {
let task_id = task_id_for_job.clone();
let task_label = task_label_for_job.clone();
let app_handle = app_handle_clone.clone();
let scheduler = scheduler_clone.clone();
let ollama_url = ollama_url_clone.clone();
Box::pin(async move {
log::info!("Executando task agendada: {} ({})", task_label, task_id);
// Obter task atualizada
let task_opt = {
let sched = scheduler.lock().await;
sched.get_task(&task_id).cloned()
};
if let Some(task) = task_opt {
if !task.enabled {
log::info!("Task {} está desabilitada, pulando", task_id);
return;
}
// Obter browser - precisa acessar via app_handle
let browser_arc = {
// Criar browser diretamente se necessário
use crate::web_scraper::create_browser;
match create_browser() {
Ok(b) => Arc::new(b),
Err(e) => {
log::error!("Erro ao criar browser para task {}: {}", task_id, e);
return;
}
}
};
// Executar task
match execute_task(&task, app_handle.clone(), browser_arc, ollama_url).await {
Ok(_) => {
// Atualizar last_run
let mut sched = scheduler.lock().await;
let _ = sched.update_last_run(&task_id, Utc::now());
log::info!("Task {} executada com sucesso", task_id);
}
Err(e) => {
log::error!("Erro ao executar task {}: {}", task_id, e);
}
}
} else {
log::warn!("Task {} não encontrada", task_id);
}
})
})
.map_err(|e| format!("Failed to create job for task {}: {}", task_id_for_log, e))?;
sched.add(job).await
.map_err(|e| format!("Failed to add job to scheduler: {}", e))?;
log::info!("Task '{}' agendada com cron: {}", task_label_for_log, cron_expr);
}
Ok(())
}
+276
View File
@@ -0,0 +1,276 @@
use crate::scheduler::{SentinelTask, TaskAction};
use crate::ollama_client::OllamaClient;
use crate::web_scraper::search_and_scrape;
use crate::{Message, ChatSession, get_chats_dir};
use std::sync::Arc;
use std::fs;
use headless_chrome::Browser;
use chrono::Utc;
use tauri::AppHandle;
use tauri_plugin_notification::NotificationExt;
use sysinfo::System;
/// Executa uma task agendada
pub async fn execute_task(
task: &SentinelTask,
app_handle: AppHandle,
browser: Arc<Browser>,
ollama_url: Option<String>,
) -> Result<(), String> {
log::info!("Executando task: {} ({})", task.label, task.id);
let client = OllamaClient::new(ollama_url);
match &task.action {
TaskAction::SearchAndSummarize { query, model, max_results } => {
execute_search_and_summarize(
task,
query,
model,
*max_results,
&app_handle,
browser,
&client,
).await
}
TaskAction::JustPing { message } => {
execute_just_ping(task, message, &app_handle).await
}
TaskAction::CustomPrompt { prompt, model } => {
execute_custom_prompt(
task,
prompt,
model,
&app_handle,
&client,
).await
}
}
}
/// Executa pesquisa e resumo
async fn execute_search_and_summarize(
task: &SentinelTask,
query: &str,
model: &str,
max_results: usize,
app_handle: &AppHandle,
browser: Arc<Browser>,
ollama_client: &OllamaClient,
) -> Result<(), String> {
// 1. Buscar conteúdo na web
log::info!("Buscando conteúdo para: {}", query);
let scraped = search_and_scrape(query, max_results, browser, vec![])
.await
.map_err(|e| format!("Erro ao buscar conteúdo: {}", e))?;
if scraped.is_empty() {
return Err("Nenhum resultado encontrado na busca".to_string());
}
// 2. Combinar conteúdo em markdown
let web_context: String = scraped
.iter()
.map(|s| format!("---\nTítulo: {}\nURL: {}\n---\n\n{}", s.title, s.url, s.markdown))
.collect::<Vec<_>>()
.join("\n\n");
// 3. Criar prompt para o Ollama
let system_prompt = format!(
"Você é um assistente especializado em resumir e analisar informações da web.\n\
DATA ATUAL: {}\n\n\
Use as informações fornecidas abaixo para criar um resumo detalhado e útil.",
Utc::now().format("%d/%m/%Y %H:%M")
);
let user_prompt = format!(
"Com base nas informações abaixo sobre '{}', crie um resumo detalhado e estruturado.\n\n\
## CONTEXTO WEB\n{}\n\n\
Por favor, forneça:\n\
1. Resumo executivo (2-3 parágrafos)\n\
2. Principais pontos encontrados\n\
3. Conclusões ou insights relevantes",
query,
web_context
);
// 4. Enviar para Ollama
log::info!("Enviando para Ollama (modelo: {})", model);
let summary = ollama_client
.query_ollama_headless(model, Some(&system_prompt), &user_prompt)
.await
.map_err(|e| format!("Erro ao consultar Ollama: {}", e))?;
// 5. Salvar como sessão de chat
let session_id = uuid::Uuid::new_v4().to_string();
let now = Utc::now();
let messages = vec![
Message {
role: "user".to_string(),
content: format!("Pesquisa agendada: {}", query),
metadata: Some(serde_json::json!({
"task_id": task.id,
"task_label": task.label,
"sources_count": scraped.len(),
})),
},
Message {
role: "assistant".to_string(),
content: summary,
metadata: Some(serde_json::json!({
"task_id": task.id,
"sources": scraped.iter().map(|s| serde_json::json!({
"title": s.title,
"url": s.url,
})).collect::<Vec<_>>(),
})),
},
];
// Salvar sessão diretamente (helper function)
save_task_session_internal(
app_handle,
&session_id,
&format!("[Agendado] {}", task.label),
messages,
)?;
// 6. Enviar notificação
app_handle
.notification()
.builder()
.title("Pesquisa Agendada Concluída")
.body(&format!("{} está pronta! Verifique sua sessão de chat.", task.label))
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
log::info!("Task {} executada com sucesso. Sessão salva: {}", task.id, session_id);
Ok(())
}
/// Executa apenas ping/notificação
async fn execute_just_ping(
task: &SentinelTask,
message: &str,
app_handle: &AppHandle,
) -> Result<(), String> {
app_handle
.notification()
.builder()
.title(&task.label)
.body(message)
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
log::info!("Ping enviado para task: {}", task.id);
Ok(())
}
/// Helper para salvar sessão de task (sem usar State do Tauri)
fn save_task_session_internal(
app_handle: &AppHandle,
session_id: &str,
title: &str,
messages: Vec<Message>,
) -> 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();
// Try to load existing to keep created_at, or use now
let created_at = if file_path.exists() {
if let Ok(content) = fs::read_to_string(&file_path) {
if let Ok(session) = serde_json::from_str::<ChatSession>(&content) {
session.created_at
} else {
now
}
} else {
now
}
} else {
now
};
let platform = System::name().unwrap_or("Unknown".to_string());
let session = ChatSession {
id: session_id.to_string(),
title: title.to_string(),
messages,
created_at,
updated_at: now,
platform,
memory_context: Vec::new(),
};
let json = serde_json::to_string_pretty(&session)
.map_err(|e| format!("Failed to serialize session: {}", e))?;
// Escrever em arquivo temporário primeiro, depois renomear (atomic write)
let temp_path = file_path.with_extension("json.tmp");
fs::write(&temp_path, json)
.map_err(|e| format!("Failed to write temp session file: {}", e))?;
// Renomear atomicamente
fs::rename(&temp_path, &file_path)
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
Ok(())
}
/// Executa prompt customizado
async fn execute_custom_prompt(
task: &SentinelTask,
prompt: &str,
model: &str,
app_handle: &AppHandle,
ollama_client: &OllamaClient,
) -> Result<(), String> {
let response = ollama_client
.query_ollama_headless(model, None, prompt)
.await
.map_err(|e| format!("Erro ao consultar Ollama: {}", e))?;
// Salvar como sessão
let session_id = uuid::Uuid::new_v4().to_string();
let messages = vec![
Message {
role: "user".to_string(),
content: prompt.to_string(),
metadata: Some(serde_json::json!({
"task_id": task.id,
"task_label": task.label,
})),
},
Message {
role: "assistant".to_string(),
content: response,
metadata: Some(serde_json::json!({
"task_id": task.id,
})),
},
];
save_task_session_internal(
app_handle,
&session_id,
&format!("[Agendado] {}", task.label),
messages,
)?;
// Notificação
app_handle
.notification()
.builder()
.title("Task Executada")
.body(&format!("{} foi executada com sucesso!", task.label))
.show()
.map_err(|e| format!("Erro ao enviar notificação: {}", e))?;
Ok(())
}
+574
View File
@@ -0,0 +1,574 @@
use anyhow::Result;
use headless_chrome::{Browser, LaunchOptions};
use reqwest::header::USER_AGENT;
use scraper::{Html, Selector};
use std::sync::Arc;
use std::time::Duration;
use url::Url;
use rand::Rng;
use tokio::sync::Semaphore;
use regex::Regex;
/// Resultado da extração de conteúdo de uma URL
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct ScrapedContent {
pub title: String,
pub url: String,
pub content: String,
pub markdown: String,
}
/// Categoria de busca com sites curados
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct SearchCategory {
pub id: String,
pub name: String,
pub base_sites: Vec<String>,
pub enabled: bool,
}
/// Configuração completa de busca
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct SearchConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent_tabs: usize,
#[serde(default = "default_total_sources")]
pub total_sources_limit: usize,
pub categories: Vec<SearchCategory>,
#[serde(default)]
pub user_custom_sites: Vec<String>,
#[serde(default)]
pub excluded_domains: Vec<String>,
}
fn default_max_concurrent() -> usize {
5
}
fn default_total_sources() -> usize {
40
}
/// Pool de User-Agents para rotação (evita bloqueios 429)
const USER_AGENTS: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
];
/// Retorna um User-Agent aleatório do pool
fn get_random_user_agent() -> &'static str {
let mut rng = rand::thread_rng();
let index = rng.gen_range(0..USER_AGENTS.len());
USER_AGENTS[index]
}
/// Busca no DuckDuckGo e retorna URLs dos resultados
pub async fn search_duckduckgo(query: &str, limit: usize) -> Result<Vec<String>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let url = format!("https://html.duckduckgo.com/html/?q={}",
urlencoding::encode(query));
// User-Agent rotativo para evitar bloqueios
let user_agent = get_random_user_agent();
log::debug!("Usando User-Agent: {}", user_agent);
let res = client
.get(&url)
.header(USER_AGENT, user_agent)
.send()
.await?
.text()
.await?;
let document = Html::parse_document(&res);
// Seletores para links orgânicos do DuckDuckGo
// Tenta múltiplos seletores para maior compatibilidade
let selectors = vec![
".result__a",
".web-result__link",
"a.result__a",
];
let mut links = Vec::new();
for selector_str in selectors {
if let Ok(selector) = Selector::parse(selector_str) {
for element in document.select(&selector).take(limit) {
if let Some(href) = element.value().attr("href") {
// DuckDuckGo usa redirecionamento, extrair URL real
if let Some(real_url) = extract_real_url(href) {
if !links.contains(&real_url) {
links.push(real_url);
if links.len() >= limit {
break;
}
}
}
}
}
if !links.is_empty() {
break;
}
}
}
Ok(links)
}
/// Extrai a URL real do redirecionamento do DuckDuckGo
fn extract_real_url(ddg_redirect: &str) -> Option<String> {
// DuckDuckGo usa formato: /l/?kh=-1&uddg=<URL_ENCODED>
if let Some(uddg_start) = ddg_redirect.find("uddg=") {
if let Some(encoded_url) = ddg_redirect.get(uddg_start + 5..) {
if let Some(ampersand) = encoded_url.find('&') {
if let Ok(decoded) = urlencoding::decode(&encoded_url[..ampersand]) {
return Some(decoded.to_string());
}
} else if let Ok(decoded) = urlencoding::decode(encoded_url) {
return Some(decoded.to_string());
}
}
}
// Se não for redirecionamento DDG, retorna como está (se for URL válida)
if ddg_redirect.starts_with("http://") || ddg_redirect.starts_with("https://") {
Some(ddg_redirect.to_string())
} else {
None
}
}
/// Extrai o domínio de uma URL
fn extract_domain(url: &str) -> Option<String> {
if let Ok(parsed) = Url::parse(url) {
if let Some(host) = parsed.host_str() {
return Some(host.to_string());
}
}
None
}
/// Verifica se uma URL está na lista de domínios bloqueados
fn is_domain_blocked(url: &str, excluded_domains: &[String]) -> bool {
if excluded_domains.is_empty() {
return false;
}
if let Some(domain) = extract_domain(url) {
let domain_lower = domain.to_lowercase();
for excluded in excluded_domains {
let excluded_lower = excluded.to_lowercase();
// Match exato ou subdomínio
if domain_lower == excluded_lower || domain_lower.ends_with(&format!(".{}", excluded_lower)) {
log::debug!("URL bloqueada por blacklist: {} (domínio: {})", url, excluded);
return true;
}
}
}
false
}
/// Verifica se uma URL é de anúncio/tracker (deve ser ignorada)
fn is_ad_or_tracker_url(url: &str) -> bool {
let ad_patterns = vec![
r"duckduckgo\.com/y\.js",
r"googleadservices\.com",
r"doubleclick\.net",
r"googlesyndication\.com",
r"aclick",
r"/y\.js",
r"advertising\.com",
r"adsystem\.com",
];
for pattern in ad_patterns {
if let Ok(re) = Regex::new(pattern) {
if re.is_match(url) {
log::debug!("URL de anúncio/tracker ignorada: {}", url);
return true;
}
}
}
false
}
/// Limpa URL removendo parâmetros de tracking e redirecionamento
fn clean_url(url: &str) -> Option<String> {
// Se for URL de anúncio, ignorar completamente
if is_ad_or_tracker_url(url) {
return None;
}
// Tentar extrair URL real de redirecionamentos do DuckDuckGo
if url.contains("duckduckgo.com") {
// Formato: /l/?kh=-1&uddg=<URL_ENCODED> ou /y.js?ad_provider=...
if url.contains("/y.js") || url.contains("aclick") {
return None; // É anúncio
}
if let Some(uddg_start) = url.find("uddg=") {
if let Some(encoded_url) = url.get(uddg_start + 5..) {
if let Some(ampersand) = encoded_url.find('&') {
if let Ok(decoded) = urlencoding::decode(&encoded_url[..ampersand]) {
return Some(decoded.to_string());
}
} else if let Ok(decoded) = urlencoding::decode(encoded_url) {
return Some(decoded.to_string());
}
}
}
}
// Se já for URL limpa, validar formato
if url.starts_with("http://") || url.starts_with("https://") {
Some(url.to_string())
} else {
None
}
}
/// Busca usando query "site:" para categorias específicas
async fn search_with_site_filter(query: &str, sites: &[String], limit: usize) -> Result<Vec<String>> {
if sites.is_empty() {
return Ok(Vec::new());
}
// Construir query: site:site1.com OR site:site2.com ... {query}
let site_filters: Vec<String> = sites.iter()
.map(|site| format!("site:{}", site))
.collect();
let site_query = format!("({}) {}", site_filters.join(" OR "), query);
search_duckduckgo(&site_query, limit).await
}
/// Busca inteligente híbrida: geral + curada por categorias
pub async fn smart_search(query: &str, config: &SearchConfig) -> Result<Vec<String>> {
let mut all_urls = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
// 1. Busca geral no DuckDuckGo (ignorando anúncios)
log::info!("Executando busca geral para: {}", query);
let general_urls = search_duckduckgo(query, config.total_sources_limit).await?;
for url in general_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
// 2. Busca direta por categorias ativas (site: filters)
for category in &config.categories {
if !category.enabled || category.base_sites.is_empty() {
continue;
}
log::info!("Buscando em categoria '{}' ({} sites)", category.name, category.base_sites.len());
// Limitar sites por categoria para não exceder o limite total
let sites_to_search = category.base_sites.iter()
.take(config.total_sources_limit / config.categories.len().max(1))
.cloned()
.collect::<Vec<_>>();
match search_with_site_filter(query, &sites_to_search, config.total_sources_limit).await {
Ok(category_urls) => {
for url in category_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
}
Err(e) => {
log::warn!("Erro ao buscar categoria '{}': {}", category.name, e);
}
}
}
// 3. Adicionar sites customizados do usuário
if !config.user_custom_sites.is_empty() {
log::info!("Buscando em {} sites customizados", config.user_custom_sites.len());
match search_with_site_filter(query, &config.user_custom_sites, config.total_sources_limit).await {
Ok(custom_urls) => {
for url in custom_urls {
if let Some(cleaned) = clean_url(&url) {
if !is_domain_blocked(&cleaned, &config.excluded_domains) {
if seen_urls.insert(cleaned.clone()) {
all_urls.push(cleaned);
}
}
}
}
}
Err(e) => {
log::warn!("Erro ao buscar sites customizados: {}", e);
}
}
}
// Limitar ao total_sources_limit
all_urls.truncate(config.total_sources_limit);
log::info!("Total de {} URLs únicas coletadas", all_urls.len());
Ok(all_urls)
}
/// Busca e extrai conteúdo de múltiplas URLs em paralelo com Semaphore
pub async fn search_and_scrape(
query: &str,
limit: usize,
browser: Arc<Browser>,
excluded_domains: Vec<String>,
) -> Result<Vec<ScrapedContent>> {
// Configuração padrão (backward compatibility)
let config = SearchConfig {
max_concurrent_tabs: 5,
total_sources_limit: limit,
categories: Vec::new(),
user_custom_sites: Vec::new(),
excluded_domains,
};
search_and_scrape_with_config(query, &config, browser).await
}
/// Versão nova com SearchConfig completo
pub async fn search_and_scrape_with_config(
query: &str,
config: &SearchConfig,
browser: Arc<Browser>,
) -> Result<Vec<ScrapedContent>> {
// 1. Busca inteligente híbrida
let urls = smart_search(query, config).await?;
if urls.is_empty() {
log::warn!("Nenhuma URL encontrada para a query: {}", query);
return Ok(Vec::new());
}
// 2. Scraping paralelo com Semaphore (limita abas simultâneas)
let semaphore = Arc::new(Semaphore::new(config.max_concurrent_tabs));
let mut handles = Vec::new();
for url in urls {
let browser_clone = browser.clone();
let permit = semaphore.clone().acquire_owned().await.unwrap();
let handle = tokio::task::spawn_blocking(move || {
let result = fetch_and_convert_sync(&browser_clone, &url);
drop(permit); // Libera o semáforo após processar
result
});
handles.push(handle);
}
// 3. Coletar resultados (ignorar erros individuais, continuar com sucessos)
let mut results = Vec::new();
for handle in handles {
match handle.await {
Ok(Ok(content)) => {
// Filtrar conteúdo muito curto (< 200 caracteres)
let content_length = content.content.chars().count();
let markdown_length = content.markdown.chars().count();
if content_length < 200 && markdown_length < 200 {
log::debug!(
"Fonte descartada por conteúdo muito curto ({} chars): {}",
content_length.max(markdown_length),
content.url
);
} else {
results.push(content);
}
}
Ok(Err(e)) => {
let err_msg = format!("{}", e);
if err_msg.contains("Timeout") || err_msg.contains("ERR_HTTP") {
log::debug!("URL ignorada (timeout/erro HTTP): {}", err_msg);
} else {
log::warn!("Erro ao processar URL: {}", e);
}
}
Err(e) => {
log::warn!("Erro na task de scraping: {}", e);
}
}
}
if results.is_empty() {
log::warn!("Nenhuma fonte foi extraída com sucesso para a query: {}", query);
} else {
log::info!("Extraídas {} fontes com sucesso", results.len());
}
Ok(results)
}
/// Busca e extrai conteúdo de uma única URL
pub async fn scrape_url(
url: &str,
browser: Arc<Browser>,
) -> Result<ScrapedContent> {
let browser_clone = browser.clone();
let url_str = url.to_string();
tokio::task::spawn_blocking(move || {
fetch_and_convert_sync(&browser_clone, &url_str)
})
.await
.map_err(|e| anyhow::anyhow!("Erro na task: {}", e))?
}
/// Extrai conteúdo de uma URL e converte para Markdown (versão síncrona)
/// Retorna erro se timeout ou falha HTTP, mas não mata o processo
fn fetch_and_convert_sync(browser: &Browser, url: &str) -> Result<ScrapedContent> {
use std::time::Instant;
let start_time = Instant::now();
let max_duration = Duration::from_secs(10); // Timeout agressivo de 10s
// Criar nova aba com tratamento de erro
let tab = match browser.new_tab() {
Ok(t) => t,
Err(e) => {
log::warn!("Falha ao criar aba para {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao criar aba: {}", e));
}
};
// Timeout reduzido para navegação
tab.set_default_timeout(Duration::from_secs(8));
// Tentar navegar com tratamento de erro HTTP
match tab.navigate_to(url) {
Ok(_) => {},
Err(e) => {
let err_msg = format!("{}", e);
// Se for erro HTTP, apenas logar e retornar erro
if err_msg.contains("ERR_HTTP_RESPONSE_CODE_FAILURE") ||
err_msg.contains("net::ERR") {
log::warn!("Erro HTTP ao navegar para {}: {}", url, e);
return Err(anyhow::anyhow!("Erro HTTP: {}", err_msg));
}
return Err(anyhow::anyhow!("Falha ao navegar: {}", e));
}
}
// Aguardar navegação com verificação de timeout
match tab.wait_until_navigated() {
Ok(_) => {},
Err(e) => {
let err_msg = format!("{}", e);
if err_msg.contains("ERR_HTTP_RESPONSE_CODE_FAILURE") ||
err_msg.contains("net::ERR") {
log::warn!("Erro HTTP após navegação para {}: {}", url, e);
return Err(anyhow::anyhow!("Erro HTTP: {}", err_msg));
}
// Timeout ou outro erro
if start_time.elapsed() > max_duration {
log::warn!("Timeout ao aguardar navegação para {}", url);
return Err(anyhow::anyhow!("Timeout ao carregar página"));
}
return Err(anyhow::anyhow!("Falha ao aguardar navegação: {}", e));
}
}
// Verificar timeout antes de continuar
if start_time.elapsed() > max_duration {
log::warn!("Timeout antes de extrair conteúdo de {}", url);
return Err(anyhow::anyhow!("Timeout ao processar página"));
}
// Aguardar um pouco para JS/SPAs carregarem (máximo 1.5s)
let wait_time = Duration::from_millis(1500);
if start_time.elapsed() + wait_time < max_duration {
std::thread::sleep(wait_time);
} else {
let remaining = max_duration.saturating_sub(start_time.elapsed());
if !remaining.is_zero() {
std::thread::sleep(remaining);
}
}
// Verificar timeout final
if start_time.elapsed() > max_duration {
log::warn!("Timeout ao processar {}", url);
return Err(anyhow::anyhow!("Timeout ao processar página"));
}
// Extrair HTML renderizado
let content = match tab.get_content() {
Ok(c) => c,
Err(e) => {
log::warn!("Falha ao extrair conteúdo de {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao extrair HTML: {}", e));
}
};
// Limpeza com Readability (remove ads, menus, footers)
let mut reader = std::io::Cursor::new(content.as_bytes());
let url_obj = match Url::parse(url) {
Ok(u) => u,
Err(e) => {
log::warn!("URL inválida {}: {}", url, e);
return Err(anyhow::anyhow!("URL inválida: {}", e));
}
};
let product = match readability::extractor::extract(&mut reader, &url_obj) {
Ok(p) => p,
Err(e) => {
log::warn!("Falha ao extrair conteúdo legível de {}: {}", url, e);
return Err(anyhow::anyhow!("Falha ao processar conteúdo: {}", e));
}
};
// Conversão para Markdown
let markdown = html2text::from_read(product.content.as_bytes(), 80);
// Clonar title antes de mover para poder usar no format!
let title = product.title.clone();
let result = ScrapedContent {
title: product.title,
url: url.to_string(),
content: product.content,
markdown: format!(
"---\nTitle: {}\nSource: {}\n---\n\n{}",
title,
url,
markdown
),
};
Ok(result)
}
/// Cria uma instância do Browser (singleton para reutilização)
pub fn create_browser() -> Result<Browser> {
let options = LaunchOptions {
headless: true,
..Default::default()
};
Browser::new(options)
.map_err(|e| anyhow::anyhow!("Falha ao criar browser: {}", e))
}
+242
View File
@@ -0,0 +1,242 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface SearchCategory {
id: string;
name: string;
baseSites: string[];
enabled: boolean;
}
export interface SettingsState {
// AI & Models
ollamaUrl: string;
selectedModel: string;
systemPrompt: string;
contextWindow: number;
// Web Search
webSearch: {
enabled: boolean;
maxResults: number;
timeout: number;
excludedDomains: string[];
maxConcurrentTabs: number;
totalSourcesLimit: number;
categories: SearchCategory[];
userCustomSites: string[];
};
// Actions
setOllamaUrl: (url: string) => void;
setSelectedModel: (model: string) => void;
setSystemPrompt: (prompt: string) => void;
setContextWindow: (ctx: number) => void;
setWebSearchEnabled: (enabled: boolean) => void;
setWebSearchMaxResults: (max: number) => void;
setWebSearchTimeout: (timeout: number) => void;
setWebSearchMaxConcurrentTabs: (max: number) => void;
setWebSearchTotalSourcesLimit: (limit: number) => void;
addExcludedDomain: (domain: string) => void;
removeExcludedDomain: (domain: string) => void;
toggleCategory: (categoryId: string) => void;
updateCategory: (category: SearchCategory) => void;
addCustomSite: (site: string) => void;
removeCustomSite: (site: string) => void;
resetSettings: () => void;
}
const defaultSystemPrompt = `Você é um assistente de IA local integrado ao OllaHub.
## REGRAS ABSOLUTAS
1. **Nunca** escreva tags \`<metadata>\`, JSON oculto ou textos como "Metadados:". Toda resposta deve ser apenas texto visível ao usuário.
2. Use Markdown limpo: títulos, listas e blocos de código com linguagem indicada.
3. Seja direto, técnico e objetivo. Resumos iniciais são bem-vindos em respostas longas.
4. Comandos e códigos devem estar em blocos triplos (\`\`\`bash, \`\`\`json, etc.).
5. Se receber um bloco [CONTEXTO WEB], use essas informações prioritariamente para responder.
## 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.
- Se o contexto não for suficiente, diga isso claramente.
## FORMATO SUGERIDO
- Resumo inicial (1–2 frases) quando necessário.
- Seções \`##\` para organizar o conteúdo.
- Listas para passos/itens.
- Blocos de código com linguagem específica.
## ESTILO
- Cite limitações, hipóteses e próximos passos.
- Inclua links apenas quando essencial.
- Respostas sempre limpas, sem metadados ou conteúdo oculto.`;
// Categorias padrão curadas
const defaultCategories: SearchCategory[] = [
{
id: 'academic',
name: 'Acadêmico',
baseSites: [
'scielo.br',
'arxiv.org',
'scholar.google.com',
'pubmed.ncbi.nlm.nih.gov',
'researchgate.net',
],
enabled: false,
},
{
id: 'news',
name: 'Notícias',
baseSites: [
'g1.globo.com',
'bbc.com',
'cnnbrasil.com.br',
'folha.uol.com.br',
'estadao.com.br',
],
enabled: true,
},
{
id: 'tech',
name: 'Tech & Dev',
baseSites: [
'github.com',
'stackoverflow.com',
'dev.to',
'tabnews.com.br',
'medium.com',
],
enabled: true,
},
{
id: 'finance',
name: 'Financeiro',
baseSites: [
'infomoney.com.br',
'bloomberg.com',
'valor.globo.com',
'investing.com',
'yahoo.com/finance',
],
enabled: false,
},
];
const initialState = {
ollamaUrl: 'http://localhost:11434',
selectedModel: '',
systemPrompt: defaultSystemPrompt,
contextWindow: 4096,
webSearch: {
enabled: true,
maxResults: 5,
timeout: 10,
excludedDomains: ['youtube.com', 'linkedin.com'],
maxConcurrentTabs: 5,
totalSourcesLimit: 40,
categories: defaultCategories,
userCustomSites: [],
},
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
...initialState,
setOllamaUrl: (url) => set({ ollamaUrl: url }),
setSelectedModel: (model) => set({ selectedModel: model }),
setSystemPrompt: (prompt) => set({ systemPrompt: prompt }),
setContextWindow: (ctx) => set({ contextWindow: ctx }),
setWebSearchEnabled: (enabled) =>
set((state) => ({
webSearch: { ...state.webSearch, enabled },
})),
setWebSearchMaxResults: (max) =>
set((state) => ({
webSearch: { ...state.webSearch, maxResults: max },
})),
setWebSearchTimeout: (timeout) =>
set((state) => ({
webSearch: { ...state.webSearch, timeout },
})),
setWebSearchMaxConcurrentTabs: (max) =>
set((state) => ({
webSearch: { ...state.webSearch, maxConcurrentTabs: max },
})),
setWebSearchTotalSourcesLimit: (limit) =>
set((state) => ({
webSearch: { ...state.webSearch, totalSourcesLimit: limit },
})),
addExcludedDomain: (domain) =>
set((state) => {
const normalized = domain.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
if (!normalized || state.webSearch.excludedDomains.includes(normalized)) {
return state;
}
return {
webSearch: {
...state.webSearch,
excludedDomains: [...state.webSearch.excludedDomains, normalized],
},
};
}),
removeExcludedDomain: (domain) =>
set((state) => ({
webSearch: {
...state.webSearch,
excludedDomains: state.webSearch.excludedDomains.filter((d) => d !== domain),
},
})),
toggleCategory: (categoryId) =>
set((state) => ({
webSearch: {
...state.webSearch,
categories: state.webSearch.categories.map((cat) =>
cat.id === categoryId ? { ...cat, enabled: !cat.enabled } : cat
),
},
})),
updateCategory: (category) =>
set((state) => ({
webSearch: {
...state.webSearch,
categories: state.webSearch.categories.map((cat) =>
cat.id === category.id ? category : cat
),
},
})),
addCustomSite: (site) =>
set((state) => {
const normalized = site.toLowerCase().trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
if (!normalized || state.webSearch.userCustomSites.includes(normalized)) {
return state;
}
return {
webSearch: {
...state.webSearch,
userCustomSites: [...state.webSearch.userCustomSites, normalized],
},
};
}),
removeCustomSite: (site) =>
set((state) => ({
webSearch: {
...state.webSearch,
userCustomSites: state.webSearch.userCustomSites.filter((s) => s !== site),
},
})),
resetSettings: () => set(initialState),
}),
{
name: 'ollahub-settings',
version: 1,
}
)
);