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 { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2 } from "lucide-react";
|
||||||
import { ChatInput } from "@/components/chat/chat-input";
|
import { ChatInput } from "@/components/chat/chat-input";
|
||||||
import { ChatMessage } from "@/components/chat/chat-message";
|
import { ChatMessage } from "@/components/chat/chat-message";
|
||||||
import { SystemPanel } from "@/components/chat/system-panel";
|
import { SystemPanel } from "@/components/chat/system-panel";
|
||||||
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
|
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
|
||||||
|
import { SidebarList } from "@/components/chat/sidebar-list";
|
||||||
import { useChat } from "@/hooks/use-chat";
|
import { useChat } from "@/hooks/use-chat";
|
||||||
import { useLocalModels } from "@/hooks/use-local-models";
|
import { useLocalModels } from "@/hooks/use-local-models";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useChatStorage } from "@/hooks/use-chat-storage";
|
||||||
|
import { useAutoLabelingModel } from "@/hooks/use-auto-labeling-model";
|
||||||
|
import { useWebSearch } from "@/hooks/use-web-search";
|
||||||
|
import { SearchProgress } from "@/components/chat/search-progress";
|
||||||
|
import { ThinkingIndicator, ThinkingStep } from "@/components/chat/thinking-indicator";
|
||||||
|
import { useQueryGenerator } from "@/hooks/use-query-generator";
|
||||||
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { ImperativePanelHandle } from "react-resizable-panels";
|
import { ImperativePanelHandle } from "react-resizable-panels";
|
||||||
|
import { sanitizeWebSources } from "@/lib/sanitize-web-content";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
import defaultFormatPrompt from "@/data/prompts/default-format.md";
|
||||||
|
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { messages, sendMessage, isLoading, stop } = useChat();
|
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
|
||||||
const { models } = useLocalModels();
|
const { models } = useLocalModels();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
loadSessionHistory,
|
||||||
|
loadSessions,
|
||||||
|
saveSession,
|
||||||
|
deleteSession,
|
||||||
|
isGeneratingTitle
|
||||||
|
} = useChatStorage();
|
||||||
|
|
||||||
|
const { isDownloading, progress } = useAutoLabelingModel();
|
||||||
|
const webSearch = useWebSearch();
|
||||||
|
const { generateQuery, isGenerating: isGeneratingQuery } = useQueryGenerator();
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
const [selectedModel, setSelectedModel] = useState("");
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
// Initialize with default format prompt
|
// Initialize with default format prompt
|
||||||
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
|
||||||
|
const [thinkingStep, setThinkingStep] = useState<ThinkingStep | null>(null);
|
||||||
|
|
||||||
const sidebarRef = useRef<ImperativePanelHandle>(null);
|
const chatsSidebarRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastSavedMessagesRef = useRef<number>(0);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-select first model
|
// Auto-select first model
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,15 +70,226 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}, [models, selectedModel]);
|
}, [models, selectedModel]);
|
||||||
|
|
||||||
const handleSend = (content: string) => {
|
// Auto-scroll para o final durante streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesEndRef.current && (isLoading || messages.length > 0)) {
|
||||||
|
// Pequeno delay para garantir que o DOM foi atualizado
|
||||||
|
setTimeout(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'end'
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
// Save session when loading finishes (with debounce to avoid race conditions)
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any pending save
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only save if:
|
||||||
|
// 1. Not currently loading
|
||||||
|
// 2. We have a session ID
|
||||||
|
// 3. We have messages
|
||||||
|
// 4. Messages actually changed (by count or reference)
|
||||||
|
if (!isLoading && currentSessionId && messages.length > 0) {
|
||||||
|
// Check if messages actually changed (simple length check, could be improved)
|
||||||
|
const currentMessageCount = messages.length;
|
||||||
|
if (currentMessageCount !== lastSavedMessagesRef.current) {
|
||||||
|
// Small delay to ensure metadata processing is complete
|
||||||
|
saveTimeoutRef.current = setTimeout(() => {
|
||||||
|
lastSavedMessagesRef.current = currentMessageCount;
|
||||||
|
saveSession(currentSessionId, messages);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount or dependency change
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isLoading, currentSessionId, messages, saveSession]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleSend = async (content: string) => {
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
sendMessage(content, selectedModel, systemPrompt);
|
|
||||||
|
// Generate ID if new session (but don't create card yet - wait for title)
|
||||||
|
if (!currentSessionId) {
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
setCurrentSessionId(newId);
|
||||||
|
// Card will be created after title is generated (after first AI response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LÓGICA DE AGENTE DE PESQUISA ==========
|
||||||
|
let webContext = '';
|
||||||
|
let searchQuery = '';
|
||||||
|
let scrapedSources: any[] = [];
|
||||||
|
|
||||||
|
// Iniciar indicador de pensamento
|
||||||
|
setThinkingStep('analyzing');
|
||||||
|
|
||||||
|
if (webSearch.isEnabled) {
|
||||||
|
try {
|
||||||
|
webSearch.reset(); // Resetar estado anterior
|
||||||
|
|
||||||
|
// Passo 1: Gerar query otimizada
|
||||||
|
setThinkingStep('analyzing');
|
||||||
|
searchQuery = await generateQuery(content, selectedModel);
|
||||||
|
|
||||||
|
// Passo 2: Se precisar de busca, executar
|
||||||
|
if (searchQuery && searchQuery !== 'NO_SEARCH') {
|
||||||
|
setThinkingStep('searching');
|
||||||
|
// Buscar fontes usando configurações do store
|
||||||
|
const maxResults = settings.webSearch.maxResults;
|
||||||
|
const excludedDomains = settings.webSearch.excludedDomains;
|
||||||
|
scrapedSources = await webSearch.search(searchQuery, maxResults, excludedDomains);
|
||||||
|
|
||||||
|
// Atualizar indicador enquanto lê fontes
|
||||||
|
setThinkingStep('reading');
|
||||||
|
|
||||||
|
if (scrapedSources.length > 0) {
|
||||||
|
// Sanitizar e combinar markdown de todas as fontes
|
||||||
|
// Priorizamos o markdown completo (não apenas snippets) para garantir densidade de informação
|
||||||
|
// O scraper Rust já filtra conteúdo muito curto (< 200 chars) automaticamente
|
||||||
|
// Sanitização previne prompt injection e limita tamanho
|
||||||
|
webContext = sanitizeWebSources(scrapedSources, {
|
||||||
|
maxLength: 8000, // ~8k chars por fonte (ajustável)
|
||||||
|
removeControlChars: true,
|
||||||
|
removeHiddenText: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log para debug: verificar tamanho do contexto gerado
|
||||||
|
const contextLength = webContext.length;
|
||||||
|
console.debug(`Contexto web gerado (sanitizado): ${contextLength} caracteres de ${scrapedSources.length} fontes`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro na pesquisa web:', error);
|
||||||
|
// Continuar mesmo se a pesquisa falhar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passo 3: Formular resposta
|
||||||
|
setThinkingStep('formulating');
|
||||||
|
|
||||||
|
// ========== MONTAR SYSTEM PROMPT COM CONTEXTO TEMPORAL ==========
|
||||||
|
// Usar timezone do sistema do usuário (não fixo)
|
||||||
|
const now = new Date();
|
||||||
|
const currentDateTime = now.toLocaleString('pt-BR', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
// Não especificar timeZone - usar o do sistema
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formato adicional mais explícito para garantir que a IA entenda
|
||||||
|
const currentDateISO = now.toISOString();
|
||||||
|
const currentDateExplicit = `${now.getDate().toString().padStart(2, '0')}/${(now.getMonth() + 1).toString().padStart(2, '0')}/${now.getFullYear()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
let enhancedSystemPrompt = systemPrompt;
|
||||||
|
|
||||||
|
// Adicionar contexto temporal e web
|
||||||
|
if (webContext) {
|
||||||
|
enhancedSystemPrompt = `${systemPrompt}
|
||||||
|
|
||||||
|
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
|
||||||
|
|
||||||
|
**DATA/HORA FORMATADA:** ${currentDateTime}
|
||||||
|
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
|
||||||
|
**ISO 8601:** ${currentDateISO}
|
||||||
|
|
||||||
|
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}. Não use "amanhã" ou "ontem" a menos que seja explicitamente solicitado pelo usuário.
|
||||||
|
|
||||||
|
## INSTRUÇÕES ESPECÍFICAS PARA RESPOSTAS COM CONTEXTO WEB
|
||||||
|
|
||||||
|
1. **Contextualização Temporal Obrigatória:**
|
||||||
|
- Inicie respostas sobre notícias ou eventos citando a data atual EXATA fornecida acima.
|
||||||
|
- Use EXATAMENTE: "${currentDateTime}" (não invente outra data).
|
||||||
|
- Exemplo: "Com base nas notícias de ${currentDateTime}, os principais destaques são..."
|
||||||
|
|
||||||
|
2. **Densidade e Profundidade:**
|
||||||
|
- NUNCA responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
|
||||||
|
- Cada ponto deve conter detalhes específicos: Números, Nomes Próprios, Locais e Citações Diretas do contexto.
|
||||||
|
- Explique o *contexto* da notícia, não apenas a manchete.
|
||||||
|
- Prefira parágrafos explicativos com "quem, quando, onde, porquê".
|
||||||
|
|
||||||
|
3. **Uso Estrito de Fontes:**
|
||||||
|
- Use SOMENTE as informações do bloco [CONTEXTO WEB RECUPERADO] abaixo para fatos recentes. Não alucine.
|
||||||
|
- Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política").
|
||||||
|
- Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web.
|
||||||
|
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
|
||||||
|
|
||||||
|
4. **Formato Jornalístico:**
|
||||||
|
- Use títulos Markdown (##) para separar seções temáticas.
|
||||||
|
- Texto corrido para explicações detalhadas.
|
||||||
|
- Bullets apenas para listar dados brutos (números, estatísticas).
|
||||||
|
|
||||||
|
## CONTEXTO WEB RECUPERADO
|
||||||
|
${webContext}
|
||||||
|
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Mesmo sem contexto web, adicionar data/hora e instruções temporais
|
||||||
|
enhancedSystemPrompt = `${systemPrompt}
|
||||||
|
|
||||||
|
## ⚠️ DATA E HORA ATUAL DO SISTEMA (USE ESTA DATA EXATA)
|
||||||
|
|
||||||
|
**DATA/HORA FORMATADA:** ${currentDateTime}
|
||||||
|
**DATA/HORA NUMÉRICA:** ${currentDateExplicit}
|
||||||
|
**ISO 8601:** ${currentDateISO}
|
||||||
|
|
||||||
|
**REGRA CRÍTICA:** Você DEVE usar EXATAMENTE esta data e hora. NÃO invente ou calcule datas diferentes. Se o sistema diz que é ${currentDateTime}, então é ${currentDateTime}.
|
||||||
|
|
||||||
|
Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente a data EXATA fornecida acima (${currentDateTime}). Forneça detalhes ricos (nomes, valores, locais) extraídos do seu conhecimento. Evite resumos de uma linha - prefira explicações detalhadas e contextualizadas.
|
||||||
|
|
||||||
|
---
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendMessage(content, selectedModel, enhancedSystemPrompt);
|
||||||
|
|
||||||
|
// Marcar como completo quando começar a streamar
|
||||||
|
setTimeout(() => setThinkingStep('complete'), 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const handleSelectSession = async (id: string) => {
|
||||||
const panel = sidebarRef.current;
|
if (id === currentSessionId) return;
|
||||||
|
|
||||||
|
// Clear any pending saves
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await loadSessionHistory(id);
|
||||||
|
setMessages(history);
|
||||||
|
setCurrentSessionId(id);
|
||||||
|
lastSavedMessagesRef.current = history.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewChat = () => {
|
||||||
|
stop();
|
||||||
|
clearChat();
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
|
||||||
|
// Reset save tracking
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
lastSavedMessagesRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleChatsSidebar = () => {
|
||||||
|
const panel = chatsSidebarRef.current;
|
||||||
if (panel) {
|
if (panel) {
|
||||||
if (isSidebarCollapsed) {
|
if (isChatsSidebarCollapsed) {
|
||||||
panel.expand();
|
panel.expand();
|
||||||
} else {
|
} else {
|
||||||
panel.collapse();
|
panel.collapse();
|
||||||
@@ -59,15 +303,10 @@ export default function ChatPage() {
|
|||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
|
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
ref={sidebarRef}
|
|
||||||
defaultSize={4}
|
defaultSize={4}
|
||||||
minSize={4}
|
minSize={4}
|
||||||
maxSize={15}
|
maxSize={4}
|
||||||
collapsible={true}
|
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 w-[60px]"
|
||||||
collapsedSize={0}
|
|
||||||
onCollapse={() => setIsSidebarCollapsed(true)}
|
|
||||||
onExpand={() => setIsSidebarCollapsed(false)}
|
|
||||||
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 min-w-[60px]"
|
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
|
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
|
||||||
<MessageSquare className="w-5 h-5" />
|
<MessageSquare className="w-5 h-5" />
|
||||||
@@ -91,23 +330,54 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
<ResizablePanel
|
||||||
|
ref={chatsSidebarRef}
|
||||||
|
defaultSize={15}
|
||||||
|
minSize={10}
|
||||||
|
maxSize={25}
|
||||||
|
collapsible={true}
|
||||||
|
collapsedSize={0}
|
||||||
|
onCollapse={() => setIsChatsSidebarCollapsed(true)}
|
||||||
|
onExpand={() => setIsChatsSidebarCollapsed(false)}
|
||||||
|
className="min-w-[200px]"
|
||||||
|
>
|
||||||
|
<SidebarList
|
||||||
|
sessions={sessions}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onDeleteSession={deleteSession}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle />
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
{/* Main Chat Area */}
|
||||||
<ResizablePanel defaultSize={75} minSize={50}>
|
<ResizablePanel defaultSize={60} minSize={40}>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
|
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isSidebarCollapsed && (
|
{isChatsSidebarCollapsed ? (
|
||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8">
|
||||||
<PanelLeftOpen className="w-4 h-4" />
|
<PanelLeftOpen className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : (
|
||||||
{!isSidebarCollapsed && (
|
<Button variant="ghost" size="icon" onClick={toggleChatsSidebar} className="h-8 w-8 text-muted-foreground">
|
||||||
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8 text-muted-foreground">
|
|
||||||
<PanelLeftClose className="w-4 h-4" />
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="font-semibold">Chat</div>
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
Chat
|
||||||
|
{isGeneratingTitle && <Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />}
|
||||||
|
{isDownloading && (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
{progress}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 max-w-[300px]">
|
<div className="flex-1 max-w-[300px]">
|
||||||
@@ -134,24 +404,93 @@ export default function ChatPage() {
|
|||||||
<p>Inicie uma conversa com {selectedModel}</p>
|
<p>Inicie uma conversa com {selectedModel}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pb-4">
|
<div className="flex flex-col pb-4 max-w-4xl mx-auto w-full">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => {
|
||||||
<ChatMessage key={i} message={msg} />
|
// Verificar se a mensagem anterior teve busca web
|
||||||
))}
|
const hasWebSources = i > 0 &&
|
||||||
{isLoading && (
|
messages[i - 1].role === 'user' &&
|
||||||
|
webSearch.scrapedSources.length > 0 &&
|
||||||
|
webSearch.status === 'completed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<ChatMessage message={msg} />
|
||||||
|
{/* Mostrar fontes após resposta do assistente que usou web search */}
|
||||||
|
{msg.role === 'assistant' && hasWebSources && (
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="pt-2 border-t border-muted">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
Fontes consultadas:
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||||
|
{webSearch.scrapedSources.map((source, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-muted/50 hover:bg-muted/80 transition-colors border border-muted text-xs max-w-[200px]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://www.google.com/s2/favicons?domain=${new URL(source.url).hostname}&sz=16`}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{source.title || `Fonte ${idx + 1}`}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Web Search Progress - mostrar antes da resposta da IA */}
|
||||||
|
{(webSearch.status === 'searching' ||
|
||||||
|
webSearch.status === 'scraping' ||
|
||||||
|
webSearch.status === 'completed' ||
|
||||||
|
webSearch.status === 'error') && (
|
||||||
|
<SearchProgress
|
||||||
|
status={webSearch.status}
|
||||||
|
query={webSearch.currentQuery}
|
||||||
|
sources={webSearch.scrapedSources}
|
||||||
|
error={webSearch.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isLoading && thinkingStep === 'complete' && (
|
||||||
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
|
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
|
||||||
Gerando resposta...
|
Gerando resposta...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Elemento invisível para scroll automático */}
|
||||||
|
<div ref={messagesEndRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Thinking Indicator */}
|
||||||
|
{thinkingStep && thinkingStep !== 'complete' && (
|
||||||
|
<ThinkingIndicator
|
||||||
|
currentStep={thinkingStep}
|
||||||
|
searchQuery={webSearch.currentQuery}
|
||||||
|
sourcesRead={webSearch.scrapedSources.length}
|
||||||
|
totalSources={5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
webSearchEnabled={webSearch.isEnabled}
|
||||||
|
onWebSearchToggle={webSearch.setEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
@@ -159,8 +498,8 @@ export default function ChatPage() {
|
|||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* Right Sidebar (Settings & Monitor) */}
|
{/* Right Sidebar (Settings & Monitor) */}
|
||||||
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="bg-muted/10">
|
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="bg-muted/10">
|
||||||
<Tabs defaultValue="params" className="h-full flex flex-col">
|
<Tabs defaultValue="params" className="h-full flex flex-col overflow-hidden">
|
||||||
<div className="border-b px-4">
|
<div className="border-b px-4">
|
||||||
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
|
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -178,28 +517,31 @@ export default function ChatPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto m-0">
|
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto overflow-x-hidden m-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* System Prompt */}
|
||||||
<label className="text-sm font-medium">System Prompt</label>
|
<div className="space-y-2">
|
||||||
<PromptGeneratorDialog
|
<div className="flex items-center justify-between">
|
||||||
defaultModel={selectedModel}
|
<label className="text-sm font-medium">System Prompt</label>
|
||||||
onPromptGenerated={setSystemPrompt}
|
<PromptGeneratorDialog
|
||||||
|
defaultModel={selectedModel}
|
||||||
|
onPromptGenerated={setSystemPrompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||||
|
className="min-h-[300px] resize-none font-mono text-sm"
|
||||||
|
placeholder="Defina como a IA deve se comportar..."
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Instruções globais para o comportamento do modelo.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
|
||||||
value={systemPrompt}
|
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
|
||||||
className="min-h-[300px] resize-none font-mono text-sm"
|
|
||||||
placeholder="Defina como a IA deve se comportar..."
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Instruções globais para o comportamento do modelo.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="system" className="flex-1 m-0 overflow-hidden">
|
<TabsContent value="system" className="flex-1 m-0 overflow-y-auto overflow-x-hidden">
|
||||||
<SystemPanel />
|
<SystemPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -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 { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Send, Square } from "lucide-react";
|
import { Send, Square, Globe, WifiOff } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
webSearchEnabled?: boolean;
|
||||||
|
onWebSearchToggle?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
export function ChatInput({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
isLoading,
|
||||||
|
webSearchEnabled = true,
|
||||||
|
onWebSearchToggle,
|
||||||
|
}: ChatInputProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -36,31 +50,74 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t bg-background">
|
<div className="p-4 border-t bg-background">
|
||||||
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
|
<div className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
|
||||||
<Textarea
|
<div className="flex-1 relative">
|
||||||
ref={textareaRef}
|
<Textarea
|
||||||
value={input}
|
ref={textareaRef}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
value={input}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Digite sua mensagem..."
|
onKeyDown={handleKeyDown}
|
||||||
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
|
placeholder="Digite sua mensagem..."
|
||||||
rows={1}
|
className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
|
||||||
/>
|
rows={1}
|
||||||
<div className="absolute right-2 bottom-2">
|
/>
|
||||||
{isLoading ? (
|
<div className="absolute right-2 bottom-2 flex items-center gap-2">
|
||||||
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
{onWebSearchToggle && (
|
||||||
<Square className="h-4 w-4 fill-current" />
|
<TooltipProvider>
|
||||||
</Button>
|
<Tooltip>
|
||||||
) : (
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleSend}
|
variant={webSearchEnabled ? "default" : "ghost"}
|
||||||
disabled={!input.trim()}
|
onClick={() => {
|
||||||
className="h-8 w-8"
|
if (!webSearchEnabled) {
|
||||||
>
|
// Ao ativar, não precisa de confirmação
|
||||||
<Send className="h-4 w-4" />
|
onWebSearchToggle(true);
|
||||||
</Button>
|
} else {
|
||||||
)}
|
// Ao desativar, mostrar aviso
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
"Atenção: Desativar a pesquisa web pode reduzir a qualidade das respostas para fatos recentes, especialmente em modelos menores. Deseja continuar?"
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
onWebSearchToggle(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{webSearchEnabled ? (
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{webSearchEnabled
|
||||||
|
? "Pesquisa web ativada"
|
||||||
|
: "Pesquisa web desativada"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
||||||
|
<Square className="h-4 w-4 fill-current" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center text-muted-foreground mt-2">
|
<div className="text-xs text-center text-muted-foreground mt-2">
|
||||||
@@ -70,3 +127,5 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +28,30 @@ export function ChatMessage({ message }: { message: Message }) {
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={markdownRenderers}
|
components={markdownRenderers}
|
||||||
>
|
>
|
||||||
{message.content}
|
{(() => {
|
||||||
|
let content = message.content || '';
|
||||||
|
// Remove metadata tags
|
||||||
|
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Also remove common prefixes if they appear at the end
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:',
|
||||||
|
'Metadata:',
|
||||||
|
'Metadados:',
|
||||||
|
'Hidden metadata block:',
|
||||||
|
'JSON metadata:',
|
||||||
|
'---'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
content = content.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content || message.content;
|
||||||
|
})()}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -197,3 +197,5 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
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 }
|
export { Progress }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -157,3 +157,5 @@ export {
|
|||||||
SelectScrollDownButton,
|
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 }
|
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
|
## DIRETRIZES DE RESPOSTA (RAG)
|
||||||
- Use **Títulos (H1, H2, H3)** para organizar seções. Nunca use apenas negrito para títulos.
|
|
||||||
- Use **Listas (Bulleted ou Numbered)** para passos, itens ou características.
|
|
||||||
- Use **Separadores (---)** para dividir grandes blocos de conteúdo.
|
|
||||||
|
|
||||||
### 2. Blocos de Código
|
### 1. Contextualização Temporal Obrigatória
|
||||||
- SEMPRE use blocos de código triplos (\`\`\`) para qualquer trecho de código, comando de terminal ou configuração.
|
|
||||||
- SEMPRE especifique a linguagem após os acentos graves (ex: \`\`\`python, \`\`\`bash, \`\`\`json).
|
|
||||||
- Para menções curtas de código no meio do texto, use `código inline`.
|
|
||||||
|
|
||||||
### 3. Ênfase e Destaque
|
- **SEMPRE** inicie respostas sobre notícias, eventos ou fatos atuais citando a data atual explicitamente.
|
||||||
- Use **Negrito** para conceitos chave ou avisos importantes.
|
- Exemplo: "Com base nas informações de hoje, [DATA], os principais destaques são..."
|
||||||
- Use *Itálico* para termos estrangeiros ou ênfase sutil.
|
- Use a data fornecida no prompt como referência absoluta para "hoje", "ontem", "amanhã".
|
||||||
- Use > Blockquotes para citações ou notas laterais.
|
|
||||||
|
|
||||||
### 4. Links
|
### 2. Densidade e Profundidade
|
||||||
- Formate links corretamente: `[Texto do Link](URL)`.
|
|
||||||
|
|
||||||
## ESTILO DE RESPOSTA
|
- **NUNCA** responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
|
||||||
- Seja direto e objetivo. Evite preâmbulos desnecessários ("Claro, aqui está...").
|
- Cada ponto deve conter detalhes específicos: **Números, Nomes Próprios, Locais e Citações Diretas** do contexto.
|
||||||
- Se a resposta for longa, comece com um resumo executivo.
|
- Explique o *contexto* da notícia, não apenas a manchete.
|
||||||
- Ao explicar código, comente o que cada parte faz.
|
- Prefira parágrafos explicativos com "quem, quando, onde, porquê" ao invés de bullets genéricos.
|
||||||
|
|
||||||
|
### 3. Uso Estrito de Fontes
|
||||||
|
|
||||||
|
- Você recebeu um bloco de texto marcado como [CONTEXTO WEB] ou [CONTEXTO WEB RECUPERADO].
|
||||||
|
- Use **SOMENTE** essas informações para fatos recentes. Não alucine.
|
||||||
|
- Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política", "## Tecnologia").
|
||||||
|
- Cite as fontes usando [1], [2], [3] ao final das frases quando usar informações do contexto web.
|
||||||
|
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
|
||||||
|
|
||||||
|
### 4. Formato Jornalístico
|
||||||
|
|
||||||
|
- Use títulos Markdown (##) para separar seções temáticas.
|
||||||
|
- Texto corrido para explicações detalhadas.
|
||||||
|
- Bullets apenas para listar dados brutos (números, estatísticas, listas de nomes).
|
||||||
|
- Tom objetivo, profissional e direto.
|
||||||
|
- Evite linguagem excessivamente técnica sem contexto.
|
||||||
|
|
||||||
|
## FORMATO SUGERIDO
|
||||||
|
|
||||||
|
- **Contextualização temporal** no início (quando relevante).
|
||||||
|
- Seções `##` para organizar o conteúdo por tema.
|
||||||
|
- Parágrafos explicativos ao invés de one-liners.
|
||||||
|
- Listas para dados brutos (números, estatísticas).
|
||||||
|
- Blocos de código com linguagem específica.
|
||||||
|
|
||||||
|
## ESTILO
|
||||||
|
|
||||||
|
- Cite limitações, hipóteses e próximos passos quando relevante.
|
||||||
|
- Inclua links apenas quando essencial.
|
||||||
|
- Respostas sempre limpas, sem metadados ou conteúdo oculto.
|
||||||
|
- Priorize informações recentes e precisas do contexto fornecido.
|
||||||
@@ -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 { useState, useRef } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
|
metadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCall {
|
||||||
|
server_name: string;
|
||||||
|
tool_name: string;
|
||||||
|
arguments: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(fullContent: string): { content: string, metadata?: any } {
|
||||||
|
if (!fullContent || fullContent.trim() === '') {
|
||||||
|
return { content: fullContent || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// More flexible regex that handles whitespace and line breaks (case-insensitive)
|
||||||
|
const metadataRegex = /<metadata>\s*([\s\S]*?)\s*<\/metadata>/gi;
|
||||||
|
|
||||||
|
// First, extract all metadata blocks
|
||||||
|
const metadataMatches: Array<{ match: string, json: any }> = [];
|
||||||
|
let match;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = metadataRegex.exec(fullContent)) !== null) {
|
||||||
|
const metadataStr = match[1].trim();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(metadataStr);
|
||||||
|
metadataMatches.push({ match: match[0], json });
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid JSON, just remove the tag
|
||||||
|
console.error("Failed to parse metadata JSON", e, "Raw:", metadataStr);
|
||||||
|
}
|
||||||
|
lastIndex = metadataRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ALL metadata blocks from content
|
||||||
|
let content = fullContent.replace(/<metadata>\s*[\s\S]*?\s*<\/metadata>/gi, '').trim();
|
||||||
|
content = removeMetadataNoise(content);
|
||||||
|
|
||||||
|
// Also remove common prefixes if they appear at the end of the content
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:',
|
||||||
|
'Metadata:',
|
||||||
|
'Metadados:',
|
||||||
|
'Hidden metadata block:',
|
||||||
|
'JSON metadata:',
|
||||||
|
'---'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
content = content.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't return empty content if there was content before metadata
|
||||||
|
if (!content && fullContent.length > 0) {
|
||||||
|
// If all content was metadata, keep nothing (shouldn't happen but safe fallback)
|
||||||
|
content = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first valid metadata (if any)
|
||||||
|
const metadata = metadataMatches.length > 0 ? metadataMatches[0].json : undefined;
|
||||||
|
|
||||||
|
return { content, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to strip metadata in real-time during streaming (for display)
|
||||||
|
function stripMetadataTag(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Common prefixes that models might output before the metadata block
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:',
|
||||||
|
'Metadata:',
|
||||||
|
'Metadados:',
|
||||||
|
'Hidden metadata block:',
|
||||||
|
'JSON metadata:',
|
||||||
|
'---'
|
||||||
|
];
|
||||||
|
|
||||||
|
let cleaned = content;
|
||||||
|
|
||||||
|
// Remove any metadata tags (complete or incomplete) for real-time display
|
||||||
|
// Case-insensitive regex to catch variations
|
||||||
|
cleaned = cleaned.replace(/<metadata>[\s\S]*?$/gi, '').replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Aggressively strip known prefixes if they appear at the very end of the content
|
||||||
|
// This helps hide "Bloco oculto de metadados:" while the tag is being generated
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(cleaned)) {
|
||||||
|
cleaned = cleaned.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeMetadataNoise(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to detect tool calls in LLM response
|
||||||
|
export function detectToolCalls(content: string, availableTools: any[]): ToolCall[] {
|
||||||
|
const toolCalls: ToolCall[] = [];
|
||||||
|
|
||||||
|
// Simple pattern matching for tool calls
|
||||||
|
// Pattern: "use tool X with args Y" or "execute X(args)"
|
||||||
|
// This is a basic implementation - can be enhanced with better NLP
|
||||||
|
|
||||||
|
// Look for common patterns
|
||||||
|
const patterns = [
|
||||||
|
// Pattern 1: "use [tool_name] with [args]" or "use [tool_name] (args)"
|
||||||
|
/(?:use|call|execute|run)\s+([a-z_]+)\s+(?:with|\(|\[)\s*([^\n)]+)/gi,
|
||||||
|
// Pattern 2: "[tool_name](args)"
|
||||||
|
/([a-z_]+)\s*\(([^)]+)\)/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const potentialToolName = match[1].toLowerCase().trim();
|
||||||
|
const potentialArgs = match[2]?.trim() || '{}';
|
||||||
|
|
||||||
|
// Find matching tool in available tools
|
||||||
|
for (const toolInfo of availableTools) {
|
||||||
|
if (toolInfo.tool.name.toLowerCase() === potentialToolName) {
|
||||||
|
// Try to parse arguments
|
||||||
|
let args: any = {};
|
||||||
|
try {
|
||||||
|
// Try JSON first
|
||||||
|
args = JSON.parse(potentialArgs);
|
||||||
|
} catch {
|
||||||
|
// If not JSON, try to parse key-value pairs
|
||||||
|
try {
|
||||||
|
// Simple key=value parsing
|
||||||
|
const pairs = potentialArgs.split(',').map(p => p.trim());
|
||||||
|
args = {};
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const [key, value] = pair.split('=').map(s => s.trim());
|
||||||
|
if (key && value) {
|
||||||
|
args[key] = value.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If all fails, use empty object
|
||||||
|
args = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
server_name: toolInfo.server_name,
|
||||||
|
tool_name: toolInfo.tool.name,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to execute a tool call
|
||||||
|
export async function executeToolCall(toolCall: ToolCall): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<any>('call_mcp_tool', {
|
||||||
|
serverName: toolCall.server_name,
|
||||||
|
toolName: toolCall.tool_name,
|
||||||
|
arguments: toolCall.arguments,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format result for display
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
return result;
|
||||||
|
} else if (result.content) {
|
||||||
|
return result.content;
|
||||||
|
} else {
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tool execution error:', error);
|
||||||
|
return `Error executing tool ${toolCall.tool_name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||||
|
|
||||||
const sendMessage = async (content: string, model: string, systemPrompt?: string) => {
|
const sendMessage = async (
|
||||||
|
content: string,
|
||||||
|
model: string,
|
||||||
|
systemPrompt?: string,
|
||||||
|
options?: {
|
||||||
|
onBeforeModelRequest?: (pendingMessages: Message[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const newMessages: Message[] = [
|
const newMessages: Message[] = [
|
||||||
@@ -20,9 +213,56 @@ export function useChat() {
|
|||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
|
|
||||||
// Prepare messages for API (include system prompt if present)
|
// Prepare messages for API (include system prompt if present)
|
||||||
const apiMessages = systemPrompt
|
// Strip metadata from previous messages before sending to context to save tokens?
|
||||||
? [{ role: 'system', content: systemPrompt }, ...newMessages]
|
// Or keep it? The model might benefit from seeing previous metadata.
|
||||||
: newMessages;
|
// Let's keep it for now, but we need to make sure we send what the API expects.
|
||||||
|
// Ollama API expects { role, content }. It ignores extra fields usually, but let's be safe.
|
||||||
|
if (options?.onBeforeModelRequest) {
|
||||||
|
await options.onBeforeModelRequest(newMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar janela deslizante para limitar contexto
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const maxTokens = settings.contextWindow || 4096;
|
||||||
|
const maxMessages = 10; // Máximo de mensagens a manter
|
||||||
|
|
||||||
|
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar janela deslizante: manter últimas N mensagens que cabem no limite de tokens
|
||||||
|
let truncatedMessages = [...newMessages];
|
||||||
|
let totalTokens = systemPrompt ? estimateTokens(systemPrompt) : 0;
|
||||||
|
|
||||||
|
// Sempre manter a última mensagem do usuário
|
||||||
|
const lastUserMessage = truncatedMessages[truncatedMessages.length - 1];
|
||||||
|
truncatedMessages = truncatedMessages.slice(0, -1);
|
||||||
|
|
||||||
|
// Adicionar mensagens de trás para frente até atingir o limite
|
||||||
|
const selectedMessages: Message[] = [];
|
||||||
|
for (let i = truncatedMessages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = truncatedMessages[i];
|
||||||
|
const msgTokens = estimateTokens(msg.content);
|
||||||
|
|
||||||
|
// Limitar por número de mensagens E por tokens
|
||||||
|
if (selectedMessages.length >= maxMessages - 1) break; // -1 porque vamos adicionar a última depois
|
||||||
|
if (totalTokens + msgTokens > maxTokens * 0.9) break; // 90% do limite para margem de segurança
|
||||||
|
|
||||||
|
selectedMessages.unshift(msg);
|
||||||
|
totalTokens += msgTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar a última mensagem do usuário
|
||||||
|
selectedMessages.push(lastUserMessage);
|
||||||
|
totalTokens += estimateTokens(lastUserMessage.content);
|
||||||
|
|
||||||
|
const apiMessages = (systemPrompt
|
||||||
|
? [{ role: 'system', content: systemPrompt }, ...selectedMessages]
|
||||||
|
: selectedMessages).map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content // Send only content, not metadata (unless we want to inject it back)
|
||||||
|
}));
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
@@ -44,12 +284,21 @@ export function useChat() {
|
|||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
// Reset raw content buffer for this message
|
||||||
|
rawContentRef.current = '';
|
||||||
|
|
||||||
// Add empty assistant message to start streaming into
|
// Add empty assistant message to start streaming into
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
||||||
|
|
||||||
|
let streamFinished = false;
|
||||||
|
let metadataProcessed = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) {
|
||||||
|
streamFinished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||||
@@ -58,25 +307,83 @@ export function useChat() {
|
|||||||
try {
|
try {
|
||||||
const json = JSON.parse(line);
|
const json = JSON.parse(line);
|
||||||
if (json.message?.content) {
|
if (json.message?.content) {
|
||||||
|
// Accumulate raw content (including metadata) in ref
|
||||||
|
rawContentRef.current += json.message.content;
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last.role === 'assistant') {
|
if (last.role === 'assistant') {
|
||||||
|
// For display: strip incomplete metadata tag if present (opening tag but not closed)
|
||||||
|
// This prevents showing "<metadata>..." in the UI while streaming
|
||||||
|
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||||
return [
|
return [
|
||||||
...prev.slice(0, -1),
|
...prev.slice(0, -1),
|
||||||
{ ...last, content: last.content + json.message.content }
|
{ ...last, content: displayContent }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (json.done) {
|
if (json.done) {
|
||||||
|
streamFinished = true;
|
||||||
|
metadataProcessed = true;
|
||||||
|
|
||||||
|
// Process metadata FIRST before setting isLoading to false
|
||||||
|
// This prevents race condition with saveSession useEffect
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last.role === 'assistant') {
|
||||||
|
// Parse metadata from raw accumulated content
|
||||||
|
const rawContent = rawContentRef.current;
|
||||||
|
const { content, metadata } = parseMetadata(rawContent);
|
||||||
|
|
||||||
|
// Fallback: if content is empty after parse but we had content before,
|
||||||
|
// keep the stripped version (without metadata tag) to preserve user-visible text
|
||||||
|
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
|
||||||
|
|
||||||
|
// Clear ref for next message
|
||||||
|
rawContentRef.current = '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, content: finalContent, metadata }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set loading to false AFTER processing metadata
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
break; // Exit inner loop when done
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing chunk', e);
|
console.error('Error parsing chunk', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If stream finished without explicit json.done, process metadata anyway
|
||||||
|
if (streamFinished && !metadataProcessed && rawContentRef.current) {
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
const rawContent = rawContentRef.current;
|
||||||
|
const { content, metadata } = parseMetadata(rawContent);
|
||||||
|
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
|
||||||
|
rawContentRef.current = '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, content: finalContent, metadata }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
} else if (streamFinished && !metadataProcessed) {
|
||||||
|
// Stream finished but no content to process, just set loading to false
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
console.log('Request aborted');
|
console.log('Request aborted');
|
||||||
@@ -99,8 +406,9 @@ export function useChat() {
|
|||||||
|
|
||||||
const clearChat = () => {
|
const clearChat = () => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
rawContentRef.current = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return { messages, sendMessage, isLoading, stop, clearChat };
|
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +41,5 @@ export function useLocalModels() {
|
|||||||
return { models, loading, refresh: fetchModels, deleteModel };
|
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 };
|
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;
|
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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
@@ -27,8 +34,10 @@
|
|||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
Generated
+329
@@ -8,12 +8,21 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@radix-ui/react-checkbox':
|
||||||
|
specifier: ^1.3.3
|
||||||
|
version: 1.3.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.16
|
specifier: ^2.1.16
|
||||||
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-label':
|
||||||
|
specifier: ^2.1.8
|
||||||
|
version: 2.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -23,15 +32,27 @@ importers:
|
|||||||
'@radix-ui/react-separator':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-slider':
|
||||||
|
specifier: ^1.3.6
|
||||||
|
version: 1.3.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/[email protected])([email protected])
|
version: 1.2.4(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-switch':
|
||||||
|
specifier: ^1.2.6
|
||||||
|
version: 1.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@radix-ui/react-tabs':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.13
|
specifier: ^1.1.13
|
||||||
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-tooltip':
|
||||||
|
specifier: ^1.2.8
|
||||||
|
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
'@types/react-syntax-highlighter':
|
||||||
|
specifier: ^15.5.13
|
||||||
|
version: 15.5.13
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -62,12 +83,18 @@ importers:
|
|||||||
react-resizable-panels:
|
react-resizable-panels:
|
||||||
specifier: ^3.0.6
|
specifier: ^3.0.6
|
||||||
version: 3.0.6([email protected]([email protected]))([email protected])
|
version: 3.0.6([email protected]([email protected]))([email protected])
|
||||||
|
react-syntax-highlighter:
|
||||||
|
specifier: ^16.1.0
|
||||||
|
version: 16.1.0([email protected])
|
||||||
remark-gfm:
|
remark-gfm:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.8
|
||||||
|
version: 5.0.8(@types/[email protected])([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
@@ -164,6 +191,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/[email protected]':
|
'@babel/[email protected]':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -502,6 +533,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -621,6 +665,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -634,6 +691,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -751,6 +821,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -769,6 +852,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -782,6 +878,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/[email protected]':
|
||||||
|
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/[email protected]':
|
'@radix-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1074,11 +1183,17 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
|
resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==}
|
resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==}
|
||||||
|
|
||||||
@@ -1797,6 +1912,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1829,6 +1947,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||||
|
engines: {node: '>=0.4.x'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
|
resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1940,18 +2062,30 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||||
|
|
||||||
@@ -2261,6 +2395,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
@@ -2585,6 +2722,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2657,6 +2798,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==}
|
||||||
|
engines: {node: '>= 16.20.2'}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 0.14.0'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2665,6 +2812,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3086,6 +3236,24 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -3170,6 +3338,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.5
|
'@babel/types': 7.28.5
|
||||||
|
|
||||||
|
'@babel/[email protected]': {}
|
||||||
|
|
||||||
'@babel/[email protected]':
|
'@babel/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -3468,6 +3638,22 @@ snapshots:
|
|||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
@@ -3578,6 +3764,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3604,6 +3799,29 @@ snapshots:
|
|||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
react-remove-scroll: 2.7.1(@types/[email protected])([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6([email protected]([email protected]))([email protected])
|
'@floating-ui/react-dom': 2.1.6([email protected]([email protected]))([email protected])
|
||||||
@@ -3725,6 +3943,25 @@ snapshots:
|
|||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/number': 1.1.1
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
@@ -3739,6 +3976,21 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-size': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3755,6 +4007,26 @@ snapshots:
|
|||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
|
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||||
|
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0([email protected])
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||||
|
|
||||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
@@ -3987,10 +4259,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected](@types/[email protected])':
|
'@types/[email protected](@types/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
@@ -4890,6 +5168,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
format: 0.2.2
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
@@ -4918,6 +5200,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]([email protected]))([email protected]):
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-dom: 12.23.23
|
motion-dom: 12.23.23
|
||||||
@@ -5021,6 +5305,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -5045,12 +5333,24 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
comma-separated-tokens: 2.0.3
|
||||||
|
hast-util-parse-selector: 4.0.0
|
||||||
|
property-information: 7.1.0
|
||||||
|
space-separated-tokens: 2.0.2
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5333,6 +5633,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
fault: 1.0.4
|
||||||
|
highlight.js: 10.7.3
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
@@ -5871,6 +6176,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -5950,6 +6257,16 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.6
|
'@types/react': 19.2.6
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.4
|
||||||
|
highlight.js: 10.7.3
|
||||||
|
highlightjs-vue: 1.0.0
|
||||||
|
lowlight: 1.20.0
|
||||||
|
prismjs: 1.30.0
|
||||||
|
react: 19.2.0
|
||||||
|
refractor: 5.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -5963,6 +6280,13 @@ snapshots:
|
|||||||
get-proto: 1.0.1
|
get-proto: 1.0.1
|
||||||
which-builtin-type: 1.2.1
|
which-builtin-type: 1.2.1
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
'@types/prismjs': 1.26.5
|
||||||
|
hastscript: 9.0.1
|
||||||
|
parse-entities: 4.0.2
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -6561,4 +6885,9 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@types/[email protected])([email protected]):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.6
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
+10168
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"
|
log = "0.4"
|
||||||
tauri = { version = "2.9.2", features = [] }
|
tauri = { version = "2.9.2", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
sysinfo = "0.36.1"
|
sysinfo = "0.36.1"
|
||||||
|
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
headless_chrome = "1.0"
|
||||||
|
scraper = "0.18"
|
||||||
|
readability = "0.3"
|
||||||
|
html2text = "0.5"
|
||||||
|
anyhow = "1.0"
|
||||||
|
url = "2.5"
|
||||||
|
urlencoding = "2.1"
|
||||||
|
zip = "0.6"
|
||||||
|
rand = "0.8"
|
||||||
|
tokio-cron-scheduler = "0.9"
|
||||||
|
futures-util = "0.3"
|
||||||
|
regex = "1.10"
|
||||||
+1284
-4
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