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:
1 parent
032722e21b
commit
8834ef2b4f
55 files changed
+20030
-159
No files matched your search
@@ -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
@@ -5,30 +5,63 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { ChatMessage } from "@/components/chat/chat-message";
|
||||
import { SystemPanel } from "@/components/chat/system-panel";
|
||||
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
|
||||
import { SidebarList } from "@/components/chat/sidebar-list";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
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 { ImperativePanelHandle } from "react-resizable-panels";
|
||||
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
|
||||
// @ts-ignore
|
||||
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
||||
|
||||
|
||||
export default function ChatPage() {
|
||||
const { messages, sendMessage, isLoading, stop } = useChat();
|
||||
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
||||
const { models } = useLocalModels();
|
||||
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("");
|
||||
// Initialize with default format prompt
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -37,15 +70,226 @@ export default function ChatPage() {
|
||||
}
|
||||
}, [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;
|
||||
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 panel = sidebarRef.current;
|
||||
const handleSelectSession = async (id: string) => {
|
||||
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 (isSidebarCollapsed) {
|
||||
if (isChatsSidebarCollapsed) {
|
||||
panel.expand();
|
||||
} else {
|
||||
panel.collapse();
|
||||
@@ -59,15 +303,10 @@ export default function ChatPage() {
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||
|
||||
<ResizablePanel
|
||||
ref={sidebarRef}
|
||||
defaultSize={4}
|
||||
minSize={4}
|
||||
maxSize={15}
|
||||
collapsible={true}
|
||||
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]"
|
||||
maxSize={4}
|
||||
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]"
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
@@ -91,23 +330,54 @@ export default function ChatPage() {
|
||||
|
||||
<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 */}
|
||||
<ResizablePanel defaultSize={75} minSize={50}>
|
||||
<ResizablePanel defaultSize={60} minSize={40}>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<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">
|
||||
{isSidebarCollapsed && (
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8">
|
||||
{isChatsSidebarCollapsed ? (
|
||||
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
|
||||
<PanelLeftOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8 text-muted-foreground">
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
</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 className="flex-1 max-w-[300px]">
|
||||
@@ -134,24 +404,93 @@ export default function ChatPage() {
|
||||
<p>Inicie uma conversa com {selectedModel}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} message={msg} />
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col pb-4 max-w-4xl mx-auto w-full">
|
||||
{messages.map((msg, i) => {
|
||||
// Verificar se a mensagem anterior teve busca web
|
||||
const hasWebSources = i > 0 &&
|
||||
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">
|
||||
Gerando resposta...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Elemento invisível para scroll automático */}
|
||||
<div ref={messagesEndRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Thinking Indicator */}
|
||||
{thinkingStep && thinkingStep !== 'complete' && (
|
||||
<ThinkingIndicator
|
||||
currentStep={thinkingStep}
|
||||
searchQuery={webSearch.currentQuery}
|
||||
sourcesRead={webSearch.scrapedSources.length}
|
||||
totalSources={5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onStop={stop}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading}
|
||||
webSearchEnabled={webSearch.isEnabled}
|
||||
onWebSearchToggle={webSearch.setEnabled}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -159,8 +498,8 @@ export default function ChatPage() {
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Right Sidebar (Settings & Monitor) */}
|
||||
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="bg-muted/10">
|
||||
<Tabs defaultValue="params" className="h-full flex flex-col">
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="bg-muted/10">
|
||||
<Tabs defaultValue="params" className="h-full flex flex-col overflow-hidden">
|
||||
<div className="border-b px-4">
|
||||
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
|
||||
<TabsTrigger
|
||||
@@ -178,28 +517,31 @@ export default function ChatPage() {
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto m-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">System Prompt</label>
|
||||
<PromptGeneratorDialog
|
||||
defaultModel={selectedModel}
|
||||
onPromptGenerated={setSystemPrompt}
|
||||
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto overflow-x-hidden m-0">
|
||||
<div className="space-y-6">
|
||||
{/* System Prompt */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">System Prompt</label>
|
||||
<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>
|
||||
<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>
|
||||
</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 />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
onStop: () => void;
|
||||
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 textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -36,31 +50,74 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t bg-background">
|
||||
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2">
|
||||
{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 className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-2">
|
||||
{onWebSearchToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={webSearchEnabled ? "default" : "ghost"}
|
||||
onClick={() => {
|
||||
if (!webSearchEnabled) {
|
||||
// Ao ativar, não precisa de confirmação
|
||||
onWebSearchToggle(true);
|
||||
} 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 className="text-xs text-center text-muted-foreground mt-2">
|
||||
@@ -70,3 +127,5 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,30 @@ export function ChatMessage({ message }: { message: Message }) {
|
||||
remarkPlugins={[remarkGfm]}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,3 +43,5 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -136,3 +136,5 @@ export const markdownRenderers: Components = {
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,3 +79,5 @@ export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,3 +103,5 @@ export function SystemPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,3 +10,5 @@ export function ThemeProvider({
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -197,3 +197,5 @@ export {
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -25,3 +25,5 @@ Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
|
||||
|
||||
@@ -157,3 +157,5 @@ export {
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -21,3 +21,5 @@ Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
- 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.
|
||||
## DIRETRIZES DE RESPOSTA (RAG)
|
||||
|
||||
### 2. Blocos de Código
|
||||
- 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`.
|
||||
### 1. Contextualização Temporal Obrigatória
|
||||
|
||||
### 3. Ênfase e Destaque
|
||||
- Use **Negrito** para conceitos chave ou avisos importantes.
|
||||
- Use *Itálico* para termos estrangeiros ou ênfase sutil.
|
||||
- Use > Blockquotes para citações ou notas laterais.
|
||||
- **SEMPRE** inicie respostas sobre notícias, eventos ou fatos atuais citando a data atual explicitamente.
|
||||
- Exemplo: "Com base nas informações de hoje, [DATA], os principais destaques são..."
|
||||
- Use a data fornecida no prompt como referência absoluta para "hoje", "ontem", "amanhã".
|
||||
|
||||
### 4. Links
|
||||
- Formate links corretamente: `[Texto do Link](URL)`.
|
||||
### 2. Densidade e Profundidade
|
||||
|
||||
## ESTILO DE RESPOSTA
|
||||
- Seja direto e objetivo. Evite preâmbulos desnecessários ("Claro, aqui está...").
|
||||
- Se a resposta for longa, comece com um resumo executivo.
|
||||
- Ao explicar código, comente o que cada parte faz.
|
||||
- **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ê" 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.
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,16 +1,209 @@
|
||||
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 {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
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() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||
|
||||
const 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);
|
||||
|
||||
const newMessages: Message[] = [
|
||||
@@ -20,9 +213,56 @@ export function useChat() {
|
||||
setMessages(newMessages);
|
||||
|
||||
// Prepare messages for API (include system prompt if present)
|
||||
const apiMessages = systemPrompt
|
||||
? [{ role: 'system', content: systemPrompt }, ...newMessages]
|
||||
: newMessages;
|
||||
// Strip metadata from previous messages before sending to context to save tokens?
|
||||
// Or keep it? The model might benefit from seeing previous metadata.
|
||||
// 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();
|
||||
|
||||
@@ -44,12 +284,21 @@ export function useChat() {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Reset raw content buffer for this message
|
||||
rawContentRef.current = '';
|
||||
|
||||
// Add empty assistant message to start streaming into
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
||||
|
||||
let streamFinished = false;
|
||||
let metadataProcessed = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (done) {
|
||||
streamFinished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
@@ -58,25 +307,83 @@ export function useChat() {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.content) {
|
||||
// Accumulate raw content (including metadata) in ref
|
||||
rawContentRef.current += json.message.content;
|
||||
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
// For display: strip incomplete metadata tag if present (opening tag but not closed)
|
||||
// This prevents showing "<metadata>..." in the UI while streaming
|
||||
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, content: last.content + json.message.content }
|
||||
{ ...last, content: displayContent }
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
if (json.done) {
|
||||
streamFinished = true;
|
||||
metadataProcessed = true;
|
||||
|
||||
// Process metadata FIRST before setting isLoading to false
|
||||
// This prevents race condition with saveSession useEffect
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
// Parse metadata from raw accumulated content
|
||||
const rawContent = rawContentRef.current;
|
||||
const { content, metadata } = parseMetadata(rawContent);
|
||||
|
||||
// Fallback: if content is empty after parse but we had content before,
|
||||
// keep the stripped version (without metadata tag) to preserve user-visible text
|
||||
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
|
||||
|
||||
// Clear ref for next message
|
||||
rawContentRef.current = '';
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, content: finalContent, metadata }
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Set loading to false AFTER processing metadata
|
||||
setIsLoading(false);
|
||||
break; // Exit inner loop when done
|
||||
}
|
||||
} catch (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) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Request aborted');
|
||||
@@ -99,8 +406,9 @@ export function useChat() {
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
rawContentRef.current = '';
|
||||
};
|
||||
|
||||
return { messages, sendMessage, isLoading, stop, clearChat };
|
||||
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||
}
|
||||
|
||||
@@ -41,3 +41,5 @@ export function useLocalModels() {
|
||||
return { models, loading, refresh: fetchModels, deleteModel };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,3 +68,5 @@ export function usePromptGenerator() {
|
||||
return { generatePrompt, isGenerating };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -27,3 +27,5 @@ export function useSystemMonitor() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -9,14 +9,21 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@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-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
@@ -27,8 +34,10 @@
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
Generated
+329
@@ -8,12 +8,21 @@ importers:
|
||||
|
||||
.:
|
||||
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':
|
||||
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-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
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':
|
||||
specifier: ^1.1.8
|
||||
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':
|
||||
specifier: ^1.1.8
|
||||
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':
|
||||
specifier: ^1.2.4
|
||||
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':
|
||||
specifier: ^1.1.13
|
||||
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':
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: ^15.5.13
|
||||
version: 15.5.13
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -62,12 +83,18 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^3.0.6
|
||||
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:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
zustand:
|
||||
specifier: ^5.0.8
|
||||
version: 5.0.8(@types/[email protected])([email protected])
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
@@ -164,6 +191,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/[email protected]':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -502,6 +533,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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]':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -621,6 +665,19 @@ packages:
|
||||
'@types/react':
|
||||
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]':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
@@ -634,6 +691,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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]':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -751,6 +821,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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]':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -769,6 +852,19 @@ packages:
|
||||
'@types/react':
|
||||
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]':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
@@ -782,6 +878,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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]':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
@@ -1074,11 +1183,17 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==}
|
||||
|
||||
@@ -1797,6 +1912,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1829,6 +1947,10 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
|
||||
peerDependencies:
|
||||
@@ -1940,18 +2062,30 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||
|
||||
[email protected]:
|
||||
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]:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
@@ -2261,6 +2395,9 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@@ -2585,6 +2722,10 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -2657,6 +2798,12 @@ packages:
|
||||
'@types/react':
|
||||
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]:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2665,6 +2812,9 @@ packages:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3086,6 +3236,24 @@ packages:
|
||||
[email protected]:
|
||||
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]:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
@@ -3170,6 +3338,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected]':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -3468,6 +3638,22 @@ snapshots:
|
||||
'@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])':
|
||||
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])':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||
@@ -3578,6 +3764,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -3604,6 +3799,29 @@ snapshots:
|
||||
'@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])':
|
||||
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])':
|
||||
dependencies:
|
||||
'@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-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])':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||
@@ -3739,6 +3976,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -3755,6 +4007,26 @@ snapshots:
|
||||
'@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])':
|
||||
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])':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
@@ -3987,10 +4259,16 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected](@types/[email protected])':
|
||||
dependencies:
|
||||
'@types/react': 19.2.6
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/react': 19.2.6
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -4890,6 +5168,10 @@ snapshots:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
format: 0.2.2
|
||||
|
||||
[email protected]([email protected]):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@@ -4918,6 +5200,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
motion-dom: 12.23.23
|
||||
@@ -5021,6 +5305,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -5045,12 +5333,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@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]:
|
||||
dependencies:
|
||||
hermes-estree: 0.25.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -5333,6 +5633,11 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fault: 1.0.4
|
||||
highlight.js: 10.7.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@@ -5871,6 +6176,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -5950,6 +6257,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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]:
|
||||
@@ -5963,6 +6280,13 @@ snapshots:
|
||||
get-proto: 1.0.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]:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -6561,4 +6885,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.6
|
||||
react: 19.2.0
|
||||
|
||||
[email protected]: {}
|
||||
+10168
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
Generated
+1543
-48
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -23,5 +23,22 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.9.2", features = [] }
|
||||
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"
|
||||
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
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user